[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,417 @@
/**
* Detect archive types and build extract commands for remote SSH exec / local spawn.
* Pure helpers plus local process I/O — no SFTP session access.
*/
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { pipeline } = require("node:stream/promises");
const zlib = require("node:zlib");
const ARCHIVE_KINDS = [
{ kind: "tar.gz", suffixes: [".tar.gz", ".tgz"] },
{ kind: "tar.bz2", suffixes: [".tar.bz2", ".tbz2", ".tar.bzip2"] },
{ kind: "tar.xz", suffixes: [".tar.xz", ".txz"] },
{ kind: "tar.zst", suffixes: [".tar.zst", ".tzst"] },
{ kind: "tar", suffixes: [".tar"] },
{ kind: "zip", suffixes: [".zip"] },
{ kind: "gz", suffixes: [".gz"] },
{ kind: "bz2", suffixes: [".bz2"] },
{ kind: "xz", suffixes: [".xz"] },
];
const EXTRACT_OPEN_TIMEOUT_MS = 15_000;
const EXTRACT_BASE_TIMEOUT_MS = 60_000;
const EXTRACT_MAX_TIMEOUT_MS = 10 * 60_000;
const EXTRACT_MAX_OUTPUT_BYTES = 64 * 1024;
function getArchiveBaseName(filePath) {
const normalized = String(filePath || "").replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] || "";
}
function getArchiveKind(filePath) {
const base = getArchiveBaseName(filePath).toLowerCase();
if (!base) return null;
for (const entry of ARCHIVE_KINDS) {
if (entry.suffixes.some((suffix) => base.endsWith(suffix) && base.length > suffix.length)) {
return entry.kind;
}
}
return null;
}
function isExtractableArchive(filePath) {
return getArchiveKind(filePath) != null;
}
function posixParentDir(remotePath) {
const normalized = String(remotePath || "").replace(/\\/g, "/");
if (!normalized || normalized === "/" || normalized === ".") {
throw new Error("Archive path has no parent directory");
}
const trimmed = normalized.replace(/\/+$/, "") || "/";
if (trimmed === "/") {
throw new Error("Archive path has no parent directory");
}
const idx = trimmed.lastIndexOf("/");
if (idx < 0) return ".";
if (idx === 0) return "/";
return trimmed.slice(0, idx);
}
function stripCompressionSuffix(filePath, kind) {
const suffix = kind === "gz" ? ".gz" : kind === "bz2" ? ".bz2" : kind === "xz" ? ".xz" : "";
if (!suffix) {
throw new Error(`Cannot strip suffix for archive kind: ${kind}`);
}
const base = getArchiveBaseName(filePath);
if (base.length <= suffix.length || !base.toLowerCase().endsWith(suffix)) {
throw new Error(`Archive name does not match kind ${kind}`);
}
const parent = posixParentDir(filePath);
const stem = base.slice(0, base.length - suffix.length);
if (parent === "/") return `/${stem}`;
if (parent === ".") return stem;
return `${parent}/${stem}`;
}
function computeExtractTimeoutMs(archiveSize) {
const size = Number(archiveSize);
if (!Number.isFinite(size) || size <= 0) return EXTRACT_MAX_TIMEOUT_MS;
const extra = Math.ceil(size / (10 * 1024 * 1024)) * 30_000;
return Math.min(EXTRACT_MAX_TIMEOUT_MS, Math.max(EXTRACT_BASE_TIMEOUT_MS, EXTRACT_BASE_TIMEOUT_MS + extra));
}
function buildExtractCommand(archivePath, { encoding = "utf-8" } = {}) {
const kind = getArchiveKind(archivePath);
if (!kind) {
throw new Error(`Unsupported archive type: ${getArchiveBaseName(archivePath) || archivePath}`);
}
const { assertSafeRemotePath, shellQuotePath } = require("./scpShell.cjs");
const remotePath = assertSafeRemotePath(archivePath);
const parent = posixParentDir(remotePath);
const qArchive = shellQuotePath(remotePath, encoding);
const qParent = shellQuotePath(parent, encoding);
if (kind === "tar") return `tar -xf ${qArchive} -C ${qParent}`;
if (kind === "tar.gz") return `tar -xzf ${qArchive} -C ${qParent}`;
if (kind === "tar.bz2") return `tar -xjf ${qArchive} -C ${qParent}`;
if (kind === "tar.xz") return `tar -xJf ${qArchive} -C ${qParent}`;
if (kind === "tar.zst") return `tar --zstd -xf ${qArchive} -C ${qParent}`;
if (kind === "zip") {
return [
`if command -v unzip >/dev/null 2>&1; then`,
` unzip -qo ${qArchive} -d ${qParent} >/dev/null`,
`elif tar -tf ${qArchive} >/dev/null 2>&1; then`,
` tar -xf ${qArchive} -C ${qParent}`,
`else`,
` echo 'unzip is not installed on the remote host' >&2`,
` exit 127`,
`fi`,
].join("\n");
}
const outputPath = stripCompressionSuffix(remotePath, kind);
const decoder = kind === "gz" ? "gzip" : kind === "bz2" ? "bzip2" : kind === "xz" ? "xz" : null;
if (!decoder) throw new Error(`Unsupported archive type: ${kind}`);
return buildSingleFileExtractCommand(decoder, qArchive, outputPath, encoding);
}
function buildSingleFileExtractCommand(decoder, qArchive, outputPath, encoding) {
const { shellQuotePath } = require("./scpShell.cjs");
const qOut = shellQuotePath(outputPath, encoding);
return [
"set -e",
`out=${qOut}`,
`archive=${qArchive}`,
"n=0",
"stage=",
"while [ \"$n\" -lt 32 ]; do",
" candidate=\"$out.netcatty-extract.$$.$n\"",
" if (umask 077; set -C; : > \"$candidate\") 2>/dev/null; then",
" stage=\"$candidate\"",
" break",
" fi",
" n=$((n + 1))",
"done",
"if [ -z \"$stage\" ]; then",
" echo 'could not allocate extraction staging file' >&2",
" exit 1",
"fi",
"trap 'rm -f -- \"$stage\"' EXIT",
`${decoder} -dc -- "$archive" > "$stage"`,
"if [ -d \"$out\" ]; then",
" echo 'extraction target is a directory' >&2",
" exit 1",
"fi",
"mv -f -- \"$stage\" \"$out\"",
"trap - EXIT",
].join("\n");
}
function tarArgs(flag, archivePath, parentDir) {
return flag ? [flag, archivePath, "-C", parentDir] : ["-xf", archivePath, "-C", parentDir];
}
function buildLocalExtractPlan(archivePath, platform = process.platform) {
const kind = getArchiveKind(archivePath);
if (!kind) {
throw new Error(`Unsupported archive type: ${path.basename(archivePath) || archivePath}`);
}
const parentDir = path.dirname(archivePath);
if (kind === "tar") return { command: "tar", args: tarArgs("-xf", archivePath, parentDir) };
if (kind === "tar.gz") return { command: "tar", args: tarArgs("-xzf", archivePath, parentDir) };
if (kind === "tar.bz2") return { command: "tar", args: tarArgs("-xjf", archivePath, parentDir) };
if (kind === "tar.xz") return { command: "tar", args: tarArgs("-xJf", archivePath, parentDir) };
if (kind === "tar.zst") return { command: "tar", args: ["--zstd", "-xf", archivePath, "-C", parentDir] };
if (kind === "zip") {
if (platform === "win32") {
return { command: "tar", args: ["-xf", archivePath, "-C", parentDir] };
}
return {
command: "unzip",
args: ["-qo", archivePath, "-d", parentDir],
fallback: { command: "tar", args: ["-xf", archivePath, "-C", parentDir] },
};
}
const outputPath = path.join(parentDir, path.basename(stripCompressionSuffix(archivePath.replace(/\\/g, "/"), kind)));
if (kind === "gz") return { builtin: "gunzip", archivePath, stdoutFile: outputPath };
if (kind === "bz2") return { command: "bzip2", args: ["-dc", archivePath], stdoutFile: outputPath };
if (kind === "xz") return { command: "xz", args: ["-dc", archivePath], stdoutFile: outputPath };
throw new Error(`Unsupported archive type: ${kind}`);
}
function isMissingBinaryError(error) {
const code = error?.code;
return code === "ENOENT" || code === 127 || code === "127";
}
async function allocateLocalStagingFile(destFile) {
for (let i = 0; i < 32; i += 1) {
const stagingFile = `${destFile}.netcatty-extract.${crypto.randomBytes(6).toString("hex")}`;
try {
const handle = await fs.promises.open(stagingFile, "wx");
await handle.close();
return stagingFile;
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
}
throw new Error("Could not allocate extraction staging file");
}
async function replaceLocalFile(stagingFile, destFile) {
try {
const destStat = await fs.promises.lstat(destFile);
if (destStat.isDirectory()) {
throw new Error("Extraction target is a directory");
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
try {
await fs.promises.rename(stagingFile, destFile);
} catch (error) {
if (error?.code === "EEXIST" || error?.code === "EPERM") {
const backupFile = `${destFile}.netcatty-backup.${crypto.randomBytes(6).toString("hex")}`;
await fs.promises.rename(destFile, backupFile);
try {
await fs.promises.rename(stagingFile, destFile);
} catch (publishError) {
try {
await fs.promises.rename(backupFile, destFile);
} catch {
/* keep the backup file if restore also fails */
}
throw publishError;
}
await fs.promises.unlink(backupFile).catch(() => {});
return;
}
try {
await fs.promises.copyFile(stagingFile, destFile);
} finally {
await fs.promises.unlink(stagingFile).catch(() => {});
}
}
}
function collectBoundedStderr(stream) {
let stderr = "";
stream.on("data", (chunk) => {
if (stderr.length >= EXTRACT_MAX_OUTPUT_BYTES) return;
stderr += String(chunk);
if (stderr.length > EXTRACT_MAX_OUTPUT_BYTES) {
stderr = stderr.slice(0, EXTRACT_MAX_OUTPUT_BYTES);
}
});
return () => stderr;
}
async function gunzipLocalFile(archivePath, stdoutFile, timeoutMs) {
const stagingFile = await allocateLocalStagingFile(stdoutFile);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
timer.unref?.();
try {
await pipeline(
fs.createReadStream(archivePath),
zlib.createGunzip(),
fs.createWriteStream(stagingFile),
{ signal: ac.signal },
);
await replaceLocalFile(stagingFile, stdoutFile);
} catch (error) {
await fs.promises.unlink(stagingFile).catch(() => {});
if (error?.name === "AbortError") {
throw new Error(`Local extraction timed out after ${timeoutMs} ms`);
}
const detail = error?.message ? `: ${error.message}` : "";
throw new Error(`Local extraction failed${detail}`);
} finally {
clearTimeout(timer);
}
}
async function pipeCommandToFile(command, args, stdoutFile, timeoutMs) {
const stagingFile = await allocateLocalStagingFile(stdoutFile);
await new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
const out = fs.createWriteStream(stagingFile);
const readStderr = collectBoundedStderr(child.stderr);
let settled = false;
const timer = setTimeout(() => {
finish(new Error(`Local extraction timed out after ${timeoutMs} ms`));
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
timer.unref?.();
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { out.destroy(); } catch { /* ignore */ }
if (error) {
fs.promises.unlink(stagingFile).catch(() => {});
reject(error);
return;
}
resolve();
};
child.stdout.pipe(out);
out.on("error", (error) => finish(error));
child.on("error", (error) => finish(error));
child.on("close", (code) => {
if (code === 0) {
out.end(() => finish(null));
return;
}
const stderr = readStderr();
finish(new Error(
`Local extraction failed (code ${code})${stderr ? `: ${stderr.trim()}` : ""}`,
));
});
});
try {
await replaceLocalFile(stagingFile, stdoutFile);
} catch (error) {
await fs.promises.unlink(stagingFile).catch(() => {});
throw error;
}
}
function runSpawnedExtract(command, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
const readStderr = collectBoundedStderr(child.stderr);
let settled = false;
const timer = setTimeout(() => {
finish(new Error(`Local extraction timed out after ${timeoutMs} ms`));
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
timer.unref?.();
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
child.stdout.on("data", () => {});
child.on("error", (error) => finish(error));
child.on("close", (code) => {
if (code === 0) {
finish(null);
return;
}
const stderr = readStderr();
finish(new Error(
`Local extraction failed (code ${code})${stderr ? `: ${stderr.trim()}` : ""}`,
));
});
});
}
async function runLocalExtractCommand(command, args, stdoutFile, timeoutMs) {
if (stdoutFile) {
await pipeCommandToFile(command, args, stdoutFile, timeoutMs);
return;
}
await runSpawnedExtract(command, args, timeoutMs);
}
async function executeLocalExtractPlan(plan, { timeoutMs = EXTRACT_MAX_TIMEOUT_MS } = {}) {
if (plan.builtin === "gunzip") {
await gunzipLocalFile(plan.archivePath, plan.stdoutFile, timeoutMs);
return;
}
try {
await runLocalExtractCommand(plan.command, plan.args, plan.stdoutFile, timeoutMs);
} catch (error) {
if (plan.fallback && isMissingBinaryError(error)) {
await runLocalExtractCommand(
plan.fallback.command,
plan.fallback.args,
plan.fallback.stdoutFile,
timeoutMs,
);
return;
}
throw error;
}
}
async function extractLocalArchiveFile(archivePath) {
if (typeof archivePath !== "string" || !archivePath) {
throw new Error("Archive path is required");
}
const stat = await fs.promises.stat(archivePath);
if (stat.isDirectory()) {
throw new Error("Cannot extract a directory");
}
const plan = buildLocalExtractPlan(archivePath);
await executeLocalExtractPlan(plan, { timeoutMs: computeExtractTimeoutMs(stat.size) });
return { success: true };
}
module.exports = {
ARCHIVE_KINDS,
EXTRACT_OPEN_TIMEOUT_MS,
EXTRACT_MAX_TIMEOUT_MS,
EXTRACT_MAX_OUTPUT_BYTES,
getArchiveKind,
isExtractableArchive,
posixParentDir,
stripCompressionSuffix,
computeExtractTimeoutMs,
buildExtractCommand,
buildLocalExtractPlan,
executeLocalExtractPlan,
extractLocalArchiveFile,
};

View File

@@ -0,0 +1,188 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
getArchiveKind,
isExtractableArchive,
posixParentDir,
stripCompressionSuffix,
computeExtractTimeoutMs,
buildExtractCommand,
buildLocalExtractPlan,
EXTRACT_MAX_TIMEOUT_MS,
} = require("./archiveExtract.cjs");
test("detects compound archive suffixes before single-file compression", () => {
assert.equal(getArchiveKind("backup.tar.gz"), "tar.gz");
assert.equal(getArchiveKind("/var/a.tgz"), "tar.gz");
assert.equal(getArchiveKind("logs.tar.bz2"), "tar.bz2");
assert.equal(getArchiveKind("src.tar.xz"), "tar.xz");
assert.equal(getArchiveKind("app.tar"), "tar");
assert.equal(getArchiveKind("payload.zip"), "zip");
assert.equal(getArchiveKind("notes.txt.gz"), "gz");
assert.equal(getArchiveKind("notes.txt"), null);
assert.equal(getArchiveKind(".gz"), null);
assert.equal(isExtractableArchive("bundle.tgz"), true);
assert.equal(isExtractableArchive("readme.md"), false);
});
test("posix parent and gzip output stay in the archive directory", () => {
assert.equal(posixParentDir("/home/app/a.tar.gz"), "/home/app");
assert.equal(posixParentDir("/a.zip"), "/");
assert.equal(stripCompressionSuffix("/home/app/notes.txt.gz", "gz"), "/home/app/notes.txt");
assert.equal(stripCompressionSuffix("/notes.txt.gz", "gz"), "/notes.txt");
});
test("extract commands quote spaces and single quotes", () => {
const tarCmd = buildExtractCommand("/tmp/my files/app's.tgz");
assert.match(tarCmd, /tar -xzf '/);
assert.match(tarCmd, /'\/tmp\/my files\/app'\\''s\.tgz'/);
assert.match(tarCmd, /-C '\/tmp\/my files'/);
const zipCmd = buildExtractCommand("/opt/build/out.zip");
assert.match(zipCmd, /unzip -qo '\/opt\/build\/out\.zip' -d '\/opt\/build' >\/dev\/null/);
assert.match(zipCmd, /tar -xf '\/opt\/build\/out\.zip' -C '\/opt\/build'/);
const gzCmd = buildExtractCommand("/var/log/syslog.gz");
assert.match(gzCmd, /out='\/var\/log\/syslog'/);
assert.match(gzCmd, /archive='\/var\/log\/syslog\.gz'/);
assert.match(gzCmd, /trap 'rm -f -- "\$stage"' EXIT/);
assert.match(gzCmd, /gzip -dc -- "\$archive" > "\$stage"/);
assert.match(gzCmd, /if \[ -d "\$out" \]/);
assert.match(gzCmd, /mv -f -- "\$stage" "\$out"/);
const spacedGz = buildExtractCommand("/tmp/my file.txt.gz");
assert.match(spacedGz, /out='\/tmp\/my file\.txt'/);
assert.match(spacedGz, /archive='\/tmp\/my file\.txt\.gz'/);
assert.doesNotMatch(spacedGz, /trap 'rm -f -- '/);
});
test("generated gzip extract script can install its EXIT trap with spaces in the path", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-trap-"));
const archive = path.join(dir, "my file.txt.gz");
fs.writeFileSync(archive, "x");
const cmd = buildExtractCommand(archive);
const prefix = [];
for (const line of cmd.split("\n")) {
prefix.push(line);
if (line.startsWith("trap ")) break;
}
prefix.push("trap - EXIT");
prefix.push("rm -f -- \"$stage\"");
const ran = spawnSync("sh", ["-c", prefix.join("\n")], { encoding: "utf8" });
assert.equal(ran.status, 0, ran.stderr);
fs.rmSync(dir, { recursive: true, force: true });
});
test("extract command rejects newlines and unknown types", () => {
assert.throws(() => buildExtractCommand("/tmp/bad\n.tar.gz"), /NUL or newlines/);
assert.throws(() => buildExtractCommand("/tmp/notes.txt"), /Unsupported archive type/);
});
test("local extract plan uses unzip with tar fallback off Windows", () => {
const unixZip = buildLocalExtractPlan("/tmp/a.zip", "linux");
assert.deepEqual(unixZip.command, "unzip");
assert.deepEqual(unixZip.args, ["-qo", "/tmp/a.zip", "-d", "/tmp"]);
assert.deepEqual(unixZip.fallback, { command: "tar", args: ["-xf", "/tmp/a.zip", "-C", "/tmp"] });
const winZip = buildLocalExtractPlan("C:\\tmp\\a.zip", "win32");
assert.equal(winZip.command, "tar");
assert.ok(winZip.args.includes("-xf"));
const gz = buildLocalExtractPlan("/tmp/notes.txt.gz", "win32");
assert.equal(gz.builtin, "gunzip");
assert.equal(gz.archivePath, "/tmp/notes.txt.gz");
assert.equal(gz.stdoutFile, "/tmp/notes.txt");
});
test("unknown archive size uses the maximum extract timeout", () => {
assert.equal(computeExtractTimeoutMs(undefined), EXTRACT_MAX_TIMEOUT_MS);
assert.ok(computeExtractTimeoutMs(1024) < EXTRACT_MAX_TIMEOUT_MS);
});
test("extractLocalArchiveFile unpacks a tar.gz next to the archive", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-"));
const source = path.join(dir, "hello.txt");
const archive = path.join(dir, "hello.tgz");
fs.writeFileSync(source, "hello-extract");
const packed = spawnSync("tar", ["-czf", archive, "-C", dir, "hello.txt"], { encoding: "utf8" });
assert.equal(packed.status, 0, packed.stderr);
fs.unlinkSync(source);
await extractLocalArchiveFile(archive);
assert.equal(fs.readFileSync(source, "utf8"), "hello-extract");
fs.rmSync(dir, { recursive: true, force: true });
});
test("failed gzip extract leaves an existing sibling file intact", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-keep-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
fs.writeFileSync(dest, "keep-me");
fs.writeFileSync(archive, "this-is-not-gzip");
await assert.rejects(() => extractLocalArchiveFile(archive), /Local extraction failed/);
assert.equal(fs.readFileSync(dest, "utf8"), "keep-me");
assert.deepEqual(
fs.readdirSync(dir).filter((name) => name.includes(".netcatty-extract")),
[],
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("extractLocalArchiveFile gunzips with Node zlib and keeps a sibling staging name", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-gz-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
const sibling = `${dest}.netcatty-extract`;
fs.writeFileSync(sibling, "do-not-touch");
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
await extractLocalArchiveFile(archive);
assert.equal(fs.readFileSync(dest, "utf8"), "hello-gzip");
assert.equal(fs.readFileSync(sibling, "utf8"), "do-not-touch");
fs.rmSync(dir, { recursive: true, force: true });
});
test("failed gzip extract leaves a sibling directory target intact", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-dir-"));
const dest = path.join(dir, "notes.txt");
const archive = path.join(dir, "notes.txt.gz");
fs.mkdirSync(dest);
fs.writeFileSync(path.join(dest, "keep.txt"), "inside");
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
await assert.rejects(() => extractLocalArchiveFile(archive), /directory/);
assert.equal(fs.readFileSync(path.join(dest, "keep.txt"), "utf8"), "inside");
fs.rmSync(dir, { recursive: true, force: true });
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
function createExtractApi({ execImpl, clients } = {}) {
const commands = [];
const optionsLog = [];
const api = createFileOpsApi({
sftpClients: clients || new Map([
["sftp-1", { client: { exec() {} } }],
]),
resolveEncodingForRequest: () => "utf-8",
throwIfAborted: () => {},
encodePath: (value) => value,
requireSftpChannel: async () => ({}),
lstatAsync: async () => ({ size: 1024 }),
isScpModeClient: () => false,
execRemoteShellCommand: async (_ssh, command, options) => {
commands.push(command);
optionsLog.push(options);
if (typeof execImpl === "function") return execImpl(command);
return { stdout: "", stderr: "", code: 0 };
},
});
return { api, commands, optionsLog };
}
test("extractSftpArchive runs a quoted remote tar command", async () => {
const { api, commands, optionsLog } = createExtractApi();
const result = await api.extractSftpArchive(null, {
sftpId: "sftp-1",
path: "/home/app/backup.tar.gz",
});
assert.deepEqual(result, { success: true });
assert.equal(commands.length, 1);
assert.match(commands[0], /tar -xzf '\/home\/app\/backup\.tar\.gz' -C '\/home\/app'/);
assert.equal(optionsLog[0].discardStdout, true);
});
test("extractSftpArchive rejects unsupported files", async () => {
const { api, commands } = createExtractApi();
await assert.rejects(
() => api.extractSftpArchive(null, { sftpId: "sftp-1", path: "/home/app/notes.txt" }),
/Unsupported archive type/,
);
assert.equal(commands.length, 0);
});
test("extractSftpArchive requires an open SFTP session", async () => {
const { api } = createExtractApi({ clients: new Map() });
await assert.rejects(
() => api.extractSftpArchive(null, { sftpId: "missing", path: "/tmp/a.zip" }),
/SFTP session not found/,
);
});

View File

@@ -0,0 +1,514 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
const channel = {};
const listed = [];
const api = createFileOpsApi({
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async (resolvedChannel, remotePath) => {
assert.equal(resolvedChannel, channel);
assert.equal(remotePath, ".");
return "/";
},
readdirAsync: async (resolvedChannel, remotePath) => {
listed.push([resolvedChannel, remotePath]);
return [{ filename: "data" }];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
assert.deepEqual(result, { success: true, homeDir: "/" });
assert.deepEqual(listed, [[channel, "/"]]);
});
test("home discovery rejects non-listable root so candidate probing can run", async () => {
const channel = {};
const api = createFileOpsApi({
sftpClients: new Map([["restricted", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/",
readdirAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
assert.equal(result.success, false);
assert.match(result.error || "", /Could not determine home directory/);
});
test("home discovery still accepts non-root realpath without listing", async () => {
const channel = {};
let readdirCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["normal", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/home/deploy",
readdirAsync: async () => {
readdirCalls += 1;
return [];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
assert.equal(readdirCalls, 0);
});
test("statSftp follows symlinks and reports target size for resume sizing", async () => {
const channel = { stat() {}, lstat() {} };
let lstatCalls = 0;
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
lstatCalls += 1;
return {
size: 11,
mode: 0o120777,
mtime: 10,
isDirectory: () => false,
isSymbolicLink: () => true,
};
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
const result = await api.statSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
});
assert.equal(statCalls, 1);
assert.equal(lstatCalls, 0, "shared stat must follow for resume/sizing");
assert.equal(result.type, "file");
assert.equal(result.size, 42);
});
test("lstatSftp classifies symlinks without following the target", async () => {
const channel = { lstat() {} };
let lstatCalls = 0;
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
lstatCalls += 1;
return {
size: 11,
mode: 0o120777,
mtime: 10,
isDirectory: () => false,
isSymbolicLink: () => true,
};
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
const result = await api.lstatSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
});
assert.equal(lstatCalls, 1);
assert.equal(statCalls, 0, "must not follow the symlink with STAT");
assert.equal(result.type, "symlink");
assert.equal(result.size, 11);
});
test("lstatSftp refuses followed STAT when LSTAT is unsupported", async () => {
const channel = { lstat() {}, stat() {} };
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("SSH_FX_OP_UNSUPPORTED");
error.code = 8;
throw error;
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
(error) => {
assert.equal(error.code, "ENOTSUP");
assert.equal(error.lstatUnavailable, true);
assert.match(String(error.message), /does not support LSTAT/i);
return true;
},
);
assert.equal(statCalls, 0, "must not classify via followed STAT");
});
test("lstatSftp returns null for SSH_FX_NO_SUCH_FILE even with a localized message", async () => {
const channel = { lstat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("File not found");
error.code = 2;
throw error;
},
});
const result = await api.lstatSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/new-file.sh",
});
assert.equal(result, null);
});
test("lstatSftp still throws permission errors on an existing path", async () => {
const channel = { lstat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/root/secret" }),
(error) => {
assert.equal(error.code, "EACCES");
assert.match(String(error.message), /Permission denied/);
return true;
},
);
});
test("lstatSftp refuses a channel without native LSTAT", async () => {
const channel = { stat() {} };
let lstatCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
lstatAsync: async () => { lstatCalls += 1; },
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
(error) => {
assert.equal(error.code, "ENOTSUP");
assert.equal(error.lstatUnavailable, true);
return true;
},
);
assert.equal(lstatCalls, 0, "must not call the followed-stat fallback");
});
test("expected symlink delete refuses a replacement file", async () => {
const channel = { lstat() {} };
let unlinkCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => ({
size: 4,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
unlinkAsync: async () => { unlinkCalls += 1; },
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ESTALE");
return true;
},
);
assert.equal(unlinkCalls, 0);
});
test("SCP expected symlink delete stays non-recursive after the type check", async () => {
let unlinkCalls = 0;
let removeCalls = 0;
const backend = {
async stat() {
return { isDirectory: false, isSymbolicLink: true };
},
async unlink(remotePath, options) {
unlinkCalls += 1;
assert.equal(remotePath, "/usr/local/bin/tool");
assert.equal(options.encoding, "utf-8");
},
async remove() {
removeCalls += 1;
},
};
const client = {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
};
const api = createFileOpsApi({
sftpClients: new Map([["scp-1", client]]),
throwIfAborted() {},
resolveEncodingForRequest: () => "utf-8",
});
await api.deleteSftp(null, {
sftpId: "scp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
});
assert.equal(unlinkCalls, 1);
assert.equal(removeCalls, 0);
});
test("expected symlink delete refuses a channel without native LSTAT", async () => {
const channel = { stat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ENOTSUP");
return true;
},
);
});
test("non-UTF-8 expected symlink delete refuses a replacement file", async () => {
const channel = { lstat() {} };
let removeCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "gb18030",
normalizeRemotePathString: async (_client, remotePath) => remotePath,
encodePath: (remotePath) => Buffer.from(remotePath),
lstatAsync: async () => ({
size: 4,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
removeRemotePathInternal: async () => { removeCalls += 1; },
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/remote/tool",
encoding: "gb18030",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ESTALE");
return true;
},
);
assert.equal(removeCalls, 0);
});
test("non-UTF-8 expected symlink delete stays non-recursive after the type check", async () => {
const channel = { lstat() {} };
let unlinkCalls = 0;
let removeCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "gb18030",
normalizeRemotePathString: async (_client, remotePath) => remotePath,
encodePath: (remotePath) => Buffer.from(remotePath),
lstatAsync: async () => ({
size: 4,
mode: 0o120777,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => true,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
unlinkAsync: async (_sftp, encodedPath) => {
unlinkCalls += 1;
assert.equal(encodedPath.toString(), "/remote/tool");
},
removeRemotePathInternal: async () => { removeCalls += 1; },
});
await api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/remote/tool",
encoding: "gb18030",
expectedType: "symlink",
});
assert.equal(unlinkCalls, 1);
assert.equal(removeCalls, 0);
});
test("listSftp includes owner from longname and falls back to uid", async () => {
const channel = {
readdir(_path, callback) {
callback(null, [
{
filename: "root.txt",
longname: "-rw-r--r-- 1 root root 12 Jan 1 00:00 root.txt",
attrs: {
size: 12,
mtime: 1700000000,
uid: 0,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
},
},
{
filename: "uid-only.bin",
longname: "",
attrs: {
size: 1,
mtime: 1700000000,
uid: 1000,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
},
},
]);
},
};
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
normalizeEncoding: (value) => value || "utf-8",
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
requireSftpChannel: async () => channel,
detectEncodingFromList: () => "utf-8",
updateResolvedEncoding: (_id, _req, detected) => detected,
decodeName: (raw) => (raw ? Buffer.from(raw).toString("utf8") : ""),
isAsciiString: () => true,
sftpEncodingState: new Map(),
});
const entries = await api.listSftp(null, { sftpId: "sftp-1", path: "/home" });
assert.equal(entries[0].name, "root.txt");
assert.equal(entries[0].owner, "root");
assert.equal(entries[1].name, "uid-only.bin");
assert.equal(entries[1].owner, "1000");
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
function makeExecStream() {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = () => {};
stream.close = () => {};
stream.end = () => {};
stream.destroy = () => {};
return stream;
}
function loadSftpBridgeWithFailingFreshSudo(t) {
const bridgePath = require.resolve("../sftpBridge.cjs");
const openConnectionPath = require.resolve("./openConnection.cjs");
const authHelperPath = require.resolve("../sshAuthHelper.cjs");
const realAuthHelper = require(authHelperPath);
const originalLoad = Module._load;
class MockSftpClient extends EventEmitter {
constructor() {
super();
MockSftpClient.instances.push(this);
this.sftp = null;
this.client = new EventEmitter();
this.client._sock = { setTimeout() {} };
this.client.setMaxListeners = () => {};
this.client.connect = () => {
setImmediate(() => {
this.client.emit("connect");
this.client.emit("handshake");
this.client.emit("ready");
});
};
this.client.exec = (command, execOptions, callback) => {
const done = typeof execOptions === "function" ? execOptions : callback;
this.execCommands.push(command);
const stream = makeExecStream();
done(null, stream);
setImmediate(() => {
if (command.startsWith("test -x ")) stream.emit("close", 1);
else stream.emit("exit", 127);
});
};
this.client.sftp = (callback) => {
this.standardSftpCalls += 1;
callback(null, new EventEmitter());
};
this.client.end = () => { this.sshEnded = true; };
this.client.destroy = () => { this.sshDestroyed = true; };
this.execCommands = [];
this.standardSftpCalls = 0;
this.sshEnded = false;
this.sshDestroyed = false;
}
end() {
this.highLevelEnded = true;
}
}
MockSftpClient.instances = [];
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2-sftp-client") return MockSftpClient;
if (request === "./sshAuthHelper.cjs") {
return {
...realAuthHelper,
findAllDefaultPrivateKeys: async () => [],
getAvailableAgentSocket: async () => null,
prepareSystemSshAgentForAuth: async () => null,
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[openConnectionPath];
const bridge = require("../sftpBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[openConnectionPath];
Module._load = originalLoad;
});
return { bridge, MockSftpClient };
}
test("fresh sudo SFTP failure rejects without ordinary SFTP or SCP downgrade", async (t) => {
const { bridge, MockSftpClient } = loadSftpBridgeWithFailingFreshSudo(t);
const sftpClients = new Map();
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
await assert.rejects(
bridge.openSftp(
{ sender: { id: 1, isDestroyed: () => false, send: () => {} } },
{
sessionId: "fresh-sudo-failure",
hostname: "target.example",
port: 22,
username: "alice",
password: "secret",
authMethod: "password",
useSshAgent: false,
verifyHostKeys: false,
fileProtocol: "auto",
sudo: true,
},
),
/SFTP sudo failed with exit code 127/,
);
const client = MockSftpClient.instances[0];
assert.ok(client.execCommands.some((command) => command.startsWith("sudo -S")));
assert.equal(client.standardSftpCalls, 0);
assert.equal(sftpClients.size, 0);
assert.equal(client.sshEnded, true);
assert.equal(client.sshDestroyed, true);
});

View File

@@ -0,0 +1,988 @@
/**
* Drive the shipped downloadSftpToLocal / uploadLocalToSftp SCP branches with
* AbortSignal — the AI/MCP transfer path must cancel mid-flight, not only
* throwIfAborted before/after.
*/
"use strict";
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const sftpBridge = require("../sftpBridge.cjs");
const { createScpBackend } = require("./scpBackend.cjs");
function createMockStream() {
const ee = new EventEmitter();
ee.writable = true;
ee.readable = true;
ee.stderr = new EventEmitter();
ee.write = (buf, cb) => {
if (typeof cb === "function") cb();
return true;
};
ee.end = (cb) => { if (typeof cb === "function") cb(); };
ee.close = () => {
ee.closed = true;
ee.destroyed = true;
ee.emit("close");
};
ee.destroy = () => ee.close();
return ee;
}
describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () => {
let tmpDir;
let sftpClients;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-ai-abort-"));
sftpClients = new Map();
sftpBridge.init({
electronModule: {},
sessions: new Map(),
sftpClients,
});
});
afterEach(async () => {
// Aborted SCP transfers can still open the fixture briefly after the test
// assertion settles. Drain that work before removing the temp directory so
// Node does not promote a late ENOENT into a file-level failure.
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 25));
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
function registerScpClient(id, { hangOnStream = true } = {}) {
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
if (!hangOnStream) {
// ready ACK immediately for success paths (not used in abort tests)
setImmediate(() => stream.emit("data", Buffer.from([0])));
}
// hang: never ACK so waitForAck blocks until cancel
return stream;
},
});
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
const client = {
client: { exec: () => {} },
sftp: null,
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
async end() {},
};
sftpClients.set(id, client);
return client;
}
it("downloadSftpToLocal rejects when AbortSignal fires mid-SCP download", async () => {
registerScpClient("scp-dl");
const controller = new AbortController();
const localPath = path.join(tmpDir, "out.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const promise = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-dl",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
});
await new Promise((r) => setTimeout(r, 40));
controller.abort();
await assert.rejects(() => promise, /cancel|abort/i);
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("downloadSftpToLocal settles when abort closes SCP before parser listeners attach", async () => {
const controller = new AbortController();
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async (_command, options = {}) => {
const stream = createMockStream();
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
// Reproduce the transition race deterministically: the unified abort
// listener marks the transfer cancelled and this stream closes before
// downloadToWritable can attach its parser close/error listeners.
controller.abort();
return stream;
},
});
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
sftpClients.set("scp-transition-abort", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "transition-abort.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const download = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-transition-abort",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
});
await assert.rejects(
() => Promise.race([
download,
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
]),
/cancel|abort/i,
);
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("downloadSftpToLocal rejects when SCP closes before parser listeners attach", async () => {
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
stream.close();
return stream;
},
});
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
sftpClients.set("scp-transition-close", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "transition-close.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const download = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-transition-close",
remotePath: "/remote/file.bin",
localPath,
});
await assert.rejects(
() => Promise.race([
download,
new Promise((_, reject) => setTimeout(() => reject(new Error("close timed out")), 500)),
]),
/closed|protocol|channel/i,
);
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("readSftpBinary reports AbortSignal-only SCP cancellation as cancelled", async () => {
const controller = new AbortController();
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async (_command, options = {}) => {
const stream = createMockStream();
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
return stream;
},
});
sftpClients.set("scp-read-signal", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const read = sftpBridge.readSftpBinary(null, {
sftpId: "scp-read-signal",
path: "/remote/file.bin",
abortSignal: controller.signal,
});
setImmediate(() => controller.abort());
await assert.rejects(
() => Promise.race([
read,
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
]),
/cancel|abort/i,
);
});
it("downloadSftpToLocal preserves the destination when cancellation arrives after SCP download", async () => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
controller.abort();
},
};
sftpClients.set("scp-late-abort", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "late-abort.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-abort",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
/cancel|abort/i,
);
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("downloadSftpToLocal uses the SCP header size when downloading through a symlink", async () => {
const downloaded = Buffer.from("target-content-is-longer-than-link");
const backend = {
async stat() {
return { type: "symlink", isSymbolicLink: true, size: 4 };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-symlink-download", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "symlink-download.bin");
const result = await sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-symlink-download",
remotePath: "/remote/link",
localPath,
});
assert.equal(result.success, true);
assert.deepEqual(fs.readFileSync(localPath), downloaded);
});
it("downloadSftpToLocal preserves a local destination symlink", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("replacement-through-local-link");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-local-symlink", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const referentPath = path.join(tmpDir, "symlink-target.bin");
const localPath = path.join(tmpDir, "download-link.bin");
await fs.promises.writeFile(referentPath, Buffer.from("old-target-content"));
await fs.promises.symlink(path.basename(referentPath), localPath);
const result = await sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-local-symlink",
remotePath: "/remote/file.bin",
localPath,
});
assert.equal(result.success, true);
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
assert.equal(await fs.promises.readlink(localPath), path.basename(referentPath));
assert.deepEqual(await fs.promises.readFile(referentPath), downloaded);
});
it("downloadSftpToLocal preserves a multi-level broken local symlink chain", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("replacement-through-broken-link-chain");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-broken-link-chain", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const finalPath = path.join(tmpDir, "missing-final.bin");
const intermediatePath = path.join(tmpDir, "intermediate-link.bin");
const localPath = path.join(tmpDir, "download-link-chain.bin");
await fs.promises.symlink(path.basename(finalPath), intermediatePath);
await fs.promises.symlink(path.basename(intermediatePath), localPath);
const result = await sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-broken-link-chain",
remotePath: "/remote/file.bin",
localPath,
});
assert.equal(result.success, true);
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
assert.equal((await fs.promises.lstat(intermediatePath)).isSymbolicLink(), true);
assert.deepEqual(await fs.promises.readFile(finalPath), downloaded);
});
it("downloadSftpToLocal refuses a local symlink that points to a directory", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("must-not-replace-a-directory");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-directory-link", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const directoryPath = path.join(tmpDir, "preserved-directory");
const preservedPath = path.join(directoryPath, "keep.txt");
const localPath = path.join(tmpDir, "directory-link");
await fs.promises.mkdir(directoryPath);
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
await fs.promises.symlink(path.basename(directoryPath), localPath);
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-directory-link",
remotePath: "/remote/file.bin",
localPath,
}),
/not a regular file/i,
);
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
});
it("downloadSftpToLocal refuses an existing local directory", async () => {
const downloaded = Buffer.from("must-not-replace-a-directory");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-direct-directory", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const directoryPath = path.join(tmpDir, "direct-preserved-directory");
const preservedPath = path.join(directoryPath, "keep.txt");
await fs.promises.mkdir(directoryPath);
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-direct-directory",
remotePath: "/remote/file.bin",
localPath: directoryPath,
}),
/not a regular file/i,
);
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
});
it("downloadSftpToLocal refuses a local symbolic-link loop", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("must-not-enter-a-link-loop");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-link-loop", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const firstLink = path.join(tmpDir, "first-loop-link");
const secondLink = path.join(tmpDir, "second-loop-link");
await fs.promises.symlink(path.basename(secondLink), firstLink);
await fs.promises.symlink(path.basename(firstLink), secondLink);
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-link-loop",
remotePath: "/remote/file.bin",
localPath: firstLink,
}),
/symbolic-link loop/i,
);
assert.equal((await fs.promises.lstat(firstLink)).isSymbolicLink(), true);
assert.equal((await fs.promises.lstat(secondLink)).isSymbolicLink(), true);
});
it("downloadSftpToLocal allows a valid path to revisit one symlink", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("valid-repeated-link-path");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-repeated-link", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const nestedDir = path.join(tmpDir, "repeated-link-dir");
const upLink = path.join(nestedDir, "up");
await fs.promises.mkdir(nestedDir);
await fs.promises.symlink("..", upLink);
const localPath = path.join(upLink, path.basename(nestedDir), "up", "repeated-final.bin");
const result = await sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-repeated-link",
remotePath: "/remote/file.bin",
localPath,
});
assert.equal(result.success, true);
assert.deepEqual(await fs.promises.readFile(path.join(tmpDir, "repeated-final.bin")), downloaded);
assert.equal((await fs.promises.lstat(upLink)).isSymbolicLink(), true);
});
it("downloadSftpToLocal preserves the existing local file mode", {
skip: process.platform === "win32",
}, async () => {
const downloaded = Buffer.from("replacement-with-private-mode");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-local-mode", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "private-download.bin");
await fs.promises.writeFile(localPath, Buffer.from("private-old-content"), { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const result = await sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-local-mode",
remotePath: "/remote/file.bin",
localPath,
});
assert.equal(result.success, true);
assert.deepEqual(await fs.promises.readFile(localPath), downloaded);
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
});
it("downloadSftpToLocal stops when a parent symlink changes before promotion", {
skip: process.platform === "win32",
}, async (t) => {
const downloaded = Buffer.from("must-not-follow-the-new-parent");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-parent-link-change", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const firstDir = path.join(tmpDir, "first-parent");
const secondDir = path.join(tmpDir, "second-parent");
const parentLink = path.join(tmpDir, "parent-link");
const localPath = path.join(parentLink, "target.bin");
const firstOriginal = Buffer.from("first-original");
const secondOriginal = Buffer.from("second-original");
await fs.promises.mkdir(firstDir);
await fs.promises.mkdir(secondDir);
await fs.promises.writeFile(path.join(firstDir, "target.bin"), firstOriginal);
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
await fs.promises.symlink(path.basename(firstDir), parentLink);
const originalRename = fs.promises.rename;
let renameCalls = 0;
fs.promises.rename = async (...args) => {
await originalRename(...args);
renameCalls += 1;
if (renameCalls === 1) {
await fs.promises.unlink(parentLink);
await fs.promises.symlink(path.basename(secondDir), parentLink);
}
};
t.after(() => { fs.promises.rename = originalRename; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-parent-link-change",
remotePath: "/remote/file.bin",
localPath,
}),
/target changed before replacement/i,
);
assert.equal(await fs.promises.readlink(parentLink), path.basename(secondDir));
assert.deepEqual(await fs.promises.readFile(path.join(firstDir, "target.bin")), firstOriginal);
assert.deepEqual(await fs.promises.readFile(path.join(secondDir, "target.bin")), secondOriginal);
const leftovers = (await fs.promises.readdir(secondDir))
.filter((name) => name.includes(".netcatty-") || name.includes(".backup"));
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
skip: process.platform === "win32",
}, async (t) => {
const downloaded = Buffer.from("replacement-that-must-not-publish");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-mode-setup-failure", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "mode-setup-failure.bin");
const original = Buffer.from("private-original");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-mode-setup-failure",
remotePath: "/remote/file.bin",
localPath,
}),
/chmod failure/i,
);
assert.deepEqual(await fs.promises.readFile(localPath), original);
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
const leftovers = (await fs.promises.readdir(tmpDir))
.filter((name) => name.includes("mode-setup-failure.bin.netcatty-"));
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal rechecks the target after mode setup", {
skip: process.platform === "win32",
}, async (t) => {
const downloaded = Buffer.from("must-not-replace-late-directory");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-late-directory", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "late-directory-target");
const savedOriginalPath = path.join(tmpDir, "saved-late-original.bin");
const original = Buffer.from("original-before-late-change");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-directory",
remotePath: "/remote/file.bin",
localPath,
});
await chmodStarted;
await fs.promises.rename(localPath, savedOriginalPath);
await fs.promises.mkdir(localPath);
releaseChmod();
await assert.rejects(() => running, /not a regular file/i);
assert.equal((await fs.promises.stat(localPath)).isDirectory(), true);
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
const leftovers = (await fs.promises.readdir(tmpDir))
.filter((name) => name.includes("late-directory-target.netcatty-"));
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal does not overwrite a same-mode file that appears during mode setup", {
skip: process.platform === "win32",
}, async (t) => {
const downloaded = Buffer.from("must-not-replace-a-new-owner-file");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
return { fileSize: downloaded.length, transferred: downloaded.length };
},
};
sftpClients.set("scp-same-mode-replacement", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "same-mode-replacement.bin");
const savedOriginalPath = path.join(tmpDir, "saved-same-mode-original.bin");
const original = Buffer.from("original-before-replacement");
const newOwnerContent = Buffer.from("new-owner-content");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-same-mode-replacement",
remotePath: "/remote/file.bin",
localPath,
});
await chmodStarted;
await fs.promises.rename(localPath, savedOriginalPath);
await fs.promises.writeFile(localPath, newOwnerContent, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
releaseChmod();
await assert.rejects(() => running, /target changed before replacement/i);
assert.deepEqual(await fs.promises.readFile(localPath), newOwnerContent);
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
const leftovers = (await fs.promises.readdir(tmpDir))
.filter((name) => name.includes("same-mode-replacement.bin.netcatty-"));
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal restores the destination when cancellation arrives during promotion", async (t) => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
},
};
sftpClients.set("scp-promotion-abort", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "promotion-abort.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const originalRename = fs.promises.rename;
let renameCalls = 0;
fs.promises.rename = async (...args) => {
await originalRename(...args);
renameCalls += 1;
if (renameCalls === 2) controller.abort();
};
t.after(() => { fs.promises.rename = originalRename; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-promotion-abort",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
/cancel|abort/i,
);
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
},
};
sftpClients.set("scp-final-rename-abort", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "final-rename-abort.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const originalLink = fs.promises.link;
let published = false;
fs.promises.link = async (...args) => {
await originalLink(...args);
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
published = true;
controller.abort();
}
};
t.after(() => { fs.promises.link = originalLink; });
await assert.doesNotReject(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-final-rename-abort",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
);
assert.equal(published, true);
assert.deepEqual(fs.readFileSync(localPath), downloaded);
});
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
},
};
sftpClients.set("scp-rollback-failure", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "rollback-failure.bin");
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const originalRename = fs.promises.rename;
const originalLink = fs.promises.link;
let backupPath = null;
fs.promises.rename = async (...args) => {
await originalRename(...args);
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
backupPath = args[1];
controller.abort();
}
};
fs.promises.link = async (...args) => {
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
return originalLink(...args);
};
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-rollback-failure",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
(error) => {
assert.match(error.message, /Recovery files preserved/);
assert.match(error.message, /Backup:/);
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
return true;
},
);
assert.ok(backupPath);
assert.deepEqual(fs.readFileSync(backupPath), original);
});
it("downloadSftpToLocal reports both recovery files when pre-publish restoration fails", async (t) => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
async stat() {
return { type: "file", isDirectory: false, size: downloaded.length };
},
async downloadFile(_remotePath, localPath) {
await fs.promises.writeFile(localPath, downloaded);
},
};
sftpClients.set("scp-pre-publish-rollback-failure", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "pre-publish-rollback-failure.bin");
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
const originalRename = fs.promises.rename;
let renameCalls = 0;
let readyPath = null;
let backupPath = null;
fs.promises.rename = async (...args) => {
renameCalls += 1;
await originalRename(...args);
if (renameCalls === 1) readyPath = args[1];
if (renameCalls === 2) {
backupPath = args[1];
controller.abort();
}
};
const originalLink = fs.promises.link;
fs.promises.link = async (...args) => {
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
return originalLink(...args);
};
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-pre-publish-rollback-failure",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
(error) => {
assert.match(error.message, /Recovery files preserved/);
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
return true;
},
);
assert.equal(fs.existsSync(readyPath), true);
assert.equal(fs.existsSync(backupPath), true);
});
it("uploadLocalToSftp rejects when AbortSignal fires mid-SCP upload", async () => {
registerScpClient("scp-up");
const localPath = path.join(tmpDir, "in.bin");
fs.writeFileSync(localPath, Buffer.alloc(256, 9));
const controller = new AbortController();
const promise = sftpBridge.uploadLocalToSftp(null, {
sftpId: "scp-up",
localPath,
remotePath: "/remote/in.bin",
abortSignal: controller.signal,
});
await new Promise((r) => setTimeout(r, 40));
controller.abort();
await assert.rejects(() => promise, /cancel|abort/i);
});
it("uploadLocalToSftp aborts while SCP target inspection is still pending", async () => {
let uploadCalls = 0;
const backend = {
async stat(_remotePath, options = {}) {
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 uploadFile() {
uploadCalls += 1;
},
};
sftpClients.set("scp-setup-abort", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
});
const localPath = path.join(tmpDir, "setup.bin");
fs.writeFileSync(localPath, Buffer.alloc(16, 1));
const controller = new AbortController();
const startedAt = Date.now();
const promise = sftpBridge.uploadLocalToSftp(null, {
sftpId: "scp-setup-abort",
localPath,
remotePath: "/remote/setup.bin",
abortSignal: controller.signal,
});
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(() => promise, /cancel|abort/i);
assert.equal(uploadCalls, 0);
assert.ok(Date.now() - startedAt < 1000);
// Keep the fixture alive until any in-flight open from the aborted setup
// path has a chance to settle against the still-present file.
await new Promise((resolve) => setTimeout(resolve, 50));
});
it("legacy entry points delegate cancellation to the unified transfer engine", () => {
const src = fs.readFileSync(path.join(__dirname, "../sftpBridge.cjs"), "utf8");
assert.doesNotMatch(src, /cancelledFlag/);
assert.match(
src,
/async function downloadSftpToLocal\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "download"\);\s*\}/,
);
assert.match(
src,
/async function uploadLocalToSftp\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "upload"\);\s*\}/,
);
const unifiedIdx = src.indexOf("async function runUnifiedSftpTransfer");
const unifiedBlock = src.slice(unifiedIdx, src.indexOf("async function downloadSftpToLocal", unifiedIdx));
assert.match(unifiedBlock, /transferBridge\.startTransfer/);
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,701 @@
"use strict";
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const { Readable } = require("node:stream");
const {
createScpBackend,
createSshExecAdapters,
createTransferFromAbortSignal,
} = require("./scpBackend.cjs");
const {
buildFileControlLine,
buildAck,
SCP_OK,
} = require("./scpProtocol.cjs");
function createMockStream() {
const ee = new EventEmitter();
ee.writable = true;
ee.readable = true;
ee.stderr = new EventEmitter();
ee._chunks = [];
ee.write = (buf, cb) => {
ee._chunks.push(Buffer.from(buf));
ee.emit("_write", Buffer.from(buf));
if (typeof cb === "function") cb();
return true;
};
ee.end = (cb) => {
ee.emit("end");
if (typeof cb === "function") cb();
};
ee.close = () => {
ee.emit("close");
};
ee.destroy = () => {
ee.emit("close");
};
ee.pushFromRemote = (buf) => {
ee.emit("data", Buffer.from(buf));
};
return ee;
}
describe("scpBackend browse/manage with fake exec", () => {
let commands;
let backend;
beforeEach(() => {
commands = [];
backend = createScpBackend({
exec: async (command) => {
commands.push({ type: "exec", command });
if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("rm ") || command.includes("rmdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("mv ")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("chmod ")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("$HOME") || command.includes('printf "B64:"') || command.includes('printf "RAW:')) {
const b64 = Buffer.from("/home/test", "utf8").toString("base64");
return { stdout: `B64:${b64}\n`, stderr: "", code: 0 };
}
if (command.includes("for f in")) {
const name = Buffer.from("readme.txt").toString("base64");
const dir = Buffer.from("docs").toString("base64");
return {
stdout: `f|-rw-r--r--|5|1700000000|${name}\nd|drwxr-xr-x|0|1700000001|${dir}\n`,
stderr: "",
code: 0,
};
}
if (command.includes("wc -c") || command.includes("if [ ! -e")) {
// stat command
return {
stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt\n",
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 0 };
},
execStream: async (command) => {
commands.push({ type: "execStream", command });
const stream = createMockStream();
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
const text = buf.toString("utf8");
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
return stream;
},
});
});
it("lists directory entries via shell", async () => {
const entries = await backend.list("/home/test");
assert.equal(entries.length, 2);
assert.equal(entries[0].name, "readme.txt");
assert.equal(entries[0].type, "file");
assert.equal(entries[1].name, "docs");
assert.equal(entries[1].type, "directory");
assert.ok(commands.some((c) => c.command.includes("cd '/home/test'")));
});
it("resolves symlink linkTarget so directory links are navigable", async () => {
backend = createScpBackend({
exec: async (command) => {
commands.push({ type: "exec", command });
if (command.includes("for f in") || command.includes("cd ")) {
const link = Buffer.from("shared").toString("base64");
const fileLink = Buffer.from("alias.txt").toString("base64");
return {
stdout:
`l|lrwxrwxrwx|0|1700000000|${link}\n` +
`l|lrwxrwxrwx|0|1700000000|${fileLink}\n`,
stderr: "",
code: 0,
};
}
// resolveSymlinkTargetType probes
if (command.includes("/home/test/shared") || command.includes("shared")) {
if (command.includes('echo directory') || command.includes("[ -d")) {
return { stdout: "directory\n", stderr: "", code: 0 };
}
}
if (command.includes("alias.txt")) {
return { stdout: "file\n", stderr: "", code: 0 };
}
return { stdout: "file\n", stderr: "", code: 0 };
},
execStream: async () => createMockStream(),
});
const entries = await backend.list("/home/test");
assert.equal(entries.length, 2);
const shared = entries.find((e) => e.name === "shared");
const alias = entries.find((e) => e.name === "alias.txt");
assert.equal(shared?.type, "symlink");
assert.equal(shared?.linkTarget, "directory");
assert.equal(alias?.type, "symlink");
assert.equal(alias?.linkTarget, "file");
});
it("mkdir rename delete and chmod issue quoted shell commands", async () => {
await backend.mkdir("/tmp/a b/c");
await backend.rename("/tmp/a b/c", "/tmp/a b/d");
await backend.remove("/tmp/a b/d", { recursive: true });
await backend.chmod("/tmp/file", "644");
await backend.chmod("/tmp/zero-mode", 0);
assert.ok(commands.some((c) => c.command.includes("mkdir -p -- '/tmp/a b/c'")));
assert.ok(commands.some((c) => c.command.includes("mv -- '/tmp/a b/c' '/tmp/a b/d'")));
assert.ok(commands.some((c) => c.command.includes("rm -rf -- '/tmp/a b/d'")));
assert.ok(commands.some((c) => c.command.includes("chmod 644 -- '/tmp/file'")));
assert.ok(commands.some((c) => c.command.includes("chmod 000 -- '/tmp/zero-mode'")));
});
it("unlinks without a directory probe or recursive delete", async () => {
await backend.unlink("/tmp/a b/link");
const matching = commands.filter((c) => c.command.includes("/tmp/a b/link"));
assert.equal(matching.length, 1);
assert.match(matching[0].command, /rm -f -- '\/tmp\/a b\/link'/);
assert.doesNotMatch(matching[0].command, /rm -rf/);
assert.doesNotMatch(matching[0].command, /rmdir/);
});
it("stats a remote path", async () => {
const st = await backend.stat("/home/test/readme.txt");
assert.equal(st.size, 5);
assert.equal(st.isDirectory, false);
const statCommand = commands.find((entry) => entry.command.includes("if [ ! -e"))?.command || "";
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
});
it("resolves home directory", async () => {
const home = await backend.homeDir();
assert.equal(home, "/home/test");
});
it("falls back to gb18030 when $HOME bytes are not valid UTF-8", async () => {
const iconv = require("iconv-lite");
const pathBytes = Buffer.concat([
Buffer.from("/home/", "utf8"),
iconv.encode("用户", "gb18030"),
]);
const b64 = pathBytes.toString("base64");
let detected = null;
backend = createScpBackend({
exec: async (command) => {
if (command.includes("$HOME") || command.includes('printf "B64:"')) {
return { stdout: `B64:${b64}\n`, stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
},
execStream: async () => createMockStream(),
});
const home = await backend.homeDir({
encoding: "utf-8",
onDetectedEncoding: (enc) => {
detected = enc;
},
});
assert.equal(home, "/home/用户");
assert.equal(detected, "gb18030");
});
});
it("SCP in-memory reads reject a grown file from its protocol header", async () => {
const stream = createMockStream();
let ackCount = 0;
stream.on("_write", (buf) => {
if (!(buf.length === 1 && buf[0] === SCP_OK)) return;
ackCount += 1;
if (ackCount === 1) {
setImmediate(() => stream.pushFromRemote(
buildFileControlLine({ mode: 0o644, size: 5, name: "grown.bin" }),
));
} else if (ackCount === 2) {
setImmediate(() => stream.pushFromRemote(
Buffer.concat([Buffer.from("ABCDE"), Buffer.from([0x00])]),
));
}
});
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => stream,
});
await assert.rejects(
backend.readFile("/remote/grown.bin", { maxBytes: 4 }),
/too large.*4 bytes.*download/i,
);
assert.equal(ackCount, 1, "oversized content must not be acknowledged for transfer");
});
describe("scpBackend upload/download with fake scp streams", () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-"));
});
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch { /* ignore */ }
});
it("uploads a local file through scp -t handshake and reports progress", async () => {
const localFile = path.join(tmpDir, "local.txt");
fs.writeFileSync(localFile, "hello-scp");
const written = [];
let progressCalls = [];
const backend = createScpBackend({
exec: async (command) => {
if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 };
return { stdout: "", stderr: "", code: 0 };
},
execStream: async (command) => {
assert.match(command, /scp -t -- /);
const stream = createMockStream();
// Delay ready ACK until after waitForAck attaches (setImmediate > microtask).
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
written.push(Buffer.from(buf));
const text = buf.toString("utf8");
// ACK after control line and after trailing NUL
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
return stream;
},
});
const transfer = { cancelled: false, abort: null };
await backend.uploadFile(localFile, "/remote/dir/local.txt", {
transfer,
onProgress: (t, total) => progressCalls.push([t, total]),
});
const joined = Buffer.concat(written).toString("utf8");
assert.match(joined, /C0[0-7]{3} 9 local\.txt\n/);
assert.ok(joined.includes("hello-scp"));
assert.ok(progressCalls.length > 0);
assert.equal(progressCalls[progressCalls.length - 1][0], 9);
});
it("uploads from a caller-provided verified read stream", async () => {
const localFile = path.join(tmpDir, "local.txt");
fs.writeFileSync(localFile, "local-data");
const verifiedPayload = Buffer.from("safe-stream");
const written = [];
let opened = 0;
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
written.push(Buffer.from(buf));
const text = buf.toString("utf8");
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
return stream;
},
});
await backend.uploadFile(localFile, "/remote/local.txt", {
fileSize: verifiedPayload.length,
openReadStream() {
opened += 1;
return Readable.from([verifiedPayload]);
},
});
const joined = Buffer.concat(written);
assert.equal(opened, 1);
assert.equal(joined.includes(verifiedPayload), true);
assert.equal(joined.includes(Buffer.from("local-data")), false);
});
it("rejects verified read streams whose bytes do not match the declared size", async () => {
const localFile = path.join(tmpDir, "local.txt");
fs.writeFileSync(localFile, "local-data");
async function runCase(payload, expectedSize, message) {
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
const text = buf.toString("utf8");
if (text.startsWith("C")) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
return stream;
},
});
await assert.rejects(
backend.uploadFile(localFile, "/remote/local.txt", {
fileSize: expectedSize,
openReadStream: () => Readable.from([Buffer.from(payload)]),
}),
message,
);
}
await runCase("short", 10, /ended early/);
await runCase("too-many-bytes", 5, /exceeded its declared size/);
});
it("waits for verified read cleanup before returning a stream error", async () => {
const localFile = path.join(tmpDir, "local.txt");
fs.writeFileSync(localFile, "local-data");
let resolveCleanup;
const cleanup = new Promise((resolve) => { resolveCleanup = resolve; });
let uploadSettled = false;
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
if (buf.toString("utf8").startsWith("C")) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
} else if (buf.toString("utf8") === "local-data") {
setImmediate(() => {
stream.emit("error", new Error("remote write failed"));
});
}
});
return stream;
},
});
let uploadError = null;
const running = backend.uploadFile(localFile, "/remote/local.txt", {
fileSize: 10,
openReadStream: () => ({
stream: Readable.from([Buffer.from("local-data")]),
completed: cleanup,
}),
}).then(
() => { uploadSettled = true; },
(err) => {
uploadSettled = true;
uploadError = err;
},
);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(uploadSettled, false);
resolveCleanup();
await running;
assert.match(uploadError?.message || "", /remote write failed/);
});
it("downloads via scp -f parser into a local file", async () => {
const localOut = path.join(tmpDir, "out.bin");
const payload = Buffer.from("ABCD");
const backend = createScpBackend({
exec: async (command) => {
if (command.includes("if [ ! -e")) {
return { stdout: "f|-rw-r--r--|4|1700000000|/remote/x.bin\n", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
},
execStream: async (command) => {
assert.match(command, /scp -f -- /);
const stream = createMockStream();
let ackCount = 0;
stream.on("_write", (buf) => {
if (!(buf[0] === SCP_OK && buf.length === 1)) return;
ackCount += 1;
if (ackCount === 1) {
// Client ready → send control
setImmediate(() => {
stream.pushFromRemote(buildFileControlLine({ mode: 0o644, size: 4, name: "x.bin" }));
});
} else if (ackCount === 2) {
// Client accepted control → send data + trailing NUL
setImmediate(() => {
stream.pushFromRemote(Buffer.concat([payload, Buffer.from([0x00])]));
});
}
});
return stream;
},
});
const progress = [];
const result = await backend.downloadFile("/remote/x.bin", localOut, {
fileSize: 4,
onProgress: (t, total) => progress.push([t, total]),
});
assert.equal(fs.readFileSync(localOut).toString(), "ABCD");
assert.ok(progress.length > 0);
assert.deepEqual(result, { fileSize: 4, transferred: 4 });
});
it("cleans AbortSignal listeners after successful, failed, and early-closed downloads", async () => {
const runCase = async (outcome) => {
const listeners = new Set();
let addCalls = 0;
let removeCalls = 0;
const signal = {
aborted: false,
addEventListener(type, listener) {
if (type !== "abort") return;
addCalls += 1;
listeners.add(listener);
},
removeEventListener(type, listener) {
if (type !== "abort") return;
removeCalls += 1;
listeners.delete(listener);
},
};
const sshClient = {
exec(_command, callback) {
if (outcome === "sync-throw") throw new Error("session already closed");
const stream = createMockStream();
if (outcome === "success") {
let ackCount = 0;
stream.on("_write", (buf) => {
if (!(buf.length === 1 && buf[0] === SCP_OK)) return;
ackCount += 1;
if (ackCount === 1) {
setImmediate(() => stream.pushFromRemote(
buildFileControlLine({ mode: 0o644, size: 4, name: "x.bin" }),
));
} else if (ackCount === 2) {
setImmediate(() => stream.pushFromRemote(
Buffer.concat([Buffer.from("ABCD"), Buffer.from([0x00])]),
));
}
});
} else if (outcome === "failure") {
stream.on("_write", () => setImmediate(() => stream.emit("error", new Error("remote failed"))));
}
callback(null, stream);
if (outcome === "early-close") stream.close();
},
};
const backend = createScpBackend(createSshExecAdapters(sshClient));
if (outcome === "success") {
assert.deepEqual(await backend.readFile("/remote/x.bin", { signal }), Buffer.from("ABCD"));
} else {
await assert.rejects(
() => backend.readFile("/remote/x.bin", { signal }),
outcome === "failure"
? /remote failed/
: outcome === "sync-throw"
? /session already closed/
: /closed before protocol setup/,
);
}
assert.equal(addCalls, 1, `${outcome}: expected one abort listener`);
assert.equal(removeCalls, 1, `${outcome}: expected one abort listener cleanup`);
assert.equal(listeners.size, 0, `${outcome}: abort listener leaked`);
};
await runCase("success");
await runCase("failure");
await runCase("early-close");
await runCase("sync-throw");
});
it("rejects signal-only upload cancellation during the exec handoff", async () => {
const controller = new AbortController();
const stream = createMockStream();
let deliverStream = null;
const sshClient = {
exec(_command, callback) {
deliverStream = () => callback(null, stream);
},
};
const backend = createScpBackend(createSshExecAdapters(sshClient));
const upload = backend.uploadBuffer(Buffer.from("payload"), "file.bin", {
signal: controller.signal,
});
assert.equal(typeof deliverStream, "function");
controller.abort();
deliverStream();
await assert.rejects(
() => Promise.race([
upload,
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
]),
/cancel|abort/i,
);
assert.equal(stream._chunks.length, 0);
});
it("rejects signal-only upload cancellation when exec never calls back", async () => {
const controller = new AbortController();
let invalidations = 0;
const sshClient = {
exec() {
// Deliberately never calls back: cancellation must settle the pending
// execStream promise without waiting for an SSH channel.
},
destroy() { invalidations += 1; },
};
const backend = createScpBackend(createSshExecAdapters(sshClient));
const upload = backend.uploadBuffer(Buffer.from("payload"), "file.bin", {
signal: controller.signal,
});
controller.abort();
await assert.rejects(
() => Promise.race([
upload,
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
]),
/cancel|abort/i,
);
assert.equal(invalidations, 1);
});
it("rejects upload streams that close or error before ACK listeners attach", async () => {
const runCase = async (outcome) => {
const sshClient = {
exec(_command, callback) {
const stream = createMockStream();
callback(null, stream);
if (outcome === "close") stream.close();
else stream.emit("error", new Error("remote failed during handoff"));
},
};
const backend = createScpBackend(createSshExecAdapters(sshClient));
await assert.rejects(
() => Promise.race([
backend.uploadBuffer(Buffer.from("payload"), "file.bin"),
new Promise((_, reject) => setTimeout(() => reject(new Error("handoff timed out")), 500)),
]),
outcome === "close" ? /closed before waiting for ACK/ : /remote failed during handoff/,
);
};
await runCase("close");
await runCase("error");
});
it("cancel aborts an in-flight upload", async () => {
const localFile = path.join(tmpDir, "big.bin");
fs.writeFileSync(localFile, Buffer.alloc(1024, 7));
const transfer = { cancelled: false, abort: null };
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => {
const stream = createMockStream();
// Never send ACK — upload blocks in waitForAck until cancelled.
return stream;
},
});
const uploadPromise = backend.uploadFile(localFile, "/remote/big.bin", { transfer });
await new Promise((r) => setTimeout(r, 30));
transfer.cancelled = true;
if (typeof transfer.abort === "function") transfer.abort();
await assert.rejects(() => uploadPromise, /cancel/i);
});
it("download settles when abort closes the stream before parser listeners attach", async () => {
const localOut = path.join(tmpDir, "race-closed.bin");
const transfer = { cancelled: false, abort: null };
let sawAbortInstall = false;
Object.defineProperty(transfer, "abort", {
configurable: true,
enumerable: true,
get() {
return this._abort;
},
set(fn) {
this._abort = fn;
if (sawAbortInstall || typeof fn !== "function") return;
sawAbortInstall = true;
// Simulate cancelTransfer winning immediately after abort is wired and
// closing the stream before downloadToWritable attaches parser listeners.
this.cancelled = true;
queueMicrotask(() => {
try { fn(); } catch { /* ignore */ }
});
},
});
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => backend.downloadFile("/remote/x.bin", localOut, {
fileSize: 4,
transfer,
}),
/cancel/i,
);
});
it("AbortSignal via createTransferFromAbortSignal cancels in-flight upload (AI path)", async () => {
const localFile = path.join(tmpDir, "sig.bin");
fs.writeFileSync(localFile, Buffer.alloc(512, 1));
const controller = new AbortController();
const transfer = createTransferFromAbortSignal(controller.signal);
assert.equal(transfer.cancelled, false);
const backend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => createMockStream(), // no ACK
});
const uploadPromise = backend.uploadFile(localFile, "/remote/sig.bin", { transfer });
await new Promise((r) => setTimeout(r, 30));
controller.abort();
assert.equal(transfer.cancelled, true);
await assert.rejects(() => uploadPromise, /cancel/i);
transfer.detachAbortSignal?.();
});
it("createTransferFromAbortSignal marks already-aborted signals", () => {
const controller = new AbortController();
controller.abort();
const transfer = createTransferFromAbortSignal(controller.signal);
assert.equal(transfer.cancelled, true);
assert.equal(createTransferFromAbortSignal(null), null);
});
it("list aborts when AbortSignal fires during shell exec", async () => {
const controller = new AbortController();
const backend = createScpBackend({
exec: (_command, options = {}) => new Promise((resolve, reject) => {
const signal = options.signal;
if (signal?.aborted) {
reject(new Error("Transfer cancelled"));
return;
}
const onAbort = () => reject(new Error("Transfer cancelled"));
signal?.addEventListener("abort", onAbort, { once: true });
// Never resolve until aborted (simulates hung remote).
}),
execStream: async () => createMockStream(),
});
const listPromise = backend.list("/tmp", { signal: controller.signal });
await new Promise((r) => setTimeout(r, 20));
controller.abort();
await assert.rejects(() => listPromise, /cancel/i);
});
});

