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>
215 lines
7.8 KiB
TypeScript
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));
|
|
}
|
|
}
|