Compare commits

...
Author SHA1 Message Date
bincxz 89ccb2bc3b fix(sftp): refuse ambiguous or replaced resume endpoints 2026-09-06 11:38:52 +08:00
3 changed files with 80 additions and 8 deletions

No files matched your search

@@ -97,10 +97,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint prefers id then label", () => {
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")?.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);
});
@@ -1998,3 +1999,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
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);
});
}
@@ -95,8 +95,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
}
/**
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
* label/hostname when the id is stale after vault edits or older task records.
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
@@ -104,16 +104,16 @@ export function resolveHostForTransferEndpoint(
hostLabel?: string,
): Host | null {
if (hostId) {
const byId = hosts.find((host) => host.id === hostId);
if (byId) return byId;
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
return hosts.find((host) => {
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
}) ?? null;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
@@ -0,0 +1,41 @@
# SFTP restart recovery must not guess another server
Audit baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
## Confirmed defect
`resolveHostForTransferEndpoint` used a display-name/hostname search when a
recorded host id no longer existed, and returned the first match when multiple
hosts shared a name. Dedicated resume used those credentials to open the target
and start uploading. A saved task for a deleted server could therefore send
its source file to an unrelated same-named server. Both endpoint directions and
folder recovery use this resolver.
Two regressions invoke `resumeTransferWithDedicatedSession`, with the real
resolver, scheduler, credential construction, and resume path but instrumented
bridge I/O. Before the fix, both selected an unrelated endpoint and called
`startStreamTransfer`: a deleted recorded id with one same-named replacement,
and a legacy task without an id with two same-named hosts. Both now stop before
opening or uploading.
## Fix contract
- A recorded host id is authoritative; absence must not fall back to a name.
- Legacy tasks without an id may resolve a name only if exactly one host matches.
- Preserve exact-id recovery, unique-name legacy recovery, and a still-live
original session when vault credentials are absent.
- Never select an arbitrary member of an ambiguous name match.
This does not freeze future edits to the connection options of an existing host
id; a persisted immutable endpoint snapshot would be a separate schema and
migration change. It also does not claim to explain all historical restart
failures from issue #3213 or #2638.
## Verification
`node --test --import tsx application/state/sftp/dedicatedTransferResume.test.ts`:
39 passed, including both regressions and existing single-file/folder restart,
remote-to-remote, source-validation, and live-session fallback tests.
Broader SFTP audit remains in progress in the sibling `codex/sftp-transfer-audit`
worktree; its first control-ordering fix is PR #3284.