Files
NetMesh/application/state/sftp/dedicatedTransferResume.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2134 lines
80 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { Host, TransferTask } from "../../../domain/models";
import {
classifyDedicatedResumeEndpoints,
classifyResumeSourceValidationError,
createPersistedResumeChildLookup,
findPersistedChildForResumeFile,
MAX_CONCURRENT_DEDICATED_SESSION_OPENS,
resetDedicatedSessionOpenGateForTests,
resumeTransferWithDedicatedSession,
resolveDirectoryResumeTargetRoot,
isUsableTransferSessionId,
resolveHostForTransferEndpoint,
resolveResumeHosts,
shouldSkipCompletedResumeChild,
withDedicatedSessionOpenSlot,
} from "./dedicatedTransferResume";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
appendDirectoryCheckpointIdentity,
appendDirectoryManifestIdentity,
createDirectoryManifestAccumulator,
createDirectoryEntryIdentity,
createEmptyDirectoryResumeCheckpoint,
} from "../../../domain/sftpDirectoryCheckpoint";
const host = (id: string, label: string, hostname = label): Host => ({
id,
label,
hostname,
port: 22,
username: "root",
authMethod: "password",
protocol: "ssh",
} as Host);
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const originalGet = netcattyBridge.get;
let abort = false;
let running: Promise<unknown> | undefined;
let childId: string | undefined;
const parent: TransferTask = {
id: "superseded-compacted-folder", fileName: "folder",
sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
targetHostId: "h1", direction: "upload", status: "transferring",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
t.after(async () => {
abort = true;
await running;
netcattyBridge.get = originalGet;
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
sftpTransferCenterStore.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
const persisted: TransferTask = {
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
}],
mkdirSftp: async () => {},
statLocal: async () => {
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
return { size: 10, lastModified: 1 };
},
startStreamTransfer: async (options: {
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
}) => {
childId = options.transferId;
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "started", transferId: childId, lifecycleEpoch: 0,
parentTaskId: options.parentTaskId,
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
if (newerPause) {
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
assert.equal(childId, undefined, "newer paused child must not start");
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
return;
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.equal((result as { success: boolean }).success, true);
});
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
resolveDirectoryResumeTargetRoot({
targetPath: "/final/dir",
stagedTargetPath: "/final/dir.netcatty-abc.part",
replaceExistingTarget: true,
}),
"/final/dir.netcatty-abc.part",
);
assert.equal(
resolveDirectoryResumeTargetRoot({
targetPath: "/final/dir",
}),
"/final/dir",
);
});
test("findPersistedChildForResumeFile matches staged target paths", () => {
const staged = "/final/dir.netcatty-abc.part/a.txt";
const child = {
id: "c1",
status: "completed" as const,
sourcePath: "/src/a.txt",
targetPath: staged,
checkpointBytes: 10,
transferredBytes: 10,
totalBytes: 10,
};
const planRoot = resolveDirectoryResumeTargetRoot({
targetPath: "/final/dir",
stagedTargetPath: "/final/dir.netcatty-abc.part",
});
const planned = { sourcePath: "/src/a.txt", targetPath: `${planRoot}/a.txt` };
assert.equal(findPersistedChildForResumeFile([child], planned)?.id, "c1");
assert.equal(shouldSkipCompletedResumeChild(findPersistedChildForResumeFile([child], planned)), true);
});
test("persisted resume child lookup handles 50,000 retained rows in linear time", () => {
const children = Array.from({ length: 50_000 }, (_, index) => ({
id: `child-${index}`,
status: "failed" as const,
sourcePath: `/source/file-${index}`,
targetPath: `/target/file-${index}`,
checkpointBytes: index,
transferredBytes: index,
totalBytes: index + 1,
}));
const startedAt = Date.now();
const lookup = createPersistedResumeChildLookup(children);
for (let index = 0; index < children.length; index += 1) {
assert.equal(lookup({
sourcePath: `/source/file-${index}`,
targetPath: `/target/file-${index}`,
})?.id, `child-${index}`);
}
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
});
test("isUsableTransferSessionId rejects SFTP pane connection ids", () => {
assert.equal(isUsableTransferSessionId("term-alive"), true);
assert.equal(isUsableTransferSessionId("local"), false);
assert.equal(isUsableTransferSessionId("agent"), false);
assert.equal(isUsableTransferSessionId("left-abc"), false);
assert.equal(isUsableTransferSessionId("right-deadbeef"), false);
assert.equal(isUsableTransferSessionId(undefined), false);
});
test("resolveResumeHosts accepts ephemeral quick-connect hosts in the host list", () => {
const ephemeral = {
...host("quick-1", "10.2.0.32", "10.2.0.32"),
ephemeral: true,
password: "secret",
} as Host;
const resolved = resolveResumeHosts({
id: "up-1",
fileName: "ChatGPT.dmg",
sourcePath: "/local/ChatGPT.dmg",
targetPath: "/remote/ChatGPT.dmg",
sourceConnectionId: "local",
targetConnectionId: "sess-1",
targetHostId: "quick-1",
targetHostLabel: "10.2.0.32",
direction: "upload",
status: "paused",
totalBytes: 100,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: false,
} as TransferTask, { hosts: [ephemeral], keys: [], identities: [] });
assert.equal(resolved.ok, true);
if (resolved.ok) {
assert.equal(resolved.endpoints.targetHost?.id, "quick-1");
// Pane-style ids are not terminal sessions — only real session ids attach.
assert.equal(resolved.endpoints.targetSessionId, "sess-1");
}
});
test("resolveResumeHosts ignores left-/right- pane connection ids", () => {
const vaultHost = host("h1", "box", "1.2.3.4");
const resolved = resolveResumeHosts({
id: "up-pane",
fileName: "file.bin",
sourcePath: "/local/file.bin",
targetPath: "/remote/file.bin",
sourceConnectionId: "local",
targetConnectionId: "left-not-a-terminal",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 10,
transferredBytes: 2,
speed: 0,
startTime: 1,
isDirectory: false,
} as TransferTask, { hosts: [vaultHost], keys: [], identities: [] });
assert.equal(resolved.ok, true);
if (resolved.ok) {
assert.equal(resolved.endpoints.targetHost?.id, "h1");
assert.equal(resolved.endpoints.targetSessionId, undefined);
}
});
test("resolveResumeHosts prefers attaching live session even when vault host exists", () => {
const vaultHost = host("h1", "box", "1.2.3.4");
const resolved = resolveResumeHosts({
id: "up-shared",
fileName: "file.bin",
sourcePath: "/local/file.bin",
targetPath: "/remote/file.bin",
sourceConnectionId: "local",
targetConnectionId: "term-live",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 10,
transferredBytes: 2,
speed: 0,
startTime: 1,
isDirectory: false,
} as TransferTask, { hosts: [vaultHost], keys: [], identities: [] });
assert.equal(resolved.ok, true);
if (resolved.ok) {
assert.equal(resolved.endpoints.targetHost?.id, "h1");
assert.equal(resolved.endpoints.targetSessionId, "term-live");
}
});
test("resolveResumeHosts falls back to a live session when vault host is missing", () => {
const resolved = resolveResumeHosts({
id: "up-2",
fileName: "ChatGPT.dmg",
sourcePath: "/local/ChatGPT.dmg",
targetPath: "/remote/ChatGPT.dmg",
sourceConnectionId: "local",
targetConnectionId: "sess-live",
targetHostId: "quick-missing",
targetHostLabel: "10.2.0.32",
direction: "upload",
status: "paused",
totalBytes: 100,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: false,
} as TransferTask, { hosts: [], keys: [], identities: [] });
assert.equal(resolved.ok, true);
if (resolved.ok) {
assert.equal(resolved.endpoints.targetHost, null);
assert.equal(resolved.endpoints.targetSessionId, "sess-live");
}
});
test("resolveResumeHosts errors when neither vault host nor live session exists", () => {
const resolved = resolveResumeHosts({
id: "up-3",
fileName: "ChatGPT.dmg",
sourcePath: "/local/ChatGPT.dmg",
targetPath: "/remote/ChatGPT.dmg",
sourceConnectionId: "local",
targetConnectionId: "local",
targetHostId: "gone",
targetHostLabel: "10.2.0.32",
direction: "upload",
status: "paused",
totalBytes: 100,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: false,
} as TransferTask, { hosts: [], keys: [], identities: [] });
assert.equal(resolved.ok, false);
if (!resolved.ok) {
assert.match(resolved.error, /Cannot find host "10\.2\.0\.32"/);
}
});
test("dedicated resume of quick-connect host uses session when vault list is empty", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let openedSession: string | undefined;
let startOptions: Record<string, unknown> | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => {
throw new Error("must not dial vault for missing host");
},
openSftpForSession: async (sessionId: string) => {
openedSession = sessionId;
return "session-sftp";
},
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async (options: Record<string, unknown>) => {
startOptions = options;
return { transferId: "qc-resume" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "qc-resume",
fileName: "ChatGPT.dmg",
sourcePath: "/local/ChatGPT.dmg",
targetPath: "/remote/ChatGPT.dmg",
sourceConnectionId: "local",
targetConnectionId: "term-sess-1",
targetHostId: "quick-1",
targetHostLabel: "10.2.0.32",
direction: "upload",
status: "paused",
totalBytes: 100,
transferredBytes: 4,
checkpointBytes: 4,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
assert.equal(openedSession, "term-sess-1");
assert.equal(startOptions?.targetSftpId, "session-sftp");
assert.equal(startOptions?.checkpointBytes, 4);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("hard resume with vault host + live session reuses session transport (not cold dedicated dial)", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let openSftpCalls = 0;
let openForSessionCalls = 0;
let openSftpOpts: { reuseTransport?: boolean } | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { reuseTransport?: boolean }) => {
openSftpCalls += 1;
openSftpOpts = options;
return "unexpected-cold-sftp";
},
openSftpForSession: async (sessionId: string) => {
openForSessionCalls += 1;
assert.equal(sessionId, "term-alive");
return "shared-sftp";
},
closeSftp: async () => {},
statLocal: async () => ({ size: 50, lastModified: 1 }),
startStreamTransfer: async (options: { targetSftpId?: string }) => {
assert.equal(options.targetSftpId, "shared-sftp");
return { transferId: "shared-resume" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "shared-resume",
fileName: "file.bin",
sourcePath: "/local/file.bin",
targetPath: "/remote/file.bin",
sourceConnectionId: "local",
targetConnectionId: "term-alive",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 50,
transferredBytes: 10,
checkpointBytes: 10,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
assert.equal(openForSessionCalls, 1, "must open on the live terminal transport");
assert.equal(openSftpCalls, 0, "must not cold-dial a dedicated SSH connection");
assert.equal(openSftpOpts, undefined);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("classifyDedicatedResumeEndpoints detects download, upload, and remote-to-remote", () => {
assert.deepEqual(classifyDedicatedResumeEndpoints({
direction: "download",
sourceHostId: "h1",
targetConnectionId: "local",
}), { isDownload: true, isUpload: false, isRemoteToRemote: false });
assert.deepEqual(classifyDedicatedResumeEndpoints({
direction: "upload",
targetHostId: "h1",
sourceConnectionId: "local",
}), { isDownload: false, isUpload: true, isRemoteToRemote: false });
assert.deepEqual(classifyDedicatedResumeEndpoints({
direction: "remote-to-remote",
sourceHostId: "a",
targetHostId: "b",
}), { isDownload: false, isUpload: false, isRemoteToRemote: true });
assert.deepEqual(classifyDedicatedResumeEndpoints({
direction: "download",
sourceHostId: "a",
targetHostId: "b",
sourceConnectionId: "s1",
targetConnectionId: "s2",
}), { isDownload: false, isUpload: false, isRemoteToRemote: true });
});
test("directory resume skips completed children and matches by path", () => {
const children = [
{
id: "c1",
status: "completed" as const,
sourcePath: "/remote/a.txt",
targetPath: "/local/a.txt",
checkpointBytes: 10,
transferredBytes: 10,
totalBytes: 10,
},
{
id: "c2",
status: "interrupted" as const,
sourcePath: "/remote/b.txt",
targetPath: "/local/b.txt",
checkpointBytes: 4,
transferredBytes: 4,
totalBytes: 20,
},
];
assert.equal(
shouldSkipCompletedResumeChild(findPersistedChildForResumeFile(children, {
sourcePath: "/remote/a.txt",
targetPath: "/local/a.txt",
})),
true,
);
assert.equal(
shouldSkipCompletedResumeChild(findPersistedChildForResumeFile(children, {
sourcePath: "/remote/b.txt",
targetPath: "/local/b.txt",
})),
false,
);
assert.equal(
findPersistedChildForResumeFile(children, {
sourcePath: "/remote/b.txt",
targetPath: "/local/b.txt",
})?.checkpointBytes,
4,
);
assert.equal(
findPersistedChildForResumeFile(children, {
sourcePath: "/remote/missing.txt",
targetPath: "/local/missing.txt",
}),
null,
);
// Ambiguous OR match must not win: two children share sourcePath different targets.
const ambiguous = [
{ id: "x", status: "interrupted" as const, sourcePath: "/s/a", targetPath: "/t/1", checkpointBytes: 1, transferredBytes: 1, totalBytes: 10 },
{ id: "y", status: "interrupted" as const, sourcePath: "/s/a", targetPath: "/t/2", checkpointBytes: 2, transferredBytes: 2, totalBytes: 10 },
];
assert.equal(
findPersistedChildForResumeFile(ambiguous, { sourcePath: "/s/a", targetPath: "/t/other" }),
null,
);
assert.equal(
findPersistedChildForResumeFile(ambiguous, { sourcePath: "/s/a", targetPath: "/t/2" })?.id,
"y",
);
const legacyWrongTarget = [{
id: "legacy",
status: "completed" as const,
sourcePath: "/s/only",
targetPath: "/t/folder/folder/only",
checkpointBytes: 10,
transferredBytes: 10,
totalBytes: 10,
}];
assert.equal(
findPersistedChildForResumeFile(legacyWrongTarget, {
sourcePath: "/s/only",
targetPath: "/t/folder/only",
}),
null,
"a completed child at an obsolete target must be retransferred to the corrected target",
);
assert.equal(
createPersistedResumeChildLookup(legacyWrongTarget)({
sourcePath: "/s/only",
targetPath: "/t/folder/only",
}),
null,
);
});
test("directory parent classification stays distinct from single-file downloads", () => {
const parent = {
direction: "download" as const,
sourceHostId: "h1",
targetConnectionId: "local",
isDirectory: true,
} satisfies Pick<TransferTask, "direction" | "sourceHostId" | "targetConnectionId" | "isDirectory">;
assert.equal(parent.isDirectory, true);
assert.equal(classifyDedicatedResumeEndpoints(parent).isDownload, true);
});
test("classifyResumeSourceValidationError maps size vs modified vs fatal", () => {
assert.equal(classifyResumeSourceValidationError(null).kind, "ok");
assert.equal(classifyResumeSourceValidationError("Source size changed while the transfer was paused").kind, "restart");
assert.equal(classifyResumeSourceValidationError("Saved checkpoint is beyond the current source size").kind, "restart");
assert.equal(classifyResumeSourceValidationError("Source was modified while the transfer was paused").kind, "modified");
assert.equal(classifyResumeSourceValidationError("Source is unavailable").kind, "fatal");
});
test("dedicated session open gate limits concurrent dials", async () => {
resetDedicatedSessionOpenGateForTests();
assert.equal(MAX_CONCURRENT_DEDICATED_SESSION_OPENS, 2);
let inFlight = 0;
let peak = 0;
const work = async () => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 30));
inFlight -= 1;
};
await Promise.all([
withDedicatedSessionOpenSlot(work),
withDedicatedSessionOpenSlot(work),
withDedicatedSessionOpenSlot(work),
withDedicatedSessionOpenSlot(work),
]);
assert.ok(peak <= MAX_CONCURRENT_DEDICATED_SESSION_OPENS, `peak open slots ${peak}`);
resetDedicatedSessionOpenGateForTests();
});
test("single-file restart resume continues from checkpoint without page callbacks", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let startOptions: Record<string, unknown> | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async function (options: Record<string, unknown>) {
assert.equal(arguments.length, 1);
startOptions = options;
return { transferId: "file-restart" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "file-restart",
fileName: "file.bin",
sourcePath: "/local/file.bin",
targetPath: "/remote/file.bin",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 100,
transferredBytes: 20,
checkpointBytes: 20,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
assert.equal(startOptions?.checkpointBytes, 20);
assert.equal(startOptions?.skipAdmission, true);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder restart resume reports file-count progress without child page callbacks", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const parentProgress: number[] = [];
const childUpdates: TransferTask[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => [
{ localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1 },
{ localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file", size: 20, lastModified: 2 },
],
mkdirSftp: async () => {},
statLocal: async (path: string) => ({ size: path.endsWith("a.bin") ? 10 : 20 }),
startStreamTransfer: async function (options: { transferId: string; totalBytes?: number }) {
assert.equal(arguments.length, 1);
return { transferId: options.transferId };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "folder-restart",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 2,
transferredBytes: 0,
checkpointBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
}, (value) => parentProgress.push(value.transferred), {
children: [],
onChildUpdate: (child) => childUpdates.push(child),
});
assert.equal(result.success, true, result.error);
assert.equal(parentProgress.at(-1), 2);
assert.ok(childUpdates.some((child) => child.status === "transferring"));
assert.equal(childUpdates.filter((child) => child.status === "completed").length, 2);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder upload restart honors the real local-tree root and recreates empty directories", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const createdDirectories: string[] = [];
const startedTargets: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
// Real localFsBridge contract prefixes every relativePath with rootName.
listLocalTree: async () => [
{ localPath: "/local/folder", relativePath: "folder", type: "directory", size: 0, lastModified: 1 },
{ localPath: "/local/folder/empty", relativePath: "folder/empty", type: "directory", size: 0, lastModified: 1 },
{ localPath: "/local/folder/nested", relativePath: "folder/nested", type: "directory", size: 0, lastModified: 1 },
{ localPath: "/local/folder/nested/a.bin", relativePath: "folder/nested/a.bin", type: "file", size: 10, lastModified: 2 },
],
mkdirSftp: async (_id: string, remotePath: string) => { createdDirectories.push(remotePath); },
statLocal: async () => ({ size: 10, lastModified: 2 }),
startStreamTransfer: async (options: { targetPath: string; transferId: string }) => {
startedTargets.push(options.targetPath);
return { transferId: options.transferId };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "real-local-tree-contract",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 1,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
assert.deepEqual(startedTargets, ["/remote/folder/nested/a.bin"]);
assert.ok(createdDirectories.includes("/remote/folder/empty"));
assert.ok(createdDirectories.includes("/remote/folder/nested"));
assert.equal(createdDirectories.includes("/remote/folder/folder"), false);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder download restart recreates nested empty remote directories", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const createdDirectories: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder"
? [{ name: "empty", type: "directory", size: 0 }]
: [],
mkdirLocal: async (localPath: string) => { createdDirectories.push(localPath); },
startStreamTransfer: async () => { throw new Error("empty tree must not start a file stream"); },
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "remote-empty-directory",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/folder",
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, true, result.error);
assert.ok(createdDirectories.includes("/local/folder"));
assert.ok(createdDirectories.includes("/local/folder/empty"));
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder download restart skips a symlink directory cycle by canonical path", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const listedPaths: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
realpathSftp: async () => "/remote/folder",
listSftp: async (_id: string, remotePath: string) => {
listedPaths.push(remotePath);
return [{ name: "loop", type: "symlink", linkTarget: "directory", size: 0 }];
},
mkdirLocal: async () => {},
startStreamTransfer: async () => { throw new Error("cycle must not start a stream"); },
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "remote-directory-cycle",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/folder",
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, true, result.error);
assert.deepEqual(listedPaths, ["/remote/folder"]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder download restart stops remote discovery as soon as cancellation is observed", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let cancelled = false;
const listedPaths: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => {
listedPaths.push(remotePath);
cancelled = true;
return remotePath === "/remote/folder"
? [{ name: "nested", type: "directory", size: 0 }]
: [];
},
mkdirLocal: async () => {
throw new Error("cancelled discovery must not create destination directories");
},
startStreamTransfer: async () => {
throw new Error("cancelled discovery must not start file transfers");
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "cancel-directory-discovery",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/folder",
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
shouldAbort: () => cancelled,
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /cancelled/i);
assert.deepEqual(listedPaths, ["/remote/folder"]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder download restart rejects a Windows backslash traversal entry", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let starts = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [{ name: "..\\outside.txt", type: "file", size: 10 }],
mkdirLocal: async () => {},
startStreamTransfer: async () => {
starts += 1;
return { transferId: "unsafe" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "unsafe-remote-name",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "C:\\Users\\alice\\Downloads\\folder",
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 1,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /unsafe transfer path/i);
assert.equal(starts, 0);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder restart skips 50,000 compacted completions without rebuilding child tasks", async (t) => {
const resumeStartedAt = Date.now();
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "16", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const entries = Array.from({ length: 50_000 }, (_, index) => ({
localPath: `/local/folder/file-${index.toString().padStart(5, "0")}.bin`,
relativePath: `file-${index.toString().padStart(5, "0")}.bin`,
type: "file" as const,
size: index + 1,
lastModified: index + 10,
}));
const checkpoint = createEmptyDirectoryResumeCheckpoint();
const manifest = createDirectoryManifestAccumulator(checkpoint);
for (const entry of entries) {
manifest.append(createDirectoryEntryIdentity({
sourcePath: entry.localPath,
targetPath: `/remote/folder/${entry.relativePath}`,
size: entry.size,
lastModified: entry.lastModified,
}));
checkpoint.coveredEntries += 1;
checkpoint.completedEntries += 1;
}
checkpoint.manifestHash = manifest.digest();
const originalGet = netcattyBridge.get;
let starts = 0;
let childUpdates = 0;
let eventLoopHeartbeats = 0;
const heartbeat = setInterval(() => { eventLoopHeartbeats += 1; }, 1);
t.after(() => clearInterval(heartbeat));
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => entries,
mkdirSftp: async () => {},
startStreamTransfer: async () => {
starts += 1;
return { transferId: "unexpected" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "huge-folder-restart",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 50_000,
transferredBytes: 50_000,
checkpointBytes: 50_000,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: checkpoint,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
}, undefined, {
children: [],
onChildUpdate: () => { childUpdates += 1; },
});
assert.equal(result.success, true, result.error);
assert.equal(
eventLoopHeartbeats >= 5,
true,
"50k indexing and manifest validation must yield repeatedly to the renderer event loop",
);
assert.equal(starts, 0, "validated compact completions must not retransfer");
assert.equal(childUpdates, 0, "compacted completions must not return to the task array");
assert.ok(Date.now() - resumeStartedAt < 5_000, "50k resume validation should finish within 5 seconds");
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("a validated version 1 directory checkpoint migrates to the faster manifest", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const entry = {
localPath: "/local/folder/a.bin",
relativePath: "a.bin",
type: "file" as const,
size: 10,
lastModified: 1,
};
const identity = createDirectoryEntryIdentity({
sourcePath: entry.localPath,
targetPath: "/remote/folder/a.bin",
size: entry.size,
lastModified: entry.lastModified,
});
const legacyCheckpoint = {
version: 1 as const,
coveredEntries: 1,
completedEntries: 1,
manifestHash: appendDirectoryManifestIdentity("0".repeat(64), identity),
};
const originalGet = netcattyBridge.get;
let starts = 0;
const checkpointUpdates: Array<TransferTask["directoryResumeCheckpoint"]> = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => [entry],
mkdirSftp: async () => {},
startStreamTransfer: async () => {
starts += 1;
return { transferId: "unexpected" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "legacy-folder-restart",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 1,
transferredBytes: 1,
checkpointBytes: 1,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: legacyCheckpoint,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: [],
onDirectoryCheckpointUpdate: (checkpoint) => checkpointUpdates.push(checkpoint),
});
assert.equal(result.success, true, result.error);
assert.equal(starts, 0, "a compatible legacy checkpoint must still skip completed data");
assert.equal(checkpointUpdates.length, 1);
assert.equal(checkpointUpdates[0]?.version, 2);
assert.equal(checkpointUpdates[0]?.coveredEntries, 1);
assert.equal(checkpointUpdates[0]?.completedEntries, 1);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("out-of-order compact resume transfers index 0 and skips completed index 1", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const entries = [
{ localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file" as const, size: 10, lastModified: 1 },
{ localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file" as const, size: 20, lastModified: 2 },
];
const identities = entries.map((entry) => createDirectoryEntryIdentity({
sourcePath: entry.localPath,
targetPath: `/remote/folder/${entry.relativePath}`,
size: entry.size,
lastModified: entry.lastModified,
}));
const checkpoint = createEmptyDirectoryResumeCheckpoint();
for (const identity of identities) {
checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, identity);
checkpoint.coveredEntries += 1;
}
checkpoint.completedEntries = 1;
const originalGet = netcattyBridge.get;
const startedPaths: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => entries,
mkdirSftp: async () => {},
statLocal: async (path: string) => ({ size: path.endsWith("a.bin") ? 10 : 20, lastModified: path.endsWith("a.bin") ? 1 : 2 }),
startStreamTransfer: async (options: { sourcePath: string; transferId: string }) => {
startedPaths.push(options.sourcePath);
return { transferId: options.transferId };
},
});
try {
const parent: TransferTask = {
id: "out-of-order-parent",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 2,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: checkpoint,
};
const result = await resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: [{
...parent,
id: "unfinished-index-0",
fileName: "a.bin",
sourcePath: entries[0].localPath,
targetPath: "/remote/folder/a.bin",
parentTaskId: parent.id,
isDirectory: false,
status: "interrupted",
totalBytes: 10,
directoryEntryIndex: 0,
directoryEntryIdentity: identities[0],
}],
});
assert.equal(result.success, true, result.error);
assert.deepEqual(startedPaths, ["/local/folder/a.bin"]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("changed directory manifest clears compact completion state and retransfers safely", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalEntries = [
{ localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file" as const, size: 10, lastModified: 1 },
{ localPath: "/local/folder/b.bin", relativePath: "b.bin", type: "file" as const, size: 20, lastModified: 2 },
];
const checkpoint = createEmptyDirectoryResumeCheckpoint();
for (const entry of originalEntries) {
checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, createDirectoryEntryIdentity({
sourcePath: entry.localPath,
targetPath: `/remote/folder/${entry.relativePath}`,
size: entry.size,
lastModified: entry.lastModified,
}));
checkpoint.coveredEntries += 1;
checkpoint.completedEntries += 1;
}
const changedEntries = originalEntries.map((entry, index) => index === 1
? { ...entry, lastModified: 99 }
: entry);
const originalGet = netcattyBridge.get;
const startedPaths: string[] = [];
const startedCheckpoints = new Map<string, { checkpointBytes?: number; sourceFingerprint?: string }>();
const checkpointUpdates: Array<TransferTask["directoryResumeCheckpoint"]> = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => changedEntries,
mkdirSftp: async () => {},
statLocal: async (path: string) => {
const entry = changedEntries.find((candidate) => candidate.localPath === path)!;
return { size: entry.size, lastModified: entry.lastModified };
},
startStreamTransfer: async (options: { sourcePath: string; transferId: string; checkpointBytes?: number; sourceFingerprint?: string }) => {
startedPaths.push(options.sourcePath);
startedCheckpoints.set(options.sourcePath, {
checkpointBytes: options.checkpointBytes,
sourceFingerprint: options.sourceFingerprint,
});
return { transferId: options.transferId };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "changed-parent",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/folder",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
targetHostLabel: "box",
direction: "upload",
status: "interrupted",
totalBytes: 2,
transferredBytes: 2,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: checkpoint,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: [{
id: "old-completed-b",
fileName: "b.bin",
sourcePath: "/local/folder/b.bin",
targetPath: "/remote/folder/b.bin",
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
direction: "upload",
status: "completed",
totalBytes: 20,
transferredBytes: 20,
checkpointBytes: 20,
sourceFingerprint: "sha256:old-b",
speed: 0,
startTime: 1,
endTime: 2,
isDirectory: false,
parentTaskId: "changed-parent",
directoryEntryIndex: 1,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: "/local/folder/b.bin",
targetPath: "/remote/folder/b.bin",
size: 20,
lastModified: 2,
}),
}],
onDirectoryCheckpointUpdate: (value) => checkpointUpdates.push(value),
});
assert.equal(result.success, true, result.error);
assert.deepEqual(startedPaths.sort(), changedEntries.map((entry) => entry.localPath).sort());
assert.deepEqual(startedCheckpoints.get("/local/folder/b.bin"), {
checkpointBytes: 0,
sourceFingerprint: undefined,
});
assert.deepEqual(checkpointUpdates, [undefined]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("changed replace-directory manifest rebuilds the stage before promotion", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const operations: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder"
? [{ name: "new.txt", type: "file", size: 10, lastModified: 2 }]
: [],
mkdirLocal: async (localPath: string) => { operations.push(`mkdir:${localPath}`); },
statLocal: async (localPath: string) => {
if (localPath === "/local/final.netcatty-replace-parent.part") {
return { type: "directory", size: 0, lastModified: 1 };
}
return null;
},
statSftp: async () => ({ type: "file", size: 10, lastModified: 2 }),
deleteLocalFile: async (localPath: string) => { operations.push(`delete:${localPath}`); },
renameLocalFile: async (source: string, target: string) => {
operations.push(`rename:${source}->${target}`);
if (source === "/local/final") throw new Error("ENOENT");
},
startStreamTransfer: async (options: { transferId: string }) => {
operations.push("start");
return { transferId: options.transferId };
},
});
try {
const stage = "/local/final.netcatty-replace-parent.part";
const result = await resumeTransferWithDedicatedSession({
id: "replace-parent",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/final",
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 1,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: {
version: 1,
coveredEntries: 1,
completedEntries: 1,
manifestHash: "a".repeat(64),
},
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, true, result.error);
const resetIndex = operations.indexOf(`delete:${stage}`);
const startIndex = operations.indexOf("start");
assert.ok(resetIndex >= 0, `stage was not reset: ${operations.join(", ")}`);
assert.ok(resetIndex < startIndex, `stage reset must precede transfer: ${operations.join(", ")}`);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("local replace retries transient old-directory backup cleanup failures", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const stage = "/local/final.netcatty-local-cleanup.part";
const backup = "/local/final.netcatty-local-cleanup.backup";
const updates: TransferTask[] = [];
let backupDeleteAttempts = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [],
statLocal: async (localPath: string) => localPath === stage
? { type: "directory", size: 0, lastModified: 1 }
: null,
mkdirLocal: async () => {},
deleteLocalFile: async (localPath: string) => {
if (localPath === backup) {
backupDeleteAttempts += 1;
if (backupDeleteAttempts < 3) throw new Error("backup busy");
}
},
renameLocalFile: async () => {},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "local-cleanup",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/final",
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
onChildUpdate: (update) => updates.push(update),
});
assert.equal(result.success, true, result.error);
assert.equal(backupDeleteAttempts, 3);
assert.equal(updates.at(-1)?.stagedTargetPath, undefined);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("local replace removes a stale transfer backup before promoting a rebuilt stage", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-replace-stale-backup-"));
const target = path.join(root, "final");
const stage = `${target}.netcatty-stale-backup.part`;
const backup = `${target}.netcatty-stale-backup.backup`;
await fs.promises.mkdir(target, { recursive: true });
await fs.promises.writeFile(path.join(target, "current.txt"), "current");
await fs.promises.mkdir(stage, { recursive: true });
await fs.promises.mkdir(backup, { recursive: true });
await fs.promises.writeFile(path.join(backup, "old.txt"), "old");
t.after(async () => {
await fs.promises.rm(root, { recursive: true, force: true });
});
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [],
statLocal: async (localPath: string) => {
try {
const stat = await fs.promises.stat(localPath);
return { type: stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
mkdirLocal: async (localPath: string) => { await fs.promises.mkdir(localPath, { recursive: true }); },
deleteLocalFile: async (localPath: string) => { await fs.promises.rm(localPath, { recursive: true }); },
renameLocalFile: async (source: string, destination: string) => {
await fs.promises.rename(source, destination);
},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "stale-backup",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: target,
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, true, result.error);
await assert.rejects(fs.promises.stat(backup), { code: "ENOENT" });
assert.equal((await fs.promises.stat(target)).isDirectory(), true);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("local replace restores the only backup before retrying a failed promote", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const target = "/local/final";
const stage = `${target}.netcatty-recover-backup.part`;
const backup = `${target}.netcatty-recover-backup.backup`;
const existing = new Set([stage, backup]);
const operations: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [],
statLocal: async (localPath: string) => existing.has(localPath)
? { type: "directory", size: 0, lastModified: 1 }
: null,
mkdirLocal: async (localPath: string) => { existing.add(localPath); },
deleteLocalFile: async (localPath: string) => {
operations.push(`delete:${localPath}`);
existing.delete(localPath);
},
renameLocalFile: async (source: string, destination: string) => {
operations.push(`rename:${source}->${destination}`);
if (!existing.has(source)) throw new Error(`ENOENT: ${source}`);
if (existing.has(destination)) throw new Error(`EEXIST: ${destination}`);
existing.delete(source);
existing.add(destination);
},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "recover-backup",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: target,
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, true, result.error);
const restoreIndex = operations.indexOf(`rename:${backup}->${target}`);
const firstBackupDeleteIndex = operations.indexOf(`delete:${backup}`);
assert.ok(restoreIndex >= 0, operations.join(", "));
assert.ok(
firstBackupDeleteIndex < 0 || restoreIndex < firstBackupDeleteIndex,
`the only backup must be restored before deletion: ${operations.join(", ")}`,
);
assert.equal(existing.has(target), true);
assert.equal(existing.has(backup), false);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("remote replace stays retryable when old-directory backup cleanup keeps failing", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const stage = "/remote/final.netcatty-remote-cleanup.part";
const backup = "/remote/final.netcatty-remote-cleanup.backup";
const updates: TransferTask[] = [];
let backupDeleteAttempts = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder",
relativePath: "folder",
type: "directory",
size: 0,
lastModified: 1,
}],
statSftp: async (_sftpId: string, remotePath: string) => remotePath === stage
? { type: "directory", size: 0, lastModified: 1 }
: null,
mkdirSftp: async () => {},
deleteSftp: async (_sftpId: string, remotePath: string) => {
if (remotePath === backup) {
backupDeleteAttempts += 1;
throw new Error("backup busy");
}
},
renameSftp: async () => {},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "remote-cleanup",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: "/remote/final",
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
direction: "upload",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
onChildUpdate: (update) => updates.push(update),
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /backup busy/i);
assert.equal(backupDeleteAttempts, 3);
assert.equal(
updates.some((update) => update.id === "remote-cleanup" && update.stagedTargetPath === undefined),
false,
);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("local replace does not publish the stage when backing up the target fails", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const target = "/local/final";
const stage = `${target}.netcatty-local-backup-denied.part`;
const backup = `${target}.netcatty-local-backup-denied.backup`;
const renames: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [],
statLocal: async (localPath: string) => (
localPath === target || localPath === stage
? { type: "directory", size: 0, lastModified: 1 }
: null
),
mkdirLocal: async () => {},
deleteLocalFile: async () => {},
renameLocalFile: async (source: string, destination: string) => {
renames.push(`${source}->${destination}`);
if (source === target && destination === backup) {
const error = new Error("EACCES: permission denied");
(error as NodeJS.ErrnoException).code = "EACCES";
throw error;
}
},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "local-backup-denied",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: target,
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /permission denied/i);
assert.deepEqual(renames, [`${target}->${backup}`]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("remote replace does not publish the stage when backing up the target fails", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const target = "/remote/final";
const stage = `${target}.netcatty-remote-backup-denied.part`;
const backup = `${target}.netcatty-remote-backup-denied.backup`;
const renames: string[] = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder",
relativePath: "folder",
type: "directory",
size: 0,
lastModified: 1,
}],
statSftp: async (_sftpId: string, remotePath: string) => (
remotePath === target || remotePath === stage
? { type: "directory", size: 0, lastModified: 1 }
: null
),
mkdirSftp: async () => {},
deleteSftp: async () => {},
renameSftp: async (_sftpId: string, source: string, destination: string) => {
renames.push(`${source}->${destination}`);
if (source === target && destination === backup) {
const error = new Error("EACCES: permission denied");
(error as NodeJS.ErrnoException).code = "EACCES";
throw error;
}
},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "remote-backup-denied",
fileName: "folder",
sourcePath: "/local/folder",
targetPath: target,
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "local",
targetConnectionId: "old-sftp",
targetHostId: "h1",
direction: "upload",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /permission denied/i);
assert.deepEqual(renames, [`${target}->${backup}`]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("valid file checkpoint still rebuilds a replace stage so deleted empty directories cannot survive", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
const stage = "/local/final.netcatty-exact-replace.part";
const entry = { name: "kept.txt", type: "file", size: 10, lastModified: 2 } as const;
const identity = createDirectoryEntryIdentity({
sourcePath: "/remote/folder/kept.txt",
targetPath: `${stage}/kept.txt`,
size: 10,
lastModified: 2,
});
const checkpoint = createEmptyDirectoryResumeCheckpoint();
checkpoint.manifestHash = appendDirectoryCheckpointIdentity(checkpoint, identity);
checkpoint.coveredEntries = 1;
checkpoint.completedEntries = 1;
const operations: string[] = [];
const checkpointUpdates: Array<TransferTask["directoryResumeCheckpoint"]> = [];
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => remotePath === "/remote/folder" ? [entry] : [],
mkdirLocal: async () => {},
statLocal: async () => ({ type: "directory", size: 0, lastModified: 1 }),
statSftp: async () => ({ type: "file", size: 10, lastModified: 2 }),
deleteLocalFile: async () => { operations.push("reset-stage"); },
renameLocalFile: async (source: string) => {
if (source === "/local/final") throw new Error("ENOENT");
},
startStreamTransfer: async (options: { transferId: string }) => {
operations.push("retransfer-file");
return { transferId: options.transferId };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "exact-replace",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/final",
stagedTargetPath: stage,
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 1,
transferredBytes: 1,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
directoryResumeCheckpoint: checkpoint,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
onDirectoryCheckpointUpdate: (value) => checkpointUpdates.push(value),
});
assert.equal(result.success, true, result.error);
assert.deepEqual(operations.slice(0, 2), ["reset-stage", "retransfer-file"]);
assert.deepEqual(checkpointUpdates, [undefined]);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("corrupted replace-stage history cannot delete an unrelated directory", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let deletes = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
listSftp: async () => [],
statLocal: async () => ({ type: "directory", size: 0, lastModified: 1 }),
deleteLocalFile: async () => { deletes += 1; },
mkdirLocal: async () => {},
startStreamTransfer: async () => ({ transferId: "unexpected" }),
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "safe-id",
fileName: "folder",
sourcePath: "/remote/folder",
targetPath: "/local/final",
stagedTargetPath: "/local/unrelated-important-directory",
replaceExistingTarget: true,
sourceConnectionId: "old-sftp",
targetConnectionId: "local",
sourceHostId: "h1",
sourceHostLabel: "box",
direction: "download",
status: "interrupted",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
});
assert.equal(result.success, false);
assert.match(result.error ?? "", /unsafe replacement stage path/i);
assert.equal(deletes, 0);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const opened: string[] = [];
let uploads = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
});
const hosts = scenario === "deleted-id"
? [host("different-id", "production", "different.example")]
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
const result = await resumeTransferWithDedicatedSession({
id: "wrong-host-resume", fileName: "deployment.zip",
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
sourceConnectionId: "local", targetConnectionId: "right-expired",
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
targetHostLabel: "production", direction: "upload", status: "interrupted",
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
}, {hosts, keys: [], identities: []});
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
assert.equal(uploads, 0);
});
}