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
195 lines
5.9 KiB
JavaScript
195 lines
5.9 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
const { Readable } = require("node:stream");
|
|
const { EventEmitter } = require("node:events");
|
|
|
|
const { createFileOpsApi } = require("./sftpBridge/fileOps.cjs");
|
|
|
|
const MAX_IN_MEMORY_READ_BYTES = 10 * 1024 * 1024;
|
|
|
|
function createApi(client, overrides = {}) {
|
|
return createFileOpsApi({
|
|
sftpClients: new Map([["sftp-1", client]]),
|
|
resolveEncodingForRequest: () => "utf-8",
|
|
encodePath: (value) => value,
|
|
Buffer,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test("SFTP text reads reject oversized files before opening the data stream", async () => {
|
|
let streamOpened = false;
|
|
const sftp = {
|
|
createReadStream() {
|
|
streamOpened = true;
|
|
throw new Error("oversized file stream should not open");
|
|
},
|
|
};
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => sftp,
|
|
statAsync: async () => ({ size: MAX_IN_MEMORY_READ_BYTES + 1 }),
|
|
});
|
|
|
|
await assert.rejects(
|
|
api.readSftp(null, { sftpId: "sftp-1", path: "/large.log" }),
|
|
/10 MB.*download.*external app/i,
|
|
);
|
|
assert.equal(streamOpened, false);
|
|
});
|
|
|
|
test("SFTP text reads still stream ordinary files", async () => {
|
|
const sftp = {
|
|
createReadStream() {
|
|
return Readable.from([Buffer.from("hel"), Buffer.from("lo")]);
|
|
},
|
|
};
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => sftp,
|
|
statAsync: async () => ({ size: 5 }),
|
|
});
|
|
|
|
assert.equal(
|
|
await api.readSftp(null, { sftpId: "sftp-1", path: "/small.txt" }),
|
|
"hello",
|
|
);
|
|
});
|
|
|
|
test("SFTP binary reads reject oversized files before opening the data stream", async () => {
|
|
let streamOpened = false;
|
|
const sftp = {
|
|
createReadStream() {
|
|
streamOpened = true;
|
|
throw new Error("oversized file stream should not open");
|
|
},
|
|
};
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => sftp,
|
|
statAsync: async () => ({ size: MAX_IN_MEMORY_READ_BYTES + 1 }),
|
|
});
|
|
|
|
await assert.rejects(
|
|
api.readSftpBinary(null, { sftpId: "sftp-1", path: "/large.bin" }),
|
|
/10 MB.*download.*external app/i,
|
|
);
|
|
assert.equal(streamOpened, false);
|
|
});
|
|
|
|
test("SFTP reads stop at the byte limit when the remote file grows after stat", async () => {
|
|
let legacyGetCalled = false;
|
|
const stream = Readable.from([
|
|
Buffer.alloc(MAX_IN_MEMORY_READ_BYTES),
|
|
Buffer.from("x"),
|
|
]);
|
|
const sftp = {
|
|
createReadStream() {
|
|
return stream;
|
|
},
|
|
};
|
|
const api = createApi({
|
|
async get() {
|
|
legacyGetCalled = true;
|
|
return Buffer.alloc(MAX_IN_MEMORY_READ_BYTES + 1);
|
|
},
|
|
}, {
|
|
requireSftpChannel: async () => sftp,
|
|
statAsync: async () => ({ size: 1 }),
|
|
});
|
|
|
|
await assert.rejects(
|
|
api.readSftp(null, { sftpId: "sftp-1", path: "/growing.log" }),
|
|
/10 MB.*download.*external app/i,
|
|
);
|
|
assert.equal(legacyGetCalled, false);
|
|
assert.equal(stream.destroyed, true);
|
|
assert.equal(stream.listenerCount("data"), 0);
|
|
assert.equal(stream.listenerCount("end"), 0);
|
|
assert.equal(stream.listenerCount("error"), 0);
|
|
assert.equal(stream.listenerCount("close"), 0);
|
|
});
|
|
|
|
test("SCP reads reject oversized files before starting the transfer", async () => {
|
|
let readStarted = false;
|
|
const backend = {
|
|
async stat() {
|
|
return { size: MAX_IN_MEMORY_READ_BYTES + 1 };
|
|
},
|
|
async readFile() {
|
|
readStarted = true;
|
|
throw new Error("oversized SCP transfer should not start");
|
|
},
|
|
};
|
|
const api = createApi({
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
});
|
|
|
|
await assert.rejects(
|
|
api.readSftp(null, { sftpId: "sftp-1", path: "/large.log" }),
|
|
/10 MB.*download.*external app/i,
|
|
);
|
|
assert.equal(readStarted, false);
|
|
});
|
|
|
|
test("the total in-memory SFTP read deadline also bounds a stalled stat", async () => {
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => ({}),
|
|
statAsync: async () => new Promise(() => {}),
|
|
});
|
|
|
|
await assert.rejects(
|
|
Promise.race([
|
|
api.readSftp(null, { sftpId: "sftp-1", path: "/stalled.txt", timeoutMs: 5 }),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("test watchdog")), 100)),
|
|
]),
|
|
/SFTP in-memory read timed out/i,
|
|
);
|
|
});
|
|
|
|
test("a timed-out in-memory SFTP stream is destroyed and releases every listener", async () => {
|
|
const stream = new EventEmitter();
|
|
let destroyed = 0;
|
|
stream.destroy = () => { destroyed += 1; };
|
|
stream.close = () => { destroyed += 1; };
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => ({ createReadStream: () => stream }),
|
|
statAsync: async () => ({ size: 1 }),
|
|
});
|
|
|
|
await assert.rejects(
|
|
Promise.race([
|
|
api.readSftpBinary(null, { sftpId: "sftp-1", path: "/stalled.bin", timeoutMs: 5 }),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("test watchdog")), 100)),
|
|
]),
|
|
/SFTP in-memory read timed out/i,
|
|
);
|
|
assert.ok(destroyed > 0);
|
|
assert.equal(stream.listenerCount("data"), 0);
|
|
assert.equal(stream.listenerCount("end"), 0);
|
|
assert.equal(stream.listenerCount("error"), 0);
|
|
assert.equal(stream.listenerCount("close"), 0);
|
|
});
|
|
|
|
test("in-memory SFTP stream error and premature close both release listeners", async () => {
|
|
for (const terminalEvent of ["error", "close"]) {
|
|
const stream = new EventEmitter();
|
|
stream.destroy = () => {};
|
|
stream.close = () => {};
|
|
const api = createApi({}, {
|
|
requireSftpChannel: async () => ({ createReadStream: () => stream }),
|
|
statAsync: async () => ({ size: 1 }),
|
|
});
|
|
const reading = api.readSftp(null, { sftpId: "sftp-1", path: "/broken.txt", timeoutMs: 100 });
|
|
process.nextTick(() => {
|
|
if (terminalEvent === "error") stream.emit("error", new Error("read failed"));
|
|
else stream.emit("close");
|
|
});
|
|
await assert.rejects(reading, /read failed|closed before/i);
|
|
assert.equal(stream.listenerCount("data"), 0);
|
|
assert.equal(stream.listenerCount("end"), 0);
|
|
assert.equal(stream.listenerCount("error"), 0);
|
|
assert.equal(stream.listenerCount("close"), 0);
|
|
}
|
|
});
|