[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
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:
417
electron/bridges/sftpBridge/archiveExtract.cjs
Normal file
417
electron/bridges/sftpBridge/archiveExtract.cjs
Normal 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,
|
||||
};
|
||||
188
electron/bridges/sftpBridge/archiveExtract.test.cjs
Normal file
188
electron/bridges/sftpBridge/archiveExtract.test.cjs
Normal 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 });
|
||||
});
|
||||
1221
electron/bridges/sftpBridge/fileOps.cjs
Normal file
1221
electron/bridges/sftpBridge/fileOps.cjs
Normal file
File diff suppressed because it is too large
Load Diff
58
electron/bridges/sftpBridge/fileOps.extract.test.cjs
Normal file
58
electron/bridges/sftpBridge/fileOps.extract.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
514
electron/bridges/sftpBridge/fileOps.test.cjs
Normal file
514
electron/bridges/sftpBridge/fileOps.test.cjs
Normal 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");
|
||||
});
|
||||
1385
electron/bridges/sftpBridge/openConnection.cjs
Normal file
1385
electron/bridges/sftpBridge/openConnection.cjs
Normal file
File diff suppressed because it is too large
Load Diff
120
electron/bridges/sftpBridge/openConnection.sudo.test.cjs
Normal file
120
electron/bridges/sftpBridge/openConnection.sudo.test.cjs
Normal 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);
|
||||
});
|
||||
988
electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs
Normal file
988
electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
1193
electron/bridges/sftpBridge/scpBackend.cjs
Normal file
1193
electron/bridges/sftpBridge/scpBackend.cjs
Normal file
File diff suppressed because it is too large
Load Diff
701
electron/bridges/sftpBridge/scpBackend.test.cjs
Normal file
701
electron/bridges/sftpBridge/scpBackend.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
163
electron/bridges/sftpBridge/scpIntegration.fileOps.real.test.cjs
Normal file
163
electron/bridges/sftpBridge/scpIntegration.fileOps.real.test.cjs
Normal 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); });
|
||||
});
|
||||
}
|
||||
250
electron/bridges/sftpBridge/scpIntegration.real.test.cjs
Normal file
250
electron/bridges/sftpBridge/scpIntegration.real.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
348
electron/bridges/sftpBridge/scpProtocol.cjs
Normal file
348
electron/bridges/sftpBridge/scpProtocol.cjs
Normal 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,
|
||||
};
|
||||
232
electron/bridges/sftpBridge/scpProtocol.test.cjs
Normal file
232
electron/bridges/sftpBridge/scpProtocol.test.cjs
Normal 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");
|
||||
});
|
||||
});
|
||||
225
electron/bridges/sftpBridge/scpSessionOps.test.cjs
Normal file
225
electron/bridges/sftpBridge/scpSessionOps.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
443
electron/bridges/sftpBridge/scpShell.cjs
Normal file
443
electron/bridges/sftpBridge/scpShell.cjs
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user