Files
miniSCP/ui/src/app/transfers.service.ts
T
JokinandClaude Fable 5 de75fb1480 Fix data loss: copying a file onto itself via two sessions truncated it to 0
Two panels on the same server hold two SSH sessions by design, and every
self-copy guard compared sessionId — so same-server same-path copies slipped
through all of them, and createWriteStream truncated the destination before
the read began: the file (or every file in a folder) became 0 bytes, with the
overwrite dialog making it look like a legitimate replace.

Fix, defense in depth, keyed on FILESYSTEM identity instead of session:
- fsKey on every provider: 'local', or resolvedIP:port for SFTP (host is
  DNS-resolved at connect so hostname-vs-IP aliases compare equal; username
  excluded — same host:port is the same filesystem regardless of login)
- engine guard blocks equal/nested paths when fsKeys match; per-file
  belt-and-suspenders check right before streams open
- probe layer for hidden aliases (different addresses, same machine): when
  fsKeys differ but paths collide, a token file written via dst and read via
  src decides; visible => same machine => blocked. SFTP exposes no dev/inode
  (what cp/rsync use), so probing is the only deterministic test; fails open
  and cleans up after itself
- UI: same-folder toast now fires across two sessions to one server, and the
  overwrite dialog auto-skips (never offers Replace) when the colliding item
  IS the source file

Known residual, documented in README 'Same-file protection': one filesystem
mounted at different paths on two connections is undetectable client-side.

Regression suite against the real box: file-onto-itself and folder-onto-itself
blocked with contents byte-intact, folder-into-descendant blocked, legit
same-server different-dir copies still work, local same-path still blocked,
simulated alias caught by the probe with cleanup verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:01:20 +02:00

215 lines
7.8 KiB
TypeScript

import { Injectable, computed, inject, signal } from '@angular/core';
import type { FileEntry, TransferSnapshot } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000;
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
// EMA smoothing for the displayed transfer speed
const SPEED_SMOOTHING = 0.7;
@Injectable({ providedIn: 'root' })
export class TransfersService {
private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */
readonly speeds = signal<ReadonlyMap<string, number>>(new Map());
private speedTrack = new Map<string, { bytes: number; time: number; speed: number }>();
readonly jobs = computed(() => [...this.jobMap().values()]);
readonly hasJobs = computed(() => this.jobMap().size > 0);
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
readonly currentJob = computed(
() => this.jobs().find((j) => j.state === 'running' || j.state === 'scanning') ?? null,
);
readonly queuedCount = computed(() => this.jobs().filter((j) => j.state === 'queued').length);
readonly failedJobs = computed(() => this.jobs().filter((j) => j.state === 'failed'));
readonly finishedJobs = computed(() => this.jobs().filter((j) => !ACTIVE_STATES.includes(j.state)));
/** Whether the detailed per-job list is shown (toggled from the status bar). */
readonly expanded = signal(false);
constructor() {
if (this.rpc.available) {
this.rpc.onTransferUpdate((snapshot) => this.apply(snapshot));
}
}
/** Copy the given panel's targets into the other panel's cwd (F5 / toolbar). */
async copyToOtherPanel(fromSide: Side): Promise<void> {
const src = this.panels.get(fromSide);
const dst = this.panels.other(fromSide);
const selected = src.selectedEntries();
const atCursor = src.sorted()[src.cursor()];
const targets = (selected.length ? selected : atCursor ? [atCursor] : []).filter(
(e) => e.type !== 'symlink' && e.name !== '..',
);
if (!targets.length) {
this.toast.info('Nothing to copy (symlinks are skipped)');
return;
}
const sameFs = src.fsKey() === dst.fsKey();
if (sameFs && src.cwd() === dst.cwd()) {
this.toast.info('Both panels show the same folder');
return;
}
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
if (finalTargets === null) return; // user cancelled
if (!finalTargets.length) {
this.toast.info('Nothing to copy — all conflicting items were skipped');
return;
}
const res = await this.rpc.request.enqueueTransfers({
srcSessionId: src.sessionId(),
items: finalTargets.map(({ path, type }) => ({ path, type })),
dstSessionId: dst.sessionId(),
dstDir: dst.cwd(),
});
if (!res.ok) this.toast.error(res.error);
}
/**
* Confirm name collisions with the destination listing one by one.
* Returns the approved target list, or null if the user cancelled.
*/
private async resolveCollisions(
targets: FileEntry[],
dstEntries: FileEntry[],
sameFs: boolean,
): Promise<FileEntry[] | null> {
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
const collisions = targets.filter((t) => {
const dst = dstByName.get(t.name);
return dst && (dst.type === 'dir') === (t.type === 'dir');
});
let mode: 'ask' | 'all' | 'none' = 'ask';
let asked = 0;
const finalTargets: FileEntry[] = [];
for (const target of targets) {
const dstEntry = dstByName.get(target.name);
if (!dstEntry) {
finalTargets.push(target);
continue;
}
if (sameFs && dstEntry.path === target.path) {
// replacing a file with ITSELF would only destroy it — never offer that
this.toast.info(`Skipped "${target.name}" — it is the same file`);
continue;
}
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
continue;
}
if (mode === 'all') {
finalTargets.push(target);
continue;
}
if (mode === 'none') continue;
const choice = await this.dialogs.askOverwrite({
srcEntry: target,
dstEntry,
index: ++asked,
total: collisions.length,
});
if (choice === 'cancel') return null;
if (choice === 'overwriteAll') {
mode = 'all';
finalTargets.push(target);
} else if (choice === 'skipAll') {
mode = 'none';
} else if (choice === 'overwrite') {
finalTargets.push(target);
}
}
return finalTargets;
}
async cancel(jobId: string): Promise<void> {
await this.rpc.request.cancelTransfer({ jobId });
}
/** Remove every finished (completed/failed/cancelled) entry; running ones stay. */
clearFinished(): void {
for (const job of this.finishedJobs()) {
void this.dismiss(job.id);
}
}
async dismiss(jobId: string): Promise<void> {
this.jobMap.update((prev) => {
const next = new Map(prev);
next.delete(jobId);
return next;
});
this.speedTrack.delete(jobId);
this.speeds.update((prev) => {
const next = new Map(prev);
next.delete(jobId);
return next;
});
await this.rpc.request.dismissTransfer({ jobId });
}
private apply(snapshot: TransferSnapshot): void {
const previous = this.jobMap().get(snapshot.id);
this.jobMap.update((prev) => new Map(prev).set(snapshot.id, snapshot));
this.trackSpeed(snapshot);
const justFinished = previous?.state !== snapshot.state;
if (!justFinished) return;
if (snapshot.state === 'completed') {
this.refreshPanelsOn(snapshot.dstSessionId);
if (this.settings.autoClearCompleted()) {
setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS);
}
} else if (snapshot.state === 'failed') {
this.expanded.set(true);
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
this.refreshPanelsOn(snapshot.dstSessionId);
} else if (snapshot.state === 'cancelled') {
this.refreshPanelsOn(snapshot.dstSessionId);
}
}
private refreshPanelsOn(sessionId: string): void {
for (const panel of this.panels.allPanels()) {
if (panel.sessionId() === sessionId) void panel.refresh();
}
}
private trackSpeed(snapshot: TransferSnapshot): void {
if (snapshot.state !== 'running') {
if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') {
this.speedTrack.delete(snapshot.id);
}
return;
}
const now = performance.now();
const prev = this.speedTrack.get(snapshot.id);
if (!prev) {
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 });
return;
}
const dt = (now - prev.time) / 1000;
if (dt < 0.2) return; // wait for a meaningful window
const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt);
const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant;
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed });
this.speeds.update((map) => new Map(map).set(snapshot.id, speed));
}
}