Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
185 lines
9.2 KiB
JavaScript
185 lines
9.2 KiB
JavaScript
"use strict";
|
|
|
|
// Opt-in loopback SSH/SFTP fixture; never connects to a user's server.
|
|
// NETCATTY_SFTP_LIVE=1 SFTP_LIVE_MIB=128 SFTP_LIVE_FILES=12 node scripts/sftp-transfer-resume.live.test.cjs
|
|
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { Server } = require("ssh2");
|
|
const SftpClient = require("ssh2-sftp-client");
|
|
|
|
async function main() {
|
|
const tempBridgePath = require.resolve("../electron/bridges/tempDirBridge.cjs");
|
|
const managedTempBridge = require(tempBridgePath);
|
|
const root = fs.mkdtempSync(`${managedTempBridge.getTempFilePath("sftp-live")}-`);
|
|
try {
|
|
console.log("SFTP_LIVE_ROOT", root);
|
|
// Keep the fixture visible to managed cleanup, but isolate its staging and
|
|
// identity paths from the user's app. Reload only this process's cached
|
|
// bridge after changing the environment; never delete the managed parent.
|
|
for (const key of ["TMPDIR", "TMP", "TEMP", "HOME"]) process.env[key] = root;
|
|
delete require.cache[tempBridgePath];
|
|
await runFixture(root);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function runFixture(root) {
|
|
let bridge;
|
|
if (process.env.SFTP_LIVE_BASELINE_REF) {
|
|
const Module = require("node:module");
|
|
const filename = path.resolve(__dirname, "../electron/bridges/transferBridge.cjs");
|
|
const baseline = new Module(filename, module);
|
|
baseline.filename = filename;
|
|
baseline.paths = Module._nodeModulePaths(path.dirname(filename));
|
|
baseline._compile(require("node:child_process").execFileSync("git", ["show", `${process.env.SFTP_LIVE_BASELINE_REF}:electron/bridges/transferBridge.cjs`], { cwd: path.resolve(__dirname, ".."), encoding: "utf8" }), filename);
|
|
bridge = baseline.exports;
|
|
} else {
|
|
bridge = require("../electron/bridges/transferBridge.cjs");
|
|
}
|
|
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
|
assert.equal(path.dirname(tempDirBridge.getTempDir()), root, "fixture staging must remain isolated");
|
|
const bytes = Number(process.env.SFTP_LIVE_MIB || 8) * 1024 * 1024;
|
|
const fileCount = Number(process.env.SFTP_LIVE_FILES || 1);
|
|
const payload = Buffer.allocUnsafe(bytes);
|
|
for (let i = 0; i < bytes; i += 1) payload[i] = i % 251;
|
|
const digest = crypto.createHash("sha256").update(payload).digest("hex");
|
|
const privateKey = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs1", format: "pem" });
|
|
const connections = new Set();
|
|
let reads = 0;
|
|
let activeReads = 0;
|
|
let peakReads = 0;
|
|
const server = new Server({ hostKeys: [privateKey] }, (connection) => {
|
|
connections.add(connection);
|
|
connection.on("error", () => {});
|
|
connection.on("close", () => connections.delete(connection));
|
|
connection.on("authentication", (context) => context.username === "fixture" ? context.accept() : context.reject());
|
|
connection.on("ready", () => connection.on("session", (accept) => {
|
|
const session = accept();
|
|
session.on("sftp", (acceptSftp) => {
|
|
const sftp = acceptSftp();
|
|
let nextHandle = 0;
|
|
const attrs = { size: bytes, mode: 0o100644, uid: 1, gid: 1, atime: 1700000000, mtime: 1700000000 };
|
|
sftp.on("error", () => {});
|
|
sftp.on("REALPATH", (id) => sftp.name(id, [{ filename: "/", longname: "/", attrs }]));
|
|
for (const operation of ["STAT", "LSTAT", "FSTAT"]) sftp.on(operation, (id) => sftp.attrs(id, attrs));
|
|
sftp.on("OPEN", (id) => sftp.handle(id, Buffer.from(String(nextHandle++))));
|
|
sftp.on("CLOSE", (id) => sftp.status(id, 0));
|
|
sftp.on("READ", (id, _handle, position, length) => {
|
|
reads += 1;
|
|
activeReads += 1;
|
|
peakReads = Math.max(peakReads, activeReads);
|
|
setTimeout(() => {
|
|
activeReads -= 1;
|
|
if (sftp.destroyed) return;
|
|
if (position >= bytes) sftp.status(id, 1);
|
|
else sftp.data(id, payload.subarray(position, Math.min(bytes, position + length)));
|
|
}, Number(process.env.SFTP_LIVE_DELAY_MS || 5));
|
|
});
|
|
});
|
|
}));
|
|
});
|
|
const client = new SftpClient();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
await client.connect({ host: "127.0.0.1", port: server.address().port, username: "fixture", password: "fixture" });
|
|
bridge.init({ sftpClients: new Map([["source", client]]) });
|
|
const runFile = async (index) => {
|
|
const transferId = `live-${crypto.randomUUID()}`;
|
|
const targetPath = path.join(root, `output-${index}.bin`);
|
|
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
|
const checkpoint = Math.floor(bytes / 4);
|
|
fs.writeFileSync(stagePath, payload.subarray(0, checkpoint));
|
|
let verificationStart = 0;
|
|
let verificationMs = 0;
|
|
let requestedPause = false;
|
|
let pauseResult;
|
|
const startedAt = performance.now();
|
|
const running = bridge.startTransfer({ sender: { send(_channel, event) {
|
|
if (event.phase === "verifying" && !verificationStart) verificationStart = performance.now();
|
|
if (event.phase !== "verifying" && verificationStart) {
|
|
verificationMs += performance.now() - verificationStart;
|
|
verificationStart = 0;
|
|
}
|
|
if (!requestedPause && event.transferred > bytes / 2 && event.transferred < bytes && event.phase === "transferring") {
|
|
requestedPause = true;
|
|
const pauseStarted = performance.now();
|
|
pauseResult = bridge.pauseTransfer(null, { transferId }).then(async (result) => {
|
|
assert.equal(result.success, true, result.reason);
|
|
const pauseMs = performance.now() - pauseStarted;
|
|
const resumeStarted = performance.now();
|
|
const resumed = await bridge.resumeTransfer(null, { transferId });
|
|
assert.equal(resumed.success, true, resumed.reason);
|
|
return { pauseMs: Math.round(pauseMs), resumeMs: Math.round(performance.now() - resumeStarted) };
|
|
});
|
|
}
|
|
} } }, {
|
|
transferId, sourcePath: "/source.bin", targetPath,
|
|
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
|
|
totalBytes: bytes, resumable: true, checkpointBytes: checkpoint,
|
|
sourceFingerprint: `sha256:p${bytes}:${digest}`,
|
|
});
|
|
const result = await running;
|
|
assert.equal(result.error, undefined, result.error);
|
|
const control = await pauseResult;
|
|
const outputDigest = crypto.createHash("sha256");
|
|
for await (const chunk of fs.createReadStream(targetPath)) outputDigest.update(chunk);
|
|
assert.equal(outputDigest.digest("hex"), digest);
|
|
fs.unlinkSync(targetPath);
|
|
return { index, bytes, elapsedMs: Math.round(performance.now() - startedAt), verificationMs: Math.round(verificationMs), ...control };
|
|
};
|
|
const results = [];
|
|
for (let index = 0; index < fileCount; index += 2) {
|
|
results.push(...await Promise.all(Array.from({ length: Math.min(2, fileCount - index) }, (_, offset) => runFile(index + offset))));
|
|
}
|
|
console.log("SFTP_LIVE_PASS", JSON.stringify({ results, reads, peakReads }));
|
|
} finally {
|
|
await client.end().catch(() => {});
|
|
for (const connection of connections) connection.end();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
}
|
|
|
|
if (process.env.NETCATTY_SFTP_LIVE === "1") {
|
|
const watchdog = setTimeout(() => { console.error("SFTP_LIVE_FAIL timeout"); process.exit(1); }, 120_000);
|
|
main().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => clearTimeout(watchdog));
|
|
} else {
|
|
const test = require("node:test");
|
|
for (const existingParent of [false, true]) {
|
|
test(`live SFTP fixture cleans setup failure with ${existingParent ? "existing" : "fresh"} managed parent`, (t) => {
|
|
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
|
const testRoot = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sftp-live-setup-test")}-`);
|
|
t.after(() => fs.rmSync(testRoot, { recursive: true, force: true }));
|
|
const managedParent = path.join(testRoot, "Netcatty");
|
|
if (existingParent) fs.mkdirSync(managedParent, { mode: 0o700 });
|
|
const result = require("node:child_process").spawnSync(process.execPath, [__filename], {
|
|
env: {
|
|
...process.env,
|
|
...Object.fromEntries(["TMPDIR", "TMP", "TEMP", "HOME"].map((key) => [key, testRoot])),
|
|
NETCATTY_SFTP_LIVE: "1", SFTP_LIVE_MIB: "-1", SFTP_LIVE_BASELINE_REF: "",
|
|
},
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
});
|
|
assert.equal(result.status, 1, result.stderr);
|
|
assert.match(result.stderr, /RangeError/);
|
|
const match = result.stdout.match(/^SFTP_LIVE_ROOT ([^\r\n]+)/m);
|
|
assert.ok(match, result.stdout);
|
|
const fixtureRoot = match[1];
|
|
assert.match(path.basename(fixtureRoot), /sftp-live/);
|
|
assert.equal(path.dirname(fixtureRoot), managedParent);
|
|
assert.equal(fs.existsSync(fixtureRoot), false);
|
|
assert.equal(fs.existsSync(path.dirname(fixtureRoot)), true, "must not delete the shared managed parent");
|
|
});
|
|
}
|
|
test("large-file SFTP resume over a loopback SSH connection", {
|
|
skip: "set NETCATTY_SFTP_LIVE=1 to run the real connection fixture",
|
|
}, () => {});
|
|
}
|