13011 lines
448 KiB
JavaScript
13011 lines
448 KiB
JavaScript
|
|
const test = require("node:test");
|
|||
|
|
const assert = require("node:assert/strict");
|
|||
|
|
const fs = require("node:fs");
|
|||
|
|
const os = require("node:os");
|
|||
|
|
const path = require("node:path");
|
|||
|
|
const crypto = require("node:crypto");
|
|||
|
|
const Module = require("node:module");
|
|||
|
|
const { spawnSync } = require("node:child_process");
|
|||
|
|
const { EventEmitter } = require("node:events");
|
|||
|
|
const { PassThrough, Readable, Writable } = require("node:stream");
|
|||
|
|
|
|||
|
|
const transferBridge = require("./transferBridge.cjs");
|
|||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
|||
|
|
const tempDirBridge = require("./tempDirBridge.cjs");
|
|||
|
|
const {
|
|||
|
|
DOWNLOAD_TRANSFER_CONCURRENCY,
|
|||
|
|
TRANSFER_CHUNK_SIZE,
|
|||
|
|
UPLOAD_TRANSFER_CONCURRENCY,
|
|||
|
|
} = require("./transferLimits.cjs");
|
|||
|
|
|
|||
|
|
function createSender() {
|
|||
|
|
return {
|
|||
|
|
sent: [],
|
|||
|
|
send(channel, payload) {
|
|||
|
|
this.sent.push({ channel, payload });
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function waitUntil(predicate, timeoutMs = 1000) {
|
|||
|
|
const deadline = Date.now() + timeoutMs;
|
|||
|
|
while (!predicate() && Date.now() < deadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
return predicate();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createFastSftp(overrides) {
|
|||
|
|
const sftp = new EventEmitter();
|
|||
|
|
sftp.readdir = (_path, callback) => callback(null, []);
|
|||
|
|
sftp.stat = (_path, callback) => callback(null, { size: 1024 * 1024 });
|
|||
|
|
sftp.lstat = (_path, callback) => {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
};
|
|||
|
|
sftp.mkdir = (_path, callback) => callback(null);
|
|||
|
|
sftp.unlink = (_path, callback) => callback(null);
|
|||
|
|
sftp.end = () => {};
|
|||
|
|
Object.assign(sftp, overrides);
|
|||
|
|
return sftp;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Pipelined READ mock for bulk SFTP downloads. Bulk transfer no longer has a
|
|||
|
|
* serial createReadStream path — tests must exercise open/read fanout.
|
|||
|
|
*
|
|||
|
|
* @param {Buffer | (() => Buffer)} payloadOrFactory
|
|||
|
|
* @param {object} [overrides]
|
|||
|
|
* @param {{ stall?: boolean }} [options]
|
|||
|
|
* stall — hold READ callbacks in `pendingReads` until releasePending() is called
|
|||
|
|
*/
|
|||
|
|
function createPipelinedDownloadSftp(payloadOrFactory, overrides = {}, options = {}) {
|
|||
|
|
const pendingReads = [];
|
|||
|
|
const getPayload = typeof payloadOrFactory === "function"
|
|||
|
|
? payloadOrFactory
|
|||
|
|
: () => payloadOrFactory;
|
|||
|
|
const allowFullFileHashStream = options.allowFullFileHashStream === true;
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
if (typeof flags === "function") {
|
|||
|
|
// Some call sites pass (path, callback) without flags.
|
|||
|
|
flags(null, Buffer.from("read-handle"));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, Buffer.from("read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const payload = getPayload() || Buffer.alloc(0);
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
const deliver = () => {
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
};
|
|||
|
|
if (options.stall) {
|
|||
|
|
pendingReads.push(deliver);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImmediate(deliver);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
// Hash / sample verification only (bounded start/end). Reject unbounded
|
|||
|
|
// opens so a reintroduced bulk createReadStream body path cannot green
|
|||
|
|
// success tests by streaming the full file.
|
|||
|
|
createReadStream(_remotePath, streamOptions = {}) {
|
|||
|
|
const start = streamOptions?.start;
|
|||
|
|
const end = streamOptions?.end;
|
|||
|
|
if (!allowFullFileHashStream && (!Number.isFinite(start) || !Number.isFinite(end))) {
|
|||
|
|
throw new Error("bulk createReadStream must not be used for SFTP download body");
|
|||
|
|
}
|
|||
|
|
const payload = getPayload() || Buffer.alloc(0);
|
|||
|
|
const from = Number.isFinite(start) ? start : 0;
|
|||
|
|
const to = Number.isFinite(end) ? end : Math.max(0, payload.length - 1);
|
|||
|
|
return Readable.from([Buffer.from(payload.subarray(from, to + 1))]);
|
|||
|
|
},
|
|||
|
|
...overrides,
|
|||
|
|
});
|
|||
|
|
return {
|
|||
|
|
sftp,
|
|||
|
|
pendingReads,
|
|||
|
|
releasePending() {
|
|||
|
|
for (const deliver of pendingReads.splice(0)) {
|
|||
|
|
try { deliver(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test("SFTP upload ignores cancellation after remote promotion is committed", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-commit-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.from("payload");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/payload.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old payload")]]);
|
|||
|
|
|
|||
|
|
let releaseRename;
|
|||
|
|
let markRenameStarted;
|
|||
|
|
const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; });
|
|||
|
|
let directRenameAttempted = false;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(payload.length));
|
|||
|
|
callback(null, Buffer.from(key));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
buffer.copy(remoteFiles.get(key), position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
stat: async (remotePath) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length, isDirectory: false };
|
|||
|
|
},
|
|||
|
|
chmod: async () => {},
|
|||
|
|
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
const from = String(fromPath);
|
|||
|
|
const to = String(toPath);
|
|||
|
|
if (from.includes(".netcatty-upload-") && to === targetPath && !directRenameAttempted) {
|
|||
|
|
directRenameAttempted = true;
|
|||
|
|
throw new Error("atomic replace unavailable");
|
|||
|
|
}
|
|||
|
|
if (from.includes(".netcatty-upload-") && to === targetPath) {
|
|||
|
|
markRenameStarted();
|
|||
|
|
await new Promise((resolve) => { releaseRename = resolve; });
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
|||
|
|
remoteFiles.delete(from);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "sftp-commit-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await renameStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseRename();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("replace SFTP upload restores pre-existing remote mode after the final path is published", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-replace-mode-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "tool");
|
|||
|
|
const payload = Buffer.from("#!/bin/sh\necho hi\n");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/usr/local/bin/tool";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old-tool")]]);
|
|||
|
|
const remoteMeta = new Map([[targetPath, { mode: 0o100755 }]]);
|
|||
|
|
const chmodCalls = [];
|
|||
|
|
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(payload.length));
|
|||
|
|
if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 });
|
|||
|
|
callback(null, Buffer.from(key));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
buffer.copy(remoteFiles.get(key), position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
stat: async (remotePath) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: false,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
chmod: async (remotePath, mode) => {
|
|||
|
|
chmodCalls.push({ path: String(remotePath), mode });
|
|||
|
|
const prev = remoteMeta.get(String(remotePath)) || {};
|
|||
|
|
remoteMeta.set(String(remotePath), { ...prev, mode: (mode & 0o7777) | 0o100000 });
|
|||
|
|
},
|
|||
|
|
delete: async (remotePath) => {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
remoteMeta.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
const from = String(fromPath);
|
|||
|
|
const to = String(toPath);
|
|||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
|||
|
|
remoteFiles.delete(from);
|
|||
|
|
// Simulate servers that recreate the destination inode on rename-replace
|
|||
|
|
// with umask defaults, dropping the mode restored on the `.part` stage.
|
|||
|
|
remoteMeta.set(to, { mode: 0o100644 });
|
|||
|
|
remoteMeta.delete(from);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "sftp-replace-preserve-mode",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.ok(
|
|||
|
|
chmodCalls.some((call) => call.path === targetPath && (call.mode & 0o7777) === 0o755),
|
|||
|
|
`expected chmod of ${targetPath} to 0755 after replace, got ${JSON.stringify(chmodCalls)}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(remoteMeta.get(targetPath)?.mode & 0o777, 0o755);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("new SFTP upload does not chmod a path that did not already exist", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-new-mode-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "fresh.bin");
|
|||
|
|
const payload = Buffer.from("new-bytes");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/fresh.bin";
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const remoteMeta = new Map();
|
|||
|
|
const chmodCalls = [];
|
|||
|
|
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(payload.length));
|
|||
|
|
if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 });
|
|||
|
|
callback(null, Buffer.from(key));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
buffer.copy(remoteFiles.get(key), position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
stat: async (remotePath) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: false,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
chmod: async (remotePath, mode) => {
|
|||
|
|
chmodCalls.push({ path: String(remotePath), mode });
|
|||
|
|
},
|
|||
|
|
delete: async (remotePath) => {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
remoteMeta.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
const from = String(fromPath);
|
|||
|
|
const to = String(toPath);
|
|||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
|||
|
|
remoteFiles.delete(from);
|
|||
|
|
remoteMeta.set(to, remoteMeta.get(from) || { mode: 0o100644 });
|
|||
|
|
remoteMeta.delete(from);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "sftp-new-keeps-default-mode",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.deepEqual(chmodCalls, []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("replace SFTP upload still succeeds when restoring remote mode fails", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-replace-chmod-fail-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "tool");
|
|||
|
|
const payload = Buffer.from("replaced-bytes");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/usr/local/bin/tool";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old-tool")]]);
|
|||
|
|
const remoteMeta = new Map([[targetPath, { mode: 0o100755 }]]);
|
|||
|
|
const chmodCalls = [];
|
|||
|
|
const warnings = [];
|
|||
|
|
const originalWarn = console.warn;
|
|||
|
|
console.warn = (...args) => { warnings.push(args.map(String).join(" ")); };
|
|||
|
|
t.after(() => { console.warn = originalWarn; });
|
|||
|
|
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(payload.length));
|
|||
|
|
if (!remoteMeta.has(key)) remoteMeta.set(key, { mode: 0o100644 });
|
|||
|
|
callback(null, Buffer.from(key));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
buffer.copy(remoteFiles.get(key), position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
stat: async (remotePath) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: remoteMeta.get(key)?.mode ?? 0o100644,
|
|||
|
|
isDirectory: false,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
chmod: async (remotePath, mode) => {
|
|||
|
|
chmodCalls.push({ path: String(remotePath), mode });
|
|||
|
|
if (String(remotePath) === targetPath) {
|
|||
|
|
throw new Error("chmod failed");
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
delete: async (remotePath) => {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
remoteMeta.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
const from = String(fromPath);
|
|||
|
|
const to = String(toPath);
|
|||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
|||
|
|
remoteFiles.delete(from);
|
|||
|
|
remoteMeta.set(to, remoteMeta.get(from) || { mode: 0o100644 });
|
|||
|
|
remoteMeta.delete(from);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "sftp-replace-chmod-fail",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.ok(
|
|||
|
|
chmodCalls.some((call) => call.path === targetPath && (call.mode & 0o7777) === 0o755),
|
|||
|
|
`expected chmod of ${targetPath} to 0755, got ${JSON.stringify(chmodCalls)}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
warnings.some((message) => /Failed to restore permissions/.test(message)),
|
|||
|
|
`expected chmod warning, got ${JSON.stringify(warnings)}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SCP upload ignores cancellation after remote promotion is committed", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-commit-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.from("payload");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/payload.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old payload")]]);
|
|||
|
|
|
|||
|
|
let releaseRename;
|
|||
|
|
let markRenameStarted;
|
|||
|
|
const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; });
|
|||
|
|
const backend = {
|
|||
|
|
async mkdir() {},
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
|
|||
|
|
},
|
|||
|
|
async uploadFile(localSourcePath, remotePath, options = {}) {
|
|||
|
|
remoteFiles.set(remotePath, await fs.promises.readFile(localSourcePath));
|
|||
|
|
options.onProgress?.(payload.length, payload.length);
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
if (fromPath === targetPath) {
|
|||
|
|
markRenameStarted();
|
|||
|
|
await new Promise((resolve) => { releaseRename = resolve; });
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(toPath, remoteFiles.get(fromPath));
|
|||
|
|
remoteFiles.delete(fromPath);
|
|||
|
|
},
|
|||
|
|
async remove(remotePath) { remoteFiles.delete(remotePath); },
|
|||
|
|
async chmod() {},
|
|||
|
|
};
|
|||
|
|
const client = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: backend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "scp-commit-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await renameStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseRename();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SCP upload streams the live local path without a pre-hash digest", async (t) => {
|
|||
|
|
// Industry consensus (FileZilla / WinSCP / OpenSSH): stream bytes immediately.
|
|||
|
|
// Content digests are not a precondition for SCP body transfer.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-direct-stream-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 57);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/payload.bin";
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
let uploadSourcePath = null;
|
|||
|
|
const backend = {
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
|
|||
|
|
},
|
|||
|
|
async uploadFile(sourcePath, remotePath, options) {
|
|||
|
|
uploadSourcePath = sourcePath;
|
|||
|
|
// May still receive a plain file stream helper; must not require a digest.
|
|||
|
|
if (typeof options.openReadStream === "function") {
|
|||
|
|
const opened = options.openReadStream();
|
|||
|
|
const chunks = [];
|
|||
|
|
for await (const chunk of opened.stream) chunks.push(chunk);
|
|||
|
|
remoteFiles.set(remotePath, Buffer.concat(chunks));
|
|||
|
|
} else {
|
|||
|
|
remoteFiles.set(remotePath, await fs.promises.readFile(sourcePath));
|
|||
|
|
}
|
|||
|
|
options.onProgress?.(payload.length, payload.length);
|
|||
|
|
},
|
|||
|
|
async remove() {},
|
|||
|
|
async rename() {},
|
|||
|
|
async chmod() {},
|
|||
|
|
};
|
|||
|
|
const client = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: backend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "scp-direct-stream",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(uploadSourcePath, localPath);
|
|||
|
|
// Staging may write to a .part path then rename; accept any staged body.
|
|||
|
|
const uploaded = [...remoteFiles.values()].find((buf) => Buffer.isBuffer(buf) && buf.length === payload.length);
|
|||
|
|
assert.ok(uploaded, "expected SCP body to be written to a remote path");
|
|||
|
|
assert.deepEqual(uploaded, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SCP staged streaming preserves executable mode without a local snapshot", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-executable-mode-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "tool.sh");
|
|||
|
|
const payload = Buffer.from("#!/bin/sh\necho ok\n");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
await fs.promises.chmod(localPath, 0o755);
|
|||
|
|
const targetPath = "/usr/local/bin/tool.sh";
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
let uploadedMode = null;
|
|||
|
|
let uploadedSourcePath = null;
|
|||
|
|
const backend = {
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
|
|||
|
|
},
|
|||
|
|
async uploadFile(sourcePath, remotePath, options) {
|
|||
|
|
uploadedSourcePath = sourcePath;
|
|||
|
|
uploadedMode = (await fs.promises.stat(sourcePath)).mode & 0o777;
|
|||
|
|
assert.equal(options.fileSize, payload.length);
|
|||
|
|
const opened = options.openReadStream();
|
|||
|
|
const chunks = [];
|
|||
|
|
for await (const chunk of opened.stream) chunks.push(chunk);
|
|||
|
|
remoteFiles.set(remotePath, Buffer.concat(chunks));
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
remoteFiles.set(toPath, remoteFiles.get(fromPath));
|
|||
|
|
remoteFiles.delete(fromPath);
|
|||
|
|
},
|
|||
|
|
async remove(remotePath) {
|
|||
|
|
remoteFiles.delete(remotePath);
|
|||
|
|
},
|
|||
|
|
async chmod() {},
|
|||
|
|
};
|
|||
|
|
const client = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: backend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "scp-executable-mode",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(uploadedSourcePath, localPath);
|
|||
|
|
assert.equal(uploadedMode, 0o755);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
const digestId = crypto.createHash("sha256")
|
|||
|
|
.update("scp-executable-mode")
|
|||
|
|
.digest("hex")
|
|||
|
|
.slice(0, 16);
|
|||
|
|
const snapshotPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-source-${digestId}`,
|
|||
|
|
"snapshot.bin",
|
|||
|
|
);
|
|||
|
|
assert.equal(fs.existsSync(snapshotPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SCP staged upload rechecks a deleted destination after mode setup", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-mode-target-race-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.from("uploaded-data");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/payload.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old-state")]]);
|
|||
|
|
const remoteMeta = new Map([[targetPath, { mode: 0o100755, modifyTime: 1 }]]);
|
|||
|
|
let renameCalls = 0;
|
|||
|
|
const backend = {
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
const meta = remoteMeta.get(remotePath) || { mode: 0o100644, modifyTime: 0 };
|
|||
|
|
return {
|
|||
|
|
type: "file",
|
|||
|
|
isDirectory: false,
|
|||
|
|
size: remoteFiles.get(remotePath).length,
|
|||
|
|
mode: meta.mode,
|
|||
|
|
permissions: "rwxr-xr-x",
|
|||
|
|
modifyTime: meta.modifyTime,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
async uploadFile(_sourcePath, remotePath, options) {
|
|||
|
|
const opened = options.openReadStream();
|
|||
|
|
const chunks = [];
|
|||
|
|
for await (const chunk of opened.stream) chunks.push(chunk);
|
|||
|
|
remoteFiles.set(remotePath, Buffer.concat(chunks));
|
|||
|
|
remoteMeta.set(remotePath, { mode: 0o100644, modifyTime: 0 });
|
|||
|
|
},
|
|||
|
|
async chmod(remotePath) {
|
|||
|
|
if (remotePath.includes(".netcatty-upload-")) {
|
|||
|
|
remoteFiles.delete(targetPath);
|
|||
|
|
remoteMeta.delete(targetPath);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
renameCalls += 1;
|
|||
|
|
remoteFiles.set(toPath, remoteFiles.get(fromPath));
|
|||
|
|
remoteFiles.delete(fromPath);
|
|||
|
|
},
|
|||
|
|
async remove(remotePath) {
|
|||
|
|
remoteFiles.delete(remotePath);
|
|||
|
|
remoteMeta.delete(remotePath);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const client = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: backend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "scp-mode-target-race",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /destination disappeared during upload/i);
|
|||
|
|
assert.equal(renameCalls, 0);
|
|||
|
|
assert.equal(remoteFiles.has(targetPath), false);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SCP staged upload does not promote when AbortSignal cancels mid-stream", async (t) => {
|
|||
|
|
// Size-based path has no final digest re-scan. Cancel during the body stream
|
|||
|
|
// must still prevent rename to the final target.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-midstream-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 91);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/payload.bin";
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
let renameCalls = 0;
|
|||
|
|
let releaseBodyRead;
|
|||
|
|
let markBodyStarted;
|
|||
|
|
const bodyStarted = new Promise((resolve) => { markBodyStarted = resolve; });
|
|||
|
|
const backend = {
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
|
|||
|
|
},
|
|||
|
|
async uploadFile(_sourcePath, remotePath, options) {
|
|||
|
|
const opened = options.openReadStream();
|
|||
|
|
const chunks = [];
|
|||
|
|
let first = true;
|
|||
|
|
for await (const chunk of opened.stream) {
|
|||
|
|
if (first) {
|
|||
|
|
first = false;
|
|||
|
|
markBodyStarted();
|
|||
|
|
await new Promise((resolve) => { releaseBodyRead = resolve; });
|
|||
|
|
}
|
|||
|
|
chunks.push(chunk);
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(remotePath, Buffer.concat(chunks));
|
|||
|
|
},
|
|||
|
|
async rename(fromPath, toPath) {
|
|||
|
|
renameCalls += 1;
|
|||
|
|
remoteFiles.set(toPath, remoteFiles.get(fromPath));
|
|||
|
|
remoteFiles.delete(fromPath);
|
|||
|
|
},
|
|||
|
|
async remove(remotePath) {
|
|||
|
|
remoteFiles.delete(remotePath);
|
|||
|
|
},
|
|||
|
|
async chmod() {},
|
|||
|
|
};
|
|||
|
|
const client = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: backend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const transferId = "scp-midstream-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
abortSignal: controller.signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await bodyStarted;
|
|||
|
|
controller.abort();
|
|||
|
|
releaseBodyRead();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(renameCalls, 0);
|
|||
|
|
assert.equal(remoteFiles.has(targetPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("in-place upload ignores cancellation during final size verification", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-in-place-commit-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.from("payload");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/link.bin";
|
|||
|
|
let remote = Buffer.from("old payload");
|
|||
|
|
let statCalls = 0;
|
|||
|
|
let releaseFinalStat;
|
|||
|
|
let markFinalStatStarted;
|
|||
|
|
const finalStatStarted = new Promise((resolve) => { markFinalStatStarted = resolve; });
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
if (String(remotePath).includes(".netcatty-backup-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remote.length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
remote = Buffer.alloc(payload.length);
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
buffer.copy(remote, position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (String(remotePath).includes(".netcatty-backup-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
statCalls += 1;
|
|||
|
|
// Hang on the post-upload size check (after OPEN replaced the payload),
|
|||
|
|
// not on an earlier existence/mode probe.
|
|||
|
|
if (remote.length === payload.length && !releaseFinalStat) {
|
|||
|
|
markFinalStatStarted();
|
|||
|
|
await new Promise((resolve) => { releaseFinalStat = resolve; });
|
|||
|
|
}
|
|||
|
|
return { size: remote.length };
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "in-place-commit-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await finalStatStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseFinalStat();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.deepEqual(remote, payload);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:progress"
|
|||
|
|
&& entry.payload.transferred === payload.length
|
|||
|
|
&& entry.payload.totalBytes === payload.length
|
|||
|
|
)), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("in-place concurrent upload ignores cancellation while the remote handle closes", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-in-place-close-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.from("payload");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let remote = Buffer.from("old payload");
|
|||
|
|
let releaseClose;
|
|||
|
|
let markCloseStarted;
|
|||
|
|
const closeStarted = new Promise((resolve) => { markCloseStarted = resolve; });
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
if (String(remotePath).includes(".netcatty-backup-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remote.length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
remote = Buffer.alloc(payload.length);
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
buffer.copy(remote, position, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
markCloseStarted();
|
|||
|
|
releaseClose = () => callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: fastSftp,
|
|||
|
|
stat: async (remotePath) => {
|
|||
|
|
if (String(remotePath).includes(".netcatty-backup-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remote.length };
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "in-place-close-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/link.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await closeStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseClose();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.deepEqual(remote, payload);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP download cancellation settles while initial metadata is stalled", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-download-stat-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
let markStatStarted;
|
|||
|
|
const statStarted = new Promise((resolve) => { markStatStarted = resolve; });
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
markStatStarted();
|
|||
|
|
return new Promise(() => {});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-stat-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath: path.join(tempDir, "target.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
resumable: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await statStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP download cancellation settles while source snapshot metadata is stalled", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-download-snapshot-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
let statCalls = 0;
|
|||
|
|
let markSnapshotStarted;
|
|||
|
|
const snapshotStarted = new Promise((resolve) => { markSnapshotStarted = resolve; });
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
statCalls += 1;
|
|||
|
|
if (statCalls === 1) return Promise.resolve({ size: 1024 });
|
|||
|
|
markSnapshotStarted();
|
|||
|
|
return new Promise(() => {});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-snapshot-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath: path.join(tempDir, "target.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
resumable: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await snapshotStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.equal(statCalls, 2);
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP upload cancellation settles while remote directory setup is stalled", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-dir-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "source.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.from("payload"));
|
|||
|
|
let releaseStat;
|
|||
|
|
let statCalls = 0;
|
|||
|
|
let markStatStarted;
|
|||
|
|
const statStarted = new Promise((resolve) => { markStatStarted = resolve; });
|
|||
|
|
const createdDirectories = [];
|
|||
|
|
const stalledSftp = createFastSftp({
|
|||
|
|
stat(_remotePath, callback) {
|
|||
|
|
statCalls += 1;
|
|||
|
|
if (statCalls > 1) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
releaseStat = () => {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
};
|
|||
|
|
markStatStarted();
|
|||
|
|
},
|
|||
|
|
mkdir(remotePath, callback) {
|
|||
|
|
createdDirectories.push(String(remotePath));
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: stalledSftp,
|
|||
|
|
};
|
|||
|
|
const clients = new Map([["target", client]]);
|
|||
|
|
sftpBridge.init({ sftpClients: clients, sessions: new Map(), electronModule: {} });
|
|||
|
|
t.after(() => {
|
|||
|
|
sftpBridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: clients });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-directory-cancel";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/stalled/directory/target.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await statStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
assert.deepEqual(createdDirectories, []);
|
|||
|
|
releaseStat();
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|||
|
|
assert.deepEqual(createdDirectories, []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP upload cancellation waits for an in-flight remote mkdir to settle", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-mkdir-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "source.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.from("payload"));
|
|||
|
|
let releaseMkdir;
|
|||
|
|
let markMkdirStarted;
|
|||
|
|
const mkdirStarted = new Promise((resolve) => { markMkdirStarted = resolve; });
|
|||
|
|
const createdDirectories = [];
|
|||
|
|
const stalledSftp = createFastSftp({
|
|||
|
|
stat(_remotePath, callback) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
mkdir(remotePath, callback) {
|
|||
|
|
const directory = String(remotePath);
|
|||
|
|
markMkdirStarted();
|
|||
|
|
releaseMkdir = () => {
|
|||
|
|
createdDirectories.push(directory);
|
|||
|
|
callback(null);
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = { sftp: stalledSftp };
|
|||
|
|
const clients = new Map([["target", client]]);
|
|||
|
|
sftpBridge.init({ sftpClients: clients, sessions: new Map(), electronModule: {} });
|
|||
|
|
t.after(() => {
|
|||
|
|
sftpBridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: clients });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-mkdir-cancel";
|
|||
|
|
let settled = false;
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/stalled/directory/target.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
resumable: false,
|
|||
|
|
}).finally(() => { settled = true; });
|
|||
|
|
|
|||
|
|
await mkdirStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|||
|
|
assert.equal(settled, false);
|
|||
|
|
assert.deepEqual(createdDirectories, []);
|
|||
|
|
|
|||
|
|
releaseMkdir();
|
|||
|
|
const result = await running;
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.deepEqual(createdDirectories, ["/stalled"]);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancelling one SFTP channel waiter does not cancel the shared reopen", async () => {
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let releaseChannel;
|
|||
|
|
const reopenedChannel = createFastSftp({});
|
|||
|
|
const client = {
|
|||
|
|
sftp: null,
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
openCalls += 1;
|
|||
|
|
releaseChannel = () => callback(null, reopenedChannel);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const firstController = new AbortController();
|
|||
|
|
const secondController = new AbortController();
|
|||
|
|
const firstWait = sftpBridge.requireSftpChannel(client, { signal: firstController.signal });
|
|||
|
|
const secondWait = sftpBridge.requireSftpChannel(client, { signal: secondController.signal });
|
|||
|
|
|
|||
|
|
firstController.abort(new Error("first waiter cancelled"));
|
|||
|
|
await assert.rejects(() => firstWait, /first waiter cancelled/i);
|
|||
|
|
assert.equal(openCalls, 1);
|
|||
|
|
releaseChannel();
|
|||
|
|
|
|||
|
|
assert.equal(await secondWait, reopenedChannel);
|
|||
|
|
assert.equal(client.sftp, reopenedChannel);
|
|||
|
|
assert.equal(openCalls, 1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared SFTP channel reopen preserves the requested timeout", async () => {
|
|||
|
|
const client = {
|
|||
|
|
sftp: null,
|
|||
|
|
client: {
|
|||
|
|
sftp() {},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
() => sftpBridge.requireSftpChannel(client, { timeoutMs: 10 }),
|
|||
|
|
/session lost/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.ok(Date.now() - startedAt < 500);
|
|||
|
|
assert.equal(client._reopeningPromise, null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("server-to-server resume cancellation settles while source identity verification is stalled", async (t) => {
|
|||
|
|
const transferId = `server-prefix-cancel-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = "/source/payload.bin";
|
|||
|
|
const tempPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath));
|
|||
|
|
await fs.promises.writeFile(tempPath, Buffer.from("abcd"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempPath, { force: true }));
|
|||
|
|
let markPrefixStarted;
|
|||
|
|
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
|
|||
|
|
const sourceClient = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
markPrefixStarted();
|
|||
|
|
return new PassThrough();
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
};
|
|||
|
|
const targetClient = { sftp: createFastSftp({}) };
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["source", sourceClient], ["target", targetClient]]),
|
|||
|
|
});
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath: "/target/payload.bin",
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: 8,
|
|||
|
|
resumable: true,
|
|||
|
|
sameHost: false,
|
|||
|
|
resumeStage: "download",
|
|||
|
|
checkpointBytes: 4,
|
|||
|
|
downloadCheckpointBytes: 4,
|
|||
|
|
sourceFingerprint: `sha256:${"0".repeat(64)}`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await prefixStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable SFTP uploads use the configured per-file request concurrency", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Need more than one fanout wave so the concurrency cap is observable.
|
|||
|
|
const fileSize = UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2;
|
|||
|
|
const localPath = path.join(tempDir, "large.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(fileSize));
|
|||
|
|
|
|||
|
|
let activeWrites = 0;
|
|||
|
|
let observedConcurrency = 0;
|
|||
|
|
let observedChunkSize = 0;
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
let holdWrites = true;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
activeWrites += 1;
|
|||
|
|
observedConcurrency = Math.max(observedConcurrency, activeWrites);
|
|||
|
|
observedChunkSize = Math.max(observedChunkSize, length);
|
|||
|
|
if (holdWrites) {
|
|||
|
|
pendingWrites.push(() => {
|
|||
|
|
activeWrites -= 1;
|
|||
|
|
callback(null);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeWrites -= 1;
|
|||
|
|
callback(null);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: fileSize });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-large",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/large.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
assert.equal(observedConcurrency, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
assert.equal(observedChunkSize, TRANSFER_CHUNK_SIZE);
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const complete of pendingWrites.splice(0)) complete();
|
|||
|
|
const result = await running;
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fast resumable uploads pause only after in-flight ranges are durable", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fast-upload-pause-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// First in-flight wave must be smaller than the file so pause can land mid-transfer.
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 13);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
let holdWrites = true;
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
const complete = () => {
|
|||
|
|
durableBytes = Math.max(durableBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
};
|
|||
|
|
if (holdWrites) pendingWrites.push(complete);
|
|||
|
|
else setImmediate(complete);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: durableBytes });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-fast-paused",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "upload-fast-paused" });
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const complete of pendingWrites.splice(0)) complete();
|
|||
|
|
const paused = await pausing;
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
assert.equal(paused.checkpointBytes, UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "upload-fast-paused" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
assert.equal(durableBytes, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("pause soft-drains concurrent ranges but resume waits before truncating", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pause-soft-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Several MB so concurrent fanout stays saturated while we hold writes.
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let holdWrites = true;
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const truncateCalls = [];
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
const complete = () => {
|
|||
|
|
durableBytes = Math.max(durableBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
};
|
|||
|
|
if (holdWrites) pendingWrites.push({ position, complete });
|
|||
|
|
else setImmediate(complete);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: durableBytes });
|
|||
|
|
},
|
|||
|
|
truncate(_remotePath, size) {
|
|||
|
|
truncateCalls.push(size);
|
|||
|
|
durableBytes = size;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-soft-pause",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-soft.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes");
|
|||
|
|
|
|||
|
|
const started = Date.now();
|
|||
|
|
// Hold writes so active ranges never drain — soft-drain must still resolve.
|
|||
|
|
const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-soft-pause" });
|
|||
|
|
const elapsed = Date.now() - started;
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
// Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain.
|
|||
|
|
assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`);
|
|||
|
|
|
|||
|
|
const outOfOrderWrite = pendingWrites.pop();
|
|||
|
|
assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint");
|
|||
|
|
outOfOrderWrite.complete();
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
|
|||
|
|
let resumeSettled = false;
|
|||
|
|
const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-soft-pause" })
|
|||
|
|
.then((result) => {
|
|||
|
|
resumeSettled = true;
|
|||
|
|
return result;
|
|||
|
|
});
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|||
|
|
const truncatedBeforeDrain = truncateCalls.length > 0;
|
|||
|
|
const resumedBeforeDrain = resumeSettled;
|
|||
|
|
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const { complete } of pendingWrites.splice(0)) complete();
|
|||
|
|
const resumeResult = await resuming;
|
|||
|
|
assert.equal(resumeResult.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumeResult.lifecycleEpoch));
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
assert.equal(truncatedBeforeDrain, false, "must not truncate while range writes are active");
|
|||
|
|
assert.equal(resumedBeforeDrain, false, "resume must wait for active range writes to settle");
|
|||
|
|
assert.equal(durableBytes, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const settleWrites of [true, false]) {
|
|||
|
|
test(`a newer pause supersedes resume while concurrent writes are draining: settle=${settleWrites}`, async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-repause-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Several MB so concurrent fanout stays saturated while we hold writes.
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let holdWrites = true;
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const truncateCalls = [];
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
const complete = () => {
|
|||
|
|
durableBytes = Math.max(durableBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
};
|
|||
|
|
if (holdWrites) pendingWrites.push({ position, complete });
|
|||
|
|
else setImmediate(complete);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: durableBytes });
|
|||
|
|
},
|
|||
|
|
truncate(_remotePath, size) {
|
|||
|
|
truncateCalls.push(size);
|
|||
|
|
durableBytes = size;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-resume-repause",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-soft.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes");
|
|||
|
|
|
|||
|
|
const started = Date.now();
|
|||
|
|
// Hold writes so active ranges never drain — soft-drain must still resolve.
|
|||
|
|
const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
|||
|
|
const elapsed = Date.now() - started;
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
// Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain.
|
|||
|
|
assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`);
|
|||
|
|
|
|||
|
|
const outOfOrderWrite = pendingWrites.pop();
|
|||
|
|
assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint");
|
|||
|
|
outOfOrderWrite.complete();
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
|
|||
|
|
let resumeSettled = false;
|
|||
|
|
const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-resume-repause" })
|
|||
|
|
.then((result) => {
|
|||
|
|
resumeSettled = true;
|
|||
|
|
return result;
|
|||
|
|
});
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|||
|
|
const truncatedBeforeDrain = truncateCalls.length > 0;
|
|||
|
|
const resumedBeforeDrain = resumeSettled;
|
|||
|
|
|
|||
|
|
const newerPause = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
|||
|
|
assert.equal(newerPause.success, true);
|
|||
|
|
if (settleWrites) {
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const { complete } of pendingWrites.splice(0)) complete();
|
|||
|
|
}
|
|||
|
|
const resumeResult = await resuming;
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const { complete } of pendingWrites.splice(0)) complete();
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "upload-resume-repause" });
|
|||
|
|
await running;
|
|||
|
|
assert.equal(resumeResult.success, false, "old resume must not restart writes after a newer pause");
|
|||
|
|
if (settleWrites) assert.match(resumeResult.reason, /superseded.*pause/i);
|
|||
|
|
assert.equal(resumeResult.superseded, true, "direct callers need a structured superseded result too");
|
|||
|
|
assert.equal(resumeResult.supersededBy, "pause");
|
|||
|
|
assert.equal(truncatedBeforeDrain, false);
|
|||
|
|
assert.equal(resumedBeforeDrain, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test("cancelling resume during soft-drain does not truncate the staged file", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-cancel-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 66);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let holdWrites = true;
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
const truncateCalls = [];
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
const complete = () => {
|
|||
|
|
durableBytes = Math.max(durableBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
};
|
|||
|
|
if (holdWrites) pendingWrites.push(complete);
|
|||
|
|
else setImmediate(complete);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: durableBytes });
|
|||
|
|
},
|
|||
|
|
truncate(_remotePath, size) {
|
|||
|
|
truncateCalls.push(size);
|
|||
|
|
durableBytes = size;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const transferId = "upload-resume-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-resume-cancel.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
assert.equal((await transferBridge.pauseTransfer(null, { transferId })).success, true);
|
|||
|
|
|
|||
|
|
const resuming = transferBridge.resumeTransfer(null, { transferId });
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const complete of pendingWrites.splice(0)) complete();
|
|||
|
|
|
|||
|
|
const transferResult = await running;
|
|||
|
|
const resumeResult = await resuming;
|
|||
|
|
assert.match(transferResult.error || "", /cancelled/i);
|
|||
|
|
assert.deepEqual(resumeResult, {
|
|||
|
|
success: false,
|
|||
|
|
reason: "Transfer is no longer active",
|
|||
|
|
});
|
|||
|
|
assert.deepEqual(truncateCalls, []);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resuming while a fast pause is pending settles the pause request", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pause-resume-race-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 67);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let finishWrite = null;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
finishWrite = () => callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "pause-resume-race",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const deadline = Date.now() + 1000;
|
|||
|
|
while (!finishWrite && Date.now() < deadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(typeof finishWrite, "function");
|
|||
|
|
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "pause-resume-race" });
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "pause-resume-race" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
assert.deepEqual(await pausing, {
|
|||
|
|
success: false,
|
|||
|
|
reason: "Pause was superseded by resume",
|
|||
|
|
superseded: true,
|
|||
|
|
supersededBy: "resume",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
finishWrite();
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("pause acknowledges quickly then publishes a full source identity", async (t) => {
|
|||
|
|
// Pause acknowledgement must not wait for full-file SHA-256. The background
|
|||
|
|
// identity is nevertheless a complete digest so a later resume cannot mix
|
|||
|
|
// bytes from different source versions.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-late-pause-race-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 71);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
let holdWrites = true;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
if (holdWrites) pendingWrites.push(callback);
|
|||
|
|
else setImmediate(() => callback(null));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
let running;
|
|||
|
|
{
|
|||
|
|
const sender = createSender();
|
|||
|
|
running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "late-pause-race",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
t.after(async () => {
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "late-pause-race" });
|
|||
|
|
await running?.catch(() => {});
|
|||
|
|
transferBridge.clearPendingCancel("late-pause-race");
|
|||
|
|
});
|
|||
|
|
const writeDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < writeDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
|
|||
|
|
const pauseStarted = Date.now();
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "late-pause-race" });
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const callback of pendingWrites.splice(0)) callback(null);
|
|||
|
|
const paused = await pausing;
|
|||
|
|
const pauseElapsed = Date.now() - pauseStarted;
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
assert.ok(pauseElapsed < 1500, `pause blocked too long: ${pauseElapsed}ms`);
|
|||
|
|
const fingerprintPublished = await waitUntil(() => sender.sent.some((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:progress"
|
|||
|
|
&& /^sha256:[a-f0-9]{64}$/.test(entry.payload.sourceFingerprint || "")
|
|||
|
|
)));
|
|||
|
|
assert.equal(fingerprintPublished, true, "full pause identity must be published after pause acknowledgement");
|
|||
|
|
const fingerprintEntry = sender.sent.findLast((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:progress"
|
|||
|
|
&& /^sha256:[a-f0-9]{64}$/.test(entry.payload.sourceFingerprint || "")
|
|||
|
|
));
|
|||
|
|
assert.equal(
|
|||
|
|
fingerprintEntry?.payload.sourceFingerprint,
|
|||
|
|
`sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "late-pause-race" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remote restart verifies the complete source with bounded concurrent reads", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("resume-window")}-`);
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `resume-window-${crypto.randomUUID()}`;
|
|||
|
|
const payload = Buffer.alloc(2 * 1024 * 1024 + 17);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
const checkpoint = 512 * 1024;
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
await fs.promises.writeFile(stagePath, payload.subarray(0, checkpoint));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagePath).catch(() => {}); });
|
|||
|
|
const sender = createSender();
|
|||
|
|
let verificationReads = 0;
|
|||
|
|
let maxVerificationReads = 0;
|
|||
|
|
let verificationBytes = 0;
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(payload, {
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const verifying = sender.sent.at(-1)?.payload.phase === "verifying";
|
|||
|
|
if (verifying) {
|
|||
|
|
verificationReads += 1;
|
|||
|
|
maxVerificationReads = Math.max(maxVerificationReads, verificationReads);
|
|||
|
|
}
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const bytes = Math.min(length, payload.length - position);
|
|||
|
|
payload.copy(buffer, offset, position, position + bytes);
|
|||
|
|
if (verifying) {
|
|||
|
|
verificationReads -= 1;
|
|||
|
|
verificationBytes += bytes;
|
|||
|
|
}
|
|||
|
|
callback(null, bytes);
|
|||
|
|
}, 2);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = { sftp, stat: async () => ({ size: payload.length }) };
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId, sourcePath: "/source.bin", targetPath,
|
|||
|
|
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length, resumable: true, checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint: `sha256:p${payload.length}:${crypto.createHash("sha256").update(payload).digest("hex")}`,
|
|||
|
|
});
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
assert.ok(maxVerificationReads > 1, "large resume verification must not wait for one network read at a time");
|
|||
|
|
assert.ok(maxVerificationReads <= DOWNLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
assert.ok(verificationBytes >= payload.length, "verify every source byte, not a sample");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const scenario of ["read-error", "open-error", "changed-content", "cancelled", "timeout"]) {
|
|||
|
|
test(`remote restart prefix compatibility: ${scenario}`, async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("resume-compat")}-`);
|
|||
|
|
const transferId = `resume-compat-${crypto.randomUUID()}`;
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 71);
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagePath, { force: true });
|
|||
|
|
});
|
|||
|
|
await fs.promises.writeFile(stagePath, payload);
|
|||
|
|
let streamOpens = 0;
|
|||
|
|
const protocolError = new Error(scenario === "cancelled" ? "Transfer cancelled" : `Range ${scenario}`);
|
|||
|
|
protocolError.code = scenario === "cancelled" ? "ABORT_ERR" : scenario === "timeout" ? "SFTP_READ_TIMEOUT" : 4;
|
|||
|
|
if (scenario === "timeout") protocolError.sftpRequestTimedOut = true;
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(payload, {
|
|||
|
|
open(_path, _flags, callback) {
|
|||
|
|
if (scenario === "open-error") callback(protocolError);
|
|||
|
|
else callback(null, Buffer.from("prefix-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
setImmediate(() => callback(protocolError));
|
|||
|
|
},
|
|||
|
|
createReadStream(_path, options) {
|
|||
|
|
streamOpens += 1;
|
|||
|
|
assert.equal(options.start, 0);
|
|||
|
|
assert.equal(options.end, payload.length - 1);
|
|||
|
|
const current = Buffer.from(payload);
|
|||
|
|
if (scenario === "changed-content") current[current.length - 1] ^= 1;
|
|||
|
|
return Readable.from([current]);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", {
|
|||
|
|
sftp, stat: async () => ({ size: payload.length }),
|
|||
|
|
}]]) });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId, sourcePath: "/source.bin", targetPath,
|
|||
|
|
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length, resumable: true, checkpointBytes: payload.length,
|
|||
|
|
sourceFingerprint: `sha256:p${payload.length}:${crypto.createHash("sha256").update(payload).digest("hex")}`,
|
|||
|
|
});
|
|||
|
|
if (scenario === "cancelled" || scenario === "timeout") {
|
|||
|
|
assert.match(result.error || "", /cancelled|Range timeout/);
|
|||
|
|
assert.equal(streamOpens, 0, "cancellation and timeouts must not start a fallback");
|
|||
|
|
} else {
|
|||
|
|
assert.ok(streamOpens > 0, "ordinary protocol errors must retain stream compatibility");
|
|||
|
|
if (scenario === "changed-content") {
|
|||
|
|
assert.match(result.error || "", /source.*changed|source.*match/i);
|
|||
|
|
assert.equal(fs.existsSync(targetPath), false, "fallback must still reject changed content");
|
|||
|
|
} else {
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test("remote prefix timeout abandons its channel and ignores late replies", async () => {
|
|||
|
|
const pending = [];
|
|||
|
|
let ended = false;
|
|||
|
|
let progress = 0;
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(Buffer.alloc(2 * TRANSFER_CHUNK_SIZE), {
|
|||
|
|
read(_handle, buffer, _offset, length, _position, callback) {
|
|||
|
|
pending.push(() => { buffer.fill(0); callback(null, length); });
|
|||
|
|
},
|
|||
|
|
end() { ended = true; },
|
|||
|
|
createReadStream() { throw new Error("must not fall back after a timeout"); },
|
|||
|
|
});
|
|||
|
|
const client = { sftp };
|
|||
|
|
await assert.rejects(transferBridge._hashRemotePrefixForTests(
|
|||
|
|
client, "source", "/source.bin", undefined, 2 * TRANSFER_CHUNK_SIZE,
|
|||
|
|
{ sftpReadTimeoutMs: 20, onProgress: () => { progress += 1; } },
|
|||
|
|
), (error) => error.code === "SFTP_READ_TIMEOUT" && error.noTransferFallback === true);
|
|||
|
|
assert.equal(ended, true);
|
|||
|
|
assert.equal(client.sftp, null);
|
|||
|
|
assert.equal(pending.length, 2);
|
|||
|
|
for (const reply of pending) reply();
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(progress, 0, "finished verification must ignore late window progress");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remote prefix watchdog observes every positive short READ", async (t) => {
|
|||
|
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 73);
|
|||
|
|
let replies = 0;
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(payload, {
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const count = Math.min(1024, length);
|
|||
|
|
payload.copy(buffer, offset, position, position + count);
|
|||
|
|
replies += 1;
|
|||
|
|
callback(null, count);
|
|||
|
|
}, 5);
|
|||
|
|
},
|
|||
|
|
createReadStream() { throw new Error("responsive range reads must not fall back"); },
|
|||
|
|
});
|
|||
|
|
const hashing = transferBridge._hashRemotePrefixForTests(
|
|||
|
|
{ sftp }, "source", "/source.bin", undefined, payload.length, { sftpReadTimeoutMs: 25 },
|
|||
|
|
);
|
|||
|
|
// Handle rejection immediately so advancing the mock clock can expose a bad
|
|||
|
|
// watchdog without generating an unrelated unhandled-rejection failure.
|
|||
|
|
const outcome = hashing.then((digest) => ({ digest }), (error) => ({ error }));
|
|||
|
|
for (let index = 0; index < 40; index += 1) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
t.mock.timers.tick(5);
|
|||
|
|
}
|
|||
|
|
const result = await outcome;
|
|||
|
|
assert.equal(result.error, undefined, result.error?.message);
|
|||
|
|
assert.equal(replies, 32);
|
|||
|
|
assert.equal(result.digest, crypto.createHash("sha256").update(payload).digest("hex"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remote resume identity rejects a same-size source rewrite", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-modifytime-id-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 11);
|
|||
|
|
for (let i = 0; i < payload.length; i += 1) payload[i] = i % 251;
|
|||
|
|
const modifyTimeMs = 1_700_000_000_000;
|
|||
|
|
const digest = crypto.createHash("sha256").update(payload).digest("hex");
|
|||
|
|
const sourceFingerprint = `sha256:p${payload.length}:${digest}`;
|
|||
|
|
const checkpoint = TRANSFER_CHUNK_SIZE;
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
"download-modifytime-id-resume",
|
|||
|
|
path.basename(targetPath),
|
|||
|
|
);
|
|||
|
|
await fs.promises.mkdir(path.dirname(stagedPath), { recursive: true });
|
|||
|
|
await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint));
|
|||
|
|
|
|||
|
|
// Same size, one byte past the saved prefix rewritten.
|
|||
|
|
const rewritten = Buffer.from(payload);
|
|||
|
|
rewritten[checkpoint + 4] = 90;
|
|||
|
|
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(rewritten);
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: rewritten.length, modifyTime: modifyTimeMs });
|
|||
|
|
},
|
|||
|
|
client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const restarted = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-modifytime-id-resume",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.match(restarted.error || "", /source file has changed|saved content does not match/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("legacy sampled identity restarts safely instead of resuming mixed content", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-meta-rewrite-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const fileSize = 512 * 1024;
|
|||
|
|
const checkpoint = 400 * 1024;
|
|||
|
|
// Between mid sample (~240–272 KiB) and tail sample (~480–512 KiB).
|
|||
|
|
const rewriteAt = 300 * 1024;
|
|||
|
|
const modifyTimeMs = 1_700_000_000_000;
|
|||
|
|
const original = Buffer.alloc(fileSize, 17);
|
|||
|
|
const rewritten = Buffer.from(original);
|
|||
|
|
rewritten.fill(99, rewriteAt, rewriteAt + TRANSFER_CHUNK_SIZE);
|
|||
|
|
|
|||
|
|
const transferId = "meta-rewrite-resume";
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
await fs.promises.mkdir(path.dirname(stagePath), { recursive: true });
|
|||
|
|
await fs.promises.writeFile(stagePath, original.subarray(0, checkpoint));
|
|||
|
|
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(rewritten, {
|
|||
|
|
// meta: fingerprints restart from zero; hash helpers may still sample.
|
|||
|
|
createReadStream(_remotePath, options = {}) {
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : rewritten.length - 1;
|
|||
|
|
return Readable.from(Buffer.from(rewritten.subarray(start, end + 1)));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: fileSize, modifyTime: modifyTimeMs });
|
|||
|
|
},
|
|||
|
|
client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint: `meta:${fileSize}:${modifyTimeMs}`,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), rewritten);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resume rejects a rewrite beyond the first 256 KiB of the saved prefix", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-full-prefix-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `full-prefix-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
const fileSize = 768 * 1024;
|
|||
|
|
const checkpoint = 400 * 1024;
|
|||
|
|
const original = Buffer.alloc(fileSize, 17);
|
|||
|
|
const current = Buffer.from(original);
|
|||
|
|
current.fill(99, 300 * 1024, 332 * 1024);
|
|||
|
|
await fs.promises.writeFile(sourcePath, current);
|
|||
|
|
await fs.promises.writeFile(stagedPath, original.subarray(0, checkpoint));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint: `sha256:${crypto.createHash("sha256").update(current).digest("hex")}`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /saved content does not match/i);
|
|||
|
|
assert.equal(fs.existsSync(targetPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local resume verification reports progress and destroys both readers on cancellation", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-verify-cancel-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `verify-cancel-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
const checkpoint = 512 * 1024;
|
|||
|
|
const verifyBytes = checkpoint;
|
|||
|
|
const payload = Buffer.alloc(checkpoint + 1, 31);
|
|||
|
|
await fs.promises.writeFile(sourcePath, payload);
|
|||
|
|
await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
const sourceFingerprint = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`;
|
|||
|
|
const originalCreateReadStream = fs.createReadStream;
|
|||
|
|
const verificationStreams = [];
|
|||
|
|
fs.createReadStream = (filePath, options = {}) => {
|
|||
|
|
if (
|
|||
|
|
(filePath === sourcePath || filePath === stagedPath)
|
|||
|
|
&& options.start === 0
|
|||
|
|
&& options.end === verifyBytes - 1
|
|||
|
|
) {
|
|||
|
|
const stream = new PassThrough();
|
|||
|
|
verificationStreams.push(stream);
|
|||
|
|
return stream;
|
|||
|
|
}
|
|||
|
|
return originalCreateReadStream(filePath, options);
|
|||
|
|
};
|
|||
|
|
t.after(() => { fs.createReadStream = originalCreateReadStream; });
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint,
|
|||
|
|
});
|
|||
|
|
t.after(async () => {
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
await running.catch(() => {});
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(await waitUntil(() => verificationStreams.length === 2), true);
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 220));
|
|||
|
|
for (const stream of verificationStreams) stream.write(Buffer.alloc(256 * 1024, 31));
|
|||
|
|
assert.equal(await waitUntil(() => sender.sent.some((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:progress"
|
|||
|
|
&& entry.payload.phase === "verifying"
|
|||
|
|
&& entry.payload.speed > 0
|
|||
|
|
))), true, "resume verification progress must be visible");
|
|||
|
|
const verifyingEvent = sender.sent.findLast((entry) => entry.payload?.phase === "verifying");
|
|||
|
|
assert.equal(verifyingEvent?.payload.transferred, checkpoint);
|
|||
|
|
assert.equal(verifyingEvent?.payload.checkpointBytes, checkpoint);
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await running;
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(verificationStreams.every((stream) => stream.destroyed), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remote resume verification destroys the SFTP reader on cancellation", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-remote-verify-cancel-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `remote-verify-cancel-${crypto.randomUUID()}`;
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
const checkpoint = 512 * 1024;
|
|||
|
|
const verifyBytes = checkpoint;
|
|||
|
|
const payload = Buffer.alloc(checkpoint + 1, 47);
|
|||
|
|
await fs.promises.writeFile(stagedPath, payload.subarray(0, checkpoint));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
const sourceFingerprint = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`;
|
|||
|
|
let verificationStream;
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
createReadStream(_remotePath, options = {}) {
|
|||
|
|
const start = Number(options.start) || 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : payload.length - 1;
|
|||
|
|
if (start === 0 && end === verifyBytes - 1) {
|
|||
|
|
verificationStream = new PassThrough();
|
|||
|
|
return verificationStream;
|
|||
|
|
}
|
|||
|
|
return Readable.from(payload.subarray(start, end + 1));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat: async () => ({ size: payload.length }),
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint,
|
|||
|
|
});
|
|||
|
|
t.after(async () => {
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
await running.catch(() => {});
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(await waitUntil(() => verificationStream !== undefined), true);
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 220));
|
|||
|
|
verificationStream.write(Buffer.alloc(256 * 1024, 47));
|
|||
|
|
assert.equal(await waitUntil(() => sender.sent.some((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:progress"
|
|||
|
|
&& entry.payload.phase === "verifying"
|
|||
|
|
&& entry.payload.speed > 0
|
|||
|
|
))), true);
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await running;
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(verificationStream.destroyed, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remote resume verification cancellation does not wait for SFTP channel reopen", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-remote-verify-reopen-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `remote-verify-reopen-${crypto.randomUUID()}`;
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
const checkpoint = 64 * 1024;
|
|||
|
|
const totalBytes = checkpoint + 1;
|
|||
|
|
await fs.promises.writeFile(stagedPath, Buffer.alloc(checkpoint, 19));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
let reopenStarted = false;
|
|||
|
|
let finishReopen;
|
|||
|
|
const client = {
|
|||
|
|
sftp: null,
|
|||
|
|
stat: async () => ({ size: totalBytes }),
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
reopenStarted = true;
|
|||
|
|
finishReopen = () => callback(null, createFastSftp({}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: checkpoint,
|
|||
|
|
sourceFingerprint: `sha256:${"0".repeat(64)}`,
|
|||
|
|
});
|
|||
|
|
t.after(async () => {
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
await running.catch(() => {});
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(await waitUntil(() => reopenStarted), true);
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 250)),
|
|||
|
|
]);
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
finishReopen?.();
|
|||
|
|
assert.equal(await waitUntil(() => client._reopeningPromise === null), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("failed resumable upload opens close their isolated channel", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-open-fail-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 23));
|
|||
|
|
let endedChannels = 0;
|
|||
|
|
let hadOpenErrorListener = false;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
hadOpenErrorListener = fastSftp.listenerCount("error") > 0;
|
|||
|
|
const error = new Error("permission denied");
|
|||
|
|
if (hadOpenErrorListener) fastSftp.emit("error", error);
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endedChannels += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
// Stream fallback also rejects so the transfer still fails after cleanup.
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createWriteStream() {
|
|||
|
|
const writeStream = new Writable({
|
|||
|
|
write(_chunk, _encoding, callback) {
|
|||
|
|
callback(new Error("permission denied"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
queueMicrotask(() => writeStream.destroy(new Error("permission denied")));
|
|||
|
|
return writeStream;
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-open-fail",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: 32 * 1024,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /permission denied/);
|
|||
|
|
assert.equal(hadOpenErrorListener, true);
|
|||
|
|
// concurrent-isolated + fastPut-isolated each open/end their own channel.
|
|||
|
|
assert.ok(endedChannels >= 1, `expected isolated channels to end, got ${endedChannels}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP upload does not create a local ranges.sha256 digest sidecar", async (t) => {
|
|||
|
|
// Size-based resume only — no whole-file content digest sidecar (industry default).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-no-digest-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 71);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.alloc(payload.length));
|
|||
|
|
callback(null, Buffer.from(String(remotePath)));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat: async (remotePath) => ({ size: remoteFiles.get(String(remotePath))?.length || 0 }),
|
|||
|
|
rename(sourcePath, destinationPath) {
|
|||
|
|
remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath)));
|
|||
|
|
remoteFiles.delete(String(sourcePath));
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const transferId = "upload-no-digest-sidecar";
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
const digestId = crypto.createHash("sha256")
|
|||
|
|
.update(transferId)
|
|||
|
|
.digest("hex")
|
|||
|
|
.slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
assert.deepEqual(remoteFiles.get("/tmp/upload.bin"), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP upload ignores leftover digest sidecars from older builds", async (t) => {
|
|||
|
|
// Older builds wrote ranges.sha256 next to the transfer id. Size-based uploads
|
|||
|
|
// must not require or recreate that sidecar.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-legacy-digest-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-legacy-digest";
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 41);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
await fs.promises.mkdir(path.dirname(digestPath), { recursive: true });
|
|||
|
|
await fs.promises.writeFile(digestPath, Buffer.alloc(32, 9));
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.alloc(payload.length));
|
|||
|
|
callback(null, Buffer.from(String(remotePath)));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["target", {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat: async (p) => ({ size: remoteFiles.get(String(p))?.length || 0 }),
|
|||
|
|
rename: async (src, dst) => {
|
|||
|
|
remoteFiles.set(String(dst), remoteFiles.get(String(src)));
|
|||
|
|
remoteFiles.delete(String(src));
|
|||
|
|
},
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: { sftp: (callback) => callback(null, fastSftp) },
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
// Legacy sidecar may still exist on disk; the upload must not depend on it.
|
|||
|
|
assert.deepEqual(remoteFiles.get("/tmp/upload.bin"), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("digest baseline cancellation removes the sidecar before opening remote upload", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-digest-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-digest-cancel";
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 53));
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
t.after(async () => fs.promises.rm(digestPath, { force: true }));
|
|||
|
|
let remoteChannelOpens = 0;
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
remoteChannelOpens += 1;
|
|||
|
|
callback(new Error("remote must not open"));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const originalOpen = fs.promises.open;
|
|||
|
|
let cancellationTriggered = false;
|
|||
|
|
fs.promises.open = async (filePath, flags, ...args) => {
|
|||
|
|
const handle = await originalOpen(filePath, flags, ...args);
|
|||
|
|
if (String(filePath) !== localPath || flags !== "r" || cancellationTriggered) return handle;
|
|||
|
|
return {
|
|||
|
|
async read(...readArgs) {
|
|||
|
|
cancellationTriggered = true;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
return handle.read(...readArgs);
|
|||
|
|
},
|
|||
|
|
close: () => handle.close(),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
let result;
|
|||
|
|
try {
|
|||
|
|
result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
} finally {
|
|||
|
|
fs.promises.open = originalOpen;
|
|||
|
|
}
|
|||
|
|
// Local source open is hooked to cancel mid-read. Depending on whether the
|
|||
|
|
// background baseline or the first upload range opens first, cancel may come
|
|||
|
|
// from that hook or from the forced remote open failure.
|
|||
|
|
assert.ok(
|
|||
|
|
cancellationTriggered || /cancel|remote must not open/i.test(result.error || ""),
|
|||
|
|
`expected cancel or remote-open failure, got error=${result.error} cancelled=${cancellationTriggered}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(result.error, "transfer must fail closed");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("failed local open for resumable upload still ends the isolated channel", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-local-open-fail-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Create the source so preflight can proceed, then delete it as soon as the
|
|||
|
|
// isolated channel opens so concurrent isolated upload fails on local open.
|
|||
|
|
const localPath = path.join(tempDir, "source.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 7));
|
|||
|
|
let endedChannels = 0;
|
|||
|
|
let remoteOpenAttempts = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
remoteOpenAttempts += 1;
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endedChannels += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createWriteStream() {
|
|||
|
|
// Stream fallback after range-path failure — keep it error-safe.
|
|||
|
|
const writeStream = new Writable({
|
|||
|
|
write(_chunk, _encoding, callback) {
|
|||
|
|
callback(new Error("stream fallback after missing local open"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return writeStream;
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
fs.promises.rm(localPath, { force: true }).finally(() => {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-local-open-fail",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: 32 * 1024,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected transfer to fail when local source disappears");
|
|||
|
|
// Critical: isolated channel must not leak when local open fails first.
|
|||
|
|
assert.ok(endedChannels >= 1, `expected isolated channel end, got ${endedChannels}`);
|
|||
|
|
assert.equal(remoteOpenAttempts, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during stalled resumable upload OPEN ends the isolated channel", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-open-stall-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(32 * 1024, 29));
|
|||
|
|
let endedChannels = 0;
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
releaseOpen = () => callback(new Error("channel closed during open"));
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endedChannels += 1;
|
|||
|
|
// Simulate ssh2 failing the pending OPEN when the channel ends.
|
|||
|
|
releaseOpen?.();
|
|||
|
|
releaseOpen = null;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createWriteStream() {
|
|||
|
|
const writeStream = new Writable({
|
|||
|
|
write(_chunk, _encoding, callback) {
|
|||
|
|
callback(new Error("should not stream-fallback after cancel"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return writeStream;
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-open-stall-cancel",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: 32 * 1024,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (!releaseOpen && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.ok(releaseOpen, "expected remote OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "upload-open-stall-cancel" });
|
|||
|
|
const result = await running;
|
|||
|
|
assert.match(result.error || "", /cancel|closed/i);
|
|||
|
|
assert.ok(endedChannels >= 1, `expected isolated channel end on cancel, got ${endedChannels}`);
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable SFTP uploads fail closed when pipelined strategies fail (no serial stream)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.from("complete fallback upload");
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let endedChannels = 0;
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
let deleteCalls = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("server rejected random-access writes"));
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endedChannels += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("server rejected random-access writes"));
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 0 });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
deleteCalls += 1;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-fail-closed",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|rejected random-access/i);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
assert.ok(endedChannels >= 1, `expected isolated channels to end, got ${endedChannels}`);
|
|||
|
|
assert.equal(deleteCalls, 0, "a resumable upload error must preserve its staged prefix");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable concurrent uploads reject a source that shrinks mid-transfer", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-source-change-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Size-based finish check (no whole-file digest). A same-size rewrite is not
|
|||
|
|
// re-hashed; shrink/grow still fails promotion so the remote .part is cleaned.
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 41);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let shrunk = false;
|
|||
|
|
let promoted = false;
|
|||
|
|
let stagedDeleted = false;
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
if (!shrunk && position === 0) {
|
|||
|
|
shrunk = true;
|
|||
|
|
fs.writeFileSync(localPath, Buffer.alloc(payload.length / 2, 42));
|
|||
|
|
}
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: remoteBytes });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
promoted = true;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
stagedDeleted = true;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-source-change",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(shrunk, true);
|
|||
|
|
assert.match(result.error || "", /source|size|changed/i);
|
|||
|
|
assert.equal(promoted, false);
|
|||
|
|
assert.equal(stagedDeleted, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("assertSourceMetadataUnchanged ignores ctime drift when content is verified separately", () => {
|
|||
|
|
const initial = {
|
|||
|
|
size: 100,
|
|||
|
|
mtimeMs: 1,
|
|||
|
|
ctimeMs: 1,
|
|||
|
|
mtime: 0.001,
|
|||
|
|
ctime: 0.001,
|
|||
|
|
ino: 42,
|
|||
|
|
};
|
|||
|
|
const drifted = {
|
|||
|
|
...initial,
|
|||
|
|
ctimeMs: 999,
|
|||
|
|
ctime: 0.999,
|
|||
|
|
};
|
|||
|
|
// Without a separate content proof, timestamp drift is a hard fail (download path).
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(initial, drifted, 100),
|
|||
|
|
/source content changed/i,
|
|||
|
|
);
|
|||
|
|
// Upload finish soft path: ignore ctime noise but still enforce mtime/ino.
|
|||
|
|
assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
drifted,
|
|||
|
|
100,
|
|||
|
|
{ ignoreCtime: true },
|
|||
|
|
));
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...drifted, mtimeMs: 2, mtime: 0.002 },
|
|||
|
|
100,
|
|||
|
|
{ ignoreCtime: true },
|
|||
|
|
),
|
|||
|
|
/source content changed/i,
|
|||
|
|
);
|
|||
|
|
// With digest / per-range verification, macOS xattr ctime bumps must not abort.
|
|||
|
|
assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
drifted,
|
|||
|
|
100,
|
|||
|
|
{ contentVerifiedSeparately: true },
|
|||
|
|
));
|
|||
|
|
// Size still fails hard even when content is verified separately.
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...drifted, size: 99 },
|
|||
|
|
100,
|
|||
|
|
{ contentVerifiedSeparately: true },
|
|||
|
|
),
|
|||
|
|
/source size changed/i,
|
|||
|
|
);
|
|||
|
|
// Append-only growth is rejected unless the download snapshot opts in.
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...initial, size: 150, mtimeMs: 2 },
|
|||
|
|
100,
|
|||
|
|
),
|
|||
|
|
/source size changed/i,
|
|||
|
|
);
|
|||
|
|
// Growth without a separate prefix proof is still rejected (rewrite+grow risk).
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...initial, size: 150, mtimeMs: 2, ctimeMs: 2 },
|
|||
|
|
100,
|
|||
|
|
{ allowSourceGrowth: true },
|
|||
|
|
),
|
|||
|
|
/source content changed/i,
|
|||
|
|
);
|
|||
|
|
assert.doesNotThrow(() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...initial, size: 150, mtimeMs: 2, ctimeMs: 2 },
|
|||
|
|
100,
|
|||
|
|
{ allowSourceGrowth: true, contentVerifiedSeparately: true },
|
|||
|
|
));
|
|||
|
|
// Growth still cannot cover a shrink.
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge._assertSourceMetadataUnchangedForTests(
|
|||
|
|
initial,
|
|||
|
|
{ ...initial, size: 80 },
|
|||
|
|
100,
|
|||
|
|
{ allowSourceGrowth: true, contentVerifiedSeparately: true },
|
|||
|
|
),
|
|||
|
|
/source size changed/i,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable upload succeeds when only source ctime drifts (pause/resume false positive)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-ctime-drift-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Reproduce the user-facing finish-path false positive: content bytes are
|
|||
|
|
// stable (digest matches) but ctime moved — common on macOS after long
|
|||
|
|
// pause/resume cycles (Spotlight / quarantine / xattr).
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 43);
|
|||
|
|
const localPath = path.join(tempDir, "ChatGPT.dmg");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const baseline = await fs.promises.stat(localPath);
|
|||
|
|
let allowCtimeDrift = false;
|
|||
|
|
const driftedStat = () => {
|
|||
|
|
if (!allowCtimeDrift) return baseline;
|
|||
|
|
return {
|
|||
|
|
...baseline,
|
|||
|
|
ctimeMs: baseline.ctimeMs + 60_000,
|
|||
|
|
ctime: (baseline.ctimeMs + 60_000) / 1000,
|
|||
|
|
// mtime intentionally stable — content not rewritten.
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
const realStat = fs.promises.stat.bind(fs.promises);
|
|||
|
|
const realOpen = fs.promises.open.bind(fs.promises);
|
|||
|
|
fs.promises.stat = async (p, ...args) => {
|
|||
|
|
if (path.resolve(String(p)) === path.resolve(localPath)) return driftedStat();
|
|||
|
|
return realStat(p, ...args);
|
|||
|
|
};
|
|||
|
|
fs.promises.open = async (p, flags, ...args) => {
|
|||
|
|
const handle = await realOpen(p, flags, ...args);
|
|||
|
|
if (path.resolve(String(p)) === path.resolve(localPath) && String(flags).includes("r")) {
|
|||
|
|
handle.stat = async () => driftedStat();
|
|||
|
|
}
|
|||
|
|
return handle;
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.promises.stat = realStat;
|
|||
|
|
fs.promises.open = realOpen;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
let promoted = false;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
// After the first remote WRITE, simulate metadata-only drift as if the
|
|||
|
|
// transfer had been pause/resumed for a long wall-clock time.
|
|||
|
|
allowCtimeDrift = true;
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: remoteBytes });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
promoted = true;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-ctime-drift-ok",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/ChatGPT.dmg",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, `expected success, got: ${result.error}`);
|
|||
|
|
assert.equal(allowCtimeDrift, true);
|
|||
|
|
assert.equal(promoted, true);
|
|||
|
|
assert.equal(remoteBytes, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable shared range uploads stream without a content digest", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-stream-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 51);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async () => ({ size: remoteBytes }),
|
|||
|
|
rename: async () => {},
|
|||
|
|
delete: async () => {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-nonresume-stream",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(remoteBytes, payload.length);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${crypto.createHash("sha256").update("upload-nonresume-stream").digest("hex").slice(0, 16)}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable isolated upload streams without a content digest sidecar", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-isolated-stream-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 53);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload.bin";
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let writeCalls = 0;
|
|||
|
|
let promoted = false;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(payload.length));
|
|||
|
|
callback(null, Buffer.from(key));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
writeCalls += 1;
|
|||
|
|
buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const value = remoteFiles.get(String(remotePath));
|
|||
|
|
if (!value) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
}
|
|||
|
|
return Promise.resolve({ size: value.length });
|
|||
|
|
},
|
|||
|
|
rename(sourcePath, destinationPath) {
|
|||
|
|
promoted = true;
|
|||
|
|
remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath)));
|
|||
|
|
remoteFiles.delete(String(sourcePath));
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-isolated-stream",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.ok(writeCalls > 0);
|
|||
|
|
assert.equal(promoted, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fastPut streams the live local path without a content snapshot", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-live-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 55);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old target")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let fastPutSourcePath = null;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const value = remoteFiles.get(String(remotePath));
|
|||
|
|
if (!value) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: value.length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
fastPut(sourcePath, remotePath, options, callback) {
|
|||
|
|
fastPutSourcePath = sourcePath;
|
|||
|
|
const uploaded = fs.readFileSync(sourcePath);
|
|||
|
|
remoteFiles.set(String(remotePath), uploaded);
|
|||
|
|
options.step?.(uploaded.length, uploaded.length, uploaded.length);
|
|||
|
|
queueMicrotask(() => callback(null));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async (remotePath) => ({ size: remoteFiles.get(String(remotePath))?.length || 0 }),
|
|||
|
|
chmod: async () => {},
|
|||
|
|
rename(sourcePath, destinationPath) {
|
|||
|
|
const source = String(sourcePath);
|
|||
|
|
const destination = String(destinationPath);
|
|||
|
|
remoteFiles.set(destination, remoteFiles.get(source));
|
|||
|
|
remoteFiles.delete(source);
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-fastput-live",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(fastPutSourcePath, localPath);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("failed local source open for non-resumable upload fails closed without digest sidecars", async (t) => {
|
|||
|
|
// Size-based uploads no longer create ranges.sha256 / snapshot.bin. A missing
|
|||
|
|
// or unreadable local source must still fail closed and leave no temp digest.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-local-open-fail-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const payload = Buffer.alloc(16 * 1024, 59);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const transferId = "upload-local-open-fail";
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
const snapshotPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-source-${digestId}`,
|
|||
|
|
"snapshot.bin",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let fastPutCalls = 0;
|
|||
|
|
let remoteOpens = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
remoteOpens += 1;
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
fastPut(_sourcePath, _remotePath, _options, callback) {
|
|||
|
|
fastPutCalls += 1;
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: { sftp: (callback) => callback(null, fastSftp) },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const realOpen = fs.promises.open.bind(fs.promises);
|
|||
|
|
fs.promises.open = async (filePath, flags, ...args) => {
|
|||
|
|
if (path.resolve(String(filePath)) === path.resolve(localPath) && String(flags).includes("r")) {
|
|||
|
|
throw new Error("local source unreadable");
|
|||
|
|
}
|
|||
|
|
return realOpen(filePath, flags, ...args);
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.promises.open = realOpen;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /local source unreadable|ENOENT|not found|source/i);
|
|||
|
|
assert.equal(fastPutCalls, 0);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
assert.equal(fs.existsSync(snapshotPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable shared range uploads remove their temporary digest after success", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-cleanup-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-nonresume-cleanup";
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 61);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["target", {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async () => ({ size: remoteBytes }),
|
|||
|
|
rename: async () => {},
|
|||
|
|
delete: async () => {},
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable shared range uploads reject a source that grows and remove their digest", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-growth-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-nonresume-growth";
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 62);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
let sourceGrew = false;
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
if (!sourceGrew) {
|
|||
|
|
sourceGrew = true;
|
|||
|
|
fs.appendFileSync(localPath, Buffer.from([1]));
|
|||
|
|
}
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["target", {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async () => ({ size: remoteBytes }),
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(sourceGrew, true);
|
|||
|
|
assert.match(result.error || "", /source size changed/i);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable digest creation cancellation removes the temporary digest", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-digest-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-nonresume-digest-cancel";
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(
|
|||
|
|
localPath,
|
|||
|
|
Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 63),
|
|||
|
|
);
|
|||
|
|
const digestId = crypto.createHash("sha256").update(transferId).digest("hex").slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
let remoteOpenAttempts = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
remoteOpenAttempts += 1;
|
|||
|
|
callback(new Error("remote must not open"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
callback(new Error("remote must not write"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["target", {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async () => ({ size: 0 }),
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const originalOpen = fs.promises.open;
|
|||
|
|
let cancellationTriggered = false;
|
|||
|
|
let result;
|
|||
|
|
try {
|
|||
|
|
fs.promises.open = async (filePath, flags, ...args) => {
|
|||
|
|
const handle = await originalOpen(filePath, flags, ...args);
|
|||
|
|
if (String(filePath) !== localPath || flags !== "r" || cancellationTriggered) return handle;
|
|||
|
|
return {
|
|||
|
|
async read(...readArgs) {
|
|||
|
|
cancellationTriggered = true;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
return handle.read(...readArgs);
|
|||
|
|
},
|
|||
|
|
stat: () => handle.stat(),
|
|||
|
|
close: () => handle.close(),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
} finally {
|
|||
|
|
fs.promises.open = originalOpen;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
assert.ok(
|
|||
|
|
cancellationTriggered || /cancel|remote must not/i.test(result.error || ""),
|
|||
|
|
`expected cancel path, got error=${result.error}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(result.error, "transfer must fail closed");
|
|||
|
|
// Remote OPEN may start while the local digest baseline is still scanning.
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-resumable shared range cancellation drains in-flight writes", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonresume-write-cancel-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const transferId = "upload-nonresume-write-cancel";
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 2, 64);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const pendingWrites = [];
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
pendingWrites.push(callback);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["target", {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat: async () => ({ size: 0 }),
|
|||
|
|
rename: async () => {},
|
|||
|
|
delete: async () => {},
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const readyDeadline = Date.now() + 2000;
|
|||
|
|
while (
|
|||
|
|
pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY
|
|||
|
|
&& Date.now() < readyDeadline
|
|||
|
|
) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingWrites.length, UPLOAD_TRANSFER_CONCURRENCY);
|
|||
|
|
let transferSettled = false;
|
|||
|
|
void running.finally(() => {
|
|||
|
|
transferSettled = true;
|
|||
|
|
});
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(transferSettled, false);
|
|||
|
|
|
|||
|
|
const firstWrite = pendingWrites.shift();
|
|||
|
|
assert.ok(pendingWrites.length > 0);
|
|||
|
|
firstWrite(new Error("write cancelled"));
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(transferSettled, false);
|
|||
|
|
|
|||
|
|
const finalWrite = pendingWrites.pop();
|
|||
|
|
for (const callback of pendingWrites.splice(0)) callback(new Error("write cancelled"));
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 2200));
|
|||
|
|
assert.equal(transferSettled, false);
|
|||
|
|
|
|||
|
|
finalWrite(new Error("write cancelled"));
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel|write cancelled/i);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable fast uploads fail closed when isolated channel errors (no serial stream)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-channel-error-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 31);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let hadErrorListener = false;
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
hadErrorListener = fastSftp.listenerCount("error") > 0;
|
|||
|
|
const error = new Error("isolated channel failed");
|
|||
|
|
queueMicrotask(() => {
|
|||
|
|
if (hadErrorListener) fastSftp.emit("error", error);
|
|||
|
|
callback(error);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("shared open also fails"));
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 0 });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-channel-error",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|isolated channel failed/i);
|
|||
|
|
assert.equal(hadErrorListener, true);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable concurrent range failure does not complete via serial stream", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-sparse-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * 32 * 1024, 41);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let secondWriteCallback = null;
|
|||
|
|
let thirdWriteCompleted = false;
|
|||
|
|
let secondWriteFailureScheduled = false;
|
|||
|
|
const scheduleSecondWriteFailure = () => {
|
|||
|
|
if (secondWriteFailureScheduled || !thirdWriteCompleted || !secondWriteCallback) return;
|
|||
|
|
secondWriteFailureScheduled = true;
|
|||
|
|
const callback = secondWriteCallback;
|
|||
|
|
secondWriteCallback = null;
|
|||
|
|
queueMicrotask(() => callback(new Error("second range failed")));
|
|||
|
|
};
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
if (position === 32 * 1024) {
|
|||
|
|
secondWriteCallback = callback;
|
|||
|
|
scheduleSecondWriteFailure();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
if (position === 2 * 32 * 1024) {
|
|||
|
|
thirdWriteCompleted = true;
|
|||
|
|
scheduleSecondWriteFailure();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("shared open also fails"));
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: remoteBytes });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-sparse-fail-closed",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|second range failed/i);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable fast uploads reject a source that grows during transfer", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-upload-source-growth-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 59);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let sourceGrew = false;
|
|||
|
|
let promoted = false;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
if (!sourceGrew) {
|
|||
|
|
sourceGrew = true;
|
|||
|
|
fs.appendFileSync(localPath, Buffer.from([1]));
|
|||
|
|
}
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
promoted = true;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-source-growth",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /source size changed/);
|
|||
|
|
assert.equal(promoted, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable uploads are size-based and do not require content digests", async (t) => {
|
|||
|
|
// WinSCP/FileZilla resume model: checkpoint = durable stage size. Source
|
|||
|
|
// rewrites mid-transfer are not blocked by a whole-file SHA baseline.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-size-resume-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(
|
|||
|
|
(UPLOAD_TRANSFER_CONCURRENCY + 8) * TRANSFER_CHUNK_SIZE,
|
|||
|
|
73,
|
|||
|
|
);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let writeCalls = 0;
|
|||
|
|
let promoted = false;
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(remotePath, _flags, callback) {
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.alloc(payload.length));
|
|||
|
|
callback(null, Buffer.from(String(remotePath)));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
writeCalls += 1;
|
|||
|
|
buffer.copy(remoteFiles.get(handle.toString()), position, offset, offset + length);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat(remotePath) {
|
|||
|
|
return Promise.resolve({ size: remoteFiles.get(String(remotePath))?.length || 0 });
|
|||
|
|
},
|
|||
|
|
rename(sourcePath, destinationPath) {
|
|||
|
|
promoted = true;
|
|||
|
|
remoteFiles.set(String(destinationPath), remoteFiles.get(String(sourcePath)));
|
|||
|
|
remoteFiles.delete(String(sourcePath));
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-size-based",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.ok(writeCalls > 0);
|
|||
|
|
assert.equal(promoted, true);
|
|||
|
|
const digestId = crypto.createHash("sha256")
|
|||
|
|
.update("upload-size-based")
|
|||
|
|
.digest("hex")
|
|||
|
|
.slice(0, 16);
|
|||
|
|
const digestPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
`upload-digest-${digestId}`,
|
|||
|
|
"ranges.sha256",
|
|||
|
|
);
|
|||
|
|
assert.equal(fs.existsSync(digestPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable fast downloads clear staged data after a same-second source change", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-source-change-"));
|
|||
|
|
const transferId = "download-source-change";
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true });
|
|||
|
|
});
|
|||
|
|
await fs.promises.writeFile(targetPath, "original");
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 61);
|
|||
|
|
const latestPayload = Buffer.alloc(payload.length, 62);
|
|||
|
|
let reads = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const currentPayload = reads++ === 0 ? payload : latestPayload;
|
|||
|
|
currentPayload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(new Error("remote close failed"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length, mtime: 1 });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /source.*changed/);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original");
|
|||
|
|
await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification prefers a bounded remote digest", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(128 * 1024, 71);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const expectedDigest = crypto.createHash("sha256").update(payload).digest("hex");
|
|||
|
|
let command = "";
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("SFTP prefix stream should not run when remote digest is available");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
exec(request, callback) {
|
|||
|
|
command = request;
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
callback(null, stream);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
stream.emit("data", Buffer.from(`${expectedDigest} -\n`));
|
|||
|
|
stream.emit("close", 0);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(command, new RegExp(`head -c ${payload.length}`));
|
|||
|
|
assert.match(command, /sha256sum|busybox|openssl/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification skips unprivileged digest for sudo SFTP", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-sudo-digest-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(96 * 1024, 72);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const emptyDigest = crypto.createHash("sha256").update("").digest("hex");
|
|||
|
|
let execCalls = 0;
|
|||
|
|
const pipelined = createPipelinedDownloadSftp(payload, {
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should remain the final fallback");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: pipelined.sftp,
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
execCalls += 1;
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
callback(null, stream);
|
|||
|
|
// Unprivileged head fails open into sha256sum and still exits 0.
|
|||
|
|
setImmediate(() => {
|
|||
|
|
stream.emit("data", Buffer.from(`${emptyDigest} -\n`));
|
|||
|
|
stream.emit("close", 0);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/root/secure.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(execCalls, 0, "sudo downloads must not use unprivileged digest commands");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification falls back after digest command timeout", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-timeout-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(96 * 1024, 69);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let rangeReads = 0;
|
|||
|
|
const pipelined = createPipelinedDownloadSftp(payload, {
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
rangeReads += 1;
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should remain the final fallback");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: pipelined.sftp,
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
callback(null, stream);
|
|||
|
|
// Open succeeds but never completes — hits SSH_EXEC_RUN_TIMEOUT.
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
sshDigestRunTimeoutMs: 30,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.ok(rangeReads > 0, "digest run timeout must fall through to SFTP range hashing");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification propagates digest open timeouts", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-digest-open-timeout-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 68);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
let rangeReads = 0;
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
rangeReads += 1;
|
|||
|
|
callback(null, Buffer.from("should-not-open"));
|
|||
|
|
},
|
|||
|
|
read() {
|
|||
|
|
throw new Error("SFTP fallback must not run after exec-open timeout");
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("SFTP stream fallback must not run after exec-open timeout");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
// Never invoke the exec callback — hits SSH_EXEC_OPEN_TIMEOUT and
|
|||
|
|
// invalidates the transport in boundedSshExec.
|
|||
|
|
exec() {},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
sshDigestOpeningTimeoutMs: 30,
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
(error) => error?.code === "SSH_EXEC_OPEN_TIMEOUT" && error?.noTransferFallback === true,
|
|||
|
|
);
|
|||
|
|
assert.equal(rangeReads, 0, "open timeout must not fall through onto a poisoned session");
|
|||
|
|
assert.equal(client.sftp, null, "exec-open timeout must drop the cached SFTP channel");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification rejects empty remote digests", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-empty-digest-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 70);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const emptyDigest = crypto.createHash("sha256").update("").digest("hex");
|
|||
|
|
let rangeReads = 0;
|
|||
|
|
const pipelined = createPipelinedDownloadSftp(payload, {
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
rangeReads += 1;
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should remain the final fallback");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: pipelined.sftp,
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
callback(null, stream);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
stream.emit("data", Buffer.from(`${emptyDigest} -\n`));
|
|||
|
|
stream.emit("close", 0);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.ok(rangeReads > 0, "empty digest must fall through to elevated/SFTP hashing");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification falls back to parallel SFTP reads", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-ranges-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(256 * 1024, 73);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const pipelined = createPipelinedDownloadSftp(payload, {
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should be the final fallback only");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: pipelined.sftp,
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const error = new Error("SSH exec unavailable");
|
|||
|
|
error.code = "SSH_EXEC_UNAVAILABLE";
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download SFTP range hashing keeps a bounded read window", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-bound-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const chunkSize = TRANSFER_CHUNK_SIZE;
|
|||
|
|
const concurrency = DOWNLOAD_TRANSFER_CONCURRENCY;
|
|||
|
|
const payload = Buffer.alloc((concurrency + 8) * chunkSize, 77);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let maxIndexStarted = -1;
|
|||
|
|
let releaseFirstWindow;
|
|||
|
|
const firstWindowBlocked = new Promise((resolve) => { releaseFirstWindow = resolve; });
|
|||
|
|
let firstWindowGateOpened = false;
|
|||
|
|
const pendingFirstWindow = [];
|
|||
|
|
const warnings = [];
|
|||
|
|
const onWarning = (warning) => {
|
|||
|
|
warnings.push(String(warning?.name || warning?.message || warning));
|
|||
|
|
};
|
|||
|
|
process.on("warning", onWarning);
|
|||
|
|
t.after(() => process.off("warning", onWarning));
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("bounded-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const index = Math.floor(position / chunkSize);
|
|||
|
|
maxIndexStarted = Math.max(maxIndexStarted, index);
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
const deliver = () => {
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
};
|
|||
|
|
if (!firstWindowGateOpened && index < concurrency) {
|
|||
|
|
pendingFirstWindow.push(deliver);
|
|||
|
|
if (pendingFirstWindow.length === 1) {
|
|||
|
|
firstWindowBlocked.then(() => {
|
|||
|
|
firstWindowGateOpened = true;
|
|||
|
|
for (const pending of pendingFirstWindow.splice(0)) pending();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImmediate(deliver);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should not run for bounded-window test");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const error = new Error("SSH exec unavailable");
|
|||
|
|
error.code = "SSH_EXEC_UNAVAILABLE";
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const verifyPromise = transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await waitUntil(() => pendingFirstWindow.length >= concurrency, 2000);
|
|||
|
|
assert.equal(
|
|||
|
|
maxIndexStarted,
|
|||
|
|
concurrency - 1,
|
|||
|
|
"later windows must wait until the current concurrency window finishes",
|
|||
|
|
);
|
|||
|
|
releaseFirstWindow();
|
|||
|
|
await verifyPromise;
|
|||
|
|
assert.ok(maxIndexStarted >= concurrency, "verification must continue past the first window");
|
|||
|
|
assert.equal(
|
|||
|
|
warnings.some((message) => /MaxListenersExceededWarning/i.test(message)),
|
|||
|
|
false,
|
|||
|
|
"shared abort gate must not attach one listener per concurrent READ",
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download SFTP range hashing resets inactivity on window progress", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-window-watchdog-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const chunkSize = TRANSFER_CHUNK_SIZE;
|
|||
|
|
const payload = Buffer.alloc(3 * chunkSize, 79);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const pendingReads = [];
|
|||
|
|
let pumping = false;
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("watchdog-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
pendingReads.push(() => {
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
if (!pumping && pendingReads.length >= 3) {
|
|||
|
|
pumping = true;
|
|||
|
|
// Serialize completions so wall time exceeds the inactivity budget,
|
|||
|
|
// but keep landing progress so a window watchdog stays armed.
|
|||
|
|
void (async () => {
|
|||
|
|
while (pendingReads.length > 0) {
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|||
|
|
pendingReads.shift()?.();
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should not hide window-watchdog behavior");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const error = new Error("SSH exec unavailable");
|
|||
|
|
error.code = "SSH_EXEC_UNAVAILABLE";
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
// 80ms per-request deadlines would kill the 3rd serialized read (~120ms),
|
|||
|
|
// but window inactivity resets on each completion.
|
|||
|
|
sftpReadTimeoutMs: 80,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download SFTP range hashing drops channel when CLOSE stalls", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-close-stall-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 81);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("close-stall-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
const slice = payload.subarray(position, end);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close() {
|
|||
|
|
// Never invoke the callback — CLOSE watchdog must abandon the channel.
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should not run for CLOSE-stall test");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
client: {
|
|||
|
|
exec(_request, callback) {
|
|||
|
|
const error = new Error("SSH exec unavailable");
|
|||
|
|
error.code = "SSH_EXEC_UNAVAILABLE";
|
|||
|
|
callback(error);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
sftpCloseTimeoutMs: 20,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.equal(client.sftp, null, "stalled CLOSE must drop the wedged SFTP channel");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix verification times out a stalled SFTP READ", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-timeout-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 74);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_path, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("stalled-handle"));
|
|||
|
|
},
|
|||
|
|
read() {},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial prefix stream should not hide a stalled range");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
sftpReadTimeoutMs: 20,
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
(error) => (
|
|||
|
|
/SFTP READ timed out/.test(error?.message || "")
|
|||
|
|
&& error?.sftpRequestTimedOut === true
|
|||
|
|
&& error?.noTransferFallback === true
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
assert.equal(client.sftp, null, "timed-out verification must drop the wedged SFTP channel");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("growing-download prefix stream fallback times out without data", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-stream-timeout-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 75);
|
|||
|
|
const localPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
return new Readable({ read() {} });
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
transferBridge._assertDownloadSourceAfterTransferForTests(
|
|||
|
|
{ size: payload.length, mtimeMs: 1 },
|
|||
|
|
{ size: payload.length + 1, mtimeMs: 2 },
|
|||
|
|
payload.length,
|
|||
|
|
{
|
|||
|
|
localPath,
|
|||
|
|
client,
|
|||
|
|
remotePath: "/var/log/app.log",
|
|||
|
|
signal: new AbortController().signal,
|
|||
|
|
sftpReadTimeoutMs: 20,
|
|||
|
|
preferSftpRanges: false,
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
(error) => (
|
|||
|
|
/SFTP stream timed out/.test(error?.message || "")
|
|||
|
|
&& error?.sftpRequestTimedOut === true
|
|||
|
|
&& error?.noTransferFallback === true
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
assert.equal(client.sftp, null, "stream timeout must drop the wedged SFTP channel");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable SFTP downloads succeed when the remote source only grows (live logs)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-growth-"));
|
|||
|
|
const transferId = "download-source-growth";
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true }).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Snapshot at transfer start; remote appends more bytes while ranges run.
|
|||
|
|
const snapshot = Buffer.alloc(64 * 1024, 71);
|
|||
|
|
const grownTail = Buffer.alloc(8 * 1024, 72);
|
|||
|
|
let remoteSize = snapshot.length;
|
|||
|
|
let mtimeMs = 1_000;
|
|||
|
|
const remotePayload = () => Buffer.concat([snapshot, Buffer.alloc(Math.max(0, remoteSize - snapshot.length), 72)]);
|
|||
|
|
const serveReadStream = (_remotePath, options = {}) => {
|
|||
|
|
const current = remotePayload();
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : current.length - 1;
|
|||
|
|
return Readable.from([Buffer.from(current.subarray(start, end + 1))]);
|
|||
|
|
};
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
// First range completion simulates an append-only log writer.
|
|||
|
|
if (remoteSize === snapshot.length) {
|
|||
|
|
remoteSize = snapshot.length + grownTail.length;
|
|||
|
|
mtimeMs += 1;
|
|||
|
|
}
|
|||
|
|
const current = remotePayload();
|
|||
|
|
current.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream: serveReadStream,
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
// Shared session is used for post-transfer prefix verification on growth.
|
|||
|
|
sftp: createFastSftp({ createReadStream: serveReadStream }),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: remoteSize,
|
|||
|
|
mtimeMs,
|
|||
|
|
ctimeMs: mtimeMs,
|
|||
|
|
mtime: mtimeMs / 1000,
|
|||
|
|
ctime: mtimeMs / 1000,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/var/log/app.log",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: snapshot.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(remoteSize, snapshot.length + grownTail.length);
|
|||
|
|
const downloaded = await fs.promises.readFile(targetPath);
|
|||
|
|
assert.equal(downloaded.length, snapshot.length);
|
|||
|
|
assert.deepEqual(downloaded, snapshot);
|
|||
|
|
await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable SFTP downloads reject growth when the planned prefix was rewritten", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-rewrite-growth-"));
|
|||
|
|
const transferId = "download-source-rewrite-growth";
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true }).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Mid-transfer rotate: prefix bytes change and the file also grows.
|
|||
|
|
const snapshot = Buffer.alloc(64 * 1024, 71);
|
|||
|
|
const rewritten = Buffer.alloc(64 * 1024, 81);
|
|||
|
|
const grownTail = Buffer.alloc(8 * 1024, 72);
|
|||
|
|
let useRewritten = false;
|
|||
|
|
let remoteSize = snapshot.length;
|
|||
|
|
let mtimeMs = 1_000;
|
|||
|
|
const remotePayload = () => {
|
|||
|
|
const head = useRewritten ? rewritten : snapshot;
|
|||
|
|
if (remoteSize <= head.length) return Buffer.from(head.subarray(0, remoteSize));
|
|||
|
|
return Buffer.concat([head, Buffer.alloc(remoteSize - head.length, 72)]);
|
|||
|
|
};
|
|||
|
|
const serveReadStream = (_remotePath, options = {}) => {
|
|||
|
|
const current = remotePayload();
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : current.length - 1;
|
|||
|
|
return Readable.from([Buffer.from(current.subarray(start, end + 1))]);
|
|||
|
|
};
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
// After half the snapshot is staged from the original prefix, rotate the
|
|||
|
|
// remote file in place and grow it so finish-path growth acceptance is
|
|||
|
|
// the only remaining safety net if samples miss the hole.
|
|||
|
|
if (!useRewritten && position >= snapshot.length / 2) {
|
|||
|
|
useRewritten = true;
|
|||
|
|
remoteSize = snapshot.length + grownTail.length;
|
|||
|
|
mtimeMs += 1;
|
|||
|
|
}
|
|||
|
|
const current = remotePayload();
|
|||
|
|
current.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream: serveReadStream,
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({ createReadStream: serveReadStream }),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: remoteSize,
|
|||
|
|
mtimeMs,
|
|||
|
|
ctimeMs: mtimeMs,
|
|||
|
|
mtime: mtimeMs / 1000,
|
|||
|
|
ctime: mtimeMs / 1000,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/var/log/app.log",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: snapshot.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Sample verify or full planned-prefix proof must reject the mixed file.
|
|||
|
|
assert.match(result.error || "", /source|content|changed/i);
|
|||
|
|
await assert.rejects(fs.promises.stat(stagedPath), { code: "ENOENT" });
|
|||
|
|
if (fs.existsSync(targetPath)) {
|
|||
|
|
assert.notEqual((await fs.promises.stat(targetPath)).size, snapshot.length);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("checkpoint-complete resume verifies growth with bounded prefix ranges", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-complete-checkpoint-"));
|
|||
|
|
const transferId = "download-complete-checkpoint-growth";
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true }).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Staged file already holds the full planned snapshot; remote has grown since.
|
|||
|
|
const snapshot = Buffer.alloc(16 * 1024, 91);
|
|||
|
|
const grownRemote = Buffer.concat([snapshot, Buffer.alloc(4 * 1024, 92)]);
|
|||
|
|
await fs.promises.mkdir(path.dirname(stagedPath), { recursive: true });
|
|||
|
|
await fs.promises.writeFile(stagedPath, snapshot);
|
|||
|
|
// Prefix fingerprint keeps the checkpoint (meta: fingerprints are cleared
|
|||
|
|
// and restart from zero before downloadFile runs). Matches the planned
|
|||
|
|
// snapshot size so append growth on the remote does not fail identity.
|
|||
|
|
const digest = crypto.createHash("sha256").update(snapshot).digest("hex");
|
|||
|
|
const sourceFingerprint = `sha256:p${snapshot.length}:${digest}`;
|
|||
|
|
|
|||
|
|
let bodyOpenAtOldEof = false;
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let verifyRangeReads = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
openCalls += 1;
|
|||
|
|
callback(null, Buffer.from("verify-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (position >= snapshot.length) {
|
|||
|
|
bodyOpenAtOldEof = true;
|
|||
|
|
throw new Error("must not READ past planned snapshot");
|
|||
|
|
}
|
|||
|
|
verifyRangeReads += 1;
|
|||
|
|
const end = Math.min(position + length, snapshot.length);
|
|||
|
|
snapshot.subarray(position, end).copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, end - position));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream(_remotePath, options = {}) {
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
// Prefix verification may sample [0, snapshot); body open at EOF is the bug.
|
|||
|
|
if (start >= snapshot.length) bodyOpenAtOldEof = true;
|
|||
|
|
// Allow prefix hash reads for resume verification; reject transfer-body EOF opens.
|
|||
|
|
if (start >= snapshot.length) {
|
|||
|
|
throw new Error("must not open createReadStream at planned EOF");
|
|||
|
|
}
|
|||
|
|
return Readable.from([Buffer.from(grownRemote.subarray(start, (options.end ?? start) + 1))]);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: grownRemote.length,
|
|||
|
|
mtimeMs: 2_000,
|
|||
|
|
ctimeMs: 2_000,
|
|||
|
|
mtime: 2,
|
|||
|
|
ctime: 2,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/var/log/app.log",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: snapshot.length,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: snapshot.length,
|
|||
|
|
sourceFingerprint,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.ok(verifyRangeReads > 0, "checkpoint-complete growth must use bounded prefix ranges");
|
|||
|
|
assert.ok(openCalls > 0, "prefix verification may open a read handle at offset 0");
|
|||
|
|
assert.equal(bodyOpenAtOldEof, false, "must not open transfer body at the old EOF");
|
|||
|
|
const downloaded = await fs.promises.readFile(targetPath);
|
|||
|
|
assert.deepEqual(downloaded, snapshot);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("zero-byte snapshot is complete when remote has grown (no body open)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-empty-growth-"));
|
|||
|
|
const transferId = "download-empty-snapshot-growth";
|
|||
|
|
const targetPath = path.join(tempDir, "empty.log");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true }).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Preflight sees an empty file; later stats report append growth. The body
|
|||
|
|
// path must not open (unbounded read would pull the tail and fail).
|
|||
|
|
const grownRemote = Buffer.from("appended-after-empty-snapshot");
|
|||
|
|
let remoteSize = 0;
|
|||
|
|
let bodyOpened = false;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open() {
|
|||
|
|
bodyOpened = true;
|
|||
|
|
throw new Error("must not OPEN body for zero-byte snapshot");
|
|||
|
|
},
|
|||
|
|
read() {
|
|||
|
|
bodyOpened = true;
|
|||
|
|
throw new Error("must not READ body for zero-byte snapshot");
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
bodyOpened = true;
|
|||
|
|
throw new Error("must not createReadStream for zero-byte snapshot");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: remoteSize,
|
|||
|
|
mtimeMs: remoteSize > 0 ? 3_000 : 1_000,
|
|||
|
|
ctimeMs: remoteSize > 0 ? 3_000 : 1_000,
|
|||
|
|
mtime: remoteSize > 0 ? 3 : 1,
|
|||
|
|
ctime: remoteSize > 0 ? 3 : 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
// Grow after the planned empty snapshot is fixed (async gap before body I/O).
|
|||
|
|
const growTimer = setTimeout(() => { remoteSize = grownRemote.length; }, 0);
|
|||
|
|
t.after(() => clearTimeout(growTimer));
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/var/log/empty-then-grown.log",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 0,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(bodyOpened, false, "zero-byte planned snapshot must not open a source body");
|
|||
|
|
const downloaded = await fs.promises.readFile(targetPath);
|
|||
|
|
assert.equal(downloaded.length, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("omitted totalBytes discovers remote size and downloads non-empty body", async (t) => {
|
|||
|
|
// Explicit totalBytes:0 is an empty snapshot; omitting totalBytes must STAT
|
|||
|
|
// and READ the remote (external-editor temp download path, #2787).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-omit-total-"));
|
|||
|
|
const transferId = "download-omit-total-bytes";
|
|||
|
|
const targetPath = path.join(tempDir, "report.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true }).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const expected = Buffer.from("external-editor-contents");
|
|||
|
|
let statCalls = 0;
|
|||
|
|
let bodyOpened = false;
|
|||
|
|
const { sftp: sharedSftp } = createPipelinedDownloadSftp(expected, {
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
bodyOpened = true;
|
|||
|
|
if (typeof flags === "function") {
|
|||
|
|
flags(null, Buffer.from("read-handle"));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, Buffer.from("read-handle"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
statCalls += 1;
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: expected.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/remote/report.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
// intentionally omit totalBytes
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.ok(statCalls >= 1, "omitted totalBytes must discover remote size via STAT");
|
|||
|
|
assert.equal(bodyOpened, true, "omitted totalBytes must open remote body for non-empty file");
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), expected);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP uploads fail when remote size does not match local size", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-size-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "archive.zip");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(1024 * 1024));
|
|||
|
|
|
|||
|
|
let deletedRemotePath = null;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
// Simulate a truncated remote file after a "successful" fastPut.
|
|||
|
|
return Promise.resolve({ size: 512 * 1024 });
|
|||
|
|
},
|
|||
|
|
delete(remotePath) {
|
|||
|
|
deletedRemotePath = remotePath;
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-truncated",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/archive.zip",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /Upload size mismatch/);
|
|||
|
|
assert.match(String(deletedRemotePath), /\.netcatty-upload-.*archive\.zip\.part$/);
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("uploads prefer concurrent shared channel over serial WriteStream", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-concurrent-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 91);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let maxInFlight = 0;
|
|||
|
|
let activeWrites = 0;
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
callback(null, Buffer.from("shared-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
activeWrites += 1;
|
|||
|
|
maxInFlight = Math.max(maxInFlight, activeWrites);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeWrites -= 1;
|
|||
|
|
callback(null);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not be used when concurrent works");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
// Force the isolated-channel path to be unavailable (sudo / no secondary channel).
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-shared-concurrent",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
// Pipelined fanout must be multi-WRITE (not serial 1-in-flight).
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlight >= 2,
|
|||
|
|
`expected pipelined concurrency >= 2, got ${maxInFlight}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlight <= UPLOAD_TRANSFER_CONCURRENCY,
|
|||
|
|
`expected concurrency <= ${UPLOAD_TRANSFER_CONCURRENCY}, got ${maxInFlight}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during stalled shared upload OPEN drains before remote cleanup", async (t) => {
|
|||
|
|
// Codex P2 on a7f96c94: cancel rejected shared write OPEN immediately, so
|
|||
|
|
// runRemoteUploadTransaction cleaned up the stage before a late "w" OPEN
|
|||
|
|
// recreated it — leaving an orphan .part (or truncating an in-place target).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-drain-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 41));
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
}
|
|||
|
|
return Promise.resolve({ size: remoteFiles.get(key).length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-drain-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
|
|||
|
|
// Must still be waiting for OPEN drain — immediate settle would be the bug.
|
|||
|
|
let settledEarly = false;
|
|||
|
|
await Promise.race([
|
|||
|
|
running.then(() => { settledEarly = true; }),
|
|||
|
|
new Promise((resolve) => setTimeout(resolve, 100)),
|
|||
|
|
]);
|
|||
|
|
assert.equal(settledEarly, false, "shared write OPEN cancel must drain before settle");
|
|||
|
|
|
|||
|
|
releaseOpen?.();
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("transfer did not settle after drained shared write OPEN")), 3000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
|
|||
|
|
const closeIdx = eventLog.indexOf("close");
|
|||
|
|
const deleteIdx = eventLog.findIndex((entry) => entry.startsWith("delete:"));
|
|||
|
|
assert.ok(closeIdx >= 0, `expected CLOSE before cleanup, log=${eventLog.join(",")}`);
|
|||
|
|
assert.ok(deleteIdx >= 0, `expected stage delete after CLOSE, log=${eventLog.join(",")}`);
|
|||
|
|
assert.ok(closeIdx < deleteIdx, `CLOSE must precede cleanup delete, log=${eventLog.join(",")}`);
|
|||
|
|
assert.equal(
|
|||
|
|
[...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")),
|
|||
|
|
false,
|
|||
|
|
`expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload OPEN drain force-completes when cancel has no OPEN callback", async (t) => {
|
|||
|
|
// Codex P2 on cd57d960: cancel settle without force-complete left
|
|||
|
|
// sharedWriteOpenDrain pending forever when OPEN never arrived and no
|
|||
|
|
// channel error fired, hanging the transfer and SFTP lease.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-cancel-no-callback-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 51));
|
|||
|
|
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let openCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, _callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
openCalls += 1;
|
|||
|
|
// Never invoke the OPEN callback (stalled shared channel, no error).
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close() {
|
|||
|
|
throw new Error("CLOSE must not run without an OPEN handle");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete() {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-cancel-no-callback";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-cancel-hang.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => openCalls >= 1, 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting shared write OPEN drain after cancel")),
|
|||
|
|
5500,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload cancel settle does not hang when best-effort unlink never callbacks", async (t) => {
|
|||
|
|
// Codex P2 on 3f0f4608: cancel + OPEN "w" waited for unlink before finishCancel.
|
|||
|
|
// A dead shared channel that never invokes unlink left the transfer/lease active.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-unlink-hang-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61));
|
|||
|
|
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let unlinkCalls = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let openCallback = null;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
openCalls += 1;
|
|||
|
|
openCallback = callback;
|
|||
|
|
},
|
|||
|
|
unlink(_remotePath, _callback) {
|
|||
|
|
unlinkCalls += 1;
|
|||
|
|
// Never invoke unlink callback (stalled shared channel).
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback?.(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete() {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-cancel-unlink-hang";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-cancel-unlink-hang.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => openCalls >= 1 && openCallback, 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall with callback held");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
// OPEN returns after cancel — triggers close + best-effort unlink path.
|
|||
|
|
openCallback(null, Buffer.from("handle:hang-unlink"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting best-effort unlink after cancel")),
|
|||
|
|
5500,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.ok(unlinkCalls >= 1, "expected best-effort unlink after cancel OPEN");
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload OPEN drain survives cancel settle timeout", async (t) => {
|
|||
|
|
// Codex P2 on af9cef2e / 0cda4a39 / cd57d960: cancel settle + drain
|
|||
|
|
// force-complete must not hang, and a late truncating OPEN on a generated
|
|||
|
|
// stage after that must close + unlink so stage cleanup cannot leave a
|
|||
|
|
// recreate orphan.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-drain-timeout-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 43));
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
}
|
|||
|
|
return Promise.resolve({ size: remoteFiles.get(key).length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-drain-timeout";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-timeout.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
|
|||
|
|
// Past cancel settle (2s) + drain force-complete (2s): transfer must settle
|
|||
|
|
// without an OPEN callback so lease/admission cannot hang.
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("transfer hung after cancel OPEN drain force-complete")), 5500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
|
|||
|
|
// Late OPEN after force-complete — must close + unlink, no orphan stage.
|
|||
|
|
releaseOpen?.();
|
|||
|
|
const lateCleanup = await waitUntil(
|
|||
|
|
() => eventLog.includes("close") && eventLog.some((entry) => entry.startsWith("unlink:")),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(lateCleanup, `expected late OPEN close+unlink, log=${eventLog.join(",")}`);
|
|||
|
|
assert.equal(
|
|||
|
|
[...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")),
|
|||
|
|
false,
|
|||
|
|
`expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("in-place shared upload cancel does not unlink final target after late OPEN", async (t) => {
|
|||
|
|
// Codex P1 on a9f748c8: cancel+truncating "w" unlinked filePath unconditionally,
|
|||
|
|
// so late OPEN on an in-place/symlink destination could delete the final target.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-inplace-open-cancel-nounlink-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 53));
|
|||
|
|
const targetPath = "/tmp/inplace-link.bin";
|
|||
|
|
const existingPayload = Buffer.from("keep-me");
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from(existingPayload)]]);
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
assert.equal(key, targetPath, "in-place upload must OPEN the final target");
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-inplace-open-cancel-nounlink";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared in-place write OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("transfer hung after in-place cancel OPEN drain force-complete")), 5500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
|
|||
|
|
releaseOpen?.();
|
|||
|
|
const lateClose = await waitUntil(() => eventLog.includes("close"), 2000);
|
|||
|
|
assert.ok(lateClose, `expected late OPEN close, log=${eventLog.join(",")}`);
|
|||
|
|
assert.equal(
|
|||
|
|
eventLog.some((entry) => entry.startsWith("unlink:")),
|
|||
|
|
false,
|
|||
|
|
`in-place cancel must not unlink final target, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
eventLog.some((entry) => entry === `delete:${targetPath}`),
|
|||
|
|
false,
|
|||
|
|
`in-place cancel must not delete final target, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(remoteFiles.has(targetPath), "final in-place target must still exist after cancel");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload OPEN drain survives channel error before OPEN callback", async (t) => {
|
|||
|
|
// Codex P2 on 532cf6cc: channel error completed sharedWriteOpenDrain before the
|
|||
|
|
// OPEN callback, so cleanup could race a late truncating OPEN.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-drain-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 47));
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
}
|
|||
|
|
return Promise.resolve({ size: remoteFiles.get(key).length });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-channel-error-drain";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-channel-error.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died during upload OPEN"));
|
|||
|
|
|
|||
|
|
// Channel error settles the OPEN wait, but cleanup must wait for drain.
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|||
|
|
assert.equal(eventLog.some((entry) => entry.startsWith("delete:")), false,
|
|||
|
|
"cleanup must not run before late OPEN drain after channel error");
|
|||
|
|
|
|||
|
|
releaseOpen?.();
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("transfer did not settle after channel error + late OPEN")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected transfer to fail after channel error");
|
|||
|
|
assert.match(result.error, /shared SFTP channel died during upload OPEN/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.ok(eventLog.includes("close"), `expected CLOSE after late OPEN, log=${eventLog.join(",")}`);
|
|||
|
|
// Resumable uploads preserve the stage on error (not cancel), so delete may
|
|||
|
|
// not run; the invariant is drain completed (CLOSE) before the failure path
|
|||
|
|
// returned, with no racing truncating OPEN after settle.
|
|||
|
|
assert.ok(
|
|||
|
|
eventLog.indexOf("close") >= 0
|
|||
|
|
&& eventLog.findIndex((entry) => entry.startsWith("open-created:")) >= 0
|
|||
|
|
&& eventLog.indexOf("close")
|
|||
|
|
> eventLog.findIndex((entry) => entry.startsWith("open-created:")),
|
|||
|
|
`late OPEN must be closed before failure returns, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload OPEN drain force-completes when channel error has no OPEN callback", async (t) => {
|
|||
|
|
// Codex P2 on 90494c0c: channel error rejected OPEN but left sharedWriteOpenDrain
|
|||
|
|
// pending forever when the OPEN callback never arrived, hanging the transfer
|
|||
|
|
// and SFTP lease on a dead shared channel.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-no-callback-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 49));
|
|||
|
|
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let openCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, _callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
openCalls += 1;
|
|||
|
|
// Never invoke the OPEN callback (dead channel after error).
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
close() {
|
|||
|
|
throw new Error("CLOSE must not run without an OPEN handle");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
return Promise.reject(error);
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete() {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-channel-error-no-callback";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-channel-error-hang.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => openCalls >= 1, 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died with no OPEN callback"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting shared write OPEN drain after channel error")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected transfer to fail after channel error");
|
|||
|
|
assert.match(result.error, /shared SFTP channel died with no OPEN callback/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fastPut fallback cancel settles while prior isolated write OPEN gate is unresolved", async (t) => {
|
|||
|
|
// #2755 / Codex P2 on 667e9115: non-resumable concurrent-isolated OPEN emits a
|
|||
|
|
// channel error without invoking its callback, so pendingWriteOpenPathGate
|
|||
|
|
// stays unresolved by design. uploadFile then awaits that gate before
|
|||
|
|
// fastPut; uploadFileConcurrent already cleared transfer.abort, so Cancel
|
|||
|
|
// must still settle the transfer (not hang on the unconditional await).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-gate-cancel-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 61);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-fastput-gate-cancel.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let fastPutCalls = 0;
|
|||
|
|
let reopenedEnded = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
open(_remotePath, flags, _cb) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
// Never invoke the OPEN callback (dead isolated channel).
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
close() {
|
|||
|
|
throw new Error("CLOSE must not run without an OPEN handle");
|
|||
|
|
},
|
|||
|
|
fastPut() {
|
|||
|
|
throw new Error("first isolated channel must not fastPut");
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// Reopened channel for fastPut fallback — would succeed if reached.
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
fastPut(_local, _remote, _opts, cb) {
|
|||
|
|
fastPutCalls += 1;
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
reopenedEnded += 1;
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-fastput-gate-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected concurrent-isolated write OPEN to stall");
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
// Wait until uploadFile has fallen through concurrent and reopened for fastPut
|
|||
|
|
// (second isolated channel), which is when it awaits pendingWriteOpenPathGate.
|
|||
|
|
const reopened = await waitUntil(() => isolatedChannelCount >= 2, 2000);
|
|||
|
|
assert.ok(reopened, "expected fastPut isolated channel reopen while OPEN gate pending");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting pendingWriteOpenPathGate before fastPut")),
|
|||
|
|
1500,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(fastPutCalls, 0, "cancel must settle before fastPut runs on unresolved gate");
|
|||
|
|
assert.equal(reopenedEnded, 1, "cancel during gate wait must end the reopened isolated channel");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fastPut fallback fails closed when prior isolated write OPEN gate never settles", async (t) => {
|
|||
|
|
// #2755 companion: without cancel, a dead isolated OPEN that never callbacks
|
|||
|
|
// must not pin the transfer forever on pendingWriteOpenPathGate, and must not
|
|||
|
|
// fall through to another writer that races the still-pending truncate.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fastput-gate-timeout-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 62);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-fastput-gate-timeout.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let fastPutCalls = 0;
|
|||
|
|
let reopenedEnded = 0;
|
|||
|
|
let sharedWriteOpens = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
sharedWriteOpens += 1;
|
|||
|
|
callback(null, Buffer.from(`shared-handle:${flags}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run after noTransferFallback gate timeout");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
open(_remotePath, flags) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
fastPut(_local, _remote, _opts, cb) {
|
|||
|
|
fastPutCalls += 1;
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
reopenedEnded += 1;
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-fastput-gate-timeout";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected concurrent-isolated write OPEN to stall");
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting pendingWriteOpenPathGate before fastPut")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /Timed out waiting for prior write OPEN to settle before fastPut/i);
|
|||
|
|
assert.equal(fastPutCalls, 0, "must not fastPut while prior OPEN gate is unresolved");
|
|||
|
|
assert.equal(sharedWriteOpens, 0, "must not fall through to shared write after gate timeout");
|
|||
|
|
assert.equal(reopenedEnded, 1, "gate timeout must end the reopened isolated channel");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("later same-path upload fails promptly after prior write OPEN gate is poisoned", async (t) => {
|
|||
|
|
// Codex P2 on dca41093: after the fail-closed fastPut gate timeout, the dead
|
|||
|
|
// OPEN's truncatingSharedWriteOpenGates entry must not leave a later in-place
|
|||
|
|
// upload blocked forever in pathGate.waitForPrior. Use a symlink destination
|
|||
|
|
// so both attempts OPEN the same final path (allowInPlaceFallback). Subsequent
|
|||
|
|
// waiters must fail promptly (noTransferFallback) while the barrier stays
|
|||
|
|
// fail-closed — they must not hang and must not issue another truncating OPEN.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 63);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-inplace-gate-fail-closed.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstOpenPath = null;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let laterWriteOpens = 0;
|
|||
|
|
const symlinkLstat = (remotePath, callback) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
laterWriteOpens += 1;
|
|||
|
|
callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run after fail-closed path gate");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
assert.equal(String(remotePath), targetPath, "first attempt must OPEN in-place final path");
|
|||
|
|
firstOpenPath = String(remotePath);
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// Later isolated channels: OPEN must not be reached while the path gate
|
|||
|
|
// is fail-closed; count attempts if the barrier is incorrectly released.
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, cb) {
|
|||
|
|
laterWriteOpens += 1;
|
|||
|
|
cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
fastPut(_local, _remote, _opts, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstSender = createSender();
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: firstSender },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-fail-closed-first",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected concurrent-isolated in-place write OPEN to stall");
|
|||
|
|
assert.equal(firstOpenPath, targetPath);
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("first transfer hung awaiting pendingWriteOpenPathGate before fastPut")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(
|
|||
|
|
firstResult.error || "",
|
|||
|
|
/isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Later same-path in-place upload must fail promptly — not hang on the
|
|||
|
|
// unresolved truncatingSharedWriteOpenGates entry (Codex P2).
|
|||
|
|
const laterSender = createSender();
|
|||
|
|
const laterStartedAt = Date.now();
|
|||
|
|
const laterRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: laterSender },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-fail-closed-later",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const laterResult = await Promise.race([
|
|||
|
|
laterRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("later same-path upload hung on unresolved write OPEN path gate")),
|
|||
|
|
1500,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(
|
|||
|
|
Date.now() - laterStartedAt < 1500,
|
|||
|
|
"later upload must settle promptly after fail-closed path gate",
|
|||
|
|
);
|
|||
|
|
assert.ok(laterResult.error, "expected later upload to fail closed on unresolved path gate");
|
|||
|
|
assert.match(
|
|||
|
|
laterResult.error,
|
|||
|
|
/Timed out waiting for prior write OPEN|prior write OPEN never settled|path gate is fail-closed|write OPEN path gate|In-place write OPEN poison/i,
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
laterWriteOpens,
|
|||
|
|
0,
|
|||
|
|
"fail-closed path gate must not allow a later truncating write OPEN",
|
|||
|
|
);
|
|||
|
|
assert.equal(laterSender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("path gate poison survives a same-path waiter queued during fastPut timeout", async (t) => {
|
|||
|
|
// Codex P1 on 0292802c: if another upload replaces the map entry during the
|
|||
|
|
// 2s fastPut gate wait, fail() must still leave a fail-closed barrier so a
|
|||
|
|
// third upload cannot OPEN while the original truncating OPEN may land.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-poison-successor-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 64);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-inplace-gate-poison-successor.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let thirdWriteOpens = 0;
|
|||
|
|
const symlinkLstat = (remotePath, callback) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open() {
|
|||
|
|
throw new Error("shared OPEN must not run while path gate is fail-closed");
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run after fail-closed path gate");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
assert.equal(String(remotePath), targetPath);
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, cb) {
|
|||
|
|
thirdWriteOpens += 1;
|
|||
|
|
cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
fastPut(_local, _remote, _opts, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-successor-first",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected first in-place write OPEN to stall");
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
// In-place OPEN poison is terminal immediately (no fastPut wait). Queue a
|
|||
|
|
// second same-path upload while the first is settling so fail() must still
|
|||
|
|
// leave a fail-closed barrier for a third upload.
|
|||
|
|
const secondRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-successor-second",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(
|
|||
|
|
firstResult.error || "",
|
|||
|
|
/isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const secondResult = await Promise.race([
|
|||
|
|
secondRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("second transfer hung after prior gate poison")), 1500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(secondResult.error, "expected second waiter to fail closed after poison");
|
|||
|
|
assert.match(
|
|||
|
|
secondResult.error,
|
|||
|
|
/prior write OPEN never settled|path gate is fail-closed|In-place write OPEN poison/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const thirdStartedAt = Date.now();
|
|||
|
|
const thirdRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-successor-third",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const thirdResult = await Promise.race([
|
|||
|
|
thirdRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("third upload hung or raced after successor release")), 1500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(Date.now() - thirdStartedAt < 1500, "third upload must settle promptly");
|
|||
|
|
assert.ok(thirdResult.error, "expected third upload to fail closed");
|
|||
|
|
assert.match(thirdResult.error, /prior write OPEN never settled|path gate is fail-closed/i);
|
|||
|
|
assert.equal(thirdWriteOpens, 0, "poisoned barrier must survive successor waiter release");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("path gate poison clears after late original OPEN settles past successor", async (t) => {
|
|||
|
|
// Codex P2 on 64450bfd: after fail() poisons a queued successor, the OPEN
|
|||
|
|
// owner's late callback must still be able to release the barrier. Successor
|
|||
|
|
// fail propagation must not steal the map slot, or every later upload stays
|
|||
|
|
// fail-closed forever even though the dangerous OPEN has settled.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-poison-clear-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 66);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-inplace-gate-poison-clear.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let releaseFirstOpen = null;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let postClearWriteOpens = 0;
|
|||
|
|
const symlinkLstat = (remotePath, callback) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run in this test");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, openCb) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
assert.equal(String(remotePath), targetPath);
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
releaseFirstOpen = () => {
|
|||
|
|
openCb(null, Buffer.from(`late-handle:${remotePath}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
unlink(_remotePath, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, cb) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.from(payload));
|
|||
|
|
cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
fastPut(local, remote, _opts, cb) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
remoteFiles.set(String(remote), Buffer.from(payload));
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-clear-first",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected first in-place write OPEN to stall");
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
const secondRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-clear-second",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(
|
|||
|
|
firstResult.error || "",
|
|||
|
|
/isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await Promise.race([
|
|||
|
|
secondRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("second transfer hung after prior gate poison")), 1500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Late OPEN from the original attempt settles and must clear the owner's
|
|||
|
|
// poisoned barrier even though a successor was poisoned during the wait.
|
|||
|
|
assert.equal(typeof releaseFirstOpen, "function", "expected late OPEN callback to be capturable");
|
|||
|
|
releaseFirstOpen();
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|||
|
|
|
|||
|
|
const thirdRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-poison-clear-third",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const thirdResult = await Promise.race([
|
|||
|
|
thirdRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("third upload hung after late OPEN should have cleared poison")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.notEqual(
|
|||
|
|
thirdResult.error && /prior write OPEN never settled|path gate is fail-closed/i.test(thirdResult.error),
|
|||
|
|
true,
|
|||
|
|
`third upload must not stay permanently fail-closed after late OPEN release, error=${thirdResult.error}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
postClearWriteOpens > 0
|
|||
|
|
|| (thirdResult.transferId && thirdResult.error == null && thirdResult.cancelled !== true)
|
|||
|
|
|| thirdResult.cancelled === true
|
|||
|
|
|| thirdResult.error,
|
|||
|
|
`expected third upload to settle after barrier clear, result=${JSON.stringify(thirdResult)}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("later same-path upload fails promptly after cancel during unresolved OPEN gate wait", async (t) => {
|
|||
|
|
// Codex P2 on 0292802c: cancel during the fastPut pending-gate wait must
|
|||
|
|
// poison the shared path gate, same as the idle timeout path.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-cancel-poison-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 65);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-inplace-gate-cancel-poison.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let laterWriteOpens = 0;
|
|||
|
|
const symlinkLstat = (remotePath, callback) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open() {
|
|||
|
|
laterWriteOpens += 1;
|
|||
|
|
throw new Error("shared OPEN must not run while path gate is fail-closed");
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run after fail-closed path gate");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
assert.equal(String(remotePath), targetPath);
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, cb) {
|
|||
|
|
laterWriteOpens += 1;
|
|||
|
|
cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
fastPut(_local, _remote, _opts, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstTransferId = "upload-inplace-gate-cancel-poison-first";
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: firstTransferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected first in-place write OPEN to stall");
|
|||
|
|
|
|||
|
|
// Cancel while the in-place OPEN is still unsettled. After the settle timeout
|
|||
|
|
// the path gate is poisoned so later same-path uploads fail promptly.
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: firstTransferId });
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("first transfer hung on cancel during OPEN")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(firstResult.error || "", /cancel/i);
|
|||
|
|
|
|||
|
|
const laterStartedAt = Date.now();
|
|||
|
|
const laterRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-cancel-poison-later",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const laterResult = await Promise.race([
|
|||
|
|
laterRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("later upload hung after cancel left path gate unresolved")), 1500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(Date.now() - laterStartedAt < 1500, "later upload must settle promptly after cancel poison");
|
|||
|
|
assert.ok(laterResult.error, "expected later upload to fail closed");
|
|||
|
|
assert.match(laterResult.error, /prior write OPEN never settled|path gate is fail-closed/i);
|
|||
|
|
assert.equal(laterWriteOpens, 0, "cancel poison must not allow a later truncating write OPEN");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("path gate poison clears after owning isolated transport ends", async (t) => {
|
|||
|
|
// Codex P2 on 713719c2: a dead OPEN that never callbacks leaves a host-keyed
|
|||
|
|
// poison. Once the owning isolated channel has ended and a short grace
|
|||
|
|
// passes, clear the barrier so reconnect / later uploads are not
|
|||
|
|
// permanently fail-closed until process restart.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-gate-transport-clear-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 67);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
const targetPath = "/tmp/upload-inplace-gate-transport-clear.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old")]]);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let isolatedChannelCount = 0;
|
|||
|
|
let firstOpenStarted = false;
|
|||
|
|
let firstIsolated = null;
|
|||
|
|
let postClearWriteOpens = 0;
|
|||
|
|
const symlinkLstat = (remotePath, callback) => {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (key.includes(".netcatty-backup-") || key.includes(".netcatty-upload-") || key.includes(".netcatty-")) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.from(payload));
|
|||
|
|
callback(null, Buffer.from(`shared-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("stream fallback must not run in this test");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
isolatedChannelCount += 1;
|
|||
|
|
if (isolatedChannelCount === 1) {
|
|||
|
|
firstIsolated = createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
assert.equal(String(remotePath), targetPath);
|
|||
|
|
firstOpenStarted = true;
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
this.emit("end");
|
|||
|
|
this.emit("close");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
callback(null, firstIsolated);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, createFastSftp({
|
|||
|
|
lstat: symlinkLstat,
|
|||
|
|
open(remotePath, flags, cb) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
remoteFiles.set(String(remotePath), Buffer.from(payload));
|
|||
|
|
cb(null, Buffer.from(`isolated-handle:${flags}:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, _position, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, cb) {
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
fastPut(local, remote, _opts, cb) {
|
|||
|
|
postClearWriteOpens += 1;
|
|||
|
|
remoteFiles.set(String(remote), Buffer.from(payload));
|
|||
|
|
cb(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-transport-clear-first",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const openReady = await waitUntil(() => firstOpenStarted, 2000);
|
|||
|
|
assert.ok(openReady, "expected first in-place write OPEN to stall");
|
|||
|
|
firstIsolated.emit("error", new Error("isolated SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("first transfer hung on gate timeout")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(
|
|||
|
|
firstResult.error || "",
|
|||
|
|
/isolated SFTP channel died during OPEN|In-place write OPEN poison|Timed out waiting for prior write OPEN/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Immediately after poison the barrier must still fail closed.
|
|||
|
|
const midRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-transport-clear-mid",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const midResult = await Promise.race([
|
|||
|
|
midRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("mid upload hung on poisoned gate")), 1500);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.match(midResult.error || "", /prior write OPEN never settled|path gate is fail-closed/i);
|
|||
|
|
|
|||
|
|
// After transport-gone grace, the host-keyed poison must clear.
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 2100));
|
|||
|
|
|
|||
|
|
const laterRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-inplace-gate-transport-clear-later",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const laterResult = await Promise.race([
|
|||
|
|
laterRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("later upload hung after transport-gone poison clear")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.notEqual(
|
|||
|
|
laterResult.error && /prior write OPEN never settled|path gate is fail-closed/i.test(laterResult.error),
|
|||
|
|
true,
|
|||
|
|
`later upload must not stay permanently fail-closed after transport end, error=${laterResult.error}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
postClearWriteOpens > 0
|
|||
|
|
|| (laterResult.transferId && laterResult.error == null && laterResult.cancelled !== true)
|
|||
|
|
|| laterResult.cancelled === true
|
|||
|
|
|| laterResult.error,
|
|||
|
|
`expected later upload to settle after transport-gone clear, result=${JSON.stringify(laterResult)}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload OPEN drain unlinks late staged OPEN after channel-error force-complete", async (t) => {
|
|||
|
|
// Codex P2 on bd42c51c: late truncating OPEN after channel-error drain
|
|||
|
|
// force-complete is not a cancel (`transfer.cancelled` false). Non-resumable
|
|||
|
|
// staged uploads already cleaned the stage after the forced drain; the late
|
|||
|
|
// "w" OPEN must still unlink the recreated `.netcatty-upload-*` orphan.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-upload-open-channel-error-late-unlink-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 55));
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "upload-shared-open-channel-error-late-unlink";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload-channel-error-late-unlink.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
// Non-resumable → cleanupStage runs after failure (no preserve-on-error).
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died before OPEN callback"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung awaiting shared write OPEN drain after channel error")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(result.error, "expected transfer to fail after channel error");
|
|||
|
|
assert.match(result.error, /shared SFTP channel died before OPEN callback/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
|
|||
|
|
// Late OPEN after drain force-complete — must close + unlink generated stage.
|
|||
|
|
releaseOpen?.();
|
|||
|
|
const lateCleanup = await waitUntil(
|
|||
|
|
() => eventLog.includes("close") && eventLog.some((entry) => entry.startsWith("unlink:")),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(lateCleanup, `expected late OPEN close+unlink after channel error, log=${eventLog.join(",")}`);
|
|||
|
|
assert.equal(
|
|||
|
|
[...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")),
|
|||
|
|
false,
|
|||
|
|
`expected no orphan staged upload, remaining=${[...remoteFiles.keys()].join(",")}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("late shared OPEN unlink still cleans non-resumable stage under same-id retry", async (t) => {
|
|||
|
|
// Codex P2 on 7c446c7: retry ownership must not suppress unlink for every
|
|||
|
|
// generatedStagePath. Non-resumable uploads use a unique
|
|||
|
|
// `.netcatty-upload-*` path per attempt; a stale late OPEN on attempt 1's
|
|||
|
|
// path must still unlink so the orphan is not left behind when a same-id
|
|||
|
|
// retry already owns activeTransfers.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-nonresume-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 59));
|
|||
|
|
const targetPath = "/tmp/upload-late-unlink-nonresume.bin";
|
|||
|
|
const transferId = "upload-shared-open-late-unlink-nonresume";
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseFirstOpen = null;
|
|||
|
|
let openGeneration = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseRetryWrites = null;
|
|||
|
|
const retryWritesReleased = new Promise((resolve) => {
|
|||
|
|
releaseRetryWrites = resolve;
|
|||
|
|
});
|
|||
|
|
/** @type {string | null} */
|
|||
|
|
let firstStagePath = null;
|
|||
|
|
/** @type {string | null} */
|
|||
|
|
let retryStagePath = null;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
openGeneration += 1;
|
|||
|
|
const generation = openGeneration;
|
|||
|
|
if (generation === 1) {
|
|||
|
|
firstStagePath = key;
|
|||
|
|
assert.match(key, /\.netcatty-upload-.*\.part$/, "first attempt must OPEN unique non-resumable stage");
|
|||
|
|
releaseFirstOpen = () => {
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
}
|
|||
|
|
eventLog.push(`open-created-1:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-1:${key}`));
|
|||
|
|
};
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
retryStagePath = key;
|
|||
|
|
assert.match(key, /\.netcatty-upload-.*\.part$/, "retry must OPEN a fresh non-resumable stage");
|
|||
|
|
assert.notEqual(key, firstStagePath, "non-resumable retry must not reuse first stage path");
|
|||
|
|
remoteFiles.set(key, Buffer.from("retry-stage"));
|
|||
|
|
eventLog.push(`open-created-${generation}:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-${generation}:${key}`));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
void retryWritesReleased.then(() => {
|
|||
|
|
const key = String(handle).replace(/^handle-\d+:/, "");
|
|||
|
|
const current = remoteFiles.get(key) || Buffer.alloc(0);
|
|||
|
|
const end = position + length;
|
|||
|
|
const next = Buffer.alloc(Math.max(current.length, end));
|
|||
|
|
current.copy(next);
|
|||
|
|
buffer.copy(next, position, offset, offset + length);
|
|||
|
|
remoteFiles.set(key, next);
|
|||
|
|
eventLog.push(`write:${key}:${length}@${position}`);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(handle, callback) {
|
|||
|
|
eventLog.push(`close:${String(handle)}`);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstSender = createSender();
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: firstSender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000);
|
|||
|
|
assert.ok(firstOpenReady, "expected first shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback"));
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(firstResult.error, "expected first transfer to fail after channel error");
|
|||
|
|
assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
|
|||
|
|
// Same transfer id retry (non-resumable) owns a different stage path before
|
|||
|
|
// the stale OPEN callback from attempt 1 arrives.
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
const retrySender = createSender();
|
|||
|
|
const retryRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: retrySender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const retryOpenReady = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
3000,
|
|||
|
|
);
|
|||
|
|
assert.ok(retryOpenReady, `expected retry OPEN, log=${eventLog.join(",")}`);
|
|||
|
|
assert.ok(retryStagePath, "retry must have opened a stage");
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.has(retryStagePath),
|
|||
|
|
"retry must own its unique stage before stale late OPEN",
|
|||
|
|
);
|
|||
|
|
const retryStageBeforeLateOpen = Buffer.from(remoteFiles.get(retryStagePath));
|
|||
|
|
|
|||
|
|
// Stale late OPEN from attempt 1: must close + unlink attempt-1 stage only.
|
|||
|
|
releaseFirstOpen?.();
|
|||
|
|
const lateCleanup = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("close:handle-1:"))
|
|||
|
|
&& eventLog.some((entry) => entry === `unlink:${firstStagePath}`),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
lateCleanup,
|
|||
|
|
`expected late close+unlink of first non-resumable stage, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
eventLog.some((entry) => entry === `unlink:${retryStagePath}`),
|
|||
|
|
false,
|
|||
|
|
`stale late OPEN must not unlink retry stage, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.has(retryStagePath),
|
|||
|
|
"retry stage must survive stale late OPEN on a different path",
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.get(retryStagePath).equals(retryStageBeforeLateOpen),
|
|||
|
|
"retry stage bytes must not be removed by stale late OPEN unlink",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
remoteFiles.has(firstStagePath),
|
|||
|
|
false,
|
|||
|
|
"first attempt non-resumable stage must be unlinked (no orphan)",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
releaseRetryWrites?.();
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const retryResult = await Promise.race([
|
|||
|
|
retryRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("retry transfer hung after cancel")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(
|
|||
|
|
retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")),
|
|||
|
|
`expected retry cancel settle, result=${JSON.stringify(retryResult)}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("late shared OPEN unlink skips same-id retry resume stage", async (t) => {
|
|||
|
|
// Codex P2 on d19ecb88 + P1 on 42a27ef7:
|
|||
|
|
// - After drain force-complete, a late truncating "w" must not unlink a
|
|||
|
|
// same-id retry's deterministic resume stage.
|
|||
|
|
// - Path gate serializes truncating OPENs on that path; if force-complete
|
|||
|
|
// already released the gate and the retry accepted its own OPEN, a late
|
|||
|
|
// stale OPEN that re-truncates must invalidate the retry rather than allow
|
|||
|
|
// a sparse promote.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-retry-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 57));
|
|||
|
|
const targetPath = "/tmp/upload-late-unlink-retry.bin";
|
|||
|
|
const transferId = "upload-shared-open-late-unlink-retry";
|
|||
|
|
const deterministicStagePath = `/tmp/.upload-late-unlink-retry.bin.netcatty-${transferId}.part`;
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseFirstOpen = null;
|
|||
|
|
let openGeneration = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseRetryWrites = null;
|
|||
|
|
const retryWritesReleased = new Promise((resolve) => {
|
|||
|
|
releaseRetryWrites = resolve;
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
openGeneration += 1;
|
|||
|
|
const generation = openGeneration;
|
|||
|
|
if (generation === 1) {
|
|||
|
|
assert.equal(key, deterministicStagePath, "first attempt must OPEN deterministic resume stage");
|
|||
|
|
releaseFirstOpen = () => {
|
|||
|
|
// Model a late server-side truncating OPEN: wipe whatever is at the
|
|||
|
|
// path when the stale OPEN finally applies.
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created-1:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-1:${key}`));
|
|||
|
|
};
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(key, Buffer.from("retry-stage"));
|
|||
|
|
eventLog.push(`open-created-${generation}:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-${generation}:${key}`));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
// Hold retry WRITEs so the stale OPEN can land mid-attempt.
|
|||
|
|
void retryWritesReleased.then(() => {
|
|||
|
|
const key = String(handle).replace(/^handle-\d+:/, "");
|
|||
|
|
const current = remoteFiles.get(key) || Buffer.alloc(0);
|
|||
|
|
const end = position + length;
|
|||
|
|
const next = Buffer.alloc(Math.max(current.length, end));
|
|||
|
|
current.copy(next);
|
|||
|
|
buffer.copy(next, position, offset, offset + length);
|
|||
|
|
remoteFiles.set(key, next);
|
|||
|
|
eventLog.push(`write:${key}:${length}@${position}`);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(handle, callback) {
|
|||
|
|
eventLog.push(`close:${String(handle)}`);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstSender = createSender();
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: firstSender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000);
|
|||
|
|
assert.ok(firstOpenReady, "expected first shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback"));
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(firstResult.error, "expected first transfer to fail after channel error");
|
|||
|
|
assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
|
|||
|
|
// Same-id retry reuses the deterministic stage. Drain force-complete does
|
|||
|
|
// not release the path gate: the retry must wait until the stale OPEN
|
|||
|
|
// callback settles (Codex P1 — no hard-cap race for different transferIds).
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
const retrySender = createSender();
|
|||
|
|
const retryRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: retrySender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|||
|
|
assert.equal(
|
|||
|
|
eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
false,
|
|||
|
|
`retry must not OPEN while prior gate is held, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Settling the first OPEN frees the gate; then the retry may proceed.
|
|||
|
|
releaseFirstOpen?.();
|
|||
|
|
const lateClose = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("close:handle-1:")),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(lateClose, `expected close of first OPEN handle, log=${eventLog.join(",")}`);
|
|||
|
|
|
|||
|
|
const retryOpenReady = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
assert.ok(retryOpenReady, `expected retry OPEN after prior gate release, log=${eventLog.join(",")}`);
|
|||
|
|
|
|||
|
|
releaseRetryWrites?.();
|
|||
|
|
const retryResult = await Promise.race([
|
|||
|
|
retryRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("retry transfer hung after prior OPEN settled")), 8000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
// Normal success is { transferId, totalBytes }; cancel/error also settle.
|
|||
|
|
assert.ok(
|
|||
|
|
retryResult
|
|||
|
|
&& (
|
|||
|
|
(retryResult.transferId && retryResult.error == null && retryResult.cancelled !== true)
|
|||
|
|
|| retryResult.cancelled === true
|
|||
|
|
|| retryResult.error
|
|||
|
|
),
|
|||
|
|
`expected retry to settle after gate release, result=${JSON.stringify(retryResult)}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("truncating shared OPEN path gate serializes same-path retry until prior OPEN settles", async (t) => {
|
|||
|
|
// Codex P1 on 42a27ef7: while attempt-1's truncating OPEN is still pending
|
|||
|
|
// (no force-complete yet), a same-id retry must not issue OPEN "w" on the
|
|||
|
|
// deterministic stage — wait for the prior gate.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-path-gate-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61));
|
|||
|
|
const targetPath = "/tmp/upload-path-gate.bin";
|
|||
|
|
const transferId = "upload-shared-open-path-gate";
|
|||
|
|
const deterministicStagePath = `/tmp/.upload-path-gate.bin.netcatty-${transferId}.part`;
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
const eventLog = [];
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseFirstOpen = null;
|
|||
|
|
let openGeneration = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseRetryWrites = null;
|
|||
|
|
const retryWritesReleased = new Promise((resolve) => {
|
|||
|
|
releaseRetryWrites = resolve;
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
openGeneration += 1;
|
|||
|
|
const generation = openGeneration;
|
|||
|
|
if (generation === 1) {
|
|||
|
|
releaseFirstOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`open-created-1:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-1:${key}`));
|
|||
|
|
};
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(key, Buffer.from("retry-after-gate"));
|
|||
|
|
eventLog.push(`open-created-${generation}:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-${generation}:${key}`));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
void retryWritesReleased.then(() => {
|
|||
|
|
const key = String(handle).replace(/^handle-\d+:/, "");
|
|||
|
|
const current = remoteFiles.get(key) || Buffer.alloc(0);
|
|||
|
|
const end = position + length;
|
|||
|
|
const next = Buffer.alloc(Math.max(current.length, end));
|
|||
|
|
current.copy(next);
|
|||
|
|
buffer.copy(next, position, offset, offset + length);
|
|||
|
|
remoteFiles.set(key, next);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(handle, callback) {
|
|||
|
|
eventLog.push(`close:${String(handle)}`);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
eventLog.push(`unlink:${String(remotePath)}`);
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
remoteFiles.delete(String(remotePath));
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstSender = createSender();
|
|||
|
|
// Do not emit channel error: keep OPEN pending without force-complete so the
|
|||
|
|
// path gate stays held until the OPEN callback.
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: firstSender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000);
|
|||
|
|
assert.ok(firstOpenReady, "expected first shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
// Start same-id retry while first OPEN is still pending (gate held).
|
|||
|
|
// Overwrite activeTransfers; first attempt is still awaiting OPEN.
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
const retrySender = createSender();
|
|||
|
|
const retryRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: retrySender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|||
|
|
assert.equal(
|
|||
|
|
eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
false,
|
|||
|
|
`retry must wait on path gate while prior truncating OPEN is pending, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Settle first OPEN (cancel it via late path): release callback as success
|
|||
|
|
// but first transfer may still be the waiter — force cancel first attempt.
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: `${transferId}-noop` }).catch(() => {});
|
|||
|
|
// First attempt is no longer active (retry replaced it). Release OPEN.
|
|||
|
|
releaseFirstOpen?.();
|
|||
|
|
const lateClose = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("close:handle-1:")),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(lateClose, `expected first OPEN to close after gate release, log=${eventLog.join(",")}`);
|
|||
|
|
|
|||
|
|
const retryOpenReady = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
3000,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
retryOpenReady,
|
|||
|
|
`expected retry OPEN after prior OPEN settled, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.get(deterministicStagePath)?.equals(Buffer.from("retry-after-gate")),
|
|||
|
|
"retry stage must be written by retry OPEN after gate, not wiped by ordering race",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
releaseRetryWrites?.();
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const retryResult = await Promise.race([
|
|||
|
|
retryRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("retry hung after path-gate test")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(
|
|||
|
|
retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")),
|
|||
|
|
`expected retry cancel settle, result=${JSON.stringify(retryResult)}`,
|
|||
|
|
);
|
|||
|
|
// First attempt may still be pending cancel/error; do not leave it hanging.
|
|||
|
|
void firstRunning.catch(() => {});
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("remoteOpenPathMatchesStaged compares logical and encoded OPEN paths", () => {
|
|||
|
|
// Codex: filePath can be a session-encoded Buffer while stagedRemote.path is
|
|||
|
|
// the logical string — strict === would fail and wrongly allow unlink of the
|
|||
|
|
// retry-owned stage.
|
|||
|
|
const match = transferBridge._remoteOpenPathMatchesStagedForTests;
|
|||
|
|
assert.equal(typeof match, "function");
|
|||
|
|
const logical = "/tmp/.upload.bin.netcatty-tid.part";
|
|||
|
|
assert.equal(
|
|||
|
|
match(logical, { path: logical, sftpId: null }),
|
|||
|
|
true,
|
|||
|
|
"identical logical strings match",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
match(Buffer.from(logical), { path: logical, sftpId: null }),
|
|||
|
|
true,
|
|||
|
|
"OPEN Buffer of logical path matches staged logical path",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
match(Buffer.from(logical), {
|
|||
|
|
path: logical,
|
|||
|
|
sftpId: "missing-session",
|
|||
|
|
encoding: "utf-8",
|
|||
|
|
}),
|
|||
|
|
true,
|
|||
|
|
"utf-8 encode of logical path still matches OPEN Buffer",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
match("/tmp/other.part", { path: logical, sftpId: null }),
|
|||
|
|
false,
|
|||
|
|
"different paths must not match",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
match(logical, null),
|
|||
|
|
false,
|
|||
|
|
"missing stagedRemote must not match",
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
match(logical, { path: "" }),
|
|||
|
|
false,
|
|||
|
|
"empty staged path must not match",
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("late shared OPEN unlink still cleans stage when same-id retry is in-place", async (t) => {
|
|||
|
|
// Codex P2 on 39e20bfa: skip late unlink only when active.stagedRemote.path
|
|||
|
|
// matches. A same-id in-place retry (symlink destination → writeInPlace)
|
|||
|
|
// keeps resumable + targetPath but stagedRemote is null; a stale late OPEN
|
|||
|
|
// on the leftover `.netcatty-<id>.part` must still unlink the orphan.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-open-late-unlink-inplace-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(TRANSFER_CHUNK_SIZE, 61));
|
|||
|
|
const targetPath = "/tmp/upload-late-unlink-inplace.bin";
|
|||
|
|
const transferId = "upload-shared-open-late-unlink-inplace";
|
|||
|
|
const deterministicStagePath = `/tmp/.upload-late-unlink-inplace.bin.netcatty-${transferId}.part`;
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map();
|
|||
|
|
// After the first attempt fails, the destination appears as a symlink so the
|
|||
|
|
// retry is planned in-place (no stagedRemote).
|
|||
|
|
let destinationIsSymlink = false;
|
|||
|
|
const eventLog = [];
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseFirstOpen = null;
|
|||
|
|
let openGeneration = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
/** @type {null | (() => void)} */
|
|||
|
|
let releaseRetryWrites = null;
|
|||
|
|
const retryWritesReleased = new Promise((resolve) => {
|
|||
|
|
releaseRetryWrites = resolve;
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (destinationIsSymlink && key === targetPath) {
|
|||
|
|
callback(null, {
|
|||
|
|
size: 0,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o100644,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => false,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
openGeneration += 1;
|
|||
|
|
const generation = openGeneration;
|
|||
|
|
if (generation === 1) {
|
|||
|
|
assert.equal(key, deterministicStagePath, "first attempt must OPEN deterministic resume stage");
|
|||
|
|
releaseFirstOpen = () => {
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
}
|
|||
|
|
eventLog.push(`open-created-1:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-1:${key}`));
|
|||
|
|
};
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// In-place retry opens the final target, not the stage.
|
|||
|
|
assert.equal(key, targetPath, "in-place retry must OPEN final target");
|
|||
|
|
remoteFiles.set(key, Buffer.from("retry-inplace"));
|
|||
|
|
eventLog.push(`open-created-${generation}:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle-${generation}:${key}`));
|
|||
|
|
},
|
|||
|
|
write(handle, buffer, offset, length, position, callback) {
|
|||
|
|
void retryWritesReleased.then(() => {
|
|||
|
|
const key = String(handle).replace(/^handle-\d+:/, "");
|
|||
|
|
const current = remoteFiles.get(key) || Buffer.alloc(0);
|
|||
|
|
const end = position + length;
|
|||
|
|
const next = Buffer.alloc(Math.max(current.length, end));
|
|||
|
|
current.copy(next);
|
|||
|
|
buffer.copy(next, position, offset, offset + length);
|
|||
|
|
remoteFiles.set(key, next);
|
|||
|
|
eventLog.push(`write:${key}:${length}@${position}`);
|
|||
|
|
setImmediate(() => callback(null));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(handle, callback) {
|
|||
|
|
eventLog.push(`close:${String(handle)}`);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
unlink(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`unlink:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
eventLog.push(`delete:${key}`);
|
|||
|
|
remoteFiles.delete(key);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const firstSender = createSender();
|
|||
|
|
const firstRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: firstSender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstOpenReady = await waitUntil(() => typeof releaseFirstOpen === "function", 2000);
|
|||
|
|
assert.ok(firstOpenReady, "expected first shared write OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died before first OPEN callback"));
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
firstRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("first transfer hung awaiting shared write OPEN drain after channel error")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(firstResult.error, "expected first transfer to fail after channel error");
|
|||
|
|
assert.match(firstResult.error, /shared SFTP channel died before first OPEN callback/i);
|
|||
|
|
|
|||
|
|
// Force the retry into writeInPlace (symlink) while still resumable.
|
|||
|
|
destinationIsSymlink = true;
|
|||
|
|
// Seed a leftover stage orphan the late OPEN would otherwise protect via
|
|||
|
|
// resumable+targetPath heuristics.
|
|||
|
|
remoteFiles.set(deterministicStagePath, Buffer.from("orphan-stage"));
|
|||
|
|
transferBridge.clearPendingCancel(transferId);
|
|||
|
|
const retrySender = createSender();
|
|||
|
|
const retryRunning = transferBridge.startTransfer(
|
|||
|
|
{ sender: retrySender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: TRANSFER_CHUNK_SIZE,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const retryOpenReady = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("open-created-2:")),
|
|||
|
|
3000,
|
|||
|
|
);
|
|||
|
|
assert.ok(retryOpenReady, `expected in-place retry OPEN, log=${eventLog.join(",")}`);
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.has(deterministicStagePath),
|
|||
|
|
"orphan stage must still exist before stale late OPEN",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Stale late OPEN from attempt 1 on the stage path: must close + unlink even
|
|||
|
|
// though a same-id resumable retry owns activeTransfers (in-place, no stage).
|
|||
|
|
releaseFirstOpen?.();
|
|||
|
|
const lateCleanup = await waitUntil(
|
|||
|
|
() => eventLog.some((entry) => entry.startsWith("close:handle-1:"))
|
|||
|
|
&& eventLog.some((entry) => entry === `unlink:${deterministicStagePath}`),
|
|||
|
|
2000,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
lateCleanup,
|
|||
|
|
`expected late close+unlink of orphan stage under in-place retry, log=${eventLog.join(",")}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(
|
|||
|
|
remoteFiles.has(deterministicStagePath),
|
|||
|
|
false,
|
|||
|
|
"orphan stage must be unlinked when retry is not using it",
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
remoteFiles.has(targetPath),
|
|||
|
|
"in-place retry final target must not be unlinked",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
releaseRetryWrites?.();
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const retryResult = await Promise.race([
|
|||
|
|
retryRunning,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("retry transfer hung after cancel")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.ok(
|
|||
|
|
retryResult.cancelled === true || /cancel/i.test(String(retryResult.error || "")),
|
|||
|
|
`expected retry cancel settle, result=${JSON.stringify(retryResult)}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("sudo SFTP downloads prefer concurrent shared READ over serial createReadStream", async (t) => {
|
|||
|
|
// Sudo cannot open an isolated channel, so downloads used to fall straight to
|
|||
|
|
// createReadStream (1-in-flight READs → hundreds of KB/s). Uploads already
|
|||
|
|
// keep pipelined WRITE fanout on the shared browse channel; downloads must
|
|||
|
|
// mirror that for the elevated SFTP path (#2719).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-download-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 47);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
|
|||
|
|
let maxInFlight = 0;
|
|||
|
|
let activeReads = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
activeReads += 1;
|
|||
|
|
maxInFlight = Math.max(maxInFlight, activeReads);
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeReads -= 1;
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not be used when concurrent READ works");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sudo-shared-concurrent",
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlight >= 2,
|
|||
|
|
`expected pipelined READ concurrency >= 2, got ${maxInFlight}`,
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlight <= DOWNLOAD_TRANSFER_CONCURRENCY,
|
|||
|
|
`expected concurrency <= ${DOWNLOAD_TRANSFER_CONCURRENCY}, got ${maxInFlight}`,
|
|||
|
|
);
|
|||
|
|
const downloaded = await fs.promises.readFile(targetPath);
|
|||
|
|
assert.deepEqual(downloaded, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("non-sudo downloads use concurrent shared READ when isolated channel is unavailable", async (t) => {
|
|||
|
|
// Isolated-channel miss (pool full / open unavailable) must still pipeline
|
|||
|
|
// READs on the browse channel — same fail-closed contract as uploads (#2449).
|
|||
|
|
// Serial createReadStream is never a silent bulk-transfer fallback (#2719).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-nonsudo-shared-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 19);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 197;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let maxInFlight = 0;
|
|||
|
|
let activeReads = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
openCalls += 1;
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
activeReads += 1;
|
|||
|
|
maxInFlight = Math.max(maxInFlight, activeReads);
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeReads -= 1;
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after isolated miss");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
// No client.client → openIsolatedSftpChannel returns null (isolated miss).
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-nonsudo-isolated-miss-shared",
|
|||
|
|
sourcePath: "/home/user/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.ok(openCalls >= 1, "non-sudo isolated miss must use shared concurrent OPEN");
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "serial createReadStream must not run");
|
|||
|
|
assert.equal(endCalls, 0, "browse channel must not be ended");
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlight >= 2,
|
|||
|
|
`expected pipelined READ concurrency >= 2, got ${maxInFlight}`,
|
|||
|
|
);
|
|||
|
|
const downloaded = await fs.promises.readFile(targetPath);
|
|||
|
|
assert.deepEqual(downloaded, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("second concurrent sudo download waits for shared fast slot (no serial stream)", async (t) => {
|
|||
|
|
// FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507) still caps concurrent fanout to
|
|||
|
|
// one file; a second download waits for the slot instead of crawling via
|
|||
|
|
// serial createReadStream (#2719 fail-closed alignment).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-slot-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payloadA = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 11);
|
|||
|
|
for (let index = 0; index < payloadA.length; index += 1) payloadA[index] = index % 211;
|
|||
|
|
const payloadB = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 22);
|
|||
|
|
for (let index = 0; index < payloadB.length; index += 1) payloadB[index] = (index * 3) % 223;
|
|||
|
|
const targetA = path.join(tempDir, "a.bin");
|
|||
|
|
const targetB = path.join(tempDir, "b.bin");
|
|||
|
|
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let openBCalls = 0;
|
|||
|
|
let activeBReads = 0;
|
|||
|
|
let maxInFlightB = 0;
|
|||
|
|
const pendingReadCallbacks = [];
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
if (String(remotePath).includes("b.bin")) openBCalls += 1;
|
|||
|
|
callback(null, Buffer.from(`handle:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
const payload = key.includes("b.bin") ? payloadB : payloadA;
|
|||
|
|
// Stall first transfer READs so it keeps the shared fast slot until released.
|
|||
|
|
if (!key.includes("b.bin")) {
|
|||
|
|
pendingReadCallbacks.push(() => {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
activeBReads += 1;
|
|||
|
|
maxInFlightB = Math.max(maxInFlightB, activeBReads);
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeBReads -= 1;
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run while waiting for fast slot");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const size = String(remotePath).includes("b.bin") ? payloadB.length : payloadA.length;
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const first = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sudo-slot-first",
|
|||
|
|
sourcePath: "/root/a.bin",
|
|||
|
|
targetPath: targetA,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payloadA.length,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstReady = await waitUntil(() => pendingReadCallbacks.length >= 1, 2000);
|
|||
|
|
assert.ok(firstReady, "expected first sudo download to hold shared READ slot");
|
|||
|
|
|
|||
|
|
const secondPromise = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sudo-slot-second",
|
|||
|
|
sourcePath: "/root/b.bin",
|
|||
|
|
targetPath: targetB,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payloadB.length,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Condition-based wait: second must enter the session slot queue.
|
|||
|
|
assert.ok(
|
|||
|
|
await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000),
|
|||
|
|
"second download must enqueue on the session fast slot",
|
|||
|
|
);
|
|||
|
|
assert.equal(openBCalls, 0, "second download must wait for shared fast slot");
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "serial stream must not run while waiting");
|
|||
|
|
|
|||
|
|
// Release first transfer's stalled READs so it can finish and free the slot.
|
|||
|
|
const drainPending = () => {
|
|||
|
|
for (const release of pendingReadCallbacks.splice(0)) {
|
|||
|
|
try { release(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
// Keep draining late-arriving first-transfer READs until it settles.
|
|||
|
|
const drainTimer = setInterval(drainPending, 5);
|
|||
|
|
t.after(() => clearInterval(drainTimer));
|
|||
|
|
drainPending();
|
|||
|
|
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
first,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("first transfer did not settle after READ release")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.equal(firstResult.error, undefined, firstResult.error);
|
|||
|
|
|
|||
|
|
const second = await Promise.race([
|
|||
|
|
secondPromise,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("second transfer did not settle after slot free")), 5000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
assert.equal(second.error, undefined, second.error);
|
|||
|
|
assert.ok(openBCalls >= 1, "second download must use concurrent OPEN after slot free");
|
|||
|
|
assert.ok(
|
|||
|
|
maxInFlightB >= 2,
|
|||
|
|
`second download must pipeline READs after slot free, got maxInFlightB=${maxInFlightB}`,
|
|||
|
|
);
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "serial createReadStream must never run");
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(client), 0);
|
|||
|
|
const downloadedA = await fs.promises.readFile(targetA);
|
|||
|
|
const downloadedB = await fs.promises.readFile(targetB);
|
|||
|
|
assert.deepEqual(downloadedA, payloadA);
|
|||
|
|
assert.deepEqual(downloadedB, payloadB);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel while waiting for session fast download slot settles without serial stream", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-slot-wait-cancel-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payloadA = Buffer.alloc(4 * TRANSFER_CHUNK_SIZE, 7);
|
|||
|
|
const payloadB = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 9);
|
|||
|
|
const pendingA = [];
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let openBCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
if (String(remotePath).includes("b.bin")) openBCalls += 1;
|
|||
|
|
callback(null, Buffer.from(`handle:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString();
|
|||
|
|
if (key.includes("b.bin")) {
|
|||
|
|
throw new Error("second download must not READ after cancel on slot wait");
|
|||
|
|
}
|
|||
|
|
pendingA.push(() => {
|
|||
|
|
buffer.fill(7, offset, offset + length);
|
|||
|
|
callback(null, length);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run");
|
|||
|
|
},
|
|||
|
|
end() { endCalls += 1; },
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat(remotePath) {
|
|||
|
|
const size = String(remotePath).includes("b.bin") ? payloadB.length : payloadA.length;
|
|||
|
|
return Promise.resolve({ size, mtimeMs: 1_000, ctimeMs: 1_000, mtime: 1, ctime: 1 });
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const first = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "slot-hold-first",
|
|||
|
|
sourcePath: "/root/a.bin",
|
|||
|
|
targetPath: path.join(tempDir, "a.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payloadA.length,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.ok(await waitUntil(() => pendingA.length >= 1, 2000));
|
|||
|
|
|
|||
|
|
const secondPromise = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "slot-wait-cancel-second",
|
|||
|
|
sourcePath: "/root/b.bin",
|
|||
|
|
targetPath: path.join(tempDir, "b.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payloadB.length,
|
|||
|
|
resumable: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.ok(
|
|||
|
|
await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000),
|
|||
|
|
"second must wait on session slot",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "slot-wait-cancel-second" });
|
|||
|
|
const second = await Promise.race([
|
|||
|
|
secondPromise,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("slot-wait cancel hung")), 3000)),
|
|||
|
|
]);
|
|||
|
|
assert.match(second.error || "", /cancel/i);
|
|||
|
|
assert.equal(openBCalls, 0);
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
assert.equal(endCalls, 0);
|
|||
|
|
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(client), 0);
|
|||
|
|
|
|||
|
|
// Holder still completes after stalled READs are released.
|
|||
|
|
const drain = setInterval(() => {
|
|||
|
|
for (const release of pendingA.splice(0)) {
|
|||
|
|
try { release(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
}, 5);
|
|||
|
|
t.after(() => clearInterval(drain));
|
|||
|
|
const firstResult = await Promise.race([
|
|||
|
|
first,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("holder hung")), 5000)),
|
|||
|
|
]);
|
|||
|
|
assert.equal(firstResult.error, undefined, firstResult.error);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable concurrent download range failure fails closed (no serial stream)", async (t) => {
|
|||
|
|
// Mirror upload fail-closed: when concurrent strategies fail, never complete
|
|||
|
|
// via createReadStream crawl.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 41);
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("original"));
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let secondReadCallback = null;
|
|||
|
|
const isolated = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("iso-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (position === TRANSFER_CHUNK_SIZE) {
|
|||
|
|
secondReadCallback = callback;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length);
|
|||
|
|
if (position === 2 * TRANSFER_CHUNK_SIZE && secondReadCallback) {
|
|||
|
|
const cb = secondReadCallback;
|
|||
|
|
secondReadCallback = null;
|
|||
|
|
queueMicrotask(() => cb(new Error("second range failed")));
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
const shared = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("shared open also fails"));
|
|||
|
|
},
|
|||
|
|
read() {
|
|||
|
|
throw new Error("shared READ must not run after OPEN failure");
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not complete the download");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: shared,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) { callback(null, isolated); },
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sparse-fail-closed",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined download failed|second range failed|shared open also fails/i);
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("sudo concurrent READ failure fails closed (no createReadStream)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 55);
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("sudo-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
setImmediate(() => callback(new Error("sudo READ rejected")));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after sudo concurrent fail");
|
|||
|
|
},
|
|||
|
|
end() { endCalls += 1; },
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sudo-fail-closed",
|
|||
|
|
sourcePath: "/root/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined download failed|sudo READ rejected/i);
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended on fail-closed");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("isolated download CLOSE timeout disposes channel instead of returning it to the pool", async (t) => {
|
|||
|
|
// Codex P2 on a3b11137: success-path CLOSE timeout with disposeChannel:true
|
|||
|
|
// only logged and left remoteCloseError unset, so downloadFile released the
|
|||
|
|
// isolated channel back to the pool without dispose.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-isolated-close-timeout-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(2 * TRANSFER_CHUNK_SIZE, 33);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 173;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
|
|||
|
|
let openedChannels = 0;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let closeCalls = 0;
|
|||
|
|
const makeIsolated = () => createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from(`isolated-${openedChannels}`));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, _callback) {
|
|||
|
|
closeCalls += 1;
|
|||
|
|
// Never invoke callback — simulates a hung CLOSE on an isolated channel.
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial stream must not run when isolated concurrent READ succeeds");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
openedChannels += 1;
|
|||
|
|
callback(null, makeIsolated());
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const first = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-isolated-close-timeout",
|
|||
|
|
sourcePath: "/tmp/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(first.error, undefined, first.error);
|
|||
|
|
assert.ok(closeCalls >= 1, "expected isolated CLOSE to be attempted");
|
|||
|
|
assert.ok(endCalls >= 1, "CLOSE timeout must dispose the isolated channel");
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
|
|||
|
|
// A follow-up download must open a fresh isolated channel, not reuse the timed-out one.
|
|||
|
|
const targetPath2 = path.join(tempDir, "download-2.bin");
|
|||
|
|
const second = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-isolated-close-timeout-2",
|
|||
|
|
sourcePath: "/tmp/download.bin",
|
|||
|
|
targetPath: targetPath2,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.equal(second.error, undefined, second.error);
|
|||
|
|
assert.ok(openedChannels >= 2, `expected a new isolated channel after dispose, got ${openedChannels}`);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath2), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during stalled shared sudo download OPEN settles without sftp.end()", async (t) => {
|
|||
|
|
// Codex P2 on 2c369403: disposeChannel:false made pre-OPEN abort a no-op while
|
|||
|
|
// openSftpHandle had no cancel reject, so cancel hung forever and held the lease.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-open-stall-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const fileSize = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let closeCalls = 0;
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
releaseOpen = () => callback(null, Buffer.from("late-shared-handle"));
|
|||
|
|
},
|
|||
|
|
read() {
|
|||
|
|
throw new Error("READ must not run after cancel during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
closeCalls += 1;
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after cancel");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: fileSize,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-sudo-open-stall-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared OPEN to stall");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(() => reject(new Error("transfer did not settle after cancel during shared OPEN")), 3000);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended on cancel");
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream");
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
|
|||
|
|
// Late OPEN success after cancel should close the handle, not leave it open.
|
|||
|
|
releaseOpen?.();
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.ok(closeCalls >= 1, `expected late shared handle close, got ${closeCalls}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared sudo download OPEN rejects on channel error without hanging", async (t) => {
|
|||
|
|
// Codex P2 on 50ef8fac: outer onChannelError only recorded the error for a
|
|||
|
|
// post-await check. If the shared channel emitted error while sftp.open was
|
|||
|
|
// pending and never invoked the OPEN callback, the transfer hung forever and
|
|||
|
|
// held the SFTP lease. openSftpHandleForTransfer must reject on channel error.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-open-channel-error-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const fileSize = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let closeCalls = 0;
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
releaseOpen = () => callback(null, Buffer.from("late-shared-handle-after-error"));
|
|||
|
|
},
|
|||
|
|
read() {
|
|||
|
|
throw new Error("READ must not run after channel error during OPEN");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
closeCalls += 1;
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after pipelined OPEN failure");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: fileSize,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-sudo-open-channel-error";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => typeof releaseOpen === "function", 2000);
|
|||
|
|
assert.ok(ready, "expected shared OPEN to stall");
|
|||
|
|
|
|||
|
|
sharedSftp.emit("error", new Error("shared SFTP channel died during OPEN"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer did not settle after shared channel error during OPEN")),
|
|||
|
|
3000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected transfer to fail after channel error");
|
|||
|
|
assert.match(
|
|||
|
|
result.error,
|
|||
|
|
/shared SFTP channel died during OPEN|pipelined download failed/i,
|
|||
|
|
);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended on channel error");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
assert.equal(
|
|||
|
|
createReadStreamCalls,
|
|||
|
|
0,
|
|||
|
|
"concurrent shared OPEN failure must fail closed (no serial stream fallback)",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Late OPEN success after channel-error reject should close the handle.
|
|||
|
|
releaseOpen?.();
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.ok(closeCalls >= 1, `expected late shared handle close, got ${closeCalls}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during stalled shared sudo READ settles without sftp.end()", async (t) => {
|
|||
|
|
// Codex P2 on d855e69c: disposeChannel:false passed forceSettleOnError:false, so
|
|||
|
|
// cancel while an sftp.read callback never arrives hung forever (abortChannel is
|
|||
|
|
// a no-op on shared channels). Downloads have no remote WRITEs to drain, so they
|
|||
|
|
// must force-settle on cancel without ending the sudo session.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-read-stall-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const fileSize = 4 * TRANSFER_CHUNK_SIZE;
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let activeReads = 0;
|
|||
|
|
let maxInFlight = 0;
|
|||
|
|
const pendingReadCallbacks = [];
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
activeReads += 1;
|
|||
|
|
maxInFlight = Math.max(maxInFlight, activeReads);
|
|||
|
|
// Stall forever — never invoke callback (simulates dead/hung READ).
|
|||
|
|
pendingReadCallbacks.push(() => {
|
|||
|
|
activeReads -= 1;
|
|||
|
|
callback(new Error("late READ after cancel"));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after cancel");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: fileSize,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-sudo-read-stall-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => pendingReadCallbacks.length >= 1, 2000);
|
|||
|
|
assert.ok(ready, "expected at least one shared READ to stall");
|
|||
|
|
assert.ok(maxInFlight >= 1, `expected in-flight READ, got ${maxInFlight}`);
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
// forceSettleOnError grace is 2s; shared cancel/fail CLOSE is fire-and-forget.
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer did not settle after cancel during stalled shared READ")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended on cancel during READ");
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream");
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
|
|||
|
|
// Late READ callbacks after settle must not crash the process.
|
|||
|
|
for (const release of pendingReadCallbacks.splice(0)) {
|
|||
|
|
try { release(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during stalled shared verification READ settles without sftp.end()", async (t) => {
|
|||
|
|
// Codex P2 on cba714f3: after ranged READs finish, verifyFastDownloadSamples still
|
|||
|
|
// awaits bare sftp.read outside forceSettleOnError. Shared cancel cannot
|
|||
|
|
// sftp.end(), so a hung verification READ would hold the transfer and lease.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-stall-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 61);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let transferredBytes = 0;
|
|||
|
|
const pendingVerifyCallbacks = [];
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-verify-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
// Complete the concurrent download first; stall only verification samples.
|
|||
|
|
if (transferredBytes < payload.length) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
transferredBytes += slice.length;
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
pendingVerifyCallbacks.push(() => {
|
|||
|
|
callback(new Error("late verification READ after cancel"));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after cancel");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-sudo-verify-stall-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => pendingVerifyCallbacks.length >= 1, 3000);
|
|||
|
|
assert.ok(ready, "expected verification READ to stall after ranged download");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer did not settle after cancel during stalled verification READ")),
|
|||
|
|
5000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended during verification cancel");
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "cancel must not fall through to serial stream");
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
|
|||
|
|
for (const release of pendingVerifyCallbacks.splice(0)) {
|
|||
|
|
try { release(); } catch { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel during responsive last verification sample reports cancelled", async (t) => {
|
|||
|
|
// Codex P2 on 74ae5e0a: cancel during the last sample can let the READ win the
|
|||
|
|
// race before the 2s force-settle fires. Without a post-race cancelled check,
|
|||
|
|
// verification returns successfully and sendComplete() reports done.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-race-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 67);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let transferredBytes = 0;
|
|||
|
|
let verifyReadCount = 0;
|
|||
|
|
let releaseLastVerifyRead = null;
|
|||
|
|
const lastVerifyReadHeld = new Promise((resolve) => {
|
|||
|
|
releaseLastVerifyRead = resolve;
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-verify-race-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (transferredBytes < payload.length) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
transferredBytes += slice.length;
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
verifyReadCount += 1;
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
// Hold only the last sample briefly so cancel can land mid-READ, then
|
|||
|
|
// complete successfully (responsive server — force-settle never fires).
|
|||
|
|
if (verifyReadCount >= 3) {
|
|||
|
|
lastVerifyReadHeld.then(() => {
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial createReadStream must not run");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "download-sudo-verify-race-cancel";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const ready = await waitUntil(() => verifyReadCount >= 3, 3000);
|
|||
|
|
assert.ok(ready, "expected last verification sample to be held");
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseLastVerifyRead();
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer did not settle after cancel during last verification sample")),
|
|||
|
|
3000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended");
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"));
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared verification channel error between samples fails without hanging", async (t) => {
|
|||
|
|
// Codex P2 on 14783168: channel error between sample races (after one sample
|
|||
|
|
// resolves, before the next rejectPending is installed) was dropped; the next
|
|||
|
|
// readSftpRange could hang forever on a dead shared channel.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-verify-gap-error-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * TRANSFER_CHUNK_SIZE, 71);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
let endCalls = 0;
|
|||
|
|
let transferredBytes = 0;
|
|||
|
|
let verifyReadCount = 0;
|
|||
|
|
let sharedSftp = null;
|
|||
|
|
const pendingSecondVerify = [];
|
|||
|
|
sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-verify-gap-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (transferredBytes < payload.length) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
transferredBytes += slice.length;
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
verifyReadCount += 1;
|
|||
|
|
if (verifyReadCount === 1) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
// Emit after the first sample resolves and before the next race installs
|
|||
|
|
// rejectPending — the gap Codex identified.
|
|||
|
|
setImmediate(() => {
|
|||
|
|
sharedSftp.emit("error", new Error("shared channel died between samples"));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// Subsequent verification READs never callback (dead channel).
|
|||
|
|
pendingSecondVerify.push(callback);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial createReadStream must not run after channel error");
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: 1_000,
|
|||
|
|
ctimeMs: 1_000,
|
|||
|
|
mtime: 1,
|
|||
|
|
ctime: 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "download-sudo-verify-gap-error",
|
|||
|
|
sourcePath: "/root/download.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
new Promise((_, reject) => {
|
|||
|
|
setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung after shared verification channel error between samples")),
|
|||
|
|
4000,
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Concurrent path already staged the full file, so fallback may complete via
|
|||
|
|
// the checkpoint>=fileSize short-circuit — either outcome is fine as long as
|
|||
|
|
// we settle instead of hanging on the dead-channel READ.
|
|||
|
|
assert.ok(result, "expected transfer to settle after inter-sample channel error");
|
|||
|
|
assert.equal(endCalls, 0, "shared sudo channel must not be ended on verification error");
|
|||
|
|
assert.ok(
|
|||
|
|
sender.sent.some((entry) => (
|
|||
|
|
entry.channel === "netcatty:transfer:complete"
|
|||
|
|
|| entry.channel === "netcatty:transfer:error"
|
|||
|
|
|| entry.channel === "netcatty:transfer:cancelled"
|
|||
|
|
)),
|
|||
|
|
"expected a terminal transfer event after channel error between samples",
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared upload errors wait for all in-flight WRITEs before returning", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-shared-error-drain-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const fileSize = 4 * TRANSFER_CHUNK_SIZE;
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(fileSize, 13));
|
|||
|
|
|
|||
|
|
const pendingWriteCallbacks = [];
|
|||
|
|
let triggerFirstError;
|
|||
|
|
const firstErrorTriggered = new Promise((resolve) => {
|
|||
|
|
triggerFirstError = resolve;
|
|||
|
|
});
|
|||
|
|
let firstErrorScheduled = false;
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
callback(null, Buffer.from("shared-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
pendingWriteCallbacks.push(callback);
|
|||
|
|
if (!firstErrorScheduled && pendingWriteCallbacks.length === 4) {
|
|||
|
|
firstErrorScheduled = true;
|
|||
|
|
setImmediate(() => {
|
|||
|
|
const fail = pendingWriteCallbacks.shift();
|
|||
|
|
fail(new Error("shared WRITE failed"));
|
|||
|
|
triggerFirstError();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 0 });
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
let transferSettled = false;
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-shared-error-drain",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/upload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: fileSize,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
).finally(() => {
|
|||
|
|
transferSettled = true;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await firstErrorTriggered;
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 2100));
|
|||
|
|
const settledBeforeRemainingWrites = transferSettled;
|
|||
|
|
for (const callback of pendingWriteCallbacks.splice(0)) callback(null);
|
|||
|
|
|
|||
|
|
const result = await running;
|
|||
|
|
assert.equal(settledBeforeRemainingWrites, false);
|
|||
|
|
assert.match(result.error || "", /shared WRITE failed|pipelined upload failed/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("sudo SFTP sessions without open/write fail closed (no serial WriteStream)", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sudo-fail-closed-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const localPath = path.join(tempDir, "payload.bin");
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 7);
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
const streamOnlySftp = createFastSftp({
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
// Isolated channel skipped; shared sftp has only createWriteStream.
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: streamOnlySftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 0 });
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
delete() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "upload-sudo-fail-closed",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath: "/tmp/payload.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|open\/write missing/i);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
assert.ok(sender.sent.some((entry) => entry.channel === "netcatty:transfer:error"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable SFTP downloads preserve a 2MB request window on high-latency paths", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(4 * 1024 * 1024, 7);
|
|||
|
|
let activeReads = 0;
|
|||
|
|
let observedConcurrency = 0;
|
|||
|
|
let observedChunkSize = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
activeReads += 1;
|
|||
|
|
observedConcurrency = Math.max(observedConcurrency, activeReads);
|
|||
|
|
observedChunkSize = Math.max(observedChunkSize, length);
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeReads -= 1;
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
return Readable.from(payload);
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat(_path) {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "download-large",
|
|||
|
|
sourcePath: "/tmp/large.bin",
|
|||
|
|
targetPath: path.join(tempDir, "large.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(observedChunkSize, 32 * 1024);
|
|||
|
|
assert.equal(observedConcurrency * observedChunkSize, 2 * 1024 * 1024);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, "large.bin")), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function runIsolatedStagedDownloadRecovery(mode) {
|
|||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-transfer-root-rebind-${mode}-`));
|
|||
|
|
const home = path.join(root, "home");
|
|||
|
|
fs.mkdirSync(home);
|
|||
|
|
try {
|
|||
|
|
const script = `
|
|||
|
|
const fs = require("node:fs");
|
|||
|
|
const os = require("node:os");
|
|||
|
|
const path = require("node:path");
|
|||
|
|
const { EventEmitter } = require("node:events");
|
|||
|
|
const transferBridge = require("./electron/bridges/transferBridge.cjs");
|
|||
|
|
const tempDirBridge = require("./electron/bridges/tempDirBridge.cjs");
|
|||
|
|
|
|||
|
|
const mode = process.env.NETCATTY_TEST_REBIND_MODE;
|
|||
|
|
const transferId = mode === "after-open"
|
|||
|
|
? "download-root-rebind-after-open"
|
|||
|
|
: "download-root-rebind-before-write";
|
|||
|
|
const payload = Buffer.from(
|
|||
|
|
mode === "after-open" ? "recovered after stage open" : "recovered staged download",
|
|||
|
|
);
|
|||
|
|
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "target-"));
|
|||
|
|
const targetPath = path.join(targetDir, "download.bin");
|
|||
|
|
const initialStagedPath = tempDirBridge.getTransferTempFilePath(transferId, "download.bin");
|
|||
|
|
const tempRoot = path.dirname(initialStagedPath);
|
|||
|
|
const initialGeneration = tempDirBridge.getTempDirRebindGeneration();
|
|||
|
|
let triggered = false;
|
|||
|
|
|
|||
|
|
const originalGetTransferTempFilePath = tempDirBridge.getTransferTempFilePath;
|
|||
|
|
const originalOpen = fs.promises.open;
|
|||
|
|
if (mode === "after-open") {
|
|||
|
|
fs.promises.open = async (filePath, ...args) => {
|
|||
|
|
const opened = await originalOpen(filePath, ...args);
|
|||
|
|
if (!triggered && path.resolve(filePath) === path.resolve(initialStagedPath)) {
|
|||
|
|
triggered = true;
|
|||
|
|
fs.renameSync(tempRoot, tempRoot + ".after-open-old");
|
|||
|
|
fs.mkdirSync(tempRoot, { recursive: true, mode: 0o700 });
|
|||
|
|
}
|
|||
|
|
return opened;
|
|||
|
|
};
|
|||
|
|
} else {
|
|||
|
|
tempDirBridge.getTransferTempFilePath = (id, fileName) => {
|
|||
|
|
const stagedPath = originalGetTransferTempFilePath(id, fileName);
|
|||
|
|
if (!triggered) {
|
|||
|
|
triggered = true;
|
|||
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|||
|
|
}
|
|||
|
|
return stagedPath;
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sftp = new EventEmitter();
|
|||
|
|
sftp.open = (_remotePath, flags, callback) => {
|
|||
|
|
if (typeof flags === "function") callback = flags;
|
|||
|
|
callback(null, Buffer.from("read-handle"));
|
|||
|
|
};
|
|||
|
|
sftp.read = (_handle, buffer, offset, length, position, callback) => {
|
|||
|
|
const end = Math.min(position + length, payload.length);
|
|||
|
|
payload.copy(buffer, offset, position, end);
|
|||
|
|
setImmediate(() => callback(null, end - position));
|
|||
|
|
};
|
|||
|
|
sftp.close = (_handle, callback) => callback(null);
|
|||
|
|
sftp.readdir = (_remotePath, callback) => callback(null, []);
|
|||
|
|
sftp.stat = (_remotePath, callback) => callback(null, { size: payload.length });
|
|||
|
|
sftp.lstat = (_remotePath, callback) => {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
};
|
|||
|
|
sftp.mkdir = (_remotePath, callback) => callback(null);
|
|||
|
|
sftp.unlink = (_remotePath, callback) => callback(null);
|
|||
|
|
sftp.end = () => {};
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat: () => Promise.resolve({ size: payload.length }),
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: { send() {} } },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const content = fs.existsSync(targetPath)
|
|||
|
|
? await fs.promises.readFile(targetPath, "utf8")
|
|||
|
|
: null;
|
|||
|
|
console.log("RESULT:" + JSON.stringify({
|
|||
|
|
error: result.error || null,
|
|||
|
|
content,
|
|||
|
|
triggered,
|
|||
|
|
tempRoot,
|
|||
|
|
generation: tempDirBridge.getTempDirRebindGeneration(),
|
|||
|
|
initialGeneration,
|
|||
|
|
}));
|
|||
|
|
})().catch(error => {
|
|||
|
|
console.error(error);
|
|||
|
|
process.exitCode = 1;
|
|||
|
|
});
|
|||
|
|
`;
|
|||
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|||
|
|
cwd: path.resolve(__dirname, "../.."),
|
|||
|
|
env: {
|
|||
|
|
...process.env,
|
|||
|
|
TMPDIR: root,
|
|||
|
|
TEMP: root,
|
|||
|
|
TMP: root,
|
|||
|
|
HOME: home,
|
|||
|
|
NETCATTY_TEST_REBIND_MODE: mode,
|
|||
|
|
},
|
|||
|
|
encoding: "utf8",
|
|||
|
|
});
|
|||
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|||
|
|
const match = result.stdout.match(/^RESULT:(.+)$/m);
|
|||
|
|
assert.ok(match, result.stdout);
|
|||
|
|
const report = JSON.parse(match[1]);
|
|||
|
|
assert.equal(path.dirname(report.tempRoot), root);
|
|||
|
|
return report;
|
|||
|
|
} finally {
|
|||
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test("staged SFTP downloads recover when the temp root disappears before the first write", () => {
|
|||
|
|
const result = runIsolatedStagedDownloadRecovery("before-write");
|
|||
|
|
assert.equal(result.error, null);
|
|||
|
|
assert.equal(result.triggered, true);
|
|||
|
|
assert.equal(result.generation, result.initialGeneration + 1);
|
|||
|
|
assert.equal(result.content, "recovered staged download");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("staged SFTP downloads recover when the temp root is replaced after opening the stage", () => {
|
|||
|
|
const result = runIsolatedStagedDownloadRecovery("after-open");
|
|||
|
|
assert.equal(result.error, null);
|
|||
|
|
assert.equal(result.triggered, true);
|
|||
|
|
assert.equal(result.generation, result.initialGeneration + 1);
|
|||
|
|
assert.equal(result.content, "recovered after stage open");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fast resumable downloads pause only at a complete checkpoint", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fast-pause-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(4 * 1024 * 1024, 11);
|
|||
|
|
const pendingReads = [];
|
|||
|
|
let holdReads = true;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const complete = () => {
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
};
|
|||
|
|
if (holdReads) pendingReads.push(complete);
|
|||
|
|
else setImmediate(complete);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream() {
|
|||
|
|
return Readable.from(payload);
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "large.bin");
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-fast-paused",
|
|||
|
|
sourcePath: "/tmp/large.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingReads.length < 64 && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingReads.length, 64);
|
|||
|
|
|
|||
|
|
const originalStat = fs.promises.stat;
|
|||
|
|
fs.promises.stat = async (filePath) => {
|
|||
|
|
if (String(filePath).includes("download-fast-paused")) {
|
|||
|
|
throw new Error("fast range checkpoints must not be inferred from file size");
|
|||
|
|
}
|
|||
|
|
return originalStat(filePath);
|
|||
|
|
};
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "download-fast-paused" });
|
|||
|
|
holdReads = false;
|
|||
|
|
for (const complete of pendingReads.splice(0)) complete();
|
|||
|
|
let paused;
|
|||
|
|
try {
|
|||
|
|
paused = await pausing;
|
|||
|
|
} finally {
|
|||
|
|
fs.promises.stat = originalStat;
|
|||
|
|
}
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
assert.equal(paused.checkpointBytes, 2 * 1024 * 1024);
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "download-fast-paused" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("fast resumable downloads fall back from the highest contiguous checkpoint", async (t) => {
|
|||
|
|
// Isolated concurrent ranges fail mid-file; concurrent-shared resumes from the
|
|||
|
|
// highest contiguous checkpoint (no serial createReadStream fallback).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-contiguous-fallback-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(3 * 32 * 1024, 17);
|
|||
|
|
let sharedOpenCheckpoint = null;
|
|||
|
|
let secondReadCallback = null;
|
|||
|
|
let targetPath = null;
|
|||
|
|
let stagedPath = null;
|
|||
|
|
const progress = [];
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (position === 32 * 1024) {
|
|||
|
|
secondReadCallback = callback;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
if (position === 2 * 32 * 1024) {
|
|||
|
|
queueMicrotask(() => secondReadCallback(new Error("second range failed")));
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
try {
|
|||
|
|
sharedOpenCheckpoint = fs.statSync(stagedPath).size;
|
|||
|
|
} catch {
|
|||
|
|
sharedOpenCheckpoint = -1;
|
|||
|
|
}
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial createReadStream must not run after isolated range failure");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
targetPath = path.join(tempDir, "fallback.bin");
|
|||
|
|
stagedPath = tempDirBridge.getTransferTempFilePath(
|
|||
|
|
"download-contiguous-fallback",
|
|||
|
|
path.basename(targetPath),
|
|||
|
|
);
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-contiguous-fallback",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
(transferred) => progress.push(transferred),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(sharedOpenCheckpoint, 32 * 1024);
|
|||
|
|
const firstPastCheckpoint = progress.findIndex((transferred) => transferred > sharedOpenCheckpoint);
|
|||
|
|
assert.ok(firstPastCheckpoint >= 0);
|
|||
|
|
assert.ok(progress.slice(firstPastCheckpoint + 1).includes(sharedOpenCheckpoint));
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable download fallback rejects a remote source changed during concurrent-shared", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-download-fallback-change-"));
|
|||
|
|
const transferId = "download-fallback-change";
|
|||
|
|
const targetPath = path.join(tempDir, "download.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
await fs.promises.rm(stagedPath, { force: true });
|
|||
|
|
});
|
|||
|
|
await fs.promises.writeFile(targetPath, "original");
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(32 * 1024, 51);
|
|||
|
|
let sourceChanged = false;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
callback(new Error("range download unavailable"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
sourceChanged = true;
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial createReadStream must not run");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({
|
|||
|
|
size: payload.length,
|
|||
|
|
mtimeMs: sourceChanged ? 2 : 1,
|
|||
|
|
ctimeMs: sourceChanged ? 2 : 1,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(sourceChanged, true);
|
|||
|
|
assert.match(result.error || "", /source.*changed/i);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original");
|
|||
|
|
assert.equal(fs.existsSync(stagedPath), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("range-failure fallback truncates sparse local tail before concurrent-shared", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-sparse-tail-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Two chunks: finish the second first, then fail the first so contiguous stays 0
|
|||
|
|
// while the local file already has a sparse tail past the durable checkpoint.
|
|||
|
|
const chunk = 32 * 1024;
|
|||
|
|
const payload = Buffer.alloc(2 * chunk, 17);
|
|||
|
|
let firstReadCallback = null;
|
|||
|
|
let sizeAtSharedOpen = null;
|
|||
|
|
const transferId = "download-sparse-tail";
|
|||
|
|
const targetPath = path.join(tempDir, "sparse.bin");
|
|||
|
|
// Resumable downloads stage under the transfer temp path, not the final target.
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (position === 0) {
|
|||
|
|
firstReadCallback = () => callback(new Error("first range failed"));
|
|||
|
|
// Complete the later range first, then fail the first.
|
|||
|
|
queueMicrotask(() => {
|
|||
|
|
// second range is already in flight separately
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
queueMicrotask(() => firstReadCallback?.());
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
// Capture *staged* size when concurrent-shared opens (post-truncate).
|
|||
|
|
try {
|
|||
|
|
sizeAtSharedOpen = fs.statSync(stagedPath).size;
|
|||
|
|
} catch {
|
|||
|
|
sizeAtSharedOpen = -1;
|
|||
|
|
}
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
throw new Error("serial createReadStream must not run after range failure");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
// Contiguous checkpoint never advanced past 0; sparse tail must be truncated
|
|||
|
|
// on the staged .part before concurrent-shared resumes (final target is only
|
|||
|
|
// written at promote time).
|
|||
|
|
assert.equal(sizeAtSharedOpen, 0);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("S2S upload-phase concurrent failure does not truncate the complete local temp source", async (t) => {
|
|||
|
|
const transferId = `s2s-no-truncate-${crypto.randomUUID()}`;
|
|||
|
|
const payload = Buffer.alloc(64 * 1024, 23);
|
|||
|
|
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
|||
|
|
await fs.promises.writeFile(localStage, payload);
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.unlink(localStage).catch(() => {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let sizeAfterFail = null;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, _length, position, callback) {
|
|||
|
|
if (position === 0) {
|
|||
|
|
callback(new Error("first upload range failed"));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
const sourceClient = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat: async () => ({ size: payload.length }),
|
|||
|
|
};
|
|||
|
|
const targetClient = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("shared open also fails"));
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat: async () => ({ size: 0 }),
|
|||
|
|
rename: async () => {},
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([
|
|||
|
|
["source", sourceClient],
|
|||
|
|
["target", targetClient],
|
|||
|
|
]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/source/payload.bin",
|
|||
|
|
targetPath: "/target/payload.bin",
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
resumeStage: "upload",
|
|||
|
|
downloadCheckpointBytes: payload.length,
|
|||
|
|
uploadCheckpointBytes: 0,
|
|||
|
|
checkpointBytes: 0,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
sizeAfterFail = fs.statSync(localStage).size;
|
|||
|
|
} catch {
|
|||
|
|
sizeAfterFail = -1;
|
|||
|
|
}
|
|||
|
|
// Fail closed — local S2S temp is the fully-downloaded source, never truncated.
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|first upload range failed/i);
|
|||
|
|
assert.equal(sizeAfterFail, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancelled fast resumable downloads release their isolated channel", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-cancel-release-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2, 19);
|
|||
|
|
let openedChannels = 0;
|
|||
|
|
let fallbackReads = 0;
|
|||
|
|
const pendingReads = [];
|
|||
|
|
let lateReadScheduled = false;
|
|||
|
|
const firstChannel = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("first-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, _buffer, _offset, _length, _position, callback) {
|
|||
|
|
pendingReads.push(callback);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
if (lateReadScheduled) return;
|
|||
|
|
lateReadScheduled = true;
|
|||
|
|
setTimeout(() => {
|
|||
|
|
pendingReads.shift()?.(new Error("late channel cancellation"));
|
|||
|
|
}, 1500);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const secondChannel = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("second-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
payload.copy(buffer, offset, position, position + length);
|
|||
|
|
callback(null, length, buffer, position);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({
|
|||
|
|
createReadStream(_remotePath, options) {
|
|||
|
|
if (options?.start !== undefined) fallbackReads += 1;
|
|||
|
|
return Readable.from(payload);
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
openedChannels += 1;
|
|||
|
|
callback(null, openedChannels === 1 ? firstChannel : secondChannel);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const first = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-cancel-release-first",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath: path.join(tempDir, "first.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
const readyDeadline = Date.now() + 1000;
|
|||
|
|
while (pendingReads.length < 2 && Date.now() < readyDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(pendingReads.length, 2);
|
|||
|
|
const cancelledAt = Date.now();
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "download-cancel-release-first" });
|
|||
|
|
assert.equal((await first).error, "Transfer cancelled");
|
|||
|
|
const cancelDuration = Date.now() - cancelledAt;
|
|||
|
|
assert.ok(cancelDuration >= 1900);
|
|||
|
|
assert.ok(cancelDuration < 3000);
|
|||
|
|
|
|||
|
|
const second = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-cancel-release-second",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath: path.join(tempDir, "second.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(second.error, undefined);
|
|||
|
|
assert.equal(openedChannels, 2);
|
|||
|
|
assert.equal(fallbackReads, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP downloads fall back to concurrent shared READ after fastGet fails", async (t) => {
|
|||
|
|
// Non-resumable fastGet failure must not crawl via serial createReadStream;
|
|||
|
|
// the next pipelined strategy is concurrent-shared READ on the browse channel.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-fallback-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const expected = Buffer.from("complete fallback download");
|
|||
|
|
let fastGetAttempts = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
let maxInFlight = 0;
|
|||
|
|
let activeReads = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
fastGet(_remotePath, localPath, _options, done) {
|
|||
|
|
fastGetAttempts += 1;
|
|||
|
|
fs.promises.writeFile(localPath, "partial").then(
|
|||
|
|
() => done(new Error("server rejected concurrent reads")),
|
|||
|
|
done,
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
activeReads += 1;
|
|||
|
|
maxInFlight = Math.max(maxInFlight, activeReads);
|
|||
|
|
const slice = expected.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => {
|
|||
|
|
activeReads -= 1;
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run after fastGet failure");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: expected.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "fallback.bin");
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-fallback",
|
|||
|
|
sourcePath: "/tmp/fallback.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: expected.length,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(fastGetAttempts, 1);
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
assert.ok(maxInFlight >= 1, "expected concurrent shared READ after fastGet failure");
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), expected);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP downloads serialize concurrent files on the session fast-path slot", async (t) => {
|
|||
|
|
// FAST_DOWNLOAD_CHANNELS_PER_SESSION caps concurrent 64-READ fanout to one
|
|||
|
|
// file per session. A second download waits for the slot (pipelined path)
|
|||
|
|
// instead of crawling via serial createReadStream (#2719 / #1507).
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-budget-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const completions = [];
|
|||
|
|
let activeFastGets = 0;
|
|||
|
|
let maxActiveFastGets = 0;
|
|||
|
|
let openedChannels = 0;
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
// Shared browse channel has open/read so a slot-waiter can still pipeline if
|
|||
|
|
// the isolated pool is empty after the first channel is disposed. For this
|
|||
|
|
// test both files complete via isolated fastGet after serializing on the slot.
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "r");
|
|||
|
|
callback(null, Buffer.from("shared-read-handle"));
|
|||
|
|
},
|
|||
|
|
read(_handle, buffer, offset, length, _position, callback) {
|
|||
|
|
buffer.fill(0x64, offset, offset + length);
|
|||
|
|
setImmediate(() => callback(null, length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not run under session slot wait");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 10 });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
openedChannels += 1;
|
|||
|
|
// Fresh channel per open so the second transfer can acquire isolated
|
|||
|
|
// after the first returns its channel to the pool (or opens again).
|
|||
|
|
const channel = createFastSftp({
|
|||
|
|
fastGet(_remotePath, localPath, _options, done) {
|
|||
|
|
activeFastGets += 1;
|
|||
|
|
maxActiveFastGets = Math.max(maxActiveFastGets, activeFastGets);
|
|||
|
|
completions.push(async () => {
|
|||
|
|
await fs.promises.writeFile(localPath, "downloaded");
|
|||
|
|
activeFastGets -= 1;
|
|||
|
|
done();
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
callback(null, channel);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const start = (id) => transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: `/tmp/${id}.bin`,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 10,
|
|||
|
|
// Isolate the fast-channel budget test from global file admission.
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const first = start("download-one");
|
|||
|
|
const firstDeadline = Date.now() + 1000;
|
|||
|
|
while (completions.length < 1 && Date.now() < firstDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(completions.length, 1);
|
|||
|
|
assert.equal(openedChannels, 1);
|
|||
|
|
|
|||
|
|
const secondPromise = start("download-two");
|
|||
|
|
// Second must wait on the session slot — no second fastGet yet, no serial stream.
|
|||
|
|
assert.ok(
|
|||
|
|
await waitUntil(() => transferBridge._getSessionFastDownloadWaiterCountForTests(client) >= 1, 2000),
|
|||
|
|
"second download must enqueue on the session fast slot",
|
|||
|
|
);
|
|||
|
|
assert.equal(completions.length, 1, "second download must wait for session fast slot");
|
|||
|
|
assert.equal(createReadStreamCalls, 0);
|
|||
|
|
|
|||
|
|
await completions[0]();
|
|||
|
|
assert.equal((await first).error, undefined);
|
|||
|
|
|
|||
|
|
// After first releases the slot, second proceeds on the pipelined path.
|
|||
|
|
const secondDeadline = Date.now() + 1000;
|
|||
|
|
while (completions.length < 2 && Date.now() < secondDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(completions.length, 2, "second download should start after slot free");
|
|||
|
|
await completions[1]();
|
|||
|
|
const secondResult = await secondPromise;
|
|||
|
|
assert.equal(secondResult.error, undefined, secondResult.error);
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "serial createReadStream must never run");
|
|||
|
|
assert.equal(maxActiveFastGets, 1, "only one concurrent fastGet at a time");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("idle fast-download channels are discarded when a delayed error arrives", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-idle-error-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.from("downloaded");
|
|||
|
|
const channels = [];
|
|||
|
|
let endedChannels = 0;
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: payload.length });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
const channel = createFastSftp({
|
|||
|
|
fastGet(_remotePath, localPath, _options, done) {
|
|||
|
|
fs.promises.writeFile(localPath, payload).then(() => done(), done);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
endedChannels += 1;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
channels.push(channel);
|
|||
|
|
callback(null, channel);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const start = (id) => transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: `/tmp/${id}.bin`,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal((await start("idle-error-first")).error, undefined);
|
|||
|
|
assert.equal(channels.length, 1);
|
|||
|
|
assert.ok(channels[0].listenerCount("error") > 0);
|
|||
|
|
channels[0].emit("error", new Error("delayed idle failure"));
|
|||
|
|
assert.equal(endedChannels, 1);
|
|||
|
|
|
|||
|
|
assert.equal((await start("idle-error-second")).error, undefined);
|
|||
|
|
assert.equal(channels.length, 2);
|
|||
|
|
channels[1].emit("error", new Error("test cleanup"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("SFTP downloads cancelled while opening do not block the session", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-open-cancel-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let delayedOpen = null;
|
|||
|
|
let abandonedChannelClosed = false;
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let physicalEndCalls = 0;
|
|||
|
|
let physicalDestroyCalls = 0;
|
|||
|
|
const abandonedSftp = createFastSftp({
|
|||
|
|
end() {
|
|||
|
|
abandonedChannelClosed = true;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const workingSftp = createFastSftp({
|
|||
|
|
fastGet(_remotePath, localPath, _options, done) {
|
|||
|
|
fs.promises.writeFile(localPath, "downloaded").then(
|
|||
|
|
() => done(),
|
|||
|
|
done,
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat() {
|
|||
|
|
return Promise.resolve({ size: 10 });
|
|||
|
|
},
|
|||
|
|
client: {
|
|||
|
|
end() { physicalEndCalls++; },
|
|||
|
|
destroy() { physicalDestroyCalls++; },
|
|||
|
|
sftp(callback) {
|
|||
|
|
openCalls += 1;
|
|||
|
|
if (openCalls === 1) {
|
|||
|
|
delayedOpen = callback;
|
|||
|
|
} else {
|
|||
|
|
callback(null, workingSftp);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const start = (id) => transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: `/tmp/${id}.bin`,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 10,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const cancelledPromise = start("download-opening");
|
|||
|
|
const deadline = Date.now() + 1000;
|
|||
|
|
while (!delayedOpen && Date.now() < deadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(typeof delayedOpen, "function");
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "download-opening" });
|
|||
|
|
delayedOpen(null, abandonedSftp);
|
|||
|
|
|
|||
|
|
const cancelled = await cancelledPromise;
|
|||
|
|
assert.equal(cancelled.error, "Transfer cancelled");
|
|||
|
|
assert.equal(abandonedChannelClosed, true);
|
|||
|
|
assert.equal(physicalEndCalls, 0, "cancelling one download must preserve the shared SSH connection");
|
|||
|
|
assert.equal(physicalDestroyCalls, 0, "terminal and browsing channels must not be destroyed");
|
|||
|
|
|
|||
|
|
const next = await Promise.race([
|
|||
|
|
start("download-after-cancel"),
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("next download remained blocked")), 1000)),
|
|||
|
|
]);
|
|||
|
|
assert.equal(next.error, undefined);
|
|||
|
|
assert.equal(openCalls, 2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("shared concurrent downloads fail closed when open/read are missing (no createReadStream)", async (t) => {
|
|||
|
|
// Code-level lock: bulk download must not reintroduce serial createReadStream.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-no-stream-body-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
let createReadStreamCalls = 0;
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
// Intentionally no open/read — pipelined path unavailable.
|
|||
|
|
createReadStream() {
|
|||
|
|
createReadStreamCalls += 1;
|
|||
|
|
throw new Error("serial createReadStream must not be used for bulk SFTP download");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat() { return Promise.resolve({ size: 6 }); },
|
|||
|
|
client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "download-no-stream-body",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath: path.join(tempDir, "target.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 6,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.match(result.error || "", /pipelined download failed|open\/read missing/i);
|
|||
|
|
assert.equal(createReadStreamCalls, 0, "bulk path must never call createReadStream");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("stream local-copy pause survives write-stream drain without auto-resuming the pipe", async (t) => {
|
|||
|
|
// Regression for transfer-list pause (#2458): Node's .pipe() resumes the source
|
|||
|
|
// on destination 'drain'. SFTP upload no longer uses serial WriteStream; cover
|
|||
|
|
// unpipe on local→local copies (same pauseTransfer path). Mock write stream
|
|||
|
|
// still persists bytes so pause checkpoint stat succeeds.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pipe-pause-local-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(256 * 1024, 91);
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(sourcePath, payload);
|
|||
|
|
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const pendingWriteCallbacks = [];
|
|||
|
|
let holdWrites = true;
|
|||
|
|
let activeWriteStream = null;
|
|||
|
|
const originalCreateWriteStream = fs.createWriteStream;
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.createWriteStream = originalCreateWriteStream;
|
|||
|
|
});
|
|||
|
|
fs.createWriteStream = (filePath, options) => {
|
|||
|
|
const real = originalCreateWriteStream(filePath, options);
|
|||
|
|
activeWriteStream = new Writable({
|
|||
|
|
highWaterMark: 16,
|
|||
|
|
write(chunk, encoding, callback) {
|
|||
|
|
real.write(chunk, encoding, (err) => {
|
|||
|
|
if (err) return callback(err);
|
|||
|
|
durableBytes += chunk.length;
|
|||
|
|
activeWriteStream.bytesWritten = durableBytes;
|
|||
|
|
if (holdWrites) {
|
|||
|
|
pendingWriteCallbacks.push(callback);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback();
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
final(callback) {
|
|||
|
|
real.end(callback);
|
|||
|
|
},
|
|||
|
|
destroy(err, callback) {
|
|||
|
|
real.destroy(err);
|
|||
|
|
callback(err);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
activeWriteStream.bytesWritten = 0;
|
|||
|
|
return activeWriteStream;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "local-pipe-pause",
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const backpressureDeadline = Date.now() + 2000;
|
|||
|
|
while (pendingWriteCallbacks.length === 0 && Date.now() < backpressureDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.ok(pendingWriteCallbacks.length > 0, "write stream should be backpressured");
|
|||
|
|
const bytesWhenPauseRequested = durableBytes;
|
|||
|
|
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "local-pipe-pause" });
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const callback of pendingWriteCallbacks.splice(0)) callback();
|
|||
|
|
const paused = await pausing;
|
|||
|
|
assert.equal(paused.success, true, paused.reason);
|
|||
|
|
const checkpoint = paused.checkpointBytes;
|
|||
|
|
assert.ok(checkpoint >= bytesWhenPauseRequested);
|
|||
|
|
assert.ok(
|
|||
|
|
checkpoint < payload.length,
|
|||
|
|
"pause must stop before the copy finishes under backpressure",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|||
|
|
assert.equal(
|
|||
|
|
durableBytes,
|
|||
|
|
checkpoint,
|
|||
|
|
"paused stream copy must not keep writing after drain",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-pipe-pause" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
assert.equal(durableBytes, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("repeated resume does not double-pipe the same stream pair", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-double-resume-local-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(256 * 1024, 77);
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(sourcePath, payload);
|
|||
|
|
|
|||
|
|
let durableBytes = 0;
|
|||
|
|
const pendingWriteCallbacks = [];
|
|||
|
|
let holdWrites = true;
|
|||
|
|
let pipeCount = 0;
|
|||
|
|
let activeWriteStream = null;
|
|||
|
|
const originalCreateWriteStream = fs.createWriteStream;
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.createWriteStream = originalCreateWriteStream;
|
|||
|
|
});
|
|||
|
|
fs.createWriteStream = (filePath, options) => {
|
|||
|
|
const real = originalCreateWriteStream(filePath, options);
|
|||
|
|
activeWriteStream = new Writable({
|
|||
|
|
highWaterMark: 16,
|
|||
|
|
write(chunk, encoding, callback) {
|
|||
|
|
real.write(chunk, encoding, (err) => {
|
|||
|
|
if (err) return callback(err);
|
|||
|
|
durableBytes += chunk.length;
|
|||
|
|
activeWriteStream.bytesWritten = durableBytes;
|
|||
|
|
if (holdWrites) {
|
|||
|
|
pendingWriteCallbacks.push(callback);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback();
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
final(callback) {
|
|||
|
|
real.end(callback);
|
|||
|
|
},
|
|||
|
|
destroy(err, callback) {
|
|||
|
|
real.destroy(err);
|
|||
|
|
callback(err);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
activeWriteStream.bytesWritten = 0;
|
|||
|
|
return activeWriteStream;
|
|||
|
|
};
|
|||
|
|
const originalReadablePipe = Readable.prototype.pipe;
|
|||
|
|
t.after(() => {
|
|||
|
|
Readable.prototype.pipe = originalReadablePipe;
|
|||
|
|
});
|
|||
|
|
Readable.prototype.pipe = function patchedPipe(dest, ...args) {
|
|||
|
|
if (dest === activeWriteStream) pipeCount += 1;
|
|||
|
|
return originalReadablePipe.apply(this, [dest, ...args]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "local-double-resume",
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const backpressureDeadline = Date.now() + 2000;
|
|||
|
|
while (pendingWriteCallbacks.length === 0 && Date.now() < backpressureDeadline) {
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.ok(pendingWriteCallbacks.length > 0, "write stream should be backpressured");
|
|||
|
|
|
|||
|
|
const pausing = transferBridge.pauseTransfer(null, { transferId: "local-double-resume" });
|
|||
|
|
holdWrites = false;
|
|||
|
|
for (const callback of pendingWriteCallbacks.splice(0)) callback();
|
|||
|
|
const paused = await pausing;
|
|||
|
|
assert.equal(paused.success, true, paused.reason);
|
|||
|
|
const pipesAfterPause = pipeCount;
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-double-resume" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
{
|
|||
|
|
const resumed = await transferBridge.resumeTransfer(null, { transferId: "local-double-resume" });
|
|||
|
|
assert.equal(resumed.success, true);
|
|||
|
|
assert.ok(Number.isFinite(resumed.lifecycleEpoch));
|
|||
|
|
}
|
|||
|
|
assert.equal(
|
|||
|
|
pipeCount,
|
|||
|
|
pipesAfterPause + 1,
|
|||
|
|
"second resume must not call pipe() again on the same pair",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal((await running).error, undefined);
|
|||
|
|
assert.equal(durableBytes, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumable downloads never promote a partial staged file", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-partial-test-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("original"));
|
|||
|
|
// Remote pretends size=6 but EOF after 3 bytes — concurrent READ must fail closed
|
|||
|
|
// without promoting a partial stage over the existing target.
|
|||
|
|
const partial = Buffer.from("abc");
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(partial, {
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
if (position >= partial.length) {
|
|||
|
|
setImmediate(() => callback(null, 0));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const slice = partial.subarray(position, Math.min(position + length, partial.length));
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
sftp,
|
|||
|
|
stat() { return Promise.resolve({ size: 6 }); },
|
|||
|
|
client: { sftp(callback) { callback(new Error("isolated channel unavailable")); } },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender },
|
|||
|
|
{
|
|||
|
|
transferId: "download-partial",
|
|||
|
|
sourcePath: "/tmp/source.bin",
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 6,
|
|||
|
|
resumable: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const result = await running;
|
|||
|
|
assert.match(result.error || "", /full source|size mismatch|pipelined download failed/i);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "original");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("old-style transfers explicitly reject pause", async () => {
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
assert.deepEqual(
|
|||
|
|
await transferBridge.pauseTransfer(null, { transferId: "missing" }),
|
|||
|
|
{ success: false, reason: "Transfer is no longer active" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("server-to-server upload resume uses its own checkpoint instead of overall progress", async (t) => {
|
|||
|
|
const transferId = `server-copy-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = "/source/payload.bin";
|
|||
|
|
const targetPath = "/target/payload.bin";
|
|||
|
|
const payload = Buffer.from("abcdef");
|
|||
|
|
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
|||
|
|
await fs.promises.writeFile(localStage, payload);
|
|||
|
|
t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); });
|
|||
|
|
|
|||
|
|
let remote = Buffer.alloc(0);
|
|||
|
|
let promoted = false;
|
|||
|
|
const targetSftp = createFastSftp({
|
|||
|
|
open(_path, flags, callback) {
|
|||
|
|
callback(null, Buffer.from("target-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const chunk = buffer.subarray(offset, offset + length);
|
|||
|
|
if (remote.length < position) {
|
|||
|
|
remote = Buffer.concat([remote, Buffer.alloc(position - remote.length)]);
|
|||
|
|
}
|
|||
|
|
remote = Buffer.concat([
|
|||
|
|
remote.subarray(0, position),
|
|||
|
|
chunk,
|
|||
|
|
remote.subarray(position + chunk.length),
|
|||
|
|
]);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }) };
|
|||
|
|
const targetClient = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: targetSftp,
|
|||
|
|
stat: async () => ({ size: remote.length }),
|
|||
|
|
rename: async () => { promoted = true; },
|
|||
|
|
delete: async () => {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
resumeStage: "upload",
|
|||
|
|
checkpointBytes: payload.length / 2,
|
|||
|
|
downloadCheckpointBytes: payload.length,
|
|||
|
|
uploadCheckpointBytes: 0,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remote, payload);
|
|||
|
|
assert.equal(promoted, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("server-to-server staged upload does not promote after AbortSignal-only cancellation", async (t) => {
|
|||
|
|
const transferId = `server-copy-signal-cancel-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = "/source/payload.bin";
|
|||
|
|
const targetPath = "/target/payload.bin";
|
|||
|
|
const payload = Buffer.from("abcdef");
|
|||
|
|
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
|||
|
|
await fs.promises.writeFile(localStage, payload);
|
|||
|
|
t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); });
|
|||
|
|
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
let remote = Buffer.alloc(0);
|
|||
|
|
let promoted = false;
|
|||
|
|
const removed = [];
|
|||
|
|
const targetSftp = createFastSftp({
|
|||
|
|
open(_path, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("target-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const chunk = buffer.subarray(offset, offset + length);
|
|||
|
|
remote = Buffer.concat([
|
|||
|
|
remote.subarray(0, position),
|
|||
|
|
chunk,
|
|||
|
|
remote.subarray(position + chunk.length),
|
|||
|
|
]);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
controller.abort();
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sourceClient = { sftp: createFastSftp({}), stat: async () => ({ size: payload.length }) };
|
|||
|
|
const targetClient = {
|
|||
|
|
__netcattySudoMode: true,
|
|||
|
|
sftp: targetSftp,
|
|||
|
|
stat: async () => ({ size: remote.length }),
|
|||
|
|
rename: async () => { promoted = true; },
|
|||
|
|
delete: async (remotePath) => { removed.push(String(remotePath)); },
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
resumeStage: "upload",
|
|||
|
|
downloadCheckpointBytes: payload.length,
|
|||
|
|
uploadCheckpointBytes: 0,
|
|||
|
|
abortSignal: controller.signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /cancel/i);
|
|||
|
|
assert.equal(promoted, false);
|
|||
|
|
assert.ok(removed.some((remotePath) => remotePath.includes(".netcatty-")));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("server-to-server SCP fallback uses the followed symlink content size", async (t) => {
|
|||
|
|
const transferId = `scp-symlink-s2s-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = "/source/link";
|
|||
|
|
const targetPath = "/target/copied.bin";
|
|||
|
|
const payload = Buffer.from("followed-symlink-target-content");
|
|||
|
|
const localStage = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); });
|
|||
|
|
|
|||
|
|
const sourceBackend = {
|
|||
|
|
async stat() {
|
|||
|
|
return { type: "symlink", isSymbolicLink: true, size: 4 };
|
|||
|
|
},
|
|||
|
|
async downloadFile(_remotePath, localPath, options = {}) {
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const halfway = Math.floor(payload.length / 2);
|
|||
|
|
options.onProgress?.(halfway, payload.length);
|
|||
|
|
options.onProgress?.(payload.length, payload.length);
|
|||
|
|
return { fileSize: payload.length, transferred: payload.length };
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("old target")]]);
|
|||
|
|
let releaseFinalRename;
|
|||
|
|
let markFinalRenameStarted;
|
|||
|
|
const finalRenameStarted = new Promise((resolve) => { markFinalRenameStarted = resolve; });
|
|||
|
|
const missing = () => {
|
|||
|
|
const error = new Error("No such file");
|
|||
|
|
error.code = "ENOENT";
|
|||
|
|
return error;
|
|||
|
|
};
|
|||
|
|
const targetBackend = {
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
if (!remoteFiles.has(remotePath)) throw missing();
|
|||
|
|
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
|
|||
|
|
},
|
|||
|
|
async mkdir() {},
|
|||
|
|
async uploadFile(localPath, remotePath) {
|
|||
|
|
remoteFiles.set(remotePath, await fs.promises.readFile(localPath));
|
|||
|
|
return true;
|
|||
|
|
},
|
|||
|
|
async rename(from, to) {
|
|||
|
|
if (!remoteFiles.has(from)) throw missing();
|
|||
|
|
if (from.includes(".netcatty-upload-") && to === targetPath) {
|
|||
|
|
markFinalRenameStarted();
|
|||
|
|
await new Promise((resolve) => { releaseFinalRename = resolve; });
|
|||
|
|
}
|
|||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
|||
|
|
remoteFiles.delete(from);
|
|||
|
|
},
|
|||
|
|
async remove(remotePath) {
|
|||
|
|
remoteFiles.delete(remotePath);
|
|||
|
|
},
|
|||
|
|
async chmod() {},
|
|||
|
|
};
|
|||
|
|
const sourceClient = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: sourceBackend,
|
|||
|
|
};
|
|||
|
|
const targetClient = {
|
|||
|
|
__netcattyFileProtocol: "scp",
|
|||
|
|
__netcattyScpBackend: targetBackend,
|
|||
|
|
};
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([["source-scp", sourceClient], ["target-scp", targetClient]]),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source-scp",
|
|||
|
|
targetSftpId: "target-scp",
|
|||
|
|
resumable: false,
|
|||
|
|
sameHost: false,
|
|||
|
|
});
|
|||
|
|
await finalRenameStarted;
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
releaseFinalRename();
|
|||
|
|
const result = await running;
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-upload-")), false);
|
|||
|
|
assert.equal([...remoteFiles.keys()].some((key) => key.includes(".netcatty-backup-")), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), false);
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), true);
|
|||
|
|
const progress = sender.sent
|
|||
|
|
.filter((entry) => entry.channel === "netcatty:transfer:progress")
|
|||
|
|
.map((entry) => entry.payload);
|
|||
|
|
for (let index = 1; index < progress.length; index += 1) {
|
|||
|
|
assert.ok(
|
|||
|
|
progress[index].transferred >= progress[index - 1].transferred,
|
|||
|
|
`progress moved backwards: ${JSON.stringify(progress)}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
for (const entry of progress.slice(0, -1)) {
|
|||
|
|
assert.ok(
|
|||
|
|
entry.totalBytes === 0 || entry.transferred < entry.totalBytes,
|
|||
|
|
`progress completed before the transfer: ${JSON.stringify(progress)}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
assert.ok(
|
|||
|
|
progress.some((entry) => (
|
|||
|
|
entry.totalBytes === payload.length
|
|||
|
|
&& entry.transferred === Math.floor(payload.length / 2)
|
|||
|
|
)),
|
|||
|
|
`missing completed-download midpoint: ${JSON.stringify(progress)}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("server-to-server concurrent failure does not complete via serial stream", async (t) => {
|
|||
|
|
const transferId = `server-copy-fail-closed-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = "/source/payload.bin";
|
|||
|
|
const targetPath = "/target/payload.bin";
|
|||
|
|
const payload = Buffer.alloc(3 * 32 * 1024, 53);
|
|||
|
|
const localStage = tempDirBridge.getTransferTempFilePath(transferId, path.basename(sourcePath));
|
|||
|
|
await fs.promises.writeFile(localStage, payload);
|
|||
|
|
t.after(async () => { await fs.promises.unlink(localStage).catch(() => {}); });
|
|||
|
|
|
|||
|
|
let secondWriteCallback = null;
|
|||
|
|
let thirdWriteCompleted = false;
|
|||
|
|
let secondWriteFailureScheduled = false;
|
|||
|
|
const scheduleSecondWriteFailure = () => {
|
|||
|
|
if (secondWriteFailureScheduled || !thirdWriteCompleted || !secondWriteCallback) return;
|
|||
|
|
secondWriteFailureScheduled = true;
|
|||
|
|
const callback = secondWriteCallback;
|
|||
|
|
secondWriteCallback = null;
|
|||
|
|
queueMicrotask(() => callback(new Error("second range failed")));
|
|||
|
|
};
|
|||
|
|
let remoteBytes = 0;
|
|||
|
|
let createWriteStreamCalls = 0;
|
|||
|
|
const fastSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(null, Buffer.from("remote-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, _buffer, _offset, length, position, callback) {
|
|||
|
|
if (position === 32 * 1024) {
|
|||
|
|
secondWriteCallback = callback;
|
|||
|
|
scheduleSecondWriteFailure();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
remoteBytes = Math.max(remoteBytes, position + length);
|
|||
|
|
callback(null);
|
|||
|
|
if (position === 2 * 32 * 1024) {
|
|||
|
|
thirdWriteCompleted = true;
|
|||
|
|
scheduleSecondWriteFailure();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
});
|
|||
|
|
const targetSftp = createFastSftp({
|
|||
|
|
open(_remotePath, _flags, callback) {
|
|||
|
|
callback(new Error("shared open also fails"));
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
createWriteStreamCalls += 1;
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const sourceClient = {
|
|||
|
|
sftp: createFastSftp({}),
|
|||
|
|
stat: async () => ({ size: payload.length }),
|
|||
|
|
};
|
|||
|
|
const targetClient = {
|
|||
|
|
sftp: targetSftp,
|
|||
|
|
stat: async () => ({ size: remoteBytes }),
|
|||
|
|
rename: async () => {},
|
|||
|
|
delete: async () => {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, fastSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
resumeStage: "upload",
|
|||
|
|
downloadCheckpointBytes: payload.length,
|
|||
|
|
uploadCheckpointBytes: 0,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /pipelined upload failed|second range failed/i);
|
|||
|
|
assert.equal(createWriteStreamCalls, 0);
|
|||
|
|
// Local S2S temp (full download) must survive failed upload attempts.
|
|||
|
|
assert.equal(fs.statSync(localStage).size, payload.length);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("upload recovery without a full source identity restarts from zero", async (t) => {
|
|||
|
|
// Simulates force-quit mid-upload: UI progress saved a high checkpoint, but
|
|||
|
|
// only a shorter prefix made it into the remote staged .part file.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-crash-resume-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const transferId = `crash-resume-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE * 2 + 19);
|
|||
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
|||
|
|
await fs.promises.writeFile(sourcePath, payload);
|
|||
|
|
|
|||
|
|
// The durable prefix is deliberately not chunk-aligned. The first resumed
|
|||
|
|
// request must stop at the digest boundary, and the final bytes must match.
|
|||
|
|
let remote = Buffer.from(payload.subarray(0, 4));
|
|||
|
|
let promoted = false;
|
|||
|
|
let minWritePosition = Infinity;
|
|||
|
|
const concurrentSftp = createFastSftp({
|
|||
|
|
open(_path, flags, callback) {
|
|||
|
|
callback(null, Buffer.from("resume-handle"));
|
|||
|
|
},
|
|||
|
|
write(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
minWritePosition = Math.min(minWritePosition, position);
|
|||
|
|
const chunk = buffer.subarray(offset, offset + length);
|
|||
|
|
if (remote.length < position) {
|
|||
|
|
remote = Buffer.concat([remote, Buffer.alloc(position - remote.length)]);
|
|||
|
|
}
|
|||
|
|
remote = Buffer.concat([
|
|||
|
|
remote.subarray(0, position),
|
|||
|
|
chunk,
|
|||
|
|
remote.subarray(position + chunk.length),
|
|||
|
|
]);
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
// Resume safety samples the durable remote prefix via createReadStream.
|
|||
|
|
createReadStream(_path, options = {}) {
|
|||
|
|
const start = options.start || 0;
|
|||
|
|
const end = options.end;
|
|||
|
|
const slice = end === undefined ? remote.subarray(start) : remote.subarray(start, end + 1);
|
|||
|
|
return Readable.from([slice]);
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = {
|
|||
|
|
__netcattySudoMode: true, // shared concurrent path only
|
|||
|
|
sftp: concurrentSftp,
|
|||
|
|
// First stats clamp checkpoint from claimed 8 down to durable remote size 4.
|
|||
|
|
stat: async () => ({ size: remote.length }),
|
|||
|
|
rename: async () => { promoted = true; },
|
|||
|
|
delete: async () => {},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath: "/root/source.bin",
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: 8, // ahead of durable remote size 4
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.deepEqual(remote, payload);
|
|||
|
|
assert.equal(promoted, true);
|
|||
|
|
// A checkpoint without a full source digest cannot prove the source suffix
|
|||
|
|
// stayed unchanged across the crash, so the safe recovery is a full restart.
|
|||
|
|
assert.equal(minWritePosition, 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local resume after hard quit clamps checkpoint to durable staged file size", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-local-crash-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `local-crash-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
await fs.promises.writeFile(sourcePath, Buffer.from("abcdef"));
|
|||
|
|
// Durable staged file is shorter than claimed checkpoint (crash / unflushed write).
|
|||
|
|
await fs.promises.writeFile(stagedPath, Buffer.from("ab"));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: 6,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: 5,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "abcdef");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resume rejects a same-size temporary prefix that does not match the source", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-prefix-test-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const transferId = `prefix-${crypto.randomUUID()}`;
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const stagedPath = tempDirBridge.getTransferTempFilePath(transferId, "target.bin");
|
|||
|
|
await fs.promises.writeFile(sourcePath, Buffer.from("abcdef"));
|
|||
|
|
await fs.promises.writeFile(stagedPath, Buffer.from("xyz"));
|
|||
|
|
t.after(async () => { await fs.promises.unlink(stagedPath).catch(() => {}); });
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: 6,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes: 3,
|
|||
|
|
sourceFingerprint: `sha256:${crypto.createHash("sha256").update("abcdef").digest("hex")}`,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.match(result.error || "", /saved content does not match/i);
|
|||
|
|
assert.equal(await fs.promises.readFile(sourcePath, "utf8"), "abcdef");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("bridge admission applies one global concurrency limit across callers", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-admission-test-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const pendingByPath = new Map();
|
|||
|
|
const payloads = {
|
|||
|
|
"/first": Buffer.from("a"),
|
|||
|
|
"/second": Buffer.from("b"),
|
|||
|
|
};
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
const cb = typeof flags === "function" ? flags : callback;
|
|||
|
|
cb(null, Buffer.from(`handle:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString().replace(/^handle:/, "");
|
|||
|
|
const payload = payloads[key] || Buffer.from("x");
|
|||
|
|
// Stall only the first READ per path so admission can observe "started".
|
|||
|
|
// Later verification samples complete immediately.
|
|||
|
|
if (!pendingByPath.has(key)) {
|
|||
|
|
pendingByPath.set(key, []);
|
|||
|
|
pendingByPath.get(key).push(() => {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
// Content-hash helpers only — bulk body uses open/read above.
|
|||
|
|
createReadStream(remotePath, options = {}) {
|
|||
|
|
const payload = payloads[remotePath] || Buffer.from("x");
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : payload.length - 1;
|
|||
|
|
return Readable.from([Buffer.from(payload.subarray(start, end + 1))]);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const client = { sftp, stat: async () => ({ size: 1 }) };
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
|||
|
|
|
|||
|
|
const start = (id, remotePath) => transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: remotePath,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 1,
|
|||
|
|
resumable: true,
|
|||
|
|
globalConcurrency: 1,
|
|||
|
|
});
|
|||
|
|
const first = start("admission-first", "/first");
|
|||
|
|
assert.equal(
|
|||
|
|
await waitUntil(() => (pendingByPath.get("/first") || []).length > 0),
|
|||
|
|
true,
|
|||
|
|
"first admitted transfer did not start",
|
|||
|
|
);
|
|||
|
|
const second = start("admission-second", "/second");
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(pendingByPath.has("/second"), false);
|
|||
|
|
for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver();
|
|||
|
|
assert.equal((await first).error, undefined);
|
|||
|
|
assert.equal(
|
|||
|
|
await waitUntil(() => (pendingByPath.get("/second") || []).length > 0),
|
|||
|
|
true,
|
|||
|
|
"second admitted transfer did not start after the first completed",
|
|||
|
|
);
|
|||
|
|
for (const deliver of (pendingByPath.get("/second") || []).splice(0)) deliver();
|
|||
|
|
assert.equal((await second).error, undefined);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("bridge admission gives different remote sessions independent concurrency", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-per-session-test-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const makeClient = (label) => {
|
|||
|
|
const pending = [];
|
|||
|
|
let firstRead = true;
|
|||
|
|
const payload = Buffer.from(label);
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(payload, {
|
|||
|
|
read(_handle, buffer, offset, length, position, callback) {
|
|||
|
|
const deliver = () => {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
};
|
|||
|
|
if (firstRead) {
|
|||
|
|
firstRead = false;
|
|||
|
|
pending.push(deliver);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setImmediate(deliver);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
return {
|
|||
|
|
client: { sftp, stat: async () => ({ size: 1 }) },
|
|||
|
|
pending,
|
|||
|
|
release() {
|
|||
|
|
for (const deliver of pending.splice(0)) deliver();
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
const a = makeClient("a");
|
|||
|
|
const b = makeClient("b");
|
|||
|
|
transferBridge.init({ sftpClients: new Map([
|
|||
|
|
["source-a", a.client],
|
|||
|
|
["source-b", b.client],
|
|||
|
|
]) });
|
|||
|
|
|
|||
|
|
const start = (id, sourceSftpId) => transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: `/${id}`,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId,
|
|||
|
|
totalBytes: 1,
|
|||
|
|
resumable: true,
|
|||
|
|
globalConcurrency: 1,
|
|||
|
|
});
|
|||
|
|
const first = start("per-session-a", "source-a");
|
|||
|
|
const second = start("per-session-b", "source-b");
|
|||
|
|
const bothStarted = await waitUntil(
|
|||
|
|
() => a.pending.length > 0 && b.pending.length > 0,
|
|||
|
|
500,
|
|||
|
|
);
|
|||
|
|
a.release();
|
|||
|
|
b.release();
|
|||
|
|
await Promise.all([first, second]);
|
|||
|
|
assert.equal(bothStarted, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("clearPendingCancel allows intentional same-id start after a pre-start cancel", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-clear-pending-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(Buffer.from("a"));
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) });
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "retry-same-id" });
|
|||
|
|
transferBridge.clearPendingCancel("retry-same-id");
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "retry-same-id",
|
|||
|
|
sourcePath: "/remote",
|
|||
|
|
targetPath: path.join(tempDir, "out.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 1,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
});
|
|||
|
|
assert.equal(result.cancelled, undefined);
|
|||
|
|
assert.equal(result.error, undefined, result.error);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("cancel before skipAdmission start rejects the transfer without writing", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-pending-cancel-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
let openCalls = 0;
|
|||
|
|
const { sftp } = createPipelinedDownloadSftp(Buffer.from("a"), {
|
|||
|
|
open(_remotePath, flags, callback) {
|
|||
|
|
openCalls += 1;
|
|||
|
|
const cb = typeof flags === "function" ? flags : callback;
|
|||
|
|
cb(null, Buffer.from("read-handle"));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) });
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "pending-cancel-1" });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "pending-cancel-1",
|
|||
|
|
sourcePath: "/remote",
|
|||
|
|
targetPath: path.join(tempDir, "out.bin"),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 1,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
});
|
|||
|
|
assert.equal(result.cancelled, true);
|
|||
|
|
assert.equal(openCalls, 0, "pre-start cancel must not open a remote handle");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("pre-start cancel latches stay hard bounded when cancelled work never starts", async (t) => {
|
|||
|
|
t.after(() => {
|
|||
|
|
for (let index = 0; index < 5_000; index += 1) {
|
|||
|
|
transferBridge.clearPendingCancel(`orphan-cancel-${index}`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
for (let index = 0; index < 5_000; index += 1) {
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: `orphan-cancel-${index}` });
|
|||
|
|
}
|
|||
|
|
assert.ok(
|
|||
|
|
transferBridge._getPendingCancelCountForTests() <= 4_096,
|
|||
|
|
`pending cancel latches grew to ${transferBridge._getPendingCancelCountForTests()}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("pausing a queued admission job preserves the payload checkpoint", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-queued-checkpoint-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const pendingByPath = new Map();
|
|||
|
|
const payloads = { "/first": Buffer.from("a"), "/second": Buffer.from("b") };
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
const cb = typeof flags === "function" ? flags : callback;
|
|||
|
|
cb(null, Buffer.from(`handle:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString().replace(/^handle:/, "");
|
|||
|
|
const payload = payloads[key] || Buffer.from("x");
|
|||
|
|
if (!pendingByPath.has(key)) {
|
|||
|
|
pendingByPath.set(key, []);
|
|||
|
|
pendingByPath.get(key).push(() => {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
createReadStream(remotePath, options = {}) {
|
|||
|
|
const payload = payloads[remotePath] || Buffer.from("x");
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : payload.length - 1;
|
|||
|
|
return Readable.from([Buffer.from(payload.subarray(start, end + 1))]);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) });
|
|||
|
|
const start = (id, remotePath, checkpointBytes) => transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: remotePath,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 1,
|
|||
|
|
resumable: true,
|
|||
|
|
checkpointBytes,
|
|||
|
|
globalConcurrency: 1,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const first = start("queued-ckpt-first", "/first", 0);
|
|||
|
|
assert.ok(await waitUntil(() => (pendingByPath.get("/first") || []).length > 0));
|
|||
|
|
const second = start("queued-ckpt-second", "/second", 42);
|
|||
|
|
const paused = await transferBridge.pauseTransfer(null, { transferId: "queued-ckpt-second" });
|
|||
|
|
assert.equal(paused.success, true);
|
|||
|
|
assert.equal(paused.checkpointBytes, 42);
|
|||
|
|
assert.equal((await transferBridge.cancelTransfer(null, { transferId: "queued-ckpt-second" })).success, true);
|
|||
|
|
assert.equal((await second).cancelled, true);
|
|||
|
|
for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver();
|
|||
|
|
assert.equal((await first).error, undefined);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("queued admission jobs can be paused, resumed, prioritized, and cancelled before opening a stream", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-queued-controls-"));
|
|||
|
|
t.after(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
|
|||
|
|
const pendingByPath = new Map();
|
|||
|
|
let secondOpenCalls = 0;
|
|||
|
|
const payloads = { "/first": Buffer.from("a"), "/second": Buffer.from("b") };
|
|||
|
|
const sftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
const cb = typeof flags === "function" ? flags : callback;
|
|||
|
|
if (String(remotePath).includes("second")) secondOpenCalls += 1;
|
|||
|
|
cb(null, Buffer.from(`handle:${remotePath}`));
|
|||
|
|
},
|
|||
|
|
read(handle, buffer, offset, length, position, callback) {
|
|||
|
|
const key = handle.toString().replace(/^handle:/, "");
|
|||
|
|
const payload = payloads[key] || Buffer.from("x");
|
|||
|
|
if (!pendingByPath.has(key)) {
|
|||
|
|
pendingByPath.set(key, []);
|
|||
|
|
pendingByPath.get(key).push(() => {
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
callback(null, slice.length);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const slice = payload.subarray(position, position + length);
|
|||
|
|
slice.copy(buffer, offset);
|
|||
|
|
setImmediate(() => callback(null, slice.length));
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) { callback(null); },
|
|||
|
|
createReadStream(remotePath, options = {}) {
|
|||
|
|
const payload = payloads[remotePath] || Buffer.from("x");
|
|||
|
|
const start = Number.isFinite(options.start) ? options.start : 0;
|
|||
|
|
const end = Number.isFinite(options.end) ? options.end : payload.length - 1;
|
|||
|
|
return Readable.from([Buffer.from(payload.subarray(start, end + 1))]);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["source", { sftp, stat: async () => ({ size: 1 }) }]]) });
|
|||
|
|
const start = (id, remotePath) => transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: id,
|
|||
|
|
sourcePath: remotePath,
|
|||
|
|
targetPath: path.join(tempDir, `${id}.bin`),
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: "source",
|
|||
|
|
totalBytes: 1,
|
|||
|
|
resumable: true,
|
|||
|
|
globalConcurrency: 1,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const first = start("queued-control-first", "/first");
|
|||
|
|
assert.ok(await waitUntil(() => (pendingByPath.get("/first") || []).length > 0));
|
|||
|
|
const second = start("queued-control-second", "/second");
|
|||
|
|
assert.equal((await transferBridge.pauseTransfer(null, { transferId: "queued-control-second" })).success, true);
|
|||
|
|
assert.equal(secondOpenCalls, 0, "queued second must not open while first holds admission");
|
|||
|
|
assert.equal((await transferBridge.resumeTransfer(null, { transferId: "queued-control-second" })).success, true);
|
|||
|
|
assert.equal((await transferBridge.prioritizeTransfer(null, { transferId: "queued-control-second" })).success, true);
|
|||
|
|
assert.equal((await transferBridge.cancelTransfer(null, { transferId: "queued-control-second" })).success, true);
|
|||
|
|
assert.equal((await second).cancelled, true);
|
|||
|
|
for (const deliver of (pendingByPath.get("/first") || []).splice(0)) deliver();
|
|||
|
|
assert.equal((await first).error, undefined);
|
|||
|
|
assert.equal(secondOpenCalls, 0, "cancelled queued job must never open a remote handle");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("transfer session leases hold SFTP ids across soft-close until release", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
let hardCloseCalls = 0;
|
|||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
|||
|
|
const originalClose = sftpBridge.closeSftp;
|
|||
|
|
sftpBridge.closeSftp = async (_event, payload) => {
|
|||
|
|
if (payload?.force) {
|
|||
|
|
hardCloseCalls += 1;
|
|||
|
|
sftpTransferSessionLeaseStore.clear(payload.sftpId);
|
|||
|
|
return { success: true, deferred: false };
|
|||
|
|
}
|
|||
|
|
if (sftpTransferSessionLeaseStore.markSoftClosed(payload.sftpId)) {
|
|||
|
|
return {
|
|||
|
|
success: true,
|
|||
|
|
deferred: true,
|
|||
|
|
leaseCount: sftpTransferSessionLeaseStore.getLeaseCount(payload.sftpId),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return { success: true, deferred: false };
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
sftpBridge.closeSftp = originalClose;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
transferBridge.listTransferSftpIds({ sourceSftpId: "s1", targetSftpId: "s2", sourceHostId: "h" }),
|
|||
|
|
["s1", "s2"],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Hold two transfers on s1 before soft-close (re-acquire after soft-close
|
|||
|
|
// would clear the deferred flag by design).
|
|||
|
|
transferBridge.acquireTransferSessionLeases("xfer-1", {
|
|||
|
|
sourceSftpId: "s1",
|
|||
|
|
targetSftpId: "s2",
|
|||
|
|
});
|
|||
|
|
transferBridge.acquireTransferSessionLeases("xfer-2", { sourceSftpId: "s1" });
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount("s1"), 2);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount("s2"), 1);
|
|||
|
|
|
|||
|
|
const soft = await sftpBridge.closeSftp(null, { sftpId: "s1" });
|
|||
|
|
assert.equal(soft.deferred, true);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isSoftClosed("s1"), true);
|
|||
|
|
assert.equal(hardCloseCalls, 0);
|
|||
|
|
|
|||
|
|
transferBridge.releaseTransferSessionLeases("xfer-1", ["s1", "s2"]);
|
|||
|
|
assert.equal(hardCloseCalls, 0);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isHeld("s1"), true);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isHeld("s2"), false);
|
|||
|
|
|
|||
|
|
// Last release on soft-closed session triggers hard close.
|
|||
|
|
transferBridge.releaseTransferSessionLeases("xfer-2", ["s1"]);
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(hardCloseCalls, 1);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isHeld("s1"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a directory pool lease survives terminal close between child files", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
let hardCloseCalls = 0;
|
|||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
|||
|
|
const originalClose = sftpBridge.closeSftp;
|
|||
|
|
sftpBridge.closeSftp = async (_event, payload) => {
|
|||
|
|
if (payload?.force) {
|
|||
|
|
hardCloseCalls += 1;
|
|||
|
|
sftpTransferSessionLeaseStore.clear(payload.sftpId);
|
|||
|
|
return { success: true, deferred: false };
|
|||
|
|
}
|
|||
|
|
if (sftpTransferSessionLeaseStore.markSoftClosed(payload.sftpId)) {
|
|||
|
|
return {
|
|||
|
|
success: true,
|
|||
|
|
deferred: true,
|
|||
|
|
leaseCount: sftpTransferSessionLeaseStore.getLeaseCount(payload.sftpId),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return { success: true, deferred: false };
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
sftpBridge.closeSftp = originalClose;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["folder-sftp", {}]]) });
|
|||
|
|
assert.deepEqual(
|
|||
|
|
transferBridge.retainSftpTransferSession(null, {
|
|||
|
|
sftpId: "folder-sftp",
|
|||
|
|
leaseId: "pool:folder-sftp",
|
|||
|
|
}),
|
|||
|
|
{ success: true },
|
|||
|
|
);
|
|||
|
|
transferBridge.acquireTransferSessionLeases("child-1", { targetSftpId: "folder-sftp" });
|
|||
|
|
|
|||
|
|
const soft = await sftpBridge.closeSftp(null, { sftpId: "folder-sftp" });
|
|||
|
|
assert.equal(soft.deferred, true);
|
|||
|
|
transferBridge.releaseTransferSessionLeases("child-1", ["folder-sftp"]);
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(hardCloseCalls, 0, "the directory pool must keep the session alive between files");
|
|||
|
|
|
|||
|
|
assert.doesNotThrow(() => {
|
|||
|
|
transferBridge.acquireTransferSessionLeases("child-2", { targetSftpId: "folder-sftp" });
|
|||
|
|
});
|
|||
|
|
transferBridge.releaseTransferSessionLeases("child-2", ["folder-sftp"]);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
transferBridge.releaseSftpTransferSession(null, {
|
|||
|
|
sftpId: "folder-sftp",
|
|||
|
|
leaseId: "pool:folder-sftp",
|
|||
|
|
}),
|
|||
|
|
{ success: true },
|
|||
|
|
);
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
assert.equal(hardCloseCalls, 1, "the deferred close should run only after the pool releases");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("directory child lifecycle events preserve their parent id", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-child-event-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const sourcePath = path.join(tempDir, "child.txt");
|
|||
|
|
const targetPath = path.join(tempDir, "copied.txt");
|
|||
|
|
await fs.promises.writeFile(sourcePath, "child");
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const sender = createSender();
|
|||
|
|
|
|||
|
|
const result = await transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId: "folder-child-event",
|
|||
|
|
parentTaskId: "folder-root-event",
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: 5,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
const started = sender.sent.find((entry) => entry.channel === "netcatty:transfer:started");
|
|||
|
|
assert.equal(started?.payload?.parentTaskId, "folder-root-event");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a committed soft-close never lends the SFTP client again while client.end is pending", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
let releaseEnd;
|
|||
|
|
const endGate = new Promise((resolve) => { releaseEnd = resolve; });
|
|||
|
|
let markEndStarted;
|
|||
|
|
const endStarted = new Promise((resolve) => { markEndStarted = resolve; });
|
|||
|
|
let endCalls = 0;
|
|||
|
|
const client = {
|
|||
|
|
async end() {
|
|||
|
|
endCalls += 1;
|
|||
|
|
markEndStarted();
|
|||
|
|
await endGate;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const sftpClients = new Map([["sftp-soft-close-race", client]]);
|
|||
|
|
transferBridge.init({ sftpClients });
|
|||
|
|
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|||
|
|
t.after(() => {
|
|||
|
|
releaseEnd();
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
sftpClients.clear();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
transferBridge.acquireTransferSessionLeases("directory-child-1", {
|
|||
|
|
sourceSftpId: "sftp-soft-close-race",
|
|||
|
|
});
|
|||
|
|
const softClose = await sftpBridge.closeSftp(null, { sftpId: "sftp-soft-close-race" });
|
|||
|
|
assert.equal(softClose.deferred, true);
|
|||
|
|
|
|||
|
|
transferBridge.releaseTransferSessionLeases("directory-child-1", ["sftp-soft-close-race"]);
|
|||
|
|
await endStarted;
|
|||
|
|
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge.acquireTransferSessionLeases("directory-child-2", {
|
|||
|
|
sourceSftpId: "sftp-soft-close-race",
|
|||
|
|
}),
|
|||
|
|
/closing|closed/i,
|
|||
|
|
);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isHeld("sftp-soft-close-race"), false);
|
|||
|
|
|
|||
|
|
releaseEnd();
|
|||
|
|
const deadline = Date.now() + 1_000;
|
|||
|
|
while (sftpClients.has("sftp-soft-close-race")) {
|
|||
|
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for committed SFTP close");
|
|||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|||
|
|
}
|
|||
|
|
assert.equal(endCalls, 1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a direct panel close never lends the SFTP client while client.end is pending", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
let releaseEnd;
|
|||
|
|
const endGate = new Promise((resolve) => { releaseEnd = resolve; });
|
|||
|
|
let markEndStarted;
|
|||
|
|
const endStarted = new Promise((resolve) => { markEndStarted = resolve; });
|
|||
|
|
const client = {
|
|||
|
|
async end() {
|
|||
|
|
markEndStarted();
|
|||
|
|
await endGate;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const sftpClients = new Map([["sftp-direct-close-race", client]]);
|
|||
|
|
transferBridge.init({ sftpClients });
|
|||
|
|
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|||
|
|
t.after(() => {
|
|||
|
|
releaseEnd();
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
sftpClients.clear();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const closePromise = sftpBridge.closeSftp(null, { sftpId: "sftp-direct-close-race" });
|
|||
|
|
await endStarted;
|
|||
|
|
|
|||
|
|
assert.throws(
|
|||
|
|
() => transferBridge.acquireTransferSessionLeases("new-transfer", {
|
|||
|
|
sourceSftpId: "sftp-direct-close-race",
|
|||
|
|
}),
|
|||
|
|
/closing|closed/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
releaseEnd();
|
|||
|
|
const closeResult = await closePromise;
|
|||
|
|
assert.equal(closeResult.deferred, false);
|
|||
|
|
assert.equal(sftpClients.has("sftp-direct-close-race"), false);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.isHeld("sftp-direct-close-race"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a transfer rejected by a committed SFTP close leaves no active transfer entry", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
const sftpId = "sftp-rejected-before-lease";
|
|||
|
|
const closeToken = sftpTransferSessionLeaseStore.beginHardClose(sftpId);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.commitHardClose(sftpId, closeToken), true);
|
|||
|
|
transferBridge.init({ sftpClients: new Map([[sftpId, {}]]) });
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "rejected-before-lease",
|
|||
|
|
sourcePath: "/remote/file.bin",
|
|||
|
|
targetPath: "/tmp/rejected-before-lease.bin",
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "local",
|
|||
|
|
sourceSftpId: sftpId,
|
|||
|
|
totalBytes: 1,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
}),
|
|||
|
|
/closing|closed/i,
|
|||
|
|
);
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("same-host directory copy rejected by a committed close leaves no lease or active transfer", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
const sftpId = "same-host-directory-closing";
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
const closeToken = sftpTransferSessionLeaseStore.beginHardClose(sftpId);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.commitHardClose(sftpId, closeToken), true);
|
|||
|
|
transferBridge.init({ sftpClients: new Map([[sftpId, {}]]) });
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
transferBridge.sameHostCopyDirectory(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "same-host-directory-rejected",
|
|||
|
|
sftpId,
|
|||
|
|
sourcePath: "/source",
|
|||
|
|
targetPath: "/target",
|
|||
|
|
encoding: "utf-8",
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
/closing|closed/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
const sftpId = "same-host-directory-cancel";
|
|||
|
|
let markExecOpened;
|
|||
|
|
const execOpened = new Promise((resolve) => { markExecOpened = resolve; });
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.close = () => {};
|
|||
|
|
stream.end = () => {};
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
const client = {
|
|||
|
|
client: {
|
|||
|
|
exec(_command, callback) {
|
|||
|
|
callback(null, stream);
|
|||
|
|
markExecOpened();
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([[sftpId, client]]) });
|
|||
|
|
|
|||
|
|
const running = transferBridge.sameHostCopyDirectory(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId: "same-host-directory-cancelled",
|
|||
|
|
sftpId,
|
|||
|
|
sourcePath: "/source",
|
|||
|
|
targetPath: "/target",
|
|||
|
|
encoding: "utf-8",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
await execOpened;
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 1);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 1);
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId: "same-host-directory-cancelled" });
|
|||
|
|
await assert.rejects(running, /cancel/i);
|
|||
|
|
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("a pre-start cancellation prevents same-host directory registration and lease acquisition", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
const sftpId = "same-host-directory-pre-cancel";
|
|||
|
|
let execCalls = 0;
|
|||
|
|
transferBridge.init({
|
|||
|
|
sftpClients: new Map([[sftpId, {
|
|||
|
|
client: {
|
|||
|
|
exec() { execCalls += 1; },
|
|||
|
|
},
|
|||
|
|
}]]),
|
|||
|
|
});
|
|||
|
|
const transferId = "same-host-directory-pre-cancelled";
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
|
|||
|
|
const result = await transferBridge.sameHostCopyDirectory(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sftpId,
|
|||
|
|
sourcePath: "/source",
|
|||
|
|
targetPath: "/target",
|
|||
|
|
encoding: "utf-8",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.equal(result.cancelled, true);
|
|||
|
|
assert.equal(execCalls, 0);
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
|||
|
|
assert.equal(transferBridge._getPendingCancelCountForTests(), 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("same-host file cp cancellation releases the shared session lease", async (t) => {
|
|||
|
|
const {
|
|||
|
|
sftpTransferSessionLeaseStore,
|
|||
|
|
} = require("./sftpTransferSessionLease.cjs");
|
|||
|
|
sftpTransferSessionLeaseStore.resetForTests();
|
|||
|
|
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
|||
|
|
|
|||
|
|
const sftpId = "same-host-file-cancel";
|
|||
|
|
let markExecOpened;
|
|||
|
|
const execOpened = new Promise((resolve) => { markExecOpened = resolve; });
|
|||
|
|
const stream = new EventEmitter();
|
|||
|
|
stream.stderr = new EventEmitter();
|
|||
|
|
stream.close = () => {};
|
|||
|
|
stream.end = () => {};
|
|||
|
|
stream.destroy = () => {};
|
|||
|
|
const client = {
|
|||
|
|
client: {
|
|||
|
|
exec(_command, callback) {
|
|||
|
|
callback(null, stream);
|
|||
|
|
markExecOpened();
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([[sftpId, client]]) });
|
|||
|
|
const transferId = "same-host-file-cancelled";
|
|||
|
|
const running = transferBridge.startTransfer(
|
|||
|
|
{ sender: createSender() },
|
|||
|
|
{
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: "/source/file.bin",
|
|||
|
|
targetPath: "/target/file.bin",
|
|||
|
|
sourceType: "sftp",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
sourceSftpId: sftpId,
|
|||
|
|
targetSftpId: sftpId,
|
|||
|
|
totalBytes: 1,
|
|||
|
|
sameHost: true,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
await execOpened;
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 1);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 1);
|
|||
|
|
|
|||
|
|
await transferBridge.cancelTransfer(null, { transferId });
|
|||
|
|
const result = await running;
|
|||
|
|
assert.equal(result.error, "Transfer cancelled");
|
|||
|
|
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
|||
|
|
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local promotion restores concurrent replacement moved into the backup", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-backup-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const stagedPath = path.join(tempDir, "download.staged");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const original = Buffer.from("original-target-content");
|
|||
|
|
const replacement = Buffer.from("concurrent-replacement");
|
|||
|
|
const downloaded = Buffer.from("downloaded-replacement");
|
|||
|
|
await fs.promises.writeFile(targetPath, original);
|
|||
|
|
await fs.promises.writeFile(stagedPath, downloaded);
|
|||
|
|
|
|||
|
|
const originalStat = await fs.promises.lstat(targetPath);
|
|||
|
|
const expectedStable = transferBridge._stableLocalFileIdentityForTests(originalStat);
|
|||
|
|
|
|||
|
|
// validateTarget claims the original identity, but a concurrent process has
|
|||
|
|
// already replaced the live path. rename then moves the wrong file to backup.
|
|||
|
|
await assert.rejects(
|
|||
|
|
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
async validateTarget() {
|
|||
|
|
await fs.promises.unlink(targetPath);
|
|||
|
|
await fs.promises.writeFile(targetPath, replacement);
|
|||
|
|
return {
|
|||
|
|
// Keep existingMode null so promotion does not re-validate after chmod.
|
|||
|
|
existingMode: null,
|
|||
|
|
stableIdentity: expectedStable,
|
|||
|
|
targetIdentity: [
|
|||
|
|
originalStat.dev,
|
|||
|
|
originalStat.ino,
|
|||
|
|
originalStat.size,
|
|||
|
|
originalStat.mtimeMs,
|
|||
|
|
originalStat.ctimeMs,
|
|||
|
|
].join(":"),
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
/changed during replacement/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Replacement must still be at the target (restored from backup), download not published.
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), replacement);
|
|||
|
|
assert.equal(
|
|||
|
|
(await fs.promises.readdir(tempDir)).some((name) => name.includes(".backup")),
|
|||
|
|
false,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local promotion does not restore mismatched backup over a recreated target", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-mismatch-recreate-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const stagedPath = path.join(tempDir, "download.staged");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const original = Buffer.from("original-target-content");
|
|||
|
|
const concurrent = Buffer.from("recreated-during-mismatch");
|
|||
|
|
const downloaded = Buffer.from("downloaded-replacement");
|
|||
|
|
await fs.promises.writeFile(targetPath, original);
|
|||
|
|
await fs.promises.writeFile(stagedPath, downloaded);
|
|||
|
|
// Claim a stable identity that will not match whatever lands in backup.
|
|||
|
|
const fakeStable = "999:999:999";
|
|||
|
|
|
|||
|
|
const originalRename = fs.promises.rename.bind(fs.promises);
|
|||
|
|
let injected = false;
|
|||
|
|
fs.promises.rename = async (from, to) => {
|
|||
|
|
const result = await originalRename(from, to);
|
|||
|
|
if (!injected && path.resolve(from) === path.resolve(targetPath) && String(to).includes(".backup")) {
|
|||
|
|
injected = true;
|
|||
|
|
// Recreate target after backup move; mismatch branch must not restore over it.
|
|||
|
|
await fs.promises.writeFile(targetPath, concurrent);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.promises.rename = originalRename;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
async validateTarget() {
|
|||
|
|
return {
|
|||
|
|
existingMode: null,
|
|||
|
|
stableIdentity: fakeStable,
|
|||
|
|
targetIdentity: `${fakeStable}:0:0`,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
/changed during replacement/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
|
|||
|
|
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
|
|||
|
|
assert.ok(backupName);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local promotion does not clobber a target recreated after backup", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-recreate-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const stagedPath = path.join(tempDir, "download.staged");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
const original = Buffer.from("original-target-content");
|
|||
|
|
const concurrent = Buffer.from("recreated-after-backup");
|
|||
|
|
const downloaded = Buffer.from("downloaded-replacement");
|
|||
|
|
await fs.promises.writeFile(targetPath, original);
|
|||
|
|
await fs.promises.writeFile(stagedPath, downloaded);
|
|||
|
|
const originalStat = await fs.promises.lstat(targetPath);
|
|||
|
|
|
|||
|
|
const originalRename = fs.promises.rename.bind(fs.promises);
|
|||
|
|
let injected = false;
|
|||
|
|
fs.promises.rename = async (from, to) => {
|
|||
|
|
const result = await originalRename(from, to);
|
|||
|
|
if (!injected && path.resolve(from) === path.resolve(targetPath) && String(to).includes(".backup")) {
|
|||
|
|
injected = true;
|
|||
|
|
await fs.promises.writeFile(targetPath, concurrent);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
t.after(() => {
|
|||
|
|
fs.promises.rename = originalRename;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
async validateTarget() {
|
|||
|
|
return {
|
|||
|
|
existingMode: null,
|
|||
|
|
stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat),
|
|||
|
|
targetIdentity: [
|
|||
|
|
originalStat.dev,
|
|||
|
|
originalStat.ino,
|
|||
|
|
originalStat.size,
|
|||
|
|
originalStat.mtimeMs,
|
|||
|
|
originalStat.ctimeMs,
|
|||
|
|
].join(":"),
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
/changed during replacement/i,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
|
|||
|
|
// Original preserved in a backup artifact for recovery.
|
|||
|
|
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
|
|||
|
|
assert.ok(backupName);
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`);
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const stagedPath = path.join(tempDir, "download.staged");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, "original");
|
|||
|
|
await fs.promises.writeFile(stagedPath, "download");
|
|||
|
|
let cancelled = false;
|
|||
|
|
let committed = false;
|
|||
|
|
const link = fs.promises.link;
|
|||
|
|
t.after(() => { fs.promises.link = link; });
|
|||
|
|
fs.promises.link = async (...args) => {
|
|||
|
|
await link.apply(fs.promises, args);
|
|||
|
|
if (args[1] === targetPath && String(args[0]).endsWith(".ready")) {
|
|||
|
|
await fs.promises.unlink(targetPath);
|
|||
|
|
await fs.promises.writeFile(targetPath, "concurrent");
|
|||
|
|
cancelled = true;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
|||
|
|
onCommit() { committed = true; },
|
|||
|
|
});
|
|||
|
|
assert.equal(committed, true);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local promotion succeeds when backup still matches validated identity", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-ok-"));
|
|||
|
|
t.after(async () => {
|
|||
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const stagedPath = path.join(tempDir, "download.staged");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("old"));
|
|||
|
|
await fs.promises.writeFile(stagedPath, Buffer.from("new"));
|
|||
|
|
const originalStat = await fs.promises.lstat(targetPath);
|
|||
|
|
|
|||
|
|
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
async validateTarget() {
|
|||
|
|
const latest = await fs.promises.lstat(targetPath);
|
|||
|
|
return {
|
|||
|
|
existingMode: latest.mode & 0o7777,
|
|||
|
|
stableIdentity: transferBridge._stableLocalFileIdentityForTests(latest),
|
|||
|
|
targetIdentity: [
|
|||
|
|
latest.dev,
|
|||
|
|
latest.ino,
|
|||
|
|
latest.size,
|
|||
|
|
latest.mtimeMs,
|
|||
|
|
latest.ctimeMs,
|
|||
|
|
].join(":"),
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), Buffer.from("new"));
|
|||
|
|
assert.notEqual(
|
|||
|
|
transferBridge._stableLocalFileIdentityForTests(await fs.promises.lstat(targetPath)),
|
|||
|
|
transferBridge._stableLocalFileIdentityForTests(originalStat),
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserveTransferredDestinationMtime stamps local targets from sourceSoftIdentity", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "copied.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
|||
|
|
const before = await fs.promises.stat(targetPath);
|
|||
|
|
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
|||
|
|
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local",
|
|||
|
|
targetPath,
|
|||
|
|
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const after = await fs.promises.stat(targetPath);
|
|||
|
|
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
|||
|
|
assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "copied.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("concurrent"));
|
|||
|
|
const before = await fs.promises.stat(targetPath);
|
|||
|
|
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
|||
|
|
|
|||
|
|
// Identity recorded at publication no longer matches the pathname's file.
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local",
|
|||
|
|
targetPath,
|
|||
|
|
publishedLocalIdentity: "999999:999999:7",
|
|||
|
|
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const after = await fs.promises.stat(targetPath);
|
|||
|
|
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
|||
|
|
|
|||
|
|
// A vanished destination is likewise left alone.
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local",
|
|||
|
|
targetPath: path.join(tempDir, "missing.bin"),
|
|||
|
|
publishedLocalIdentity: "999999:999999:7",
|
|||
|
|
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "copied.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
|||
|
|
const publishedStat = await fs.promises.lstat(targetPath);
|
|||
|
|
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
|||
|
|
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local",
|
|||
|
|
targetPath,
|
|||
|
|
publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat),
|
|||
|
|
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const after = await fs.promises.stat(targetPath);
|
|||
|
|
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local publication returns an identity usable for timestamp preservation", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-"));
|
|||
|
|
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const stagedPath = path.join(tempDir, "stage");
|
|||
|
|
const targetPath = path.join(tempDir, "target");
|
|||
|
|
await fs.promises.writeFile(stagedPath, "payload");
|
|||
|
|
let publishedLocalIdentity;
|
|||
|
|
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
|||
|
|
onCommit(identity) { publishedLocalIdentity = identity; },
|
|||
|
|
});
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local", targetPath, publishedLocalIdentity,
|
|||
|
|
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
|||
|
|
});
|
|||
|
|
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-"));
|
|||
|
|
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const targetPath = path.join(tempDir, "target");
|
|||
|
|
const oldPath = path.join(tempDir, "published");
|
|||
|
|
await fs.promises.writeFile(targetPath, "payload");
|
|||
|
|
const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath));
|
|||
|
|
let replaced = false;
|
|||
|
|
const replace = async () => {
|
|||
|
|
if (replaced) return;
|
|||
|
|
replaced = true;
|
|||
|
|
await fs.promises.rename(targetPath, oldPath);
|
|||
|
|
await fs.promises.writeFile(targetPath, "concurrent");
|
|||
|
|
await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000);
|
|||
|
|
};
|
|||
|
|
const originalUtimes = fs.promises.utimes;
|
|||
|
|
const originalOpen = fs.promises.open;
|
|||
|
|
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
|||
|
|
if (name === targetPath) await replace();
|
|||
|
|
return originalUtimes(name, ...args);
|
|||
|
|
});
|
|||
|
|
t.mock.method(fs.promises, "open", async (...args) => {
|
|||
|
|
const handle = await originalOpen(...args);
|
|||
|
|
if (args[0] === targetPath) {
|
|||
|
|
const utimes = handle.utimes.bind(handle);
|
|||
|
|
handle.utimes = async (...times) => { await replace(); return utimes(...times); };
|
|||
|
|
}
|
|||
|
|
return handle;
|
|||
|
|
});
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local", targetPath, publishedLocalIdentity,
|
|||
|
|
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
|||
|
|
});
|
|||
|
|
assert.equal(replaced, true);
|
|||
|
|
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => {
|
|||
|
|
const hangingClient = {
|
|||
|
|
async chmod() {
|
|||
|
|
await new Promise(() => {});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const warnings = [];
|
|||
|
|
const originalWarn = console.warn;
|
|||
|
|
console.warn = (...args) => { warnings.push(args.map(String).join(" ")); };
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
try {
|
|||
|
|
await transferBridge._restoreRemoteUploadModeBestEffortForTests(
|
|||
|
|
hangingClient,
|
|||
|
|
"target",
|
|||
|
|
"/usr/local/bin/tool",
|
|||
|
|
"utf-8",
|
|||
|
|
0o755,
|
|||
|
|
{ timeoutMs: 40 },
|
|||
|
|
);
|
|||
|
|
} finally {
|
|||
|
|
console.warn = originalWarn;
|
|||
|
|
}
|
|||
|
|
const elapsed = Date.now() - startedAt;
|
|||
|
|
assert.ok(elapsed < 1500, `expected bounded chmod, elapsed=${elapsed}`);
|
|||
|
|
assert.ok(
|
|||
|
|
warnings.some((message) => /Failed to restore permissions|timed out/i.test(message)),
|
|||
|
|
`expected chmod timeout warning, got ${JSON.stringify(warnings)}`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserveTransferredDestinationMtime times out hanging remote setStat", async () => {
|
|||
|
|
let setStatStarted = false;
|
|||
|
|
const hangingClient = {
|
|||
|
|
sftp: {
|
|||
|
|
readdir() {},
|
|||
|
|
stat() {},
|
|||
|
|
mkdir() {},
|
|||
|
|
unlink() {},
|
|||
|
|
},
|
|||
|
|
async setStat() {
|
|||
|
|
setStatStarted = true;
|
|||
|
|
await new Promise(() => {});
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", hangingClient]]) });
|
|||
|
|
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
targetPath: "/tmp/mtime-hang.bin",
|
|||
|
|
sourceSoftIdentity: { size: 1, mtimeMs: 1_700_000_000_000 },
|
|||
|
|
}, { timeoutMs: 40 });
|
|||
|
|
const elapsed = Date.now() - startedAt;
|
|||
|
|
|
|||
|
|
assert.equal(setStatStarted, true);
|
|||
|
|
assert.ok(elapsed < 1500, `expected bounded mtime stamp, elapsed=${elapsed}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("local-to-local transfer preserves source mtime on the destination", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-mtime-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const sourcePath = path.join(tempDir, "source.bin");
|
|||
|
|
const targetPath = path.join(tempDir, "target.bin");
|
|||
|
|
await fs.promises.writeFile(sourcePath, Buffer.from("hello-mtime"));
|
|||
|
|
const sourceMtimeMs = 1_600_000_000_000; // 2020-09-13T12:26:40.000Z
|
|||
|
|
await fs.promises.utimes(sourcePath, new Date(sourceMtimeMs), new Date(sourceMtimeMs));
|
|||
|
|
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "local-mtime-preserve",
|
|||
|
|
sourcePath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "local",
|
|||
|
|
totalBytes: 11,
|
|||
|
|
resumable: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
const targetStat = await fs.promises.stat(targetPath);
|
|||
|
|
assert.equal(Math.floor(targetStat.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
|||
|
|
assert.deepEqual(await fs.promises.readFile(targetPath), Buffer.from("hello-mtime"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("waitForPendingWriteOpenPathGate timeout fails closed without clearing poison", async () => {
|
|||
|
|
let resolveGate;
|
|||
|
|
const gate = new Promise((resolve) => { resolveGate = resolve; });
|
|||
|
|
let failCalls = 0;
|
|||
|
|
const transfer = {
|
|||
|
|
pendingWriteOpenPathGate: gate,
|
|||
|
|
_resolvePendingWriteOpenPathGate() {
|
|||
|
|
resolveGate();
|
|||
|
|
transfer.pendingWriteOpenPathGate = null;
|
|||
|
|
transfer._resolvePendingWriteOpenPathGate = null;
|
|||
|
|
},
|
|||
|
|
_failPendingWriteOpenPathGate() {
|
|||
|
|
failCalls += 1;
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await assert.rejects(
|
|||
|
|
() => transferBridge._waitForPendingWriteOpenPathGateForTests(transfer, { timeoutMs: 30 }),
|
|||
|
|
/Timed out waiting for prior write OPEN to settle/i,
|
|||
|
|
);
|
|||
|
|
assert.equal(transfer.pendingWriteOpenPathGate, gate, "timeout must not clear the published gate poison");
|
|||
|
|
assert.equal(failCalls, 1, "timeout must poison the shared path gate for later waiters");
|
|||
|
|
resolveGate();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("in-place isolated OPEN channel error fails closed without waiting on shared fallback", async (t) => {
|
|||
|
|
// Codex P1 on 3d4cecfa: keeping in-place poison without force-release must not
|
|||
|
|
// fall through into concurrent-shared, which waits forever on the prior gate.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-open-terminal-poison-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 73);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/inplace-terminal-poison.bin";
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from("keep-original")]]);
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let sharedOpenCalls = 0;
|
|||
|
|
|
|||
|
|
const isolatedSftp = createFastSftp({
|
|||
|
|
open(_remotePath, flags, _callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
openCalls += 1;
|
|||
|
|
// Never invoke OPEN callback (dead channel after error).
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run while OPEN is pending");
|
|||
|
|
},
|
|||
|
|
end() {},
|
|||
|
|
fastPut() {
|
|||
|
|
throw new Error("fastPut must not run after in-place OPEN poison");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open() {
|
|||
|
|
sharedOpenCalls += 1;
|
|||
|
|
throw new Error("shared OPEN must not run after terminal in-place poison");
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async lstat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete() {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, isolatedSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId: "inplace-open-terminal-poison",
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const opened = await waitUntil(() => openCalls >= 1, 2000);
|
|||
|
|
assert.ok(opened, "expected isolated in-place OPEN to stall");
|
|||
|
|
isolatedSftp.emit("error", new Error("isolated channel died during in-place OPEN"));
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(
|
|||
|
|
() => reject(new Error("transfer hung behind permanent in-place OPEN poison")),
|
|||
|
|
4000,
|
|||
|
|
)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected fail-closed transfer");
|
|||
|
|
assert.match(result.error, /isolated channel died|pipelined upload failed/i);
|
|||
|
|
assert.equal(sharedOpenCalls, 0, "concurrent-shared must not wait on poisoned in-place gate");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("in-place isolated OPEN keeps path gate until late callback after channel error", async (t) => {
|
|||
|
|
// Codex P1 on e2cc8241: force-releasing an in-place truncating OPEN lets
|
|||
|
|
// fastPut complete, then a late OPEN truncates the reported-success file.
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-open-no-force-release-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const payload = Buffer.alloc(TRANSFER_CHUNK_SIZE, 71);
|
|||
|
|
const localPath = path.join(tempDir, "upload.bin");
|
|||
|
|
await fs.promises.writeFile(localPath, payload);
|
|||
|
|
const targetPath = "/tmp/inplace-no-force.bin";
|
|||
|
|
const existingPayload = Buffer.from("keep-original");
|
|||
|
|
|
|||
|
|
const remoteFiles = new Map([[targetPath, Buffer.from(existingPayload)]]);
|
|||
|
|
const eventLog = [];
|
|||
|
|
let releaseOpen = null;
|
|||
|
|
let openCalls = 0;
|
|||
|
|
let fastPutCalls = 0;
|
|||
|
|
|
|||
|
|
const isolatedSftp = createFastSftp({
|
|||
|
|
open(remotePath, flags, callback) {
|
|||
|
|
assert.equal(flags, "w");
|
|||
|
|
openCalls += 1;
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
releaseOpen = () => {
|
|||
|
|
remoteFiles.set(key, Buffer.alloc(0));
|
|||
|
|
eventLog.push(`late-open-truncate:${key}`);
|
|||
|
|
callback(null, Buffer.from(`handle:${key}`));
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
write() {
|
|||
|
|
throw new Error("WRITE must not run while OPEN is pending");
|
|||
|
|
},
|
|||
|
|
close(_handle, callback) {
|
|||
|
|
eventLog.push("close");
|
|||
|
|
callback(null);
|
|||
|
|
},
|
|||
|
|
end() {
|
|||
|
|
eventLog.push("isolated-end");
|
|||
|
|
},
|
|||
|
|
fastPut() {
|
|||
|
|
fastPutCalls += 1;
|
|||
|
|
throw new Error("fastPut must not run while in-place OPEN poison is held");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sharedSftp = createFastSftp({
|
|||
|
|
open() {
|
|||
|
|
throw new Error("shared OPEN must not run while isolated in-place gate is held");
|
|||
|
|
},
|
|||
|
|
createWriteStream() {
|
|||
|
|
throw new Error("serial WriteStream must not run");
|
|||
|
|
},
|
|||
|
|
lstat(remotePath, callback) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
callback(error);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
callback(null, {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const client = {
|
|||
|
|
sftp: sharedSftp,
|
|||
|
|
async lstat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
size: remoteFiles.get(key).length,
|
|||
|
|
mode: 0o120777,
|
|||
|
|
isDirectory: () => false,
|
|||
|
|
isSymbolicLink: () => true,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
async stat(remotePath) {
|
|||
|
|
const key = String(remotePath);
|
|||
|
|
if (!remoteFiles.has(key)) {
|
|||
|
|
const error = new Error("ENOENT");
|
|||
|
|
error.code = 2;
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return { size: remoteFiles.get(key).length };
|
|||
|
|
},
|
|||
|
|
rename() {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
},
|
|||
|
|
async delete() {},
|
|||
|
|
client: {
|
|||
|
|
sftp(callback) {
|
|||
|
|
callback(null, isolatedSftp);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
|||
|
|
|
|||
|
|
const sender = createSender();
|
|||
|
|
const transferId = "inplace-open-no-force-release";
|
|||
|
|
const running = transferBridge.startTransfer({ sender }, {
|
|||
|
|
transferId,
|
|||
|
|
sourcePath: localPath,
|
|||
|
|
targetPath,
|
|||
|
|
sourceType: "local",
|
|||
|
|
targetType: "sftp",
|
|||
|
|
targetSftpId: "target",
|
|||
|
|
totalBytes: payload.length,
|
|||
|
|
resumable: false,
|
|||
|
|
skipAdmission: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const opened = await waitUntil(() => openCalls >= 1, 2000);
|
|||
|
|
assert.ok(opened, "expected isolated in-place OPEN to stall");
|
|||
|
|
|
|||
|
|
isolatedSftp.emit("error", new Error("isolated channel died during in-place OPEN"));
|
|||
|
|
|
|||
|
|
// Former force-release window (2s). Path gate must still block fallbacks.
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|||
|
|
assert.equal(fastPutCalls, 0, "fastPut must not run while in-place OPEN is unsettled");
|
|||
|
|
assert.deepEqual(remoteFiles.get(targetPath), existingPayload, "destination must stay intact before late OPEN");
|
|||
|
|
|
|||
|
|
assert.equal(typeof releaseOpen, "function");
|
|||
|
|
releaseOpen();
|
|||
|
|
|
|||
|
|
const result = await Promise.race([
|
|||
|
|
running,
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("transfer hung after late in-place OPEN")), 8000)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
assert.ok(result.error, "expected transfer to fail closed rather than report success after late truncate");
|
|||
|
|
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
|||
|
|
assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close"));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-"));
|
|||
|
|
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
|
|||
|
|
const targetPath = path.join(tempDir, "copied.bin");
|
|||
|
|
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
|||
|
|
await fs.promises.chmod(targetPath, 0o200);
|
|||
|
|
const sourceMtimeMs = 1_700_000_000_000;
|
|||
|
|
|
|||
|
|
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
|||
|
|
targetType: "local",
|
|||
|
|
targetPath,
|
|||
|
|
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const after = await fs.promises.stat(targetPath);
|
|||
|
|
assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000);
|
|||
|
|
assert.equal(after.mode & 0o777, 0o200);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const failureMode of ["rejection", "timeout"]) {
|
|||
|
|
test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => {
|
|||
|
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-"));
|
|||
|
|
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
|||
|
|
const sourcePath = path.join(tempDir, "source");
|
|||
|
|
const targetPath = path.join(tempDir, "target");
|
|||
|
|
await fs.promises.writeFile(sourcePath, "payload");
|
|||
|
|
await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000);
|
|||
|
|
let preparationFailed = false;
|
|||
|
|
const utimes = fs.promises.utimes;
|
|||
|
|
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
|||
|
|
if (String(name).endsWith(".ready")) {
|
|||
|
|
preparationFailed = true;
|
|||
|
|
if (failureMode === "timeout") return new Promise(() => {});
|
|||
|
|
throw Object.assign(new Error("transient metadata failure"), { code: "EIO" });
|
|||
|
|
}
|
|||
|
|
return utimes(name, ...args);
|
|||
|
|
});
|
|||
|
|
transferBridge.init({ sftpClients: new Map() });
|
|||
|
|
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
|||
|
|
transferId: "local-mtime-retry", sourcePath, targetPath,
|
|||
|
|
sourceType: "local", targetType: "local", totalBytes: 7, resumable: true,
|
|||
|
|
});
|
|||
|
|
assert.equal(result.error, undefined);
|
|||
|
|
assert.equal(preparationFailed, true);
|
|||
|
|
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
|||
|
|
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
}
|