View File

@@ -0,0 +1,163 @@
/**
* Real-host integration through fileOps entry points (list/mkdir/write/rename/delete)
* used by UI IPC — same scpBackend under the hood, different ship surface.
*
* Env: NETCATTY_SCP_IT_HOST / USER / PASSWORD (same as scpIntegration.real.test.cjs)
*/
"use strict";
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Client: SSHClient } = require("ssh2");
const { createScpBackend, createSshExecAdapters } = require("./scpBackend.cjs");
const { createFileOpsApi } = require("./fileOps.cjs");
const HOST = process.env.NETCATTY_SCP_IT_HOST || "";
const USER = process.env.NETCATTY_SCP_IT_USER || "root";
const PASSWORD = process.env.NETCATTY_SCP_IT_PASSWORD || "";
const PORT = Number(process.env.NETCATTY_SCP_IT_PORT || 22);
const ENABLED = process.env.NETCATTY_SCP_IT === "1" || (HOST && PASSWORD);
const remotePrefix = `/tmp/netcatty-scp-fileops-it-${Date.now()}-${process.pid}`;
function connectSsh() {
return new Promise((resolve, reject) => {
const client = new SSHClient();
const timer = setTimeout(() => {
try { client.end(); } catch { /* ignore */ }
reject(new Error("SSH timeout"));
}, 20000);
client
.on("ready", () => { clearTimeout(timer); resolve(client); })
.on("error", (err) => { clearTimeout(timer); reject(err); })
.connect({
host: HOST,
port: PORT,
username: USER,
password: PASSWORD,
readyTimeout: 15000,
tryKeyboard: true,
hostVerifier: () => true,
});
client.on("keyboard-interactive", (_n, _i, _l, prompts, finish) => {
finish(prompts.map(() => PASSWORD));
});
});
}
const suite = ENABLED ? describe : describe.skip;
suite("real SCP via fileOps IPC surface", () => {
let ssh;
let sftpClients;
let api;
let localTmp;
const sftpId = "scp-it-fileops-1";
before(async () => {
localTmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-fileops-"));
ssh = await connectSsh();
const backend = createScpBackend(createSshExecAdapters(ssh));
sftpClients = new Map();
sftpClients.set(sftpId, {
client: ssh,
sftp: null,
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
async end() {},
});
api = createFileOpsApi({
get sftpClients() { return sftpClients; },
get electronModule() {
return { webContents: { fromId: () => ({ send: () => {} }) } };
},
fileWatcherBridge: { stopWatchersForSession: () => {} },
fs,
path,
Buffer,
console,
setTimeout,
clearTimeout,
jumpConnectionsMap: new Map(),
sftpEncodingState: new Map(),
normalizeEncoding: (e) => e || "utf-8",
isAsciiString: () => true,
requireSftpChannel: async () => { throw new Error("SFTP channel not used in SCP mode"); },
resolveEncodingForRequest: () => "utf-8",
updateResolvedEncoding: () => "utf-8",
encodePath: (p) => p,
decodeName: (n) => n,
detectEncodingFromList: () => null,
statResultFromAttrs: (a) => a,
normalizeRemotePathString: async (_c, p) => p,
collectReadable: async () => Buffer.alloc(0),
writeToWritable: async () => {},
throwIfAborted: () => {},
pipeStreams: async () => {},
ensureRemoteDirForSession: async () => true,
removeRemotePathInternal: async () => {},
renameRemotePath: async () => {},
realpathAsync: async () => "/",
statAsync: async () => ({}),
readdirAsync: async () => [],
mkdirAsync: async () => {},
rmdirAsync: async () => {},
unlinkAsync: async () => {},
openFileAsync: async () => ({}),
writeFileChunkAsync: async () => {},
closeFileAsync: async () => {},
createAbortError: (_s, m) => new Error(m),
copySftpEncodingState: () => {},
clearSftpEncodingState: () => {},
safeSend: () => {},
tempDirBridge: { getTempFilePath: (n) => path.join(localTmp, n) },
randomUUID: () => "it-uuid",
});
await api.mkdirSftp(null, { sftpId, path: remotePrefix });
});
after(async () => {
try {
await api.deleteSftp(null, { sftpId, path: remotePrefix });
} catch (err) {
console.warn("[scp-fileops-it] cleanup:", err.message);
}
try { ssh?.end(); } catch { /* ignore */ }
try { fs.rmSync(localTmp, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("mkdir list write rename delete via fileOps", async () => {
const sub = `${remotePrefix}/ops`;
await api.mkdirSftp(null, { sftpId, path: sub });
const listed = await api.listSftp(null, { sftpId, path: remotePrefix });
assert.ok(listed.some((e) => e.name === "ops"));
const fileA = `${sub}/a.txt`;
const fileB = `${sub}/b.txt`;
await api.writeSftp(null, { sftpId, path: fileA, content: "via-fileops\n" });
const content = await api.readSftp(null, { sftpId, path: fileA });
assert.equal(content, "via-fileops\n");
await api.renameSftp(null, { sftpId, oldPath: fileA, newPath: fileB });
const afterRename = await api.listSftp(null, { sftpId, path: sub });
const names = afterRename.map((e) => e.name);
assert.ok(names.includes("b.txt"));
assert.ok(!names.includes("a.txt"));
await api.deleteSftp(null, { sftpId, path: fileB });
const afterDel = (await api.listSftp(null, { sftpId, path: sub })).map((e) => e.name);
assert.ok(!afterDel.includes("b.txt"));
console.log("[scp-fileops-it] ops ok", { names, afterDel });
});
});
if (!ENABLED) {
describe("real SCP fileOps (skipped)", () => {
it("needs NETCATTY_SCP_IT_* env", () => { assert.ok(true); });
});
}

View File

@@ -0,0 +1,250 @@
/**
* Real OpenSSH SCP integration tests against a live host.
*
* Gated by env (or defaults for local interactive IT runs):
* NETCATTY_SCP_IT_HOST, NETCATTY_SCP_IT_USER, NETCATTY_SCP_IT_PASSWORD
* NETCATTY_SCP_IT_PORT (optional, default 22)
* NETCATTY_SCP_IT=1 to force-enable when password is set
*
* When host/password are unset, the suite skips (CI-safe).
* When set, failures fail the suite — do not soft-skip mid-run.
*
* Credentials must NOT be hard-coded here; pass via env for the run.
*/
"use strict";
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Client: SSHClient } = require("ssh2");
const {
createScpBackend,
createSshExecAdapters,
} = require("./scpBackend.cjs");
const HOST = process.env.NETCATTY_SCP_IT_HOST || "";
const USER = process.env.NETCATTY_SCP_IT_USER || "root";
const PASSWORD = process.env.NETCATTY_SCP_IT_PASSWORD || "";
const PORT = Number(process.env.NETCATTY_SCP_IT_PORT || 22);
const ENABLED = process.env.NETCATTY_SCP_IT === "1"
|| (HOST && PASSWORD);
const remotePrefix = `/tmp/netcatty-scp-it-${Date.now()}-${process.pid}`;
function connectSsh() {
return new Promise((resolve, reject) => {
const client = new SSHClient();
const timer = setTimeout(() => {
try { client.end(); } catch { /* ignore */ }
reject(new Error(`SSH connect timeout to ${HOST}:${PORT}`));
}, 20000);
client
.on("ready", () => {
clearTimeout(timer);
resolve(client);
})
.on("error", (err) => {
clearTimeout(timer);
reject(err);
})
.connect({
host: HOST,
port: PORT,
username: USER,
password: PASSWORD,
readyTimeout: 15000,
tryKeyboard: true,
// Accept first-time host keys for IT environments
hostVerifier: () => true,
});
client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
finish(prompts.map(() => PASSWORD));
});
});
}
const suite = ENABLED ? describe : describe.skip;
suite("real OpenSSH SCP integration (shipped scpBackend)", () => {
let ssh;
let backend;
let localTmp;
before(async () => {
assert.ok(HOST, "NETCATTY_SCP_IT_HOST required");
assert.ok(PASSWORD, "NETCATTY_SCP_IT_PASSWORD required");
localTmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-it-local-"));
ssh = await connectSsh();
const adapters = createSshExecAdapters(ssh);
backend = createScpBackend(adapters);
await backend.mkdir(remotePrefix, { recursive: true });
});
after(async () => {
try {
if (backend) await backend.remove(remotePrefix, { recursive: true });
} catch (err) {
console.warn("[scp-it] cleanup remove failed:", err.message);
}
try {
if (ssh) ssh.end();
} catch { /* ignore */ }
try {
if (localTmp) fs.rmSync(localTmp, { recursive: true, force: true });
} catch { /* ignore */ }
});
it("homeDir returns a non-empty path", async () => {
const home = await backend.homeDir();
assert.ok(home && home.length > 0, `homeDir empty: ${home}`);
console.log("[scp-it] homeDir=", home);
});
it("mkdir + list shows created directory", async () => {
const sub = `${remotePrefix}/subdir`;
await backend.mkdir(sub, { recursive: true });
const entries = await backend.list(remotePrefix);
const names = entries.map((e) => e.name);
console.log("[scp-it] list after mkdir:", names);
assert.ok(names.includes("subdir"), `expected subdir in ${JSON.stringify(names)}`);
const dir = entries.find((e) => e.name === "subdir");
assert.equal(dir.type, "directory");
});
it("upload then download is byte-identical for binary payload", async () => {
const payload = crypto.randomBytes(64 * 1024 + 17); // not power-of-two edge
const localUp = path.join(localTmp, "payload.bin");
const localDown = path.join(localTmp, "payload.down.bin");
fs.writeFileSync(localUp, payload);
const remoteFile = `${remotePrefix}/payload.bin`;
const progress = [];
await backend.uploadFile(localUp, remoteFile, {
onProgress: (t, total) => progress.push([t, total]),
});
assert.ok(progress.length > 0, "expected upload progress callbacks");
const entries = await backend.list(remotePrefix);
assert.ok(entries.some((e) => e.name === "payload.bin"), "list missing uploaded file");
await backend.downloadFile(remoteFile, localDown, {
fileSize: payload.length,
});
const down = fs.readFileSync(localDown);
const upHash = crypto.createHash("sha256").update(payload).digest("hex");
const downHash = crypto.createHash("sha256").update(down).digest("hex");
console.log("[scp-it] upload/download sha256", upHash, downHash, "bytes", down.length);
assert.equal(down.length, payload.length);
assert.equal(downHash, upHash);
});
it("uploadBuffer write path works for small text", async () => {
const remoteFile = `${remotePrefix}/note.txt`;
const body = Buffer.from("hello-scp-integration\nline2\n", "utf8");
await backend.writeFile(remoteFile, body, { mode: 0o0644 });
const read = await backend.readFile(remoteFile);
assert.equal(read.toString("utf8"), body.toString("utf8"));
});
it("rename then delete leaves expected tree", async () => {
const a = `${remotePrefix}/rename-a.txt`;
const b = `${remotePrefix}/rename-b.txt`;
await backend.writeFile(a, Buffer.from("rename-me\n"));
await backend.rename(a, b);
let entries = await backend.list(remotePrefix);
let names = entries.map((e) => e.name);
assert.ok(names.includes("rename-b.txt"), `after rename: ${JSON.stringify(names)}`);
assert.ok(!names.includes("rename-a.txt"), `old name still present: ${JSON.stringify(names)}`);
await backend.remove(b, { recursive: false });
entries = await backend.list(remotePrefix);
names = entries.map((e) => e.name);
assert.ok(!names.includes("rename-b.txt"), `delete failed: ${JSON.stringify(names)}`);
console.log("[scp-it] final names after rename/delete:", names);
});
it("stat reports size for uploaded file", async () => {
const remoteFile = `${remotePrefix}/payload.bin`;
// may already exist from earlier test; recreate if needed
try {
const st = await backend.stat(remoteFile);
assert.ok(st.size > 0, `stat size ${st.size}`);
assert.equal(st.isDirectory, false);
console.log("[scp-it] stat", st);
} catch {
const localUp = path.join(localTmp, "stat.bin");
fs.writeFileSync(localUp, Buffer.from("stat-payload"));
await backend.uploadFile(localUp, remoteFile);
const st = await backend.stat(remoteFile);
assert.equal(st.size, 12);
}
});
it("handles spaces in remote path for mkdir/upload/list/download/delete", async () => {
const dir = `${remotePrefix}/dir with spaces`;
const remoteFile = `${dir}/file with spaces.bin`;
const payload = Buffer.from("space-path-payload-ok");
const localUp = path.join(localTmp, "spaces-up.bin");
const localDown = path.join(localTmp, "spaces-down.bin");
fs.writeFileSync(localUp, payload);
await backend.mkdir(dir, { recursive: true });
await backend.uploadFile(localUp, remoteFile);
const entries = await backend.list(dir);
const names = entries.map((e) => e.name);
console.log("[scp-it] space path list:", names);
assert.ok(names.includes("file with spaces.bin"), `list: ${JSON.stringify(names)}`);
await backend.downloadFile(remoteFile, localDown);
assert.equal(fs.readFileSync(localDown).toString(), payload.toString());
await backend.remove(remoteFile);
const after = (await backend.list(dir)).map((e) => e.name);
assert.ok(!after.includes("file with spaces.bin"), `still present: ${JSON.stringify(after)}`);
});
it("empty file upload/download round-trips", async () => {
const remoteFile = `${remotePrefix}/empty.dat`;
const localUp = path.join(localTmp, "empty.dat");
const localDown = path.join(localTmp, "empty.down.dat");
fs.writeFileSync(localUp, Buffer.alloc(0));
await backend.uploadFile(localUp, remoteFile);
await backend.downloadFile(remoteFile, localDown, { fileSize: 0 });
assert.equal(fs.readFileSync(localDown).length, 0);
const st = await backend.stat(remoteFile);
assert.equal(st.size, 0);
});
it("directory symlink linkTarget is directory when target is a dir", async () => {
const realDir = `${remotePrefix}/real-target-dir`;
const linkPath = `${remotePrefix}/link-to-dir`;
await backend.mkdir(realDir, { recursive: true });
// Create symlink via shell (not part of SCP wire, but list must resolve it)
const { exec } = createSshExecAdapters(ssh);
const { shellQuote } = require("./scpShell.cjs");
const ln = await exec(`ln -sfn ${shellQuote(realDir)} ${shellQuote(linkPath)}`);
assert.equal(ln.code, 0, `ln failed: ${ln.stderr}`);
const entries = await backend.list(remotePrefix);
const link = entries.find((e) => e.name === "link-to-dir");
console.log("[scp-it] symlink entry:", link);
assert.ok(link, "symlink missing from list");
assert.equal(link.type, "symlink");
assert.equal(link.linkTarget, "directory");
});
});
// Always-visible marker so logs show skip reason when env unset
if (!ENABLED) {
describe("real OpenSSH SCP integration (skipped — set NETCATTY_SCP_IT_HOST/PASSWORD)", () => {
it("documents skip condition", () => {
console.log(
"[scp-it] skipped: export NETCATTY_SCP_IT_HOST NETCATTY_SCP_IT_USER NETCATTY_SCP_IT_PASSWORD (or NETCATTY_SCP_IT=1 with those set)",
);
assert.ok(true);
});
});
}

View File

@@ -0,0 +1,348 @@
/**
* Pure OpenSSH-style SCP wire protocol helpers (no I/O).
* Used by the SCP-mode remote filesystem backend for single-file transfers.
*
* Protocol summary (source = sender of file data, sink = receiver):
* - Control: Cmmmm <size> <filename>\n then <size> bytes then \0
* - Directory: Dmmmm 0 <dirname>\n ... entries ... E\n
* - ACK from peer: \0 (ok), \x01message\n (error), \x02message\n (fatal)
*/
"use strict";
const SCP_OK = 0x00;
const SCP_ERROR = 0x01;
const SCP_FATAL = 0x02;
class ScpProtocolError extends Error {
constructor(message, code = "SCP_PROTOCOL_ERROR") {
super(message);
this.name = "ScpProtocolError";
this.code = code;
}
}
/**
* Build a file control line: C0664 123 name\n
* @param {{ mode?: number|string, size: number, name: string }} opts
*/
function buildFileControlLine({ mode = 0o0644, size, name, encoding = "utf-8" }) {
if (!Number.isFinite(size) || size < 0 || !Number.isInteger(size)) {
throw new ScpProtocolError(`Invalid SCP file size: ${size}`);
}
const baseName = sanitizeScpBasename(name);
const modeStr = normalizeModeOctal(mode);
const prefix = Buffer.from(`C${modeStr} ${size} `, "utf8");
const enc = String(encoding || "utf-8").toLowerCase();
let nameBuf;
if (enc === "gb18030" || enc === "gbk" || enc === "gb2312") {
try {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
nameBuf = iconv.encode(baseName, "gb18030");
} catch {
nameBuf = Buffer.from(baseName, "utf8");
}
} else {
nameBuf = Buffer.from(baseName, "utf8");
}
return Buffer.concat([prefix, nameBuf, Buffer.from("\n", "utf8")]);
}
/**
* Build a directory control line: D0755 0 name\n
*/
function buildDirectoryControlLine({ mode = 0o0755, name }) {
const baseName = sanitizeScpBasename(name);
const modeStr = normalizeModeOctal(mode);
return Buffer.from(`D${modeStr} 0 ${baseName}\n`, "utf8");
}
/** End-of-directory marker: E\n */
function buildEndDirectoryLine() {
return Buffer.from("E\n", "utf8");
}
/** ACK byte used by sink/source to signal ready/ok */
function buildAck() {
return Buffer.from([SCP_OK]);
}
/**
* Parse a single SCP control line (without trailing newline).
* @returns {{ kind: 'file'|'directory'|'end', mode?: number, size?: number, name?: string }}
*/
function parseControlLine(line) {
const text = Buffer.isBuffer(line) ? line.toString("utf8") : String(line);
const trimmed = text.replace(/\r?\n$/, "");
if (!trimmed) {
throw new ScpProtocolError("Empty SCP control line");
}
if (trimmed === "E") {
return { kind: "end" };
}
const kindChar = trimmed[0];
if (kindChar !== "C" && kindChar !== "D" && kindChar !== "T") {
throw new ScpProtocolError(`Unknown SCP control line: ${trimmed.slice(0, 80)}`);
}
if (kindChar === "T") {
// Time header: T mtime 0 atime 0 — ignored by MVP but recognized
return { kind: "time", raw: trimmed };
}
// Cmmmm size name or Dmmmm 0 name — name may contain spaces
const match = trimmed.match(/^([CD])([0-7]{3,5})\s+(\d+)\s+(.*)$/);
if (!match) {
throw new ScpProtocolError(`Malformed SCP control line: ${trimmed.slice(0, 80)}`);
}
const kind = match[1] === "C" ? "file" : "directory";
const mode = parseInt(match[2], 8);
const size = Number(match[3]);
const name = match[4];
// Only reject path separators used by remote paths (/). Backslash is a valid
// POSIX filename character and must be allowed for download control lines.
if (!name || name.includes("/") || name === ".." || name === ".") {
throw new ScpProtocolError(`Invalid SCP entry name: ${name}`);
}
if (kind === "file" && (!Number.isFinite(size) || size < 0)) {
throw new ScpProtocolError(`Invalid SCP file size in control line: ${match[3]}`);
}
return { kind, mode, size, name };
}
/**
* Consume leading ACK/error bytes from a buffer.
* Returns { status: 'ok'|'error'|'fatal'|'incomplete', message?, consumed }
*/
function consumeAck(buffer) {
if (!buffer || buffer.length === 0) {
return { status: "incomplete", consumed: 0 };
}
const code = buffer[0];
if (code === SCP_OK) {
return { status: "ok", consumed: 1 };
}
if (code === SCP_ERROR || code === SCP_FATAL) {
let end = 1;
while (end < buffer.length && buffer[end] !== 0x0a) end += 1;
if (end >= buffer.length) {
return { status: "incomplete", consumed: 0 };
}
const message = buffer.subarray(1, end).toString("utf8").trim() || "SCP remote error";
return {
status: code === SCP_FATAL ? "fatal" : "error",
message,
consumed: end + 1,
};
}
// Some remotes send printable noise; treat unexpected non-zero as fatal if we can
return {
status: "fatal",
message: `Unexpected SCP status byte 0x${code.toString(16)}`,
consumed: 1,
};
}
/**
* Read complete lines from a buffer accumulator (for control protocol).
* Returns { lines: Buffer[], rest: Buffer }
*/
function splitCompleteLines(buffer) {
const lines = [];
let start = 0;
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 0x0a) {
lines.push(buffer.subarray(start, i));
start = i + 1;
}
}
return {
lines,
rest: start === 0 ? buffer : buffer.subarray(start),
};
}
function normalizeModeOctal(mode) {
let n;
if (typeof mode === "string") {
n = parseInt(mode, 8);
} else {
n = Number(mode);
}
if (!Number.isFinite(n) || n < 0) n = 0o0644;
// SCP control lines use 4-digit octal commonly (0 prefix + 3 digit mode)
const masked = n & 0o7777;
return masked.toString(8).padStart(4, "0");
}
/**
* Basename only — reject path separators and empty names.
*/
function sanitizeScpBasename(name) {
if (typeof name !== "string" || !name) {
throw new ScpProtocolError("SCP entry name is required");
}
if (name.includes("\0")) {
throw new ScpProtocolError("SCP entry name must not contain NUL");
}
if (name.includes("/") || name === ".." || name === ".") {
throw new ScpProtocolError(`SCP entry name must be a simple basename: ${name}`);
}
// Control line is space-delimited; reject newlines
if (/[\r\n]/.test(name)) {
throw new ScpProtocolError("SCP entry name must not contain newlines");
}
return name;
}
/**
* Incremental parser for an SCP source stream (download from remote).
* Feed buffers; yields control events and file data chunks.
*/
function createSourceStreamParser(options = {}) {
const encoding = String(options.encoding || "utf-8").toLowerCase();
let buf = Buffer.alloc(0);
let phase = "await-control"; // await-control | await-data | done
let pendingFile = null;
let remaining = 0;
let needTrailingNul = false;
const decodeControlName = (nameBytes) => {
if (encoding === "gb18030" || encoding === "gbk" || encoding === "gb2312") {
try {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
return iconv.decode(nameBytes, "gb18030");
} catch {
return nameBytes.toString("utf8");
}
}
return nameBytes.toString("utf8");
};
return {
get phase() {
return phase;
},
feed(chunk) {
const events = [];
if (!chunk || chunk.length === 0) return events;
buf = Buffer.concat([buf, Buffer.from(chunk)]);
while (buf.length > 0) {
if (phase === "await-control") {
// Control lines are text ending in \n; time/file/dir/end
const nl = buf.indexOf(0x0a);
if (nl < 0) break;
const lineBuf = buf.subarray(0, nl);
buf = buf.subarray(nl + 1);
// Decode basename with session encoding when C/D lines carry non-UTF-8 bytes.
let lineForParse = lineBuf;
if (lineBuf.length > 0 && (lineBuf[0] === 0x43 || lineBuf[0] === 0x44)) {
// C/Dmmmm size name — find third space-separated field start
let spaces = 0;
let nameStart = -1;
for (let i = 0; i < lineBuf.length; i += 1) {
if (lineBuf[i] === 0x20) {
spaces += 1;
if (spaces === 2) {
nameStart = i + 1;
break;
}
}
}
if (nameStart > 0 && nameStart < lineBuf.length) {
const head = lineBuf.subarray(0, nameStart).toString("utf8");
const name = decodeControlName(lineBuf.subarray(nameStart));
lineForParse = Buffer.from(`${head}${name}`, "utf8");
}
}
const parsed = parseControlLine(lineForParse);
if (parsed.kind === "time") {
events.push({ type: "time", raw: parsed.raw });
continue;
}
if (parsed.kind === "end") {
events.push({ type: "end-directory" });
continue;
}
if (parsed.kind === "directory") {
events.push({ type: "directory", mode: parsed.mode, name: parsed.name });
continue;
}
// file
pendingFile = { mode: parsed.mode, size: parsed.size, name: parsed.name };
remaining = parsed.size;
needTrailingNul = true;
phase = remaining === 0 ? "await-nul" : "await-data";
events.push({ type: "file-start", ...pendingFile });
if (phase === "await-nul") {
// fall through to read trailing nul
} else {
continue;
}
}
if (phase === "await-data") {
if (buf.length === 0) break;
const take = Math.min(remaining, buf.length);
const data = buf.subarray(0, take);
buf = buf.subarray(take);
remaining -= take;
events.push({ type: "file-data", data: Buffer.from(data) });
if (remaining === 0) {
phase = "await-nul";
} else {
break;
}
}
if (phase === "await-nul") {
if (buf.length === 0) break;
if (buf[0] !== 0x00) {
throw new ScpProtocolError("Expected trailing NUL after SCP file data");
}
buf = buf.subarray(1);
events.push({
type: "file-end",
name: pendingFile?.name,
size: pendingFile?.size,
mode: pendingFile?.mode,
});
pendingFile = null;
needTrailingNul = false;
phase = "await-control";
}
}
return events;
},
finish() {
if (phase === "await-data" || phase === "await-nul" || needTrailingNul) {
throw new ScpProtocolError("Incomplete SCP source stream");
}
if (buf.length > 0) {
// leftover non-empty buffer is unexpected unless only whitespace
const leftover = buf.toString("utf8").trim();
if (leftover) {
throw new ScpProtocolError(`Trailing garbage in SCP stream: ${leftover.slice(0, 80)}`);
}
}
},
};
}
module.exports = {
SCP_OK,
SCP_ERROR,
SCP_FATAL,
ScpProtocolError,
buildFileControlLine,
buildDirectoryControlLine,
buildEndDirectoryLine,
buildAck,
parseControlLine,
consumeAck,
splitCompleteLines,
normalizeModeOctal,
sanitizeScpBasename,
createSourceStreamParser,
};

View File

@@ -0,0 +1,232 @@
"use strict";
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
buildFileControlLine,
buildDirectoryControlLine,
buildEndDirectoryLine,
buildAck,
parseControlLine,
consumeAck,
createSourceStreamParser,
sanitizeScpBasename,
ScpProtocolError,
SCP_OK,
SCP_ERROR,
SCP_FATAL,
} = require("./scpProtocol.cjs");
const {
shellQuote,
assertSafeRemotePath,
buildScpSinkCommand,
buildScpSourceCommand,
buildListCommand,
buildMkdirCommand,
buildDeleteCommand,
buildUnlinkCommand,
buildRenameCommand,
buildChmodCommand,
parseListRecords,
parseLsLaOutput,
ownerFromSftpLongname,
resolveListingOwner,
normalizeFileProtocol,
} = require("./scpShell.cjs");
describe("scpProtocol control lines", () => {
it("builds a file control line with mode size and basename", () => {
const line = buildFileControlLine({ mode: 0o644, size: 11, name: "hello.txt" });
assert.equal(line.toString("utf8"), "C0644 11 hello.txt\n");
});
it("builds directory and end markers", () => {
assert.equal(buildDirectoryControlLine({ mode: 0o755, name: "dir" }).toString(), "D0755 0 dir\n");
assert.equal(buildEndDirectoryLine().toString(), "E\n");
});
it("parses file and directory control lines including spaces in names", () => {
const file = parseControlLine("C0644 3 my file.txt");
assert.deepEqual(file, { kind: "file", mode: 0o644, size: 3, name: "my file.txt" });
const dir = parseControlLine("D0755 0 nested");
assert.equal(dir.kind, "directory");
assert.equal(dir.name, "nested");
assert.equal(parseControlLine("E").kind, "end");
});
it("rejects path separators in basenames", () => {
assert.throws(() => sanitizeScpBasename("../x"), ScpProtocolError);
assert.throws(() => buildFileControlLine({ size: 1, name: "a/b" }), ScpProtocolError);
assert.throws(() => parseControlLine("C0644 1 a/b"), ScpProtocolError);
// Backslash is valid on POSIX; do not reject.
assert.equal(sanitizeScpBasename("a\\b"), "a\\b");
});
it("consumeAck handles ok error and fatal", () => {
assert.deepEqual(consumeAck(Buffer.from([SCP_OK])), { status: "ok", consumed: 1 });
const err = consumeAck(Buffer.from([SCP_ERROR, ...Buffer.from("nope\n")]));
assert.equal(err.status, "error");
assert.equal(err.message, "nope");
const fatal = consumeAck(Buffer.from([SCP_FATAL, ...Buffer.from("dead\n")]));
assert.equal(fatal.status, "fatal");
assert.equal(consumeAck(Buffer.alloc(0)).status, "incomplete");
});
it("buildAck is a single NUL", () => {
assert.deepEqual(buildAck(), Buffer.from([0]));
});
});
describe("scpProtocol source stream parser", () => {
it("parses handshake file metadata data and completion", () => {
const parser = createSourceStreamParser();
const body = Buffer.from("hi\n");
const stream = Buffer.concat([
Buffer.from("C0644 3 hi.txt\n"),
body,
Buffer.from([0x00]),
]);
const events = parser.feed(stream);
assert.equal(events[0].type, "file-start");
assert.equal(events[0].name, "hi.txt");
assert.equal(events[0].size, 3);
assert.equal(events[1].type, "file-data");
assert.deepEqual(events[1].data, body);
assert.equal(events[2].type, "file-end");
parser.finish();
});
it("handles chunked feeds and recursive directory markers", () => {
const parser = createSourceStreamParser();
const part1 = Buffer.from("D0755 0 d\nC0644 4 ");
const part2 = Buffer.from("a.txt\nabcd\0E\n");
const e1 = parser.feed(part1);
assert.equal(e1[0].type, "directory");
assert.equal(e1[0].name, "d");
const e2 = parser.feed(part2);
assert.equal(e2.find((e) => e.type === "file-start")?.name, "a.txt");
assert.ok(e2.some((e) => e.type === "file-data"));
assert.ok(e2.some((e) => e.type === "file-end"));
assert.ok(e2.some((e) => e.type === "end-directory"));
parser.finish();
});
});
describe("scpShell quoting and commands", () => {
it("quotes paths with single quotes safely", () => {
assert.equal(shellQuote("simple"), "'simple'");
assert.equal(shellQuote("a'b"), `'a'\\''b'`);
assert.throws(() => shellQuote("a\0b"), /NUL/);
assert.throws(() => assertSafeRemotePath("a\nb"), /newline/i);
});
it("builds scp -t/-f commands with -- and quoted paths", () => {
assert.equal(buildScpSinkCommand("/tmp/out"), "scp -t -- '/tmp/out'");
assert.equal(buildScpSourceCommand("/var/a b"), "scp -f -- '/var/a b'");
assert.match(buildListCommand("/home/user"), /cd '\/home\/user'/);
// Must not emit invalid `do;` which breaks POSIX sh for-loops.
assert.doesNotMatch(buildListCommand("/home/user"), /do;/);
assert.match(buildListCommand("/home/user"), /do\n/);
assert.match(buildMkdirCommand("/x/y"), /mkdir -p -- '\/x\/y'/);
assert.match(buildDeleteCommand("/x", { recursive: true }), /rm -rf -- '\/x'/);
assert.equal(buildUnlinkCommand("/x"), "rm -f -- '/x'");
assert.match(buildRenameCommand("/a", "/b"), /mv -- '\/a' '\/b'/);
assert.match(buildChmodCommand("/a", "755"), /chmod 755 -- '\/a'/);
});
it("lsModeToNumber preserves setuid/setgid/sticky bits", () => {
const { lsModeToNumber } = require("./scpShell.cjs");
// -rwsr-sr-t : setuid+setgid+sticky with execute (lowercase s/t)
assert.equal(lsModeToNumber("-rwsr-sr-t"), 0o7755);
// -rwSrwSrwT : special bits without execute (uppercase S/T)
assert.equal(lsModeToNumber("-rwSrwSrwT"), 0o7666);
});
it("rejects unsafe remote paths for shell ops", () => {
assert.throws(() => buildScpSourceCommand("x\0y"));
assert.throws(() => buildDeleteCommand(""));
});
it("parses list records with base64 names", () => {
const name = "你好 world.txt";
const b64 = Buffer.from(name, "utf8").toString("base64");
const stdout = `f|-rw-r--r--|12|1700000000|${b64}\nd|drwxr-xr-x|0|1700000001|${Buffer.from("sub").toString("base64")}\n`;
const rows = parseListRecords(stdout);
assert.equal(rows.length, 2);
assert.equal(rows[0].name, name);
assert.equal(rows[0].type, "file");
assert.equal(rows[0].size, 12);
assert.equal(rows[1].type, "directory");
assert.equal(rows[1].name, "sub");
});
it("parses list records with gb18030 basenames", () => {
const iconv = require("iconv-lite");
const name = "测试.txt";
const b64 = iconv.encode(name, "gb18030").toString("base64");
const rows = parseListRecords(`f|-rw-r--r--|1|1700000000|${b64}\n`, "gb18030");
assert.equal(rows[0]?.name, name);
});
it("parses optional owner from list records and ls -la fallback", () => {
const b64 = Buffer.from("notes.txt", "utf8").toString("base64");
const rows = parseListRecords(`f|-rw-r--r--|12|1700000000|${b64}|www-data\n`);
assert.equal(rows[0]?.owner, "www-data");
const legacy = parseListRecords(`f|-rw-r--r--|12|1700000000|${b64}\n`);
assert.equal(legacy[0]?.owner, undefined);
const lsRows = parseLsLaOutput(
"total 4\n-rw-r--r-- 1 root wheel 12 Jan 1 00:00 notes.txt\n",
);
assert.equal(lsRows[0]?.name, "notes.txt");
assert.equal(lsRows[0]?.owner, "root");
const aclRows = parseLsLaOutput(
"-rw-r--r--+ 1 alice staff 12 Jan 1 00:00 notes.txt\n",
);
assert.equal(aclRows[0]?.owner, "alice");
});
it("resolves listing owner from longname, then uid", () => {
assert.equal(
ownerFromSftpLongname("-rwxr-xr-x 1 alice staff 4096 Jan 1 00:00 bin"),
"alice",
);
assert.equal(
resolveListingOwner({ longname: "drwxr-xr-x 2 www-data www-data 4096 Jan 1 00:00 html" }),
"www-data",
);
assert.equal(resolveListingOwner({ uid: 1000 }), "1000");
assert.equal(resolveListingOwner({ owner: "UNKNOWN", uid: 0 }), "0");
assert.equal(
ownerFromSftpLongname("-rw-r--r--+ 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
assert.equal(
ownerFromSftpLongname("-rw-r--r--@ 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
assert.equal(
ownerFromSftpLongname("-rw-r--r--. 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
});
it("list command includes owner in the record", () => {
assert.match(buildListCommand("/tmp"), /awk '\{print \$3\}'/);
assert.match(buildListCommand("/tmp"), /%s\\|%s\\|%s\\|%s\\|%s\\|%s/);
});
it("list command keeps broken symlinks", () => {
assert.match(buildListCommand("/tmp"), /-L "\$f"/);
assert.match(buildListCommand("/tmp"), /-e "\$f"/);
});
it("normalizes file protocol preference", () => {
assert.equal(normalizeFileProtocol(undefined), "auto");
assert.equal(normalizeFileProtocol("SFTP"), "sftp");
assert.equal(normalizeFileProtocol("scp"), "scp");
assert.equal(normalizeFileProtocol("other"), "auto");
});
});

View File

@@ -0,0 +1,225 @@
/**
* Integration-style tests: SCP-mode clients registered in sftpClients are
* reachable through the same list/mkdir/write entry points the UI and AI tools use.
*/
"use strict";
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const { createScpBackend, isScpModeClient } = require("./scpBackend.cjs");
const { createFileOpsApi } = require("./fileOps.cjs");
const { SCP_OK, buildFileControlLine } = require("./scpProtocol.cjs");
function createMockStream() {
const ee = new EventEmitter();
ee.writable = true;
ee.readable = true;
ee.stderr = new EventEmitter();
ee.write = (buf, cb) => {
ee.emit("_write", Buffer.from(buf));
if (typeof cb === "function") cb();
return true;
};
ee.end = (cb) => { if (typeof cb === "function") cb(); };
ee.close = () => ee.emit("close");
ee.destroy = () => ee.emit("close");
ee.pushFromRemote = (buf) => ee.emit("data", Buffer.from(buf));
return ee;
}
describe("SCP-mode session ops via fileOps entry points (AI/UI shared path)", () => {
let sftpClients;
let api;
let commands;
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-session-"));
sftpClients = new Map();
commands = [];
const backend = createScpBackend({
exec: async (command) => {
commands.push(command);
if (command.includes("for f in") || command.includes("cd ")) {
const name = Buffer.from("agent.txt").toString("base64");
return {
stdout: `f|-rw-r--r--|4|1700000000|${name}\n`,
stderr: "",
code: 0,
};
}
if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("rm ") || command.includes("rmdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("mv ")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("$HOME") || command.startsWith("printf")) {
return { stdout: "/home/agent\n", stderr: "", code: 0 };
}
if (command.includes("if [ ! -e")) {
return {
stdout: "f|-rw-r--r--|4|1700000000|/home/agent/agent.txt\n",
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 0 };
},
execStream: async (command) => {
commands.push(command);
const stream = createMockStream();
if (command.includes("scp -t")) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
const text = buf.toString("utf8");
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
} else if (command.includes("scp -f")) {
let ackCount = 0;
stream.on("_write", (buf) => {
if (!(buf[0] === SCP_OK && buf.length === 1)) return;
ackCount += 1;
if (ackCount === 1) {
setImmediate(() => {
stream.pushFromRemote(buildFileControlLine({ mode: 0o644, size: 4, name: "agent.txt" }));
});
} else if (ackCount === 2) {
setImmediate(() => {
stream.pushFromRemote(Buffer.concat([Buffer.from("data"), Buffer.from([0x00])]));
});
}
});
}
return stream;
},
});
const client = {
client: { exec: () => {} },
sftp: null,
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
async end() {},
};
sftpClients.set("scp-session-1", client);
// Minimal ctx for createFileOpsApi — only what list/mkdir/rename/delete/write need
api = createFileOpsApi({
get sftpClients() { return sftpClients; },
get electronModule() {
return {
webContents: { fromId: () => ({ send: () => {} }) },
};
},
fileWatcherBridge: { stopWatchersForSession: () => {} },
fs,
path,
Buffer,
console,
setTimeout,
clearTimeout,
jumpConnectionsMap: new Map(),
sftpEncodingState: new Map(),
normalizeEncoding: (e) => e || "utf-8",
isAsciiString: () => true,
requireSftpChannel: async () => { throw new Error("should not use SFTP channel in SCP mode"); },
resolveEncodingForRequest: () => "utf-8",
updateResolvedEncoding: () => "utf-8",
encodePath: (p) => p,
decodeName: (n) => n,
detectEncodingFromList: () => null,
statResultFromAttrs: (a) => a,
normalizeRemotePathString: async (_c, p) => p,
collectReadable: async () => Buffer.alloc(0),
writeToWritable: async () => {},
throwIfAborted: () => {},
pipeStreams: async () => {},
ensureRemoteDirForSession: async () => true,
removeRemotePathInternal: async () => {},
renameRemotePath: async () => {},
realpathAsync: async () => "/",
statAsync: async () => ({}),
readdirAsync: async () => [],
mkdirAsync: async () => {},
rmdirAsync: async () => {},
unlinkAsync: async () => {},
openFileAsync: async () => ({}),
writeFileChunkAsync: async () => {},
closeFileAsync: async () => {},
createAbortError: (s, m) => new Error(m),
copySftpEncodingState: () => {},
clearSftpEncodingState: () => {},
safeSend: () => {},
tempDirBridge: { getTempFilePath: (n) => path.join(tmpDir, n) },
randomUUID: () => "test-uuid",
});
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("marks clients as SCP-mode", () => {
assert.equal(isScpModeClient(sftpClients.get("scp-session-1")), true);
});
it("list works for SCP-mode session id (AI sftp.list path)", async () => {
const entries = await api.listSftp(null, { sftpId: "scp-session-1", path: "/home/agent" });
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "agent.txt");
assert.ok(commands.some((c) => c.includes("cd ") || c.includes("for f in")));
});
it("mkdir write-class op works for SCP-mode session (AI sftp.mkdir path)", async () => {
const ok = await api.mkdirSftp(null, { sftpId: "scp-session-1", path: "/home/agent/newdir" });
assert.equal(ok, true);
assert.ok(commands.some((c) => c.includes("mkdir") && c.includes("/home/agent/newdir")));
});
it("rename and delete work for SCP-mode session", async () => {
await api.renameSftp(null, {
sftpId: "scp-session-1",
oldPath: "/home/agent/a",
newPath: "/home/agent/b",
});
await api.deleteSftp(null, { sftpId: "scp-session-1", path: "/home/agent/b" });
assert.ok(commands.some((c) => c.includes("mv --")));
assert.ok(commands.some((c) => c.includes("rm ")));
});
it("write + homeDir work for SCP-mode session", async () => {
await api.writeSftp(null, {
sftpId: "scp-session-1",
path: "/home/agent/out.txt",
content: "hi\n",
});
assert.ok(commands.some((c) => c.includes("scp -t")));
const home = await api.getSftpHomeDir(null, { sftpId: "scp-session-1" });
assert.equal(home.success, true);
assert.equal(home.homeDir, "/home/agent");
});
it("capability catalog still exposes stable sftp.* CLI verbs", () => {
const { SFTP_CAPABILITIES } = require("../../capabilities/catalog/sftp.cjs");
const ids = SFTP_CAPABILITIES.map((c) => c.id);
for (const id of [
"sftp.list",
"sftp.mkdir",
"sftp.write",
"sftp.upload",
"sftp.download",
"sftp.delete",
"sftp.rename",
]) {
assert.ok(ids.includes(id), `missing capability ${id}`);
}
const listCap = SFTP_CAPABILITIES.find((c) => c.id === "sftp.list");
assert.deepEqual(listCap.surfaces.cli.command, ["sftp", "list"]);
assert.match(listCap.description, /SCP-mode/i);
});
});

View File

@@ -0,0 +1,443 @@
/**
* Shell quoting + remote browse/manage command builders for SCP-mode sessions.
* Pure helpers — no SSH I/O. Commands are designed for POSIX / BusyBox shells.
*/
"use strict";
class ScpShellError extends Error {
constructor(message, code = "SCP_SHELL_ERROR") {
super(message);
this.name = "ScpShellError";
this.code = code;
}
}
/**
* Single-quote a remote path/argument for POSIX sh.
* Rejects NUL and rejects empty strings for path ops (callers may allow empty for flags).
*/
function shellQuote(value, { allowEmpty = false } = {}) {
if (value == null) {
throw new ScpShellError("Shell argument is required");
}
const str = String(value);
if (str.includes("\0")) {
throw new ScpShellError("Shell argument must not contain NUL");
}
if (!allowEmpty && str.length === 0) {
throw new ScpShellError("Shell argument must not be empty");
}
// ' -> '\'' (end quote, escaped quote, re-open)
return `'${str.replace(/'/g, `'\\''`)}'`;
}
/**
* Validate a remote path used in shell commands.
* Blocks obvious injection patterns beyond quoting (newlines).
*/
function assertSafeRemotePath(remotePath) {
if (typeof remotePath !== "string" || !remotePath) {
throw new ScpShellError("Remote path is required");
}
if (remotePath.includes("\0") || /[\r\n]/.test(remotePath)) {
throw new ScpShellError("Remote path must not contain NUL or newlines");
}
return remotePath;
}
/**
* Build `scp -t` sink command (upload to remote directory parent).
* Destination is the directory that will receive the file named in the C line.
*/
function buildScpSinkCommand(remoteDir, encoding = "utf-8") {
const dir = assertSafeRemotePath(remoteDir || ".");
return `scp -t -- ${shellQuotePath(dir, encoding)}`;
}
/**
* Build `scp -f` source command (download from remote path).
*/
function buildScpSourceCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
return `scp -f -- ${shellQuotePath(p, encoding)}`;
}
/**
* Portable directory listing that emits one record per line:
* T|MODE|SIZE|MTIME|B64NAME
* where T is f/d/l (file/dir/link), MODE is octal perms, MTIME unix seconds,
* B64NAME is base64 of the basename (handles spaces/unicode safely).
*
* Uses a POSIX-oriented shell loop; avoids GNU-only find -printf.
*/
function buildListCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
// Join the for-loop body with newlines so we never emit invalid `do;` (`;`
// after `do` is a syntax error on POSIX sh and would force the ls -la fallback).
const loop = [
"for f in * .[!.]* ..?*; do",
// Include broken symlinks: -e is false for dangling links, but -L is true.
' { [ -e "$f" ] || [ -L "$f" ]; } || continue',
' [ "$f" = "." ] && continue',
' [ "$f" = ".." ] && continue',
' if [ -L "$f" ]; then t=l',
' elif [ -d "$f" ]; then t=d',
' else t=f; fi',
' lsline=$(ls -ld -- "$f" 2>/dev/null)',
' mode=$(printf "%s\\n" "$lsline" | awk \'{print $1}\')',
' owner=$(printf "%s\\n" "$lsline" | awk \'{print $3}\')',
// Use metadata only — never open the file (FIFOs/special files would hang on wc -c).
' size=$(stat -c %s -- "$f" 2>/dev/null || stat -f %z -- "$f" 2>/dev/null || echo 0)',
' [ -z "$size" ] && size=0',
' mtime=$(date -r "$f" +%s 2>/dev/null || stat -c %Y -- "$f" 2>/dev/null || stat -f %m -- "$f" 2>/dev/null || echo 0)',
// basename base64: prefer base64 -w0 (GNU), fallback base64, then od
// Prefer `base64` when present; otherwise openssl. Avoid `cmd | tr || fallback`
// because a missing base64 still lets tr succeed with empty input.
' if command -v base64 >/dev/null 2>&1; then b64=$(printf "%s" "$f" | base64 2>/dev/null | tr -d "\\r\\n")',
' elif command -v openssl >/dev/null 2>&1; then b64=$(printf "%s" "$f" | openssl base64 2>/dev/null | tr -d "\\r\\n")',
' else b64=; fi',
' printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "$size" "$mtime" "$b64" "${owner:-}"',
"done",
].join("\n");
return `cd ${q} || exit 1; ${loop}`;
}
/**
* Simpler listing using `ls -la` as a fallback parse path (for tests / remotes
* where the loop above is too heavy). Primary runtime still prefers buildListCommand.
*/
function buildListCommandLs(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return `LC_ALL=C ls -la -- ${q} 2>/dev/null || LC_ALL=C ls -la ${q}`;
}
function buildStatCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
// Emit: T|MODE_OCT|SIZE|MTIME|ABS|INO
// INO is optional identity for staged-upload race checks (SFTP v3 has none).
return [
`p=${q}`,
'if [ ! -e "$p" ] && [ ! -L "$p" ]; then echo "ENOENT" >&2; exit 2; fi',
'if [ -L "$p" ]; then t=l',
'elif [ -d "$p" ]; then t=d',
'else t=f; fi',
'mode=$(ls -ld -- "$p" 2>/dev/null | awk \'{print $1}\')',
'size=$(stat -c %s -- "$p" 2>/dev/null || stat -f %z -- "$p" 2>/dev/null || echo 0)',
'mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y -- "$p" 2>/dev/null || stat -f %m -- "$p" 2>/dev/null || echo 0)',
'abs=$(cd "$(dirname -- "$p")" 2>/dev/null && printf "%s/%s\\n" "$(pwd -P 2>/dev/null || pwd)" "$(basename -- "$p")" || printf "%s\\n" "$p")',
'ino=$(stat -c %i -- "$p" 2>/dev/null || stat -f %i -- "$p" 2>/dev/null || echo)',
'printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "${size:-0}" "${mtime:-0}" "$abs" "${ino}"',
].join("; ");
}
function buildMkdirCommand(remotePath, { recursive = true, encoding = "utf-8" } = {}) {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return recursive
? `mkdir -p -- ${q}`
: `mkdir -- ${q}`;
}
function buildDeleteCommand(remotePath, { recursive = false, encoding = "utf-8" } = {}) {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
if (recursive) {
return `rm -rf -- ${q}`;
}
return `rm -f -- ${q} 2>/dev/null || rmdir -- ${q}`;
}
function buildUnlinkCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return `rm -f -- ${q}`;
}
function buildRenameCommand(oldPath, newPath, encoding = "utf-8") {
const a = shellQuotePath(assertSafeRemotePath(oldPath), encoding);
const b = shellQuotePath(assertSafeRemotePath(newPath), encoding);
return `mv -- ${a} ${b}`;
}
function buildChmodCommand(remotePath, modeOctal, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const mode = String(modeOctal);
if (!/^[0-7]{3,4}$/.test(mode)) {
throw new ScpShellError(`Invalid chmod mode: ${mode}`);
}
// Mode is validated digits-only; path is shell-quoted.
return `chmod ${mode} -- ${shellQuotePath(p, encoding)}`;
}
function buildHomeCommand() {
return 'printf "%s\\n" "$HOME"';
}
function buildRealpathCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return (
`realpath -- ${q} 2>/dev/null || ` +
`readlink -f -- ${q} 2>/dev/null || ` +
`(cd ${q} 2>/dev/null && pwd -P) || ` +
`(cd "$(dirname -- ${q})" 2>/dev/null && printf "%s/%s\\n" "$(pwd -P 2>/dev/null || pwd)" "$(basename -- ${q})")`
);
}
/**
* Decode a base64-encoded remote basename using the session filename encoding.
* @param {string} b64
* @param {string} [encoding] utf-8 | gb18030 | auto
*/
function decodeListBasename(b64, encoding = "utf-8") {
const raw = Buffer.from(b64, "base64");
const enc = String(encoding || "utf-8").toLowerCase();
if (enc === "gb18030" || enc === "gbk" || enc === "gb2312") {
try {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
return iconv.decode(raw, "gb18030");
} catch {
return raw.toString("utf8");
}
}
return raw.toString("utf8");
}
/**
* Quote a remote path for shell, encoding non-UTF-8 path bytes when needed.
* For gb18030 hosts the remote filesystem expects gb18030 bytes, not UTF-8.
*/
function shellQuotePath(remotePath, encoding = "utf-8") {
assertSafeRemotePath(remotePath);
const enc = String(encoding || "utf-8").toLowerCase();
if (enc === "gb18030" || enc === "gbk" || enc === "gb2312") {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
const b64 = iconv.encode(remotePath, "gb18030").toString("base64");
// Expand base64 to raw bytes on the remote. Prefer base64, then openssl
// (same fallback used when listing names on minimal NAS hosts).
return `"$(printf '%s' '${b64}' | base64 -d 2>/dev/null || printf '%s' '${b64}' | base64 -D 2>/dev/null || printf '%s' '${b64}' | openssl base64 -d 2>/dev/null || printf '%s' '${b64}' | openssl enc -base64 -d 2>/dev/null)"`;
}
return shellQuote(remotePath);
}
/**
* Parse a username from an SFTP longname / `ls -l` line.
* Example: "-rwxr-xr-x 1 root root 4096 Jan 1 00:00 filename"
*/
function ownerFromSftpLongname(longname) {
if (!longname) return undefined;
const match = String(longname).match(/^[dlbcps\-][rwxsStT\-]{9}[+.@]?\s+\d+\s+(\S+)\s+\S+\s+/);
const owner = match?.[1]?.trim();
return owner || undefined;
}
function ownerFromUid(uid) {
if (typeof uid !== "number" || !Number.isFinite(uid)) return undefined;
return String(uid);
}
function normalizeListingOwner(value) {
if (typeof value !== "string") return undefined;
const owner = value.trim();
if (!owner || owner === "?" || owner === "UNKNOWN") return undefined;
return owner;
}
function resolveListingOwner({ owner, longname, uid } = {}) {
return normalizeListingOwner(owner)
|| ownerFromSftpLongname(longname)
|| ownerFromUid(uid);
}
/**
* Parse records from buildListCommand output.
* @returns {Array<{ name: string, type: 'file'|'directory'|'symlink', size: number, modifyTime: number, permissions?: string, owner?: string }>}
*/
function parseListRecords(stdout, encoding = "utf-8") {
const lines = String(stdout || "").split(/\r?\n/).filter(Boolean);
const results = [];
for (const line of lines) {
const parts = line.split("|");
if (parts.length < 5) continue;
const [t, modeStr, sizeStr, mtimeStr, b64, ownerRaw] = parts;
let name;
try {
name = decodeListBasename(b64, encoding);
} catch {
continue;
}
if (!name || name === "." || name === "..") continue;
const type = t === "d" ? "directory" : t === "l" ? "symlink" : "file";
const size = Number(sizeStr) || 0;
const modifyTime = (Number(mtimeStr) || 0) * 1000;
const permissions = parseLsModeToPermissions(modeStr);
const owner = resolveListingOwner({ owner: ownerRaw });
results.push({ name, type, size, modifyTime, permissions, ...(owner ? { owner } : {}) });
}
return results;
}
/**
* Parse classic `ls -la` output as a fallback.
*/
function parseLsLaOutput(stdout, { basePath = "" } = {}) {
const lines = String(stdout || "").split(/\r?\n/);
const results = [];
for (const line of lines) {
if (!line || line.startsWith("total ")) continue;
// permissions links owner group size month day time/year name
const match = line.match(
/^([dlbcps\-])([rwxsStT\-]{9})[+.@]?\s+\d+\s+(\S+)\s+\S+\s+(\d+)\s+(\S+\s+\S+\s+\S+)\s+(.+)$/,
);
if (!match) continue;
const typeChar = match[1];
const perm = match[2];
const owner = resolveListingOwner({ owner: match[3] });
const size = Number(match[4]) || 0;
let name = match[6];
// strip " -> target" only for symlink rows (backslash filenames may contain " -> ")
if (typeChar === "l") {
const arrow = name.indexOf(" -> ");
if (arrow >= 0) name = name.slice(0, arrow);
}
name = name.trim();
if (!name || name === "." || name === "..") continue;
// when listing a directory, ls -la path shows path prefix sometimes — use basename
if (name.includes("/") && basePath) {
const base = name.endsWith("/") ? name.slice(0, -1) : name;
const idx = base.lastIndexOf("/");
if (idx >= 0) name = base.slice(idx + 1);
}
const type = typeChar === "d" ? "directory" : typeChar === "l" ? "symlink" : "file";
results.push({
name,
type,
size,
modifyTime: Date.now(),
permissions: perm,
...(owner ? { owner } : {}),
});
}
return results;
}
function parseStatRecord(stdout) {
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
if (!line || line === "ENOENT") {
const err = new ScpShellError("No such file", "ENOENT");
err.code = "ENOENT";
throw err;
}
const parts = line.split("|");
if (parts.length < 5) {
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
}
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
? String(inoStr).trim()
: undefined;
return {
type: t === "d" ? "directory" : t === "l" ? "symlink" : "file",
isDirectory: t === "d",
isSymbolicLink: t === "l",
size: Number(sizeStr) || 0,
modifyTime: (Number(mtimeStr) || 0) * 1000,
mode: lsModeToNumber(modeStr),
permissions: parseLsModeToPermissions(modeStr),
path: abs,
...(ino ? { ino } : {}),
};
}
function parseLsModeToPermissions(modeStr) {
if (!modeStr || modeStr === "?") return undefined;
// -rwxr-xr-x or drwxr-xr-x → rwxr-xr-x
if (modeStr.length >= 10) return modeStr.slice(1, 10);
if (modeStr.length === 9) return modeStr;
return undefined;
}
function lsModeToNumber(modeStr) {
const perm = parseLsModeToPermissions(modeStr);
if (!perm || perm.length < 9) return 0;
const bit = (ch, w, r, x) => {
if (ch === "r") return r;
if (ch === "w") return w;
// lowercase s/t include execute; uppercase S/T are special-bit-only
if (ch === "x" || ch === "s" || ch === "t") return x;
return 0;
};
let mode = 0;
mode |= bit(perm[0], 0, 0o400, 0);
mode |= bit(perm[1], 0o200, 0, 0);
mode |= bit(perm[2], 0, 0, 0o100);
mode |= bit(perm[3], 0, 0o040, 0);
mode |= bit(perm[4], 0o020, 0, 0);
mode |= bit(perm[5], 0, 0, 0o010);
mode |= bit(perm[6], 0, 0o004, 0);
mode |= bit(perm[7], 0o002, 0, 0);
mode |= bit(perm[8], 0, 0, 0o001);
// Special bits: setuid / setgid / sticky (s/S in user/group exec, t/T in other)
if (perm[2] === "s" || perm[2] === "S") mode |= 0o4000;
if (perm[5] === "s" || perm[5] === "S") mode |= 0o2000;
if (perm[8] === "t" || perm[8] === "T") mode |= 0o1000;
return mode;
}
function modeToPermissionsString(mode) {
if (typeof mode !== "number") return undefined;
const toTriplet = (bits) =>
`${bits & 4 ? "r" : "-"}${bits & 2 ? "w" : "-"}${bits & 1 ? "x" : "-"}`;
return `${toTriplet((mode >> 6) & 7)}${toTriplet((mode >> 3) & 7)}${toTriplet(mode & 7)}`;
}
/**
* Normalize file protocol preference from host/open options.
* @returns {'auto'|'sftp'|'scp'}
*/
function normalizeFileProtocol(value) {
const v = String(value || "auto").toLowerCase().trim();
if (v === "sftp" || v === "scp") return v;
return "auto";
}
function isScpModeClient(client) {
return !!(client && client.__netcattyFileProtocol === "scp");
}
module.exports = {
ScpShellError,
shellQuote,
assertSafeRemotePath,
buildScpSinkCommand,
buildScpSourceCommand,
buildListCommand,
buildListCommandLs,
buildStatCommand,
buildMkdirCommand,
buildDeleteCommand,
buildUnlinkCommand,
buildRenameCommand,
buildChmodCommand,
buildHomeCommand,
buildRealpathCommand,
parseListRecords,
parseLsLaOutput,
ownerFromSftpLongname,
ownerFromUid,
resolveListingOwner,
parseStatRecord,
parseLsModeToPermissions,
lsModeToNumber,
modeToPermissionsString,
decodeListBasename,
shellQuotePath,
normalizeFileProtocol,
isScpModeClient,
};