mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ccb2bc3b |
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.
|
||||
Reference in new issue
Block a user