2741 lines
90 KiB
JavaScript
2741 lines
90 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Session-backed SFTP clients (openForSession / terminal reuse) are not
|
||
|
|
* ssh2-sftp-client instances. They must still expose pipelined fastPut so
|
||
|
|
* uploadLocal stays on the high-throughput path
|
||
|
|
* (#2449 fail-closed alignment; no serial WriteStream crawl).
|
||
|
|
*/
|
||
|
|
|
||
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const { EventEmitter } = require("node:events");
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const os = require("node:os");
|
||
|
|
const path = require("node:path");
|
||
|
|
|
||
|
|
const sftpBridge = require("./sftpBridge.cjs");
|
||
|
|
const transferBridge = require("./transferBridge.cjs");
|
||
|
|
const tempDirBridge = require("./tempDirBridge.cjs");
|
||
|
|
const {
|
||
|
|
buildConnectionReuseEndpoint,
|
||
|
|
normalizeEndpoint,
|
||
|
|
} = require("./sshConnectionPool.cjs");
|
||
|
|
const {
|
||
|
|
TRANSFER_CHUNK_SIZE,
|
||
|
|
UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
} = require("./transferLimits.cjs");
|
||
|
|
|
||
|
|
function createSessionChannel(options = {}) {
|
||
|
|
const fastPutCalls = [];
|
||
|
|
const remoteFiles = new Map();
|
||
|
|
const remoteMeta = new Map(); // path -> { mode, isSymlink }
|
||
|
|
const chmodCalls = [];
|
||
|
|
const { Readable } = require("node:stream");
|
||
|
|
const channel = {
|
||
|
|
// hasSftpChannelApi requires these four methods.
|
||
|
|
readdir(_targetPath, callback) {
|
||
|
|
callback(null, []);
|
||
|
|
},
|
||
|
|
mkdir(_targetPath, callback) {
|
||
|
|
callback(null);
|
||
|
|
},
|
||
|
|
unlink(targetPath, callback) {
|
||
|
|
remoteFiles.delete(targetPath);
|
||
|
|
remoteMeta.delete(targetPath);
|
||
|
|
callback(null);
|
||
|
|
},
|
||
|
|
createReadStream(targetPath) {
|
||
|
|
const data = remoteFiles.get(targetPath);
|
||
|
|
if (!data) {
|
||
|
|
const stream = new Readable({ read() {} });
|
||
|
|
queueMicrotask(() => {
|
||
|
|
const err = new Error(`ENOENT ${targetPath}`);
|
||
|
|
err.code = 2;
|
||
|
|
stream.destroy(err);
|
||
|
|
});
|
||
|
|
return stream;
|
||
|
|
}
|
||
|
|
return Readable.from([Buffer.from(data)]);
|
||
|
|
},
|
||
|
|
stat(targetPath, callback) {
|
||
|
|
const data = remoteFiles.get(targetPath);
|
||
|
|
if (!data) {
|
||
|
|
const err = new Error(`ENOENT ${targetPath}`);
|
||
|
|
err.code = 2;
|
||
|
|
callback(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const meta = remoteMeta.get(targetPath) || {};
|
||
|
|
callback(null, {
|
||
|
|
size: data.length,
|
||
|
|
mode: meta.mode ?? 0o100644,
|
||
|
|
mtime: meta.mtime ?? 0,
|
||
|
|
uid: meta.uid ?? 0,
|
||
|
|
gid: meta.gid ?? 0,
|
||
|
|
isDirectory: () => false,
|
||
|
|
isFile: () => true,
|
||
|
|
isSymbolicLink: () => !!meta.isSymlink,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
lstat(targetPath, callback) {
|
||
|
|
const meta = remoteMeta.get(targetPath);
|
||
|
|
const data = remoteFiles.get(targetPath);
|
||
|
|
if (!data && !meta) {
|
||
|
|
const err = new Error(`ENOENT ${targetPath}`);
|
||
|
|
err.code = 2;
|
||
|
|
callback(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
callback(null, {
|
||
|
|
size: data ? data.length : 0,
|
||
|
|
mode: meta?.isSymlink ? 0o120777 : (meta?.mode ?? 0o100644),
|
||
|
|
mtime: meta?.mtime ?? 0,
|
||
|
|
uid: meta?.uid ?? 0,
|
||
|
|
gid: meta?.gid ?? 0,
|
||
|
|
isDirectory: () => false,
|
||
|
|
isFile: () => !meta?.isSymlink,
|
||
|
|
isSymbolicLink: () => !!meta?.isSymlink,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
readlink(targetPath, callback) {
|
||
|
|
const meta = remoteMeta.get(targetPath);
|
||
|
|
if (!meta?.isSymlink) {
|
||
|
|
const err = new Error(`ENOENT ${targetPath}`);
|
||
|
|
err.code = 2;
|
||
|
|
callback(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
callback(null, meta.linkPath || "/missing-target");
|
||
|
|
},
|
||
|
|
fastPut(localPath, remotePath, opts, callback) {
|
||
|
|
fastPutCalls.push({
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
concurrency: opts?.concurrency,
|
||
|
|
chunkSize: opts?.chunkSize,
|
||
|
|
});
|
||
|
|
if (typeof options.onFastPut === "function") {
|
||
|
|
const intercept = options.onFastPut(localPath, remotePath);
|
||
|
|
if (intercept?.error) {
|
||
|
|
queueMicrotask(() => callback(intercept.error));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const data = fs.readFileSync(localPath);
|
||
|
|
remoteFiles.set(remotePath, data);
|
||
|
|
if (!remoteMeta.has(remotePath)) {
|
||
|
|
remoteMeta.set(remotePath, { mode: 0o100644 });
|
||
|
|
}
|
||
|
|
if (typeof opts?.step === "function") {
|
||
|
|
opts.step(data.length, data.length, data.length);
|
||
|
|
}
|
||
|
|
queueMicrotask(() => callback(null));
|
||
|
|
} catch (err) {
|
||
|
|
queueMicrotask(() => callback(err));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
rename(from, to, callback) {
|
||
|
|
if (!remoteFiles.has(from)) {
|
||
|
|
const err = new Error(`ENOENT ${from}`);
|
||
|
|
err.code = 2;
|
||
|
|
callback(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const sourceMeta = remoteMeta.get(from);
|
||
|
|
remoteFiles.set(to, remoteFiles.get(from));
|
||
|
|
remoteFiles.delete(from);
|
||
|
|
remoteMeta.set(to, sourceMeta || { mode: 0o100644 });
|
||
|
|
remoteMeta.delete(from);
|
||
|
|
callback(null);
|
||
|
|
},
|
||
|
|
chmod(targetPath, mode, callback) {
|
||
|
|
chmodCalls.push({ targetPath, mode });
|
||
|
|
const prev = remoteMeta.get(targetPath) || {};
|
||
|
|
remoteMeta.set(targetPath, { ...prev, mode });
|
||
|
|
callback(null);
|
||
|
|
},
|
||
|
|
end() {},
|
||
|
|
};
|
||
|
|
return { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls };
|
||
|
|
}
|
||
|
|
|
||
|
|
test("openSftpForSession rejects a same-target session reached through a different jump route", async () => {
|
||
|
|
let openedChannel = false;
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) {
|
||
|
|
openedChannel = true;
|
||
|
|
callback(null, createSessionChannel().channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const base = {
|
||
|
|
hostId: "same-host",
|
||
|
|
hostname: "target.example",
|
||
|
|
port: 22,
|
||
|
|
username: "alice",
|
||
|
|
authMethod: "password",
|
||
|
|
password: "target-password",
|
||
|
|
verifyHostKeys: true,
|
||
|
|
};
|
||
|
|
const actualEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint({
|
||
|
|
...base,
|
||
|
|
jumpHosts: [{
|
||
|
|
hostId: "jump-a",
|
||
|
|
hostname: "jump-a.example",
|
||
|
|
username: "jump-user",
|
||
|
|
password: "jump-a-password",
|
||
|
|
}],
|
||
|
|
}));
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["split-route-session", {
|
||
|
|
conn: connection,
|
||
|
|
_reuseEndpoint: actualEndpoint,
|
||
|
|
}]]),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "split-route-session",
|
||
|
|
expectedEndpoint: {
|
||
|
|
...base,
|
||
|
|
jumpHosts: [{
|
||
|
|
hostId: "jump-b",
|
||
|
|
hostname: "jump-b.example",
|
||
|
|
username: "jump-user",
|
||
|
|
password: "jump-b-password",
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
(err) => err?.code === "ERR_SFTP_SOURCE_ROUTE_MISMATCH",
|
||
|
|
);
|
||
|
|
assert.equal(openedChannel, false, "the mismatched transport must not be borrowed even briefly");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("openSftpForSession skips a mismatched source hint and reuses another session with the full route identity", async () => {
|
||
|
|
let wrongRouteOpened = false;
|
||
|
|
let matchingRouteOpened = false;
|
||
|
|
const wrongConnection = {
|
||
|
|
sftp(callback) {
|
||
|
|
wrongRouteOpened = true;
|
||
|
|
callback(null, createSessionChannel().channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const matchingConnection = {
|
||
|
|
sftp(callback) {
|
||
|
|
matchingRouteOpened = true;
|
||
|
|
callback(null, createSessionChannel().channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const base = {
|
||
|
|
hostId: "same-host",
|
||
|
|
hostname: "target.example",
|
||
|
|
port: 22,
|
||
|
|
username: "alice",
|
||
|
|
authMethod: "password",
|
||
|
|
password: "target-password",
|
||
|
|
verifyHostKeys: true,
|
||
|
|
knownHosts: [{
|
||
|
|
hostname: "target.example",
|
||
|
|
port: 22,
|
||
|
|
keyType: "ssh-ed25519",
|
||
|
|
fingerprint: "target-fingerprint",
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
const wrongEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint({
|
||
|
|
...base,
|
||
|
|
proxy: {
|
||
|
|
type: "socks5",
|
||
|
|
host: "proxy-a.example",
|
||
|
|
port: 1080,
|
||
|
|
username: "proxy-user",
|
||
|
|
password: "proxy-a-password",
|
||
|
|
},
|
||
|
|
jumpHosts: [{
|
||
|
|
hostId: "jump-a",
|
||
|
|
hostname: "jump-a.example",
|
||
|
|
username: "jump-user",
|
||
|
|
password: "jump-a-password",
|
||
|
|
}],
|
||
|
|
}));
|
||
|
|
const matchingOptions = {
|
||
|
|
...base,
|
||
|
|
proxy: {
|
||
|
|
type: "socks5",
|
||
|
|
host: "proxy-b.example",
|
||
|
|
port: 1080,
|
||
|
|
username: "proxy-user",
|
||
|
|
password: "proxy-b-password",
|
||
|
|
},
|
||
|
|
jumpHosts: [{
|
||
|
|
hostId: "jump-b",
|
||
|
|
hostname: "jump-b.example",
|
||
|
|
username: "jump-user",
|
||
|
|
password: "jump-b-password",
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
const matchingEndpoint = normalizeEndpoint(buildConnectionReuseEndpoint(matchingOptions));
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([
|
||
|
|
["wrong-source-hint", { conn: wrongConnection, _reuseEndpoint: wrongEndpoint }],
|
||
|
|
["matching-source", { conn: matchingConnection, _reuseEndpoint: matchingEndpoint }],
|
||
|
|
]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "wrong-source-hint",
|
||
|
|
expectedEndpoint: matchingOptions,
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(wrongRouteOpened, false, "the endpoint-only source hint must never be borrowed");
|
||
|
|
assert.equal(matchingRouteOpened, true, "a session with the complete matching identity should be reused");
|
||
|
|
assert.match(opened.sftpId, /^matching-source-sftp-/);
|
||
|
|
assert.equal(opened.sourceSessionId, "matching-source");
|
||
|
|
assert.equal(sftpClients.has(opened.sftpId), true);
|
||
|
|
await sftpBridge.closeSftp(null, { sftpId: opened.sftpId });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("strict openSftpForSession never substitutes another matching live session", async () => {
|
||
|
|
let alternateOpened = false;
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) {
|
||
|
|
alternateOpened = true;
|
||
|
|
callback(null, createSessionChannel().channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const options = {
|
||
|
|
hostId: "same-host",
|
||
|
|
hostname: "target.example",
|
||
|
|
port: 22,
|
||
|
|
username: "alice",
|
||
|
|
authMethod: "password",
|
||
|
|
password: "target-password",
|
||
|
|
verifyHostKeys: false,
|
||
|
|
};
|
||
|
|
const endpoint = normalizeEndpoint(buildConnectionReuseEndpoint(options));
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["matching-alternate", { conn: connection, _reuseEndpoint: endpoint }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "missing-requested-session",
|
||
|
|
expectedEndpoint: options,
|
||
|
|
requireExactSourceSession: true,
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
}),
|
||
|
|
(error) => error?.code === "ERR_SFTP_SOURCE_ROUTE_MISMATCH",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(alternateOpened, false);
|
||
|
|
assert.equal(sftpClients.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("openSftpForSession reuses the hinted session when its full route and security identity match", async () => {
|
||
|
|
let openedChannel = false;
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) {
|
||
|
|
openedChannel = true;
|
||
|
|
callback(null, createSessionChannel().channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const options = {
|
||
|
|
hostId: "same-host",
|
||
|
|
hostname: "target.example",
|
||
|
|
port: 22,
|
||
|
|
username: "alice",
|
||
|
|
authMethod: "password",
|
||
|
|
password: "target-password",
|
||
|
|
requiresMfa: true,
|
||
|
|
verifyHostKeys: true,
|
||
|
|
keepaliveInterval: 15,
|
||
|
|
keepaliveCountMax: 4,
|
||
|
|
proxy: {
|
||
|
|
type: "http",
|
||
|
|
host: "proxy.example",
|
||
|
|
port: 8080,
|
||
|
|
username: "proxy-user",
|
||
|
|
password: "proxy-password",
|
||
|
|
},
|
||
|
|
jumpHosts: [{
|
||
|
|
hostId: "jump",
|
||
|
|
hostname: "jump.example",
|
||
|
|
username: "jump-user",
|
||
|
|
password: "jump-password",
|
||
|
|
}],
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["matching-source", {
|
||
|
|
conn: connection,
|
||
|
|
_reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options)),
|
||
|
|
}]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "matching-source",
|
||
|
|
expectedEndpoint: options,
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(openedChannel, true);
|
||
|
|
assert.match(opened.sftpId, /^matching-source-sftp-/);
|
||
|
|
assert.equal(opened.sourceSessionId, "matching-source");
|
||
|
|
await sftpBridge.closeSftp(null, { sftpId: opened.sftpId });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("downloadSftpToLocal aborts while initial SFTP metadata is stalled", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-stat-abort-"));
|
||
|
|
t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true }));
|
||
|
|
let markStatStarted;
|
||
|
|
const statStarted = new Promise((resolve) => { markStatStarted = resolve; });
|
||
|
|
const client = {
|
||
|
|
sftp: createSessionChannel().channel,
|
||
|
|
stat() {
|
||
|
|
markStatStarted();
|
||
|
|
return new Promise(() => {});
|
||
|
|
},
|
||
|
|
};
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map([["stalled-stat", client]]),
|
||
|
|
});
|
||
|
|
const controller = new AbortController();
|
||
|
|
const download = sftpBridge.downloadSftpToLocal(null, {
|
||
|
|
sftpId: "stalled-stat",
|
||
|
|
remotePath: "/tmp/source.bin",
|
||
|
|
localPath: path.join(tempRoot, "target.bin"),
|
||
|
|
abortSignal: controller.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
await statStarted;
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(
|
||
|
|
() => Promise.race([
|
||
|
|
download,
|
||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
||
|
|
]),
|
||
|
|
/cancel|abort/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("aborted session open closes a channel that arrives after the client is discarded", async () => {
|
||
|
|
let releaseOpen;
|
||
|
|
let markOpenStarted;
|
||
|
|
const openStarted = new Promise((resolve) => { markOpenStarted = resolve; });
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) {
|
||
|
|
releaseOpen = callback;
|
||
|
|
markOpenStarted();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-late-open", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const controller = new AbortController();
|
||
|
|
const opening = sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-late-open",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
abortSignal: controller.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
await openStarted;
|
||
|
|
controller.abort(new Error("stop opening"));
|
||
|
|
await assert.rejects(opening, /stop opening/);
|
||
|
|
|
||
|
|
const created = createSessionChannel();
|
||
|
|
let endCalls = 0;
|
||
|
|
created.channel.end = () => { endCalls += 1; };
|
||
|
|
releaseOpen(null, created.channel);
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
|
||
|
|
assert.equal(endCalls, 1);
|
||
|
|
assert.equal(sftpClients.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("session-backed uploadLocalToSftp uses pipelined fastPut on the raw SFTP channel", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-session-upload-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
const payload = Buffer.alloc(48 * 1024, 17);
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles } = createSessionChannel();
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) {
|
||
|
|
callback(null, channel);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-upload", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-upload",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
assert.equal(opened.ok, true);
|
||
|
|
assert.equal(opened.fileProtocol, "sftp");
|
||
|
|
|
||
|
|
// Session-backed wrapper must expose fastPut (not only raw channel).
|
||
|
|
const client = sftpClients.get(opened.sftpId);
|
||
|
|
assert.equal(typeof client.fastPut, "function");
|
||
|
|
assert.equal(client.__netcattySessionBacked, true);
|
||
|
|
|
||
|
|
const result = await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/home/alice/payload.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(result.success, true);
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.equal(fastPutCalls[0].concurrency, UPLOAD_TRANSFER_CONCURRENCY);
|
||
|
|
assert.equal(fastPutCalls[0].chunkSize, TRANSFER_CHUNK_SIZE);
|
||
|
|
// Size-based uploads stream the live local path (no content snapshot).
|
||
|
|
assert.equal(fastPutCalls[0].localPath, localPath);
|
||
|
|
// Final path after staged rename
|
||
|
|
assert.ok(remoteFiles.has("/home/alice/payload.bin"));
|
||
|
|
assert.deepEqual(remoteFiles.get("/home/alice/payload.bin"), payload);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("existing destinations stage then restore mode after rename", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-meta-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "exec.bin");
|
||
|
|
const payload = Buffer.from("#!/bin/sh\necho hi\n");
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel();
|
||
|
|
remoteFiles.set("/usr/local/bin/tool", Buffer.from("old"));
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 });
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-mode", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-mode",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/usr/local/bin/tool",
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
assert.ok(remoteFiles.has("/usr/local/bin/tool"));
|
||
|
|
assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), payload);
|
||
|
|
// Stage+rename replaces the inode; restore prior mode bits afterwards.
|
||
|
|
assert.ok(
|
||
|
|
chmodCalls.some((c) => String(c.targetPath).includes(".netcatty-upload-") && (c.mode & 0o777) === 0o755),
|
||
|
|
`expected mode restore via chmod, got ${JSON.stringify(chmodCalls)}`,
|
||
|
|
);
|
||
|
|
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("mode restore failure leaves the existing destination untouched", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-mode-fail-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "tool");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-tool"));
|
||
|
|
const { channel, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool"));
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 });
|
||
|
|
channel.chmod = (_targetPath, _mode, callback) => {
|
||
|
|
const err = new Error("chmod failed");
|
||
|
|
err.code = "EIO";
|
||
|
|
callback(err);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-mode-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-mode-fail",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/usr/local/bin/tool",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/chmod failed/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool"));
|
||
|
|
assert.equal(
|
||
|
|
[...remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("failed promotion and failed restore preserve both recovery files", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-restore-fail-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "data.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-data"));
|
||
|
|
const { channel, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
const finalPath = "/tmp/data.bin";
|
||
|
|
remoteFiles.set(finalPath, Buffer.from("old-data"));
|
||
|
|
remoteMeta.set(finalPath, { mode: 0o100644 });
|
||
|
|
const originalRename = channel.rename.bind(channel);
|
||
|
|
let stagePromoteAttempts = 0;
|
||
|
|
channel.rename = (from, to, callback) => {
|
||
|
|
const fromString = String(from);
|
||
|
|
const toString = String(to);
|
||
|
|
if (fromString.includes(".netcatty-upload-") && toString === finalPath) {
|
||
|
|
stagePromoteAttempts += 1;
|
||
|
|
callback(new Error("stage promote failed"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (fromString.includes(".netcatty-backup-") && toString === finalPath) {
|
||
|
|
callback(new Error("backup restore failed"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
originalRename(from, to, callback);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-restore-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-restore-fail",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: finalPath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/could not be restored/,
|
||
|
|
);
|
||
|
|
assert.ok(stagePromoteAttempts >= 2);
|
||
|
|
assert.equal(remoteFiles.has(finalPath), false);
|
||
|
|
assert.ok(
|
||
|
|
[...remoteFiles.entries()].some(([key, value]) => String(key).includes(".netcatty-upload-") && value.equals(Buffer.from("new-data"))),
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
[...remoteFiles.entries()].some(([key, value]) => String(key).includes(".netcatty-backup-") && value.equals(Buffer.from("old-data"))),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("rename fallback replaces safely and restores the old target on promotion failure", async () => {
|
||
|
|
const makeClient = ({ failEveryStagePromotion = false } = {}) => {
|
||
|
|
const files = new Map([
|
||
|
|
["/tmp/stage", Buffer.from("new")],
|
||
|
|
["/tmp/final", Buffer.from("old")],
|
||
|
|
]);
|
||
|
|
let stagePromotionAttempts = 0;
|
||
|
|
const channel = {
|
||
|
|
readdir(_path, cb) { cb(null, []); },
|
||
|
|
mkdir(_path, cb) { cb(null); },
|
||
|
|
unlink(targetPath, cb) { files.delete(targetPath); cb(null); },
|
||
|
|
stat(targetPath, cb) {
|
||
|
|
if (!files.has(targetPath)) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cb(null, { size: files.get(targetPath).length, isDirectory: false });
|
||
|
|
},
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
files,
|
||
|
|
client: {
|
||
|
|
sftp: channel,
|
||
|
|
async stat(targetPath) {
|
||
|
|
return { size: files.get(targetPath)?.length || 0, isDirectory: false };
|
||
|
|
},
|
||
|
|
async rename(from, to) {
|
||
|
|
if (from === "/tmp/stage" && to === "/tmp/final") {
|
||
|
|
stagePromotionAttempts += 1;
|
||
|
|
if (stagePromotionAttempts === 1 || failEveryStagePromotion) {
|
||
|
|
throw new Error("overwrite unsupported");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!files.has(from)) throw new Error("ENOENT");
|
||
|
|
files.set(to, files.get(from));
|
||
|
|
files.delete(from);
|
||
|
|
},
|
||
|
|
async delete(targetPath) {
|
||
|
|
files.delete(targetPath);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const successful = makeClient();
|
||
|
|
await sftpBridge._renameRemotePathForTests(
|
||
|
|
successful.client,
|
||
|
|
"/tmp/stage",
|
||
|
|
"/tmp/final",
|
||
|
|
"/tmp/backup",
|
||
|
|
);
|
||
|
|
assert.deepEqual(successful.files.get("/tmp/final"), Buffer.from("new"));
|
||
|
|
assert.equal(successful.files.has("/tmp/backup"), false);
|
||
|
|
|
||
|
|
const restored = makeClient({ failEveryStagePromotion: true });
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge._renameRemotePathForTests(
|
||
|
|
restored.client,
|
||
|
|
"/tmp/stage",
|
||
|
|
"/tmp/final",
|
||
|
|
"/tmp/backup",
|
||
|
|
),
|
||
|
|
/overwrite unsupported/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(restored.files.get("/tmp/final"), Buffer.from("old"));
|
||
|
|
assert.deepEqual(restored.files.get("/tmp/stage"), Buffer.from("new"));
|
||
|
|
assert.equal(restored.files.has("/tmp/backup"), false);
|
||
|
|
|
||
|
|
const encodedStage = Buffer.from([0x81, 0x40]);
|
||
|
|
const encodedFinal = Buffer.from([0x81, 0x41]);
|
||
|
|
const encodedBackup = Buffer.from([0x81, 0x42]);
|
||
|
|
const channel = {
|
||
|
|
readdir(_path, cb) { cb(null, []); },
|
||
|
|
mkdir(_path, cb) { cb(null); },
|
||
|
|
unlink(_path, cb) { cb(null); },
|
||
|
|
stat(_path, cb) { cb(null, { size: 1, isDirectory: false }); },
|
||
|
|
};
|
||
|
|
const encodedClient = {
|
||
|
|
sftp: channel,
|
||
|
|
async stat() { return { size: 1, isDirectory: false }; },
|
||
|
|
async rename(from, to) {
|
||
|
|
if (from === encodedFinal && to === encodedBackup) return;
|
||
|
|
if (from === encodedStage && to === encodedFinal) throw new Error("promote failed");
|
||
|
|
if (from === encodedBackup && to === encodedFinal) throw new Error("restore failed");
|
||
|
|
},
|
||
|
|
async delete() {},
|
||
|
|
};
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge._renameRemotePathForTests(
|
||
|
|
encodedClient,
|
||
|
|
encodedStage,
|
||
|
|
encodedFinal,
|
||
|
|
encodedBackup,
|
||
|
|
{
|
||
|
|
stagePath: "/目录/.netcatty-upload-stage.part",
|
||
|
|
backupPath: "/目录/.netcatty-backup-file.bak",
|
||
|
|
finalPath: "/目录/文件.txt",
|
||
|
|
},
|
||
|
|
),
|
||
|
|
(error) => {
|
||
|
|
assert.match(error.message, /\/目录\/\.netcatty-upload-stage\.part/);
|
||
|
|
assert.match(error.message, /\/目录\/\.netcatty-backup-file\.bak/);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SFTP rename fallback retries backup cleanup and reports a recoverable persistent failure", async () => {
|
||
|
|
const makeClient = (persistent) => {
|
||
|
|
const files = new Map([
|
||
|
|
["/tmp/stage", Buffer.from("new")],
|
||
|
|
["/tmp/final", Buffer.from("old")],
|
||
|
|
]);
|
||
|
|
let promoteAttempts = 0;
|
||
|
|
let deleteAttempts = 0;
|
||
|
|
return {
|
||
|
|
files,
|
||
|
|
get deleteAttempts() { return deleteAttempts; },
|
||
|
|
client: {
|
||
|
|
sftp: {
|
||
|
|
readdir(_path, cb) { cb(null, []); },
|
||
|
|
mkdir(_path, cb) { cb(null); },
|
||
|
|
unlink(_path, cb) { cb(null); },
|
||
|
|
stat(targetPath, cb) {
|
||
|
|
if (!files.has(targetPath)) {
|
||
|
|
const error = new Error("ENOENT");
|
||
|
|
error.code = 2;
|
||
|
|
cb(error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cb(null, { size: files.get(targetPath).length, isDirectory: false });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async stat(targetPath) {
|
||
|
|
return { size: files.get(targetPath)?.length || 0, isDirectory: false };
|
||
|
|
},
|
||
|
|
async rename(from, to) {
|
||
|
|
if (from === "/tmp/stage" && to === "/tmp/final" && promoteAttempts++ === 0) {
|
||
|
|
throw new Error("overwrite unsupported");
|
||
|
|
}
|
||
|
|
if (!files.has(from)) throw new Error("ENOENT");
|
||
|
|
files.set(to, files.get(from));
|
||
|
|
files.delete(from);
|
||
|
|
},
|
||
|
|
async delete(targetPath) {
|
||
|
|
deleteAttempts += 1;
|
||
|
|
if (persistent || deleteAttempts < 3) throw new Error("backup busy");
|
||
|
|
files.delete(targetPath);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const transient = makeClient(false);
|
||
|
|
await sftpBridge._renameRemotePathForTests(
|
||
|
|
transient.client,
|
||
|
|
"/tmp/stage",
|
||
|
|
"/tmp/final",
|
||
|
|
"/tmp/backup",
|
||
|
|
{ finalPath: "/tmp/final", backupPath: "/tmp/backup" },
|
||
|
|
);
|
||
|
|
assert.equal(transient.deleteAttempts, 3);
|
||
|
|
assert.deepEqual(transient.files.get("/tmp/final"), Buffer.from("new"));
|
||
|
|
assert.equal(transient.files.has("/tmp/backup"), false);
|
||
|
|
|
||
|
|
const persistent = makeClient(true);
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge._renameRemotePathForTests(
|
||
|
|
persistent.client,
|
||
|
|
"/tmp/stage",
|
||
|
|
"/tmp/final",
|
||
|
|
"/tmp/backup",
|
||
|
|
{ finalPath: "/tmp/final", backupPath: "/tmp/backup" },
|
||
|
|
),
|
||
|
|
(error) => {
|
||
|
|
assert.equal(error.recoverable, true);
|
||
|
|
assert.equal(error.remoteFinalPath, "/tmp/final");
|
||
|
|
assert.equal(error.remoteBackupPath, "/tmp/backup");
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(persistent.deleteAttempts, 3);
|
||
|
|
assert.deepEqual(persistent.files.get("/tmp/final"), Buffer.from("new"));
|
||
|
|
assert.deepEqual(persistent.files.get("/tmp/backup"), Buffer.from("old"));
|
||
|
|
});
|
||
|
|
|
||
|
|
function createScpBackupCleanupHarness({ persistent }) {
|
||
|
|
const finalPath = "/tmp/scp-final.bin";
|
||
|
|
const backupPath = sftpBridge._buildBackupRemotePathForTests(finalPath);
|
||
|
|
const files = new Map([[finalPath, Buffer.from("old")]]);
|
||
|
|
let backupDeleteAttempts = 0;
|
||
|
|
let uploadCalls = 0;
|
||
|
|
const missing = (candidate) => {
|
||
|
|
const error = new Error(`ENOENT: ${candidate}`);
|
||
|
|
error.code = "ENOENT";
|
||
|
|
return error;
|
||
|
|
};
|
||
|
|
const backend = {
|
||
|
|
async stat(candidate) {
|
||
|
|
if (!files.has(candidate)) throw missing(candidate);
|
||
|
|
return {
|
||
|
|
type: "file",
|
||
|
|
isDirectory: false,
|
||
|
|
isSymbolicLink: false,
|
||
|
|
size: files.get(candidate).length,
|
||
|
|
mode: 0o100644,
|
||
|
|
permissions: "rw-r--r--",
|
||
|
|
modifyTime: 1,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async chmod() {},
|
||
|
|
async rename(from, to) {
|
||
|
|
if (!files.has(from)) throw missing(from);
|
||
|
|
if (files.has(to)) throw new Error(`EEXIST: ${to}`);
|
||
|
|
files.set(to, files.get(from));
|
||
|
|
files.delete(from);
|
||
|
|
},
|
||
|
|
async remove(candidate) {
|
||
|
|
if (candidate === backupPath) {
|
||
|
|
backupDeleteAttempts += 1;
|
||
|
|
if (persistent || backupDeleteAttempts < 3) throw new Error("backup cleanup unavailable");
|
||
|
|
}
|
||
|
|
files.delete(candidate);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
const upload = () => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, {
|
||
|
|
expectedSize: 3,
|
||
|
|
async uploadFile(_encodedPath, uploadTarget) {
|
||
|
|
uploadCalls += 1;
|
||
|
|
files.set(uploadTarget.logicalPath, Buffer.from("new"));
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
finalPath,
|
||
|
|
backupPath,
|
||
|
|
files,
|
||
|
|
upload,
|
||
|
|
get backupDeleteAttempts() { return backupDeleteAttempts; },
|
||
|
|
get uploadCalls() { return uploadCalls; },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("SCP upload retries transient backup cleanup", async () => {
|
||
|
|
const harness = createScpBackupCleanupHarness({ persistent: false });
|
||
|
|
await harness.upload();
|
||
|
|
assert.equal(harness.backupDeleteAttempts, 3);
|
||
|
|
assert.deepEqual(harness.files.get(harness.finalPath), Buffer.from("new"));
|
||
|
|
assert.equal(harness.files.has(harness.backupPath), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP persistent cleanup failure remains recoverable and repeated upload creates no new backup", async () => {
|
||
|
|
const harness = createScpBackupCleanupHarness({ persistent: true });
|
||
|
|
await assert.rejects(harness.upload, (error) => {
|
||
|
|
assert.equal(error.recoverable, true);
|
||
|
|
assert.equal(error.remoteFinalPath, harness.finalPath);
|
||
|
|
assert.equal(error.remoteBackupPath, harness.backupPath);
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
assert.deepEqual(harness.files.get(harness.finalPath), Buffer.from("new"));
|
||
|
|
assert.deepEqual(harness.files.get(harness.backupPath), Buffer.from("old"));
|
||
|
|
assert.equal(harness.uploadCalls, 1);
|
||
|
|
|
||
|
|
await assert.rejects(harness.upload, /backup cleanup unavailable/);
|
||
|
|
assert.equal(harness.uploadCalls, 1, "the next attempt must reconcile the stable backup before uploading");
|
||
|
|
assert.deepEqual(
|
||
|
|
[...harness.files.keys()].filter((candidate) => candidate.includes(".netcatty-backup-")),
|
||
|
|
[harness.backupPath],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP upload stops when the destination type cannot be inspected", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-stat-fail-"));
|
||
|
|
t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(tempRoot, "local.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("payload"));
|
||
|
|
let uploadCalls = 0;
|
||
|
|
const backend = {
|
||
|
|
async stat() {
|
||
|
|
throw new Error("temporary stat failure");
|
||
|
|
},
|
||
|
|
async uploadFile() {
|
||
|
|
uploadCalls += 1;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map([["scp-stat-fail", client]]),
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: "scp-stat-fail",
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/remote.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/temporary stat failure/,
|
||
|
|
);
|
||
|
|
assert.equal(uploadCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP upload does not replace a symlink that appears before promotion", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-scp-symlink-race-"));
|
||
|
|
t.after(async () => fs.promises.rm(tempRoot, { recursive: true, force: true }));
|
||
|
|
const localPath = path.join(tempRoot, "local.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("payload"));
|
||
|
|
let targetStatCalls = 0;
|
||
|
|
let renameCalls = 0;
|
||
|
|
let removedStage = false;
|
||
|
|
const backend = {
|
||
|
|
async stat(remotePath) {
|
||
|
|
const candidate = String(remotePath);
|
||
|
|
if (candidate.includes(".netcatty-backup-")) {
|
||
|
|
const error = new Error("ENOENT");
|
||
|
|
error.code = "ENOENT";
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (candidate.includes(".netcatty-upload-")) {
|
||
|
|
return { type: "file", isDirectory: false, size: 7 };
|
||
|
|
}
|
||
|
|
targetStatCalls += 1;
|
||
|
|
if (targetStatCalls === 1) return { type: "file", isDirectory: false };
|
||
|
|
return { type: "symlink", isDirectory: false, isSymbolicLink: true };
|
||
|
|
},
|
||
|
|
async uploadFile() {},
|
||
|
|
async rename() {
|
||
|
|
renameCalls += 1;
|
||
|
|
},
|
||
|
|
async remove(remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) removedStage = true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map([["scp-symlink-race", client]]),
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: "scp-symlink-race",
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/remote.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/changed to a symlink/,
|
||
|
|
);
|
||
|
|
assert.equal(renameCalls, 0);
|
||
|
|
assert.equal(removedStage, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP upload cancelled during the final target check does not promote", async () => {
|
||
|
|
const controller = new AbortController();
|
||
|
|
let targetStatCalls = 0;
|
||
|
|
let renameCalls = 0;
|
||
|
|
let removedStage = false;
|
||
|
|
const backend = {
|
||
|
|
async stat(remotePath) {
|
||
|
|
const candidate = String(remotePath);
|
||
|
|
if (candidate.includes(".netcatty-backup-")) {
|
||
|
|
const error = new Error("ENOENT");
|
||
|
|
error.code = "ENOENT";
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (candidate.includes(".netcatty-upload-")) {
|
||
|
|
return { type: "file", isDirectory: false, size: 7 };
|
||
|
|
}
|
||
|
|
targetStatCalls += 1;
|
||
|
|
if (targetStatCalls > 1) controller.abort();
|
||
|
|
return { type: "file", isDirectory: false, size: 3 };
|
||
|
|
},
|
||
|
|
async rename() {
|
||
|
|
renameCalls += 1;
|
||
|
|
},
|
||
|
|
async remove(remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) removedStage = true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", "/tmp/remote.bin", {
|
||
|
|
signal: controller.signal,
|
||
|
|
expectedSize: 7,
|
||
|
|
async uploadFile() {},
|
||
|
|
}),
|
||
|
|
/abort/i,
|
||
|
|
);
|
||
|
|
assert.equal(renameCalls, 0);
|
||
|
|
assert.equal(removedStage, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP upload aborts while staged size verification is pending", async () => {
|
||
|
|
const controller = new AbortController();
|
||
|
|
let removedStage = false;
|
||
|
|
const backend = {
|
||
|
|
async stat(remotePath, options = {}) {
|
||
|
|
if (!String(remotePath).includes(".netcatty-upload-")) {
|
||
|
|
const error = new Error("No such file");
|
||
|
|
error.code = "ENOENT";
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
if (options.signal?.aborted) {
|
||
|
|
reject(new Error("Transfer cancelled"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
options.signal?.addEventListener(
|
||
|
|
"abort",
|
||
|
|
() => reject(new Error("Transfer cancelled")),
|
||
|
|
{ once: true },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
async remove(remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) removedStage = true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
const upload = sftpBridge.runRemoteUploadTransaction(
|
||
|
|
client,
|
||
|
|
"/tmp/local.bin",
|
||
|
|
"/tmp/remote.bin",
|
||
|
|
{
|
||
|
|
signal: controller.signal,
|
||
|
|
expectedSize: 7,
|
||
|
|
async uploadFile() {},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(() => upload, /cancel|abort/i);
|
||
|
|
assert.equal(removedStage, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged basenames stay within the remote NAME_MAX budget", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-long-name-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const longBase = `${"a".repeat(240)}.bin`;
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("x"));
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles } = createSessionChannel();
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-long", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-long",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: `/tmp/${longBase}`,
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
const stagedBase = path.posix.basename(fastPutCalls[0].remotePath);
|
||
|
|
assert.ok(Buffer.byteLength(stagedBase, "utf8") <= 255, stagedBase);
|
||
|
|
assert.ok(remoteFiles.has(`/tmp/${longBase}`));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("symlink destinations are written in-place (not replaced by rename)", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-symlink-upload-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "cfg.json");
|
||
|
|
const payload = Buffer.from('{"ok":true}');
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
// Symlink at the destination path; real content elsewhere.
|
||
|
|
remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 });
|
||
|
|
remoteFiles.set("/etc/app/config.json", Buffer.from("link-placeholder"));
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-link", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-link",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/etc/app/config.json",
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.equal(fastPutCalls[0].remotePath, "/etc/app/config.json");
|
||
|
|
assert.deepEqual(remoteFiles.get("/etc/app/config.json"), payload);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("truncated write-in-place uploads never delete the symlink destination", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-symlink-size-failure-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "cfg.json");
|
||
|
|
const payload = Buffer.from('{"expected":"full-content"}');
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 });
|
||
|
|
remoteFiles.set(remotePath, Buffer.from("old-target"));
|
||
|
|
channel.fastPut = (_localPath, uploadedPath, opts, callback) => {
|
||
|
|
fastPutCalls.push({ localPath: _localPath, remotePath: uploadedPath });
|
||
|
|
const truncated = payload.subarray(0, 3);
|
||
|
|
remoteFiles.set(uploadedPath, truncated);
|
||
|
|
opts?.step?.(truncated.length, truncated.length, payload.length);
|
||
|
|
queueMicrotask(() => callback(null));
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-link-size", { conn: { sftp: (callback) => callback(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-link-size",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/size mismatch/i,
|
||
|
|
);
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.equal(remoteMeta.get(remotePath)?.isSymlink, true);
|
||
|
|
assert.equal(remoteFiles.has(remotePath), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("lstat unsupported falls back to stat and preserves an existing destination", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-lstat-fallback-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "cfg.json");
|
||
|
|
const payload = Buffer.from('{"fallback":true}');
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
remoteFiles.set("/etc/app/config.json", Buffer.from("old-target-content"));
|
||
|
|
remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 });
|
||
|
|
channel.lstat = (_targetPath, callback) => {
|
||
|
|
const err = new Error("SSH_FX_OP_UNSUPPORTED");
|
||
|
|
err.code = 8;
|
||
|
|
callback(err);
|
||
|
|
};
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-lstat-fallback", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-lstat-fallback",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/etc/app/config.json",
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.equal(fastPutCalls[0].remotePath, "/etc/app/config.json");
|
||
|
|
assert.deepEqual(remoteFiles.get("/etc/app/config.json"), payload);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("new files stage safely when readlink distinguishes them without lstat", async (t) => {
|
||
|
|
for (const variant of ["missing-method", "runtime-unsupported"]) {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-new-no-lstat-${variant}-`));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "new.bin");
|
||
|
|
const payload = Buffer.from(`new-${variant}`);
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
const { channel, fastPutCalls, remoteFiles } = createSessionChannel();
|
||
|
|
if (variant === "missing-method") {
|
||
|
|
channel.lstat = undefined;
|
||
|
|
} else {
|
||
|
|
channel.lstat = (_targetPath, callback) => {
|
||
|
|
const err = new Error("SSH_FX_OP_UNSUPPORTED");
|
||
|
|
err.code = 8;
|
||
|
|
callback(err);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([[`session-${variant}`, { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: `session-${variant}`,
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: `/tmp/${variant}.bin`,
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
assert.deepEqual(remoteFiles.get(`/tmp/${variant}.bin`), payload);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("no-lstat fallback writes through a broken symlink instead of replacing it", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-broken-link-no-lstat-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-target"));
|
||
|
|
|
||
|
|
const created = createSessionChannel();
|
||
|
|
const remotePath = "/tmp/broken-link";
|
||
|
|
created.remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 });
|
||
|
|
created.channel.lstat = undefined;
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-broken-link", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-broken-link",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
});
|
||
|
|
assert.equal(created.fastPutCalls.length, 1);
|
||
|
|
assert.equal(created.fastPutCalls[0].remotePath, remotePath);
|
||
|
|
assert.equal(created.remoteMeta.get(remotePath)?.isSymlink, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("upload stops when neither lstat nor readlink can classify a missing target", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-no-link-inspection-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("payload"));
|
||
|
|
|
||
|
|
const created = createSessionChannel();
|
||
|
|
created.channel.lstat = undefined;
|
||
|
|
created.channel.readlink = undefined;
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-no-link-inspection", {
|
||
|
|
conn: { sftp: (callback) => callback(null, created.channel) },
|
||
|
|
}]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-no-link-inspection",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/new.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/cannot safely distinguish/i,
|
||
|
|
);
|
||
|
|
assert.equal(created.fastPutCalls.length, 0);
|
||
|
|
assert.equal(created.remoteFiles.has("/tmp/new.bin"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("permission failure during final target recheck never falls back to direct write", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-recheck-permission-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "data.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-data"));
|
||
|
|
const created = createSessionChannel();
|
||
|
|
created.remoteFiles.set("/tmp/data.bin", Buffer.from("old-data"));
|
||
|
|
created.remoteMeta.set("/tmp/data.bin", { mode: 0o100644 });
|
||
|
|
const originalLstat = created.channel.lstat.bind(created.channel);
|
||
|
|
let lstatCalls = 0;
|
||
|
|
created.channel.lstat = (targetPath, callback) => {
|
||
|
|
if (String(targetPath) === "/tmp/data.bin") lstatCalls += 1;
|
||
|
|
if (lstatCalls >= 2 && String(targetPath) === "/tmp/data.bin") {
|
||
|
|
const err = new Error("Permission denied");
|
||
|
|
err.code = "EACCES";
|
||
|
|
callback(err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
originalLstat(targetPath, callback);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-recheck-permission", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-recheck-permission",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/data.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/Permission denied/,
|
||
|
|
);
|
||
|
|
assert.equal(created.fastPutCalls.length, 1);
|
||
|
|
assert.match(created.fastPutCalls[0].remotePath, /\.netcatty-upload-/);
|
||
|
|
assert.deepEqual(created.remoteFiles.get("/tmp/data.bin"), Buffer.from("old-data"));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload stops if the destination becomes a symlink", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-symlink-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "cfg.json");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-content"));
|
||
|
|
let channelRef = null;
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteMeta.set("/etc/app/config.json", { isSymlink: true, mode: 0o120777 });
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
channelRef = created.channel;
|
||
|
|
created.remoteFiles.set("/etc/app/config.json", Buffer.from("old-content"));
|
||
|
|
created.remoteMeta.set("/etc/app/config.json", { mode: 0o100644 });
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-symlink-race", { conn: { sftp: (cb) => cb(null, channelRef) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-symlink-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/etc/app/config.json",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/changed to a symlink/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(created.remoteFiles.get("/etc/app/config.json"), Buffer.from("old-content"));
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload preserves a regular destination replaced during upload", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-regular-target-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
const replacement = Buffer.from("new-state");
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, uploadPath) {
|
||
|
|
if (String(uploadPath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteFiles.set(remotePath, replacement);
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 2, uid: 1000, gid: 1000 });
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.from("old-state"));
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-regular-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-regular-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/destination changed during upload/i,
|
||
|
|
);
|
||
|
|
assert.deepEqual(created.remoteFiles.get(remotePath), replacement);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload detects same-metadata destination content replacement", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-same-meta-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
// Same length/mode/owner/mtime as the original — metadata-only snapshots miss this.
|
||
|
|
const replacement = Buffer.from("new-state");
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, uploadPath) {
|
||
|
|
if (String(uploadPath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteFiles.set(remotePath, replacement);
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.from("old-state"));
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-same-meta-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-same-meta-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/destination changed during upload/i,
|
||
|
|
);
|
||
|
|
assert.deepEqual(created.remoteFiles.get(remotePath), replacement);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload detects same-size rewrite when remote inode is stable", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stable-ino-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
const replacement = Buffer.from("new-state");
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, uploadPath) {
|
||
|
|
if (String(uploadPath).includes(".netcatty-upload-")) {
|
||
|
|
// Same inode/fileId/size/mode/mtime — only content changes.
|
||
|
|
created.remoteFiles.set(remotePath, replacement);
|
||
|
|
created.remoteMeta.set(remotePath, {
|
||
|
|
mode: 0o100644,
|
||
|
|
mtime: 1,
|
||
|
|
uid: 1000,
|
||
|
|
gid: 1000,
|
||
|
|
ino: 4242,
|
||
|
|
fileId: "stable-file-id",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.from("old-state"));
|
||
|
|
created.remoteMeta.set(remotePath, {
|
||
|
|
mode: 0o100644,
|
||
|
|
mtime: 1,
|
||
|
|
uid: 1000,
|
||
|
|
gid: 1000,
|
||
|
|
ino: 4242,
|
||
|
|
fileId: "stable-file-id",
|
||
|
|
});
|
||
|
|
const baseStat = created.channel.stat.bind(created.channel);
|
||
|
|
const baseLstat = created.channel.lstat.bind(created.channel);
|
||
|
|
const withStableIds = (targetPath, callback, base) => {
|
||
|
|
base(targetPath, (error, attrs) => {
|
||
|
|
if (error) {
|
||
|
|
callback(error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const meta = created.remoteMeta.get(targetPath) || {};
|
||
|
|
callback(null, {
|
||
|
|
...attrs,
|
||
|
|
ino: meta.ino,
|
||
|
|
fileId: meta.fileId,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
};
|
||
|
|
created.channel.stat = (targetPath, callback) => withStableIds(targetPath, callback, baseStat);
|
||
|
|
created.channel.lstat = (targetPath, callback) => withStableIds(targetPath, callback, baseLstat);
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-stable-ino-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-stable-ino-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/destination changed during upload/i,
|
||
|
|
);
|
||
|
|
assert.deepEqual(created.remoteFiles.get(remotePath), replacement);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("destination content hashing aborts when the upload signal is cancelled", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-dest-hash-abort-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
const { Readable } = require("node:stream");
|
||
|
|
let markHashStarted;
|
||
|
|
const hashStarted = new Promise((resolve) => { markHashStarted = resolve; });
|
||
|
|
let streamDestroyed = false;
|
||
|
|
const created = createSessionChannel();
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.alloc(64 * 1024, 7));
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
created.channel.createReadStream = () => {
|
||
|
|
const stream = new Readable({
|
||
|
|
read() {
|
||
|
|
// Never push — simulate a stalled destination hash read.
|
||
|
|
},
|
||
|
|
});
|
||
|
|
stream.destroy = ((originalDestroy) => function destroyPatched(err) {
|
||
|
|
streamDestroyed = true;
|
||
|
|
return originalDestroy.call(this, err);
|
||
|
|
})(stream.destroy.bind(stream));
|
||
|
|
markHashStarted();
|
||
|
|
return stream;
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-dest-hash-abort", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-dest-hash-abort",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
const controller = new AbortController();
|
||
|
|
const upload = sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
abortSignal: controller.signal,
|
||
|
|
});
|
||
|
|
await hashStarted;
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(
|
||
|
|
() => Promise.race([
|
||
|
|
upload,
|
||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 1000)),
|
||
|
|
]),
|
||
|
|
/abort|cancel/i,
|
||
|
|
);
|
||
|
|
assert.equal(streamDestroyed, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload does not recreate a destination deleted during upload", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-deleted-target-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, uploadPath) {
|
||
|
|
if (String(uploadPath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteFiles.delete(remotePath);
|
||
|
|
created.remoteMeta.delete(remotePath);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.from("old-state"));
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100644, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-deleted-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-deleted-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/destination disappeared during upload/i,
|
||
|
|
);
|
||
|
|
assert.equal(created.remoteFiles.has(remotePath), false);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged SFTP upload rechecks a replaced destination after mode setup", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mode-target-race-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("uploaded-data"));
|
||
|
|
const remotePath = "/etc/app/config.json";
|
||
|
|
const replacement = Buffer.from("new-state");
|
||
|
|
const created = createSessionChannel();
|
||
|
|
created.remoteFiles.set(remotePath, Buffer.from("old-state"));
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100755, mtime: 1, uid: 1000, gid: 1000 });
|
||
|
|
const baseChmod = created.channel.chmod.bind(created.channel);
|
||
|
|
created.channel.chmod = (chmodPath, mode, callback) => {
|
||
|
|
baseChmod(chmodPath, mode, (error) => {
|
||
|
|
if (String(chmodPath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteFiles.set(remotePath, replacement);
|
||
|
|
created.remoteMeta.set(remotePath, { mode: 0o100755, mtime: 2, uid: 1000, gid: 1000 });
|
||
|
|
}
|
||
|
|
callback(error);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-mode-race", { conn: { sftp: (cb) => cb(null, created.channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-mode-race",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/destination changed during upload/i,
|
||
|
|
);
|
||
|
|
assert.deepEqual(created.remoteFiles.get(remotePath), replacement);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("no-lstat staged upload stops if a broken symlink appears before promotion", async (t) => {
|
||
|
|
for (const variant of ["missing-method", "runtime-unsupported"]) {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-late-broken-link-${variant}-`));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-content"));
|
||
|
|
const remotePath = `/tmp/${variant}.bin`;
|
||
|
|
const created = createSessionChannel({
|
||
|
|
onFastPut(_local, fastPutPath) {
|
||
|
|
if (String(fastPutPath).includes(".netcatty-upload-")) {
|
||
|
|
created.remoteMeta.set(remotePath, { isSymlink: true, mode: 0o120777 });
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
if (variant === "missing-method") {
|
||
|
|
created.channel.lstat = undefined;
|
||
|
|
} else {
|
||
|
|
created.channel.lstat = (_targetPath, callback) => {
|
||
|
|
const error = new Error("SSH_FX_OP_UNSUPPORTED");
|
||
|
|
error.code = 8;
|
||
|
|
callback(error);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([[`session-${variant}`, {
|
||
|
|
conn: { sftp: (callback) => callback(null, created.channel) },
|
||
|
|
}]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: `session-${variant}`,
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath,
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/changed to a symlink/,
|
||
|
|
);
|
||
|
|
assert.equal(created.remoteMeta.get(remotePath)?.isSymlink, true);
|
||
|
|
assert.equal(created.remoteFiles.has(remotePath), false);
|
||
|
|
assert.equal(
|
||
|
|
[...created.remoteFiles.keys()].some((key) => String(key).includes(".netcatty-upload-")),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("abort during staged mode setup prevents promotion", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-mode-abort-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "tool");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("new-tool"));
|
||
|
|
const controller = new AbortController();
|
||
|
|
const { channel, remoteFiles, remoteMeta } = createSessionChannel();
|
||
|
|
remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool"));
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 });
|
||
|
|
const originalChmod = channel.chmod.bind(channel);
|
||
|
|
channel.chmod = (targetPath, mode, callback) => {
|
||
|
|
controller.abort();
|
||
|
|
originalChmod(targetPath, mode, callback);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-mode-abort", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-mode-abort",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/usr/local/bin/tool",
|
||
|
|
encoding: "utf-8",
|
||
|
|
abortSignal: controller.signal,
|
||
|
|
}),
|
||
|
|
/abort|cancel/i,
|
||
|
|
);
|
||
|
|
assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool"));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("parent-dir permission on staged path does not risk an in-place create race", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-perm-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "data.bin");
|
||
|
|
const payload = Buffer.from("new-content");
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles } = createSessionChannel({
|
||
|
|
onFastPut(_local, remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) {
|
||
|
|
const err = new Error("Permission denied");
|
||
|
|
err.code = 3;
|
||
|
|
return { error: err };
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
// Destination does not exist → staging is attempted first.
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-perm", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-perm",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/ro-dir/file.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/Permission denied/,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1, "must not directly create after the staged attempt fails");
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
assert.equal(remoteFiles.has("/ro-dir/file.bin"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged permission failure preserves an existing destination", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-mode-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "tool.bin");
|
||
|
|
const payload = Buffer.from("replaced-bytes");
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel({
|
||
|
|
onFastPut(_local, remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) {
|
||
|
|
const err = new Error("Permission denied");
|
||
|
|
err.code = 3;
|
||
|
|
return { error: err };
|
||
|
|
}
|
||
|
|
// Simulate servers that recreate the inode on in-place OPEN|CREAT|TRUNC
|
||
|
|
// with umask defaults (losing the prior executable bit).
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100644 });
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 });
|
||
|
|
remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool"));
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-inplace-mode", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-inplace-mode",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/usr/local/bin/tool",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/Permission denied/,
|
||
|
|
);
|
||
|
|
assert.equal(fastPutCalls.length, 1, "must not directly overwrite the existing file");
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
assert.deepEqual(chmodCalls, []);
|
||
|
|
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
|
||
|
|
assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool"));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("staged permission failure does not reach a later mode restore failure", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-inplace-mode-fail-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "tool.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("replaced-bytes"));
|
||
|
|
const { channel, remoteFiles, remoteMeta } = createSessionChannel({
|
||
|
|
onFastPut(_local, remotePath) {
|
||
|
|
if (String(remotePath).includes(".netcatty-upload-")) {
|
||
|
|
const err = new Error("Permission denied");
|
||
|
|
err.code = 3;
|
||
|
|
return { error: err };
|
||
|
|
}
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100644 });
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
remoteMeta.set("/usr/local/bin/tool", { mode: 0o100755 });
|
||
|
|
remoteFiles.set("/usr/local/bin/tool", Buffer.from("old-tool"));
|
||
|
|
channel.chmod = (_targetPath, _mode, callback) => {
|
||
|
|
const error = new Error("in-place chmod failed");
|
||
|
|
error.code = "EIO";
|
||
|
|
callback(error);
|
||
|
|
};
|
||
|
|
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-inplace-mode-fail", { conn: { sftp: (cb) => cb(null, channel) } }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-inplace-mode-fail",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/usr/local/bin/tool",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/Permission denied/,
|
||
|
|
);
|
||
|
|
assert.deepEqual(remoteFiles.get("/usr/local/bin/tool"), Buffer.from("old-tool"));
|
||
|
|
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP staged permission failure preserves an existing destination", async () => {
|
||
|
|
const finalPath = "/usr/local/bin/tool";
|
||
|
|
const files = new Map([[finalPath, Buffer.from("old")]]);
|
||
|
|
const modes = new Map([[finalPath, 0o100755]]);
|
||
|
|
const chmodCalls = [];
|
||
|
|
let uploadCalls = 0;
|
||
|
|
const missing = (candidate) => {
|
||
|
|
const error = new Error(`ENOENT: ${candidate}`);
|
||
|
|
error.code = "ENOENT";
|
||
|
|
return error;
|
||
|
|
};
|
||
|
|
const backend = {
|
||
|
|
async stat(candidate) {
|
||
|
|
if (!files.has(candidate)) throw missing(candidate);
|
||
|
|
return {
|
||
|
|
type: "file",
|
||
|
|
isDirectory: false,
|
||
|
|
isSymbolicLink: false,
|
||
|
|
size: files.get(candidate).length,
|
||
|
|
mode: modes.get(candidate) ?? 0o100644,
|
||
|
|
permissions: "rwxr-xr-x",
|
||
|
|
modifyTime: 1,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async chmod(candidate, mode) {
|
||
|
|
chmodCalls.push({ path: candidate, mode });
|
||
|
|
modes.set(candidate, 0o100000 | (mode & 0o7777));
|
||
|
|
},
|
||
|
|
async rename(from, to) {
|
||
|
|
if (!files.has(from)) throw missing(from);
|
||
|
|
if (files.has(to)) throw new Error(`EEXIST: ${to}`);
|
||
|
|
files.set(to, files.get(from));
|
||
|
|
files.delete(from);
|
||
|
|
if (modes.has(from)) {
|
||
|
|
modes.set(to, modes.get(from));
|
||
|
|
modes.delete(from);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
async remove(candidate) {
|
||
|
|
files.delete(candidate);
|
||
|
|
modes.delete(candidate);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, {
|
||
|
|
expectedSize: 3,
|
||
|
|
async uploadFile(_path, uploadTarget) {
|
||
|
|
uploadCalls += 1;
|
||
|
|
if (uploadTarget.generatedStagePath) {
|
||
|
|
const err = new Error("Permission denied creating stage");
|
||
|
|
err.code = "EACCES";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
files.set(uploadTarget.logicalPath, Buffer.from("new"));
|
||
|
|
modes.set(uploadTarget.logicalPath, 0o100644);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
/Permission denied creating stage/,
|
||
|
|
);
|
||
|
|
assert.equal(uploadCalls, 1);
|
||
|
|
assert.deepEqual(files.get(finalPath), Buffer.from("old"));
|
||
|
|
assert.deepEqual(chmodCalls, []);
|
||
|
|
assert.equal(modes.get(finalPath) & 0o777, 0o755);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("SCP staged permission failure never commits an existing destination", async () => {
|
||
|
|
const finalPath = "/usr/local/bin/tool";
|
||
|
|
const files = new Map([[finalPath, Buffer.from("old")]]);
|
||
|
|
const modes = new Map([[finalPath, 0o100755]]);
|
||
|
|
const chmodCalls = [];
|
||
|
|
const controller = new AbortController();
|
||
|
|
const missing = (candidate) => {
|
||
|
|
const error = new Error(`ENOENT: ${candidate}`);
|
||
|
|
error.code = "ENOENT";
|
||
|
|
return error;
|
||
|
|
};
|
||
|
|
const backend = {
|
||
|
|
async stat(candidate) {
|
||
|
|
if (!files.has(candidate)) throw missing(candidate);
|
||
|
|
return {
|
||
|
|
type: "file",
|
||
|
|
isDirectory: false,
|
||
|
|
isSymbolicLink: false,
|
||
|
|
size: files.get(candidate).length,
|
||
|
|
mode: modes.get(candidate) ?? 0o100644,
|
||
|
|
permissions: "rwxr-xr-x",
|
||
|
|
modifyTime: 1,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async chmod(candidate, mode, options = {}) {
|
||
|
|
if (options.signal?.aborted) {
|
||
|
|
throw new Error("Transfer cancelled");
|
||
|
|
}
|
||
|
|
chmodCalls.push({ path: candidate, mode, hadSignal: Boolean(options.signal) });
|
||
|
|
modes.set(candidate, 0o100000 | (mode & 0o7777));
|
||
|
|
},
|
||
|
|
async rename(from, to) {
|
||
|
|
if (!files.has(from)) throw missing(from);
|
||
|
|
if (files.has(to)) throw new Error(`EEXIST: ${to}`);
|
||
|
|
files.set(to, files.get(from));
|
||
|
|
files.delete(from);
|
||
|
|
if (modes.has(from)) {
|
||
|
|
modes.set(to, modes.get(from));
|
||
|
|
modes.delete(from);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
async remove(candidate) {
|
||
|
|
files.delete(candidate);
|
||
|
|
modes.delete(candidate);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const client = {
|
||
|
|
__netcattyFileProtocol: "scp",
|
||
|
|
__netcattyScpBackend: backend,
|
||
|
|
};
|
||
|
|
|
||
|
|
let committed = false;
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", finalPath, {
|
||
|
|
expectedSize: 3,
|
||
|
|
signal: controller.signal,
|
||
|
|
commitPromotion() {
|
||
|
|
committed = true;
|
||
|
|
},
|
||
|
|
async uploadFile(_path, uploadTarget) {
|
||
|
|
if (uploadTarget.generatedStagePath) {
|
||
|
|
const err = new Error("Permission denied creating stage");
|
||
|
|
err.code = "EACCES";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
files.set(uploadTarget.logicalPath, Buffer.from("new"));
|
||
|
|
modes.set(uploadTarget.logicalPath, 0o100644);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
/Permission denied creating stage/,
|
||
|
|
);
|
||
|
|
assert.equal(committed, false);
|
||
|
|
assert.deepEqual(chmodCalls, []);
|
||
|
|
assert.equal(modes.get(finalPath) & 0o777, 0o755);
|
||
|
|
assert.deepEqual(files.get(finalPath), Buffer.from("old"));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("no-lstat new upload aborted during staged size verify leaves no final", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-late-abort-promote-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
const payload = Buffer.from("late-abort-payload");
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls, remoteFiles } = createSessionChannel();
|
||
|
|
channel.lstat = undefined;
|
||
|
|
const controller = new AbortController();
|
||
|
|
let renameCalled = false;
|
||
|
|
const origStat = channel.stat.bind(channel);
|
||
|
|
const origRename = channel.rename.bind(channel);
|
||
|
|
channel.stat = (targetPath, callback) => {
|
||
|
|
if (String(targetPath).includes(".netcatty-upload-")) {
|
||
|
|
// Abort while size verification is in flight so the post-stat check must
|
||
|
|
// block promotion (throwIfAborted after await client.stat).
|
||
|
|
controller.abort();
|
||
|
|
queueMicrotask(() => origStat(targetPath, callback));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
return origStat(targetPath, callback);
|
||
|
|
};
|
||
|
|
channel.rename = (from, to, callback) => {
|
||
|
|
renameCalled = true;
|
||
|
|
return origRename(from, to, callback);
|
||
|
|
};
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-late-abort", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-late-abort",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/late-abort.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
abortSignal: controller.signal,
|
||
|
|
}),
|
||
|
|
/abort|cancel/i,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
assert.equal(renameCalled, false);
|
||
|
|
assert.equal(remoteFiles.has("/tmp/late-abort.bin"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("size-mismatch on path containing 'access' does not fall back to in-place", async (t) => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-access-name-"));
|
||
|
|
t.after(async () => {
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
tempDirBridge.init?.({ getPath: () => tempRoot });
|
||
|
|
|
||
|
|
const localPath = path.join(tempRoot, "payload.bin");
|
||
|
|
const payload = Buffer.from("twelve-bytes"); // 12 bytes
|
||
|
|
await fs.promises.writeFile(localPath, payload);
|
||
|
|
|
||
|
|
const { channel, fastPutCalls } = createSessionChannel();
|
||
|
|
// Make staged-path stat report a wrong size so size-verify throws a message
|
||
|
|
// containing the path word "access" — must NOT be treated as permission.
|
||
|
|
const origStat = channel.stat.bind(channel);
|
||
|
|
channel.stat = (targetPath, callback) => {
|
||
|
|
if (String(targetPath).includes(".netcatty-upload-")) {
|
||
|
|
callback(null, {
|
||
|
|
size: 1,
|
||
|
|
mode: 0o100644,
|
||
|
|
isDirectory: () => false,
|
||
|
|
isFile: () => true,
|
||
|
|
isSymbolicLink: () => false,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
return origStat(targetPath, callback);
|
||
|
|
};
|
||
|
|
|
||
|
|
const connection = {
|
||
|
|
sftp(callback) { callback(null, channel); },
|
||
|
|
};
|
||
|
|
const sftpClients = new Map();
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: { webContents: { fromId: () => null } },
|
||
|
|
sessions: new Map([["session-access", { conn: connection }]]),
|
||
|
|
sftpClients,
|
||
|
|
});
|
||
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
||
|
|
sessionId: "session-access",
|
||
|
|
fileProtocol: "sftp",
|
||
|
|
});
|
||
|
|
|
||
|
|
await assert.rejects(
|
||
|
|
() => sftpBridge.uploadLocalToSftp(null, {
|
||
|
|
sftpId: opened.sftpId,
|
||
|
|
localPath,
|
||
|
|
remotePath: "/tmp/access-denied-name.bin",
|
||
|
|
encoding: "utf-8",
|
||
|
|
}),
|
||
|
|
/size mismatch/i,
|
||
|
|
);
|
||
|
|
// Only the staged attempt — no in-place fallback write to the final path.
|
||
|
|
assert.equal(fastPutCalls.length, 1);
|
||
|
|
assert.match(fastPutCalls[0].remotePath, /\.netcatty-upload-.*\.part$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pipelinedUploadLocalFile aborts in-flight fastPut when AbortSignal fires", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-abort-fastput-"));
|
||
|
|
const localPath = path.join(tempRoot, "abort.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(64 * 1024, 3));
|
||
|
|
|
||
|
|
let ended = false;
|
||
|
|
let fastPutStarted = false;
|
||
|
|
const channel = {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(_p, cb) { cb(null); },
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
fastPut(_local, _remote, _opts, callback) {
|
||
|
|
fastPutStarted = true;
|
||
|
|
// Stay pending until end() cancels the transfer.
|
||
|
|
this._pendingCallback = callback;
|
||
|
|
},
|
||
|
|
end() {
|
||
|
|
ended = true;
|
||
|
|
const cb = this._pendingCallback;
|
||
|
|
this._pendingCallback = null;
|
||
|
|
if (typeof cb === "function") {
|
||
|
|
const err = new Error("SFTP channel closed");
|
||
|
|
queueMicrotask(() => cb(err));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const bareClient = {
|
||
|
|
__netcattySessionBacked: true,
|
||
|
|
sftp: null,
|
||
|
|
client: {
|
||
|
|
sftp(cb) {
|
||
|
|
cb(null, channel);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const controller = new AbortController();
|
||
|
|
const uploadPromise = sftpBridge.pipelinedUploadLocalFile(
|
||
|
|
bareClient,
|
||
|
|
localPath,
|
||
|
|
"/tmp/abort-out.bin",
|
||
|
|
{
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
signal: controller.signal,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// Allow fastPut to start, then abort.
|
||
|
|
await new Promise((r) => setImmediate(r));
|
||
|
|
await new Promise((r) => setTimeout(r, 20));
|
||
|
|
assert.equal(fastPutStarted, true);
|
||
|
|
controller.abort();
|
||
|
|
|
||
|
|
await assert.rejects(uploadPromise, /abort|cancel/i);
|
||
|
|
assert.equal(ended, true);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("shared-channel fastPut cancel force-settles when callback stalls", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-abort-bound-"));
|
||
|
|
const localPath = path.join(tempRoot, "stall.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(8 * 1024, 5));
|
||
|
|
|
||
|
|
let ended = false;
|
||
|
|
let unlinkedPath = null;
|
||
|
|
const channel = {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(targetPath, cb) {
|
||
|
|
unlinkedPath = targetPath;
|
||
|
|
cb(null);
|
||
|
|
},
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
// Never invoke the callback — simulates a stalled shared-channel fastPut.
|
||
|
|
fastPut() {},
|
||
|
|
end() {
|
||
|
|
ended = true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
// No client.sftp() for a second channel → acquireUpload uses shared channel.
|
||
|
|
const sharedOnlyClient = {
|
||
|
|
__netcattySudoMode: true,
|
||
|
|
sftp: channel,
|
||
|
|
client: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const controller = new AbortController();
|
||
|
|
const uploadPromise = sftpBridge.pipelinedUploadLocalFile(
|
||
|
|
sharedOnlyClient,
|
||
|
|
localPath,
|
||
|
|
"/tmp/stall-out.bin",
|
||
|
|
{
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
signal: controller.signal,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
await new Promise((r) => setImmediate(r));
|
||
|
|
controller.abort();
|
||
|
|
|
||
|
|
const started = Date.now();
|
||
|
|
await assert.rejects(uploadPromise, /abort|cancel/i);
|
||
|
|
const elapsed = Date.now() - started;
|
||
|
|
// Must settle via the 2s force-finish path, not hang forever.
|
||
|
|
assert.ok(elapsed < 5000, `cancel took too long: ${elapsed}ms`);
|
||
|
|
// Shared channel must not be ended (would kill browse/sudo session).
|
||
|
|
assert.equal(ended, false);
|
||
|
|
// Final destinations must not be unlinked on shared-channel force-settle.
|
||
|
|
assert.equal(unlinkedPath, null);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("shared-channel force-settle unlinks explicitly generated stage paths", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-stage-unlink-"));
|
||
|
|
const localPath = path.join(tempRoot, "stall.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 9));
|
||
|
|
|
||
|
|
let unlinkedPath = null;
|
||
|
|
const channel = {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(targetPath, cb) {
|
||
|
|
unlinkedPath = targetPath;
|
||
|
|
cb(null);
|
||
|
|
},
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
fastPut() {},
|
||
|
|
end() {},
|
||
|
|
};
|
||
|
|
const sharedOnlyClient = {
|
||
|
|
__netcattySudoMode: true,
|
||
|
|
sftp: channel,
|
||
|
|
client: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const controller = new AbortController();
|
||
|
|
const stagedPath = "/tmp/.netcatty-upload-deadbeef-stall.bin.part";
|
||
|
|
const uploadPromise = sftpBridge.pipelinedUploadLocalFile(
|
||
|
|
sharedOnlyClient,
|
||
|
|
localPath,
|
||
|
|
stagedPath,
|
||
|
|
{
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
signal: controller.signal,
|
||
|
|
generatedStagePath: true,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
await new Promise((r) => setImmediate(r));
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(uploadPromise, /abort|cancel/i);
|
||
|
|
assert.equal(unlinkedPath, stagedPath);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("shared-channel cancel never unlinks a caller path that resembles a stage", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-stage-lookalike-"));
|
||
|
|
const localPath = path.join(tempRoot, "stall.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 11));
|
||
|
|
|
||
|
|
let unlinkedPath = null;
|
||
|
|
const channel = {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(targetPath, cb) {
|
||
|
|
unlinkedPath = targetPath;
|
||
|
|
cb(null);
|
||
|
|
},
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
fastPut() {},
|
||
|
|
end() {},
|
||
|
|
};
|
||
|
|
const sharedOnlyClient = {
|
||
|
|
__netcattySudoMode: true,
|
||
|
|
sftp: channel,
|
||
|
|
client: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const controller = new AbortController();
|
||
|
|
const callerPath = "/tmp/.netcatty-upload-deadbeef-user-file.part";
|
||
|
|
const uploadPromise = sftpBridge.pipelinedUploadLocalFile(
|
||
|
|
sharedOnlyClient,
|
||
|
|
localPath,
|
||
|
|
callerPath,
|
||
|
|
{
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
signal: controller.signal,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
await new Promise((r) => setImmediate(r));
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(uploadPromise, /abort|cancel/i);
|
||
|
|
assert.equal(unlinkedPath, null);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("shared-channel fastPut error force-settles when callback stalls", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-shared-error-bound-"));
|
||
|
|
const localPath = path.join(tempRoot, "err.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.alloc(4 * 1024, 7));
|
||
|
|
|
||
|
|
let ended = false;
|
||
|
|
const channel = new EventEmitter();
|
||
|
|
Object.assign(channel, {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(_p, cb) { cb(null); },
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
// Emit channel error and never invoke the fastPut callback.
|
||
|
|
fastPut() {
|
||
|
|
queueMicrotask(() => channel.emit("error", new Error("channel failed")));
|
||
|
|
},
|
||
|
|
end() {
|
||
|
|
ended = true;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const sharedOnlyClient = {
|
||
|
|
__netcattySudoMode: true,
|
||
|
|
sftp: channel,
|
||
|
|
client: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const uploadPromise = sftpBridge.pipelinedUploadLocalFile(
|
||
|
|
sharedOnlyClient,
|
||
|
|
localPath,
|
||
|
|
"/tmp/err-out.bin",
|
||
|
|
{
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const started = Date.now();
|
||
|
|
await assert.rejects(uploadPromise, /channel failed|SFTP channel/i);
|
||
|
|
const elapsed = Date.now() - started;
|
||
|
|
assert.ok(elapsed < 5000, `error settle took too long: ${elapsed}ms`);
|
||
|
|
assert.equal(ended, false);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pipelinedUploadLocalFile falls back to raw sftp.fastPut when client.fastPut is missing", async () => {
|
||
|
|
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-raw-fastput-"));
|
||
|
|
const localPath = path.join(tempRoot, "raw.bin");
|
||
|
|
await fs.promises.writeFile(localPath, Buffer.from("hello-raw"));
|
||
|
|
|
||
|
|
let sawRawFastPut = false;
|
||
|
|
const channel = {
|
||
|
|
readdir(_p, cb) { cb(null, []); },
|
||
|
|
mkdir(_p, cb) { cb(null); },
|
||
|
|
unlink(_p, cb) { cb(null); },
|
||
|
|
stat(_p, cb) {
|
||
|
|
const err = new Error("ENOENT");
|
||
|
|
err.code = 2;
|
||
|
|
cb(err);
|
||
|
|
},
|
||
|
|
fastPut(local, remote, _opts, callback) {
|
||
|
|
sawRawFastPut = local === localPath && remote === "/tmp/out.bin";
|
||
|
|
queueMicrotask(() => callback(null));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
// Bare client with only raw channel.fastPut (no high-level client.fastPut).
|
||
|
|
const bareClient = {
|
||
|
|
sftp: channel,
|
||
|
|
client: {
|
||
|
|
sftp(cb) {
|
||
|
|
cb(null, channel);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
sftpBridge.init({
|
||
|
|
electronModule: {},
|
||
|
|
sessions: new Map(),
|
||
|
|
sftpClients: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
await sftpBridge.pipelinedUploadLocalFile(bareClient, localPath, "/tmp/out.bin", {
|
||
|
|
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||
|
|
chunkSize: TRANSFER_CHUNK_SIZE,
|
||
|
|
});
|
||
|
|
assert.equal(sawRawFastPut, true);
|
||
|
|
|
||
|
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||
|
|
});
|