Files
NetMesh/electron/bridges/sftpBridge/scpBackend.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1194 lines
40 KiB
JavaScript

/**
* SCP-mode remote filesystem backend.
* Browse/manage via carefully quoted shell; transfers via OpenSSH scp -t/-f protocol.
*
* The exec layer is injectible for unit tests:
* exec(command) -> Promise<{ stdout, stderr, code }>
* execStream(command) -> Promise<Duplex-like stream with write/on/close/end>
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { executeBoundedSshCommand, terminateSshExecStream } = require("../boundedSshExec.cjs");
const { invalidateSshTransport } = require("../sshTransportInvalidation.cjs");
const {
buildFileControlLine,
buildAck,
consumeAck,
createSourceStreamParser,
ScpProtocolError,
} = require("./scpProtocol.cjs");
const {
shellQuote,
assertSafeRemotePath,
buildScpSinkCommand,
buildScpSourceCommand,
buildListCommand,
buildListCommandLs,
buildStatCommand,
buildMkdirCommand,
buildDeleteCommand,
buildUnlinkCommand,
buildRenameCommand,
buildChmodCommand,
buildHomeCommand,
buildRealpathCommand,
parseListRecords,
parseLsLaOutput,
parseStatRecord,
modeToPermissionsString,
isScpModeClient,
shellQuotePath,
ScpShellError,
} = require("./scpShell.cjs");
/**
* Bridge an AbortSignal to the transfer object used by SCP upload/download.
* scpBackend only observes transfer.cancelled + transfer.abort(); signal.aborted
* alone is never read mid-transfer.
*/
function createTransferFromAbortSignal(signal) {
if (!signal) return null;
const transfer = {
cancelled: !!signal.aborted,
abort: null,
};
const onAbort = () => {
transfer.cancelled = true;
try {
if (typeof transfer.abort === "function") transfer.abort();
} catch {
// ignore abort hook failures
}
};
if (signal.aborted) {
transfer.cancelled = true;
} else if (typeof signal.addEventListener === "function") {
signal.addEventListener("abort", onAbort, { once: true });
transfer.detachAbortSignal = () => {
try {
signal.removeEventListener("abort", onAbort);
} catch {
// ignore
}
};
}
return transfer;
}
function createScpBackend(deps = {}) {
const {
exec,
execStream,
fsModule = fs,
pathModule = path,
} = deps;
if (typeof exec !== "function") {
throw new Error("scpBackend requires deps.exec(command)");
}
if (typeof execStream !== "function") {
throw new Error("scpBackend requires deps.execStream(command)");
}
async function runOrThrow(command, { allowNonZero = false, signal = null } = {}) {
if (signal?.aborted) {
throw new Error("Transfer cancelled");
}
const result = await exec(command, { signal });
if (!allowNonZero && result.code !== 0 && result.code != null) {
const detail = (result.stderr || result.stdout || "").trim() || `exit ${result.code}`;
throw new ScpShellError(detail);
}
return result;
}
async function list(remotePath, options = {}) {
const dir = remotePath || ".";
const encoding = options.encoding || "utf-8";
const signal = options.signal || null;
assertSafeRemotePath(dir === "." ? "." : dir);
let records;
try {
const result = await runOrThrow(buildListCommand(dir, encoding), { allowNonZero: true, signal });
if (result.code === 0) {
records = parseListRecords(result.stdout, encoding);
if (records.length === 0 && (result.stdout || "").trim()) {
records = null;
}
}
} catch (err) {
if (signal?.aborted || /cancel/i.test(err?.message || "")) throw err;
records = null;
}
if (!records) {
const ls = await runOrThrow(buildListCommandLs(dir, encoding), { signal });
records = parseLsLaOutput(ls.stdout, { basePath: dir });
}
// Resolve symlink targets so UI can navigate directory links
// (isNavigableDirectory requires linkTarget === "directory").
const base = dir === "." ? "." : dir.replace(/\/+$/, "") || "/";
const entries = [];
for (const rec of records) {
if (signal?.aborted) throw new Error("Transfer cancelled");
const entry = toListEntry(rec);
if (rec.type === "symlink") {
const fullPath = base === "/"
? `/${rec.name}`
: base === "."
? rec.name
: `${base}/${rec.name}`;
entry.linkTarget = await resolveSymlinkTargetType(fullPath, { encoding, signal });
}
entries.push(entry);
}
return entries;
}
/**
* Follow a symlink with shell tests (same idea as SFTP client.stat following links).
* `[ -d path ]` follows symlinks; combined with known-symlink listing.
*/
async function resolveSymlinkTargetType(remotePath, options = {}) {
try {
const encoding = options.encoding || "utf-8";
const signal = options.signal || null;
// -e fails for broken links; -d follows to the target.
const cmd = [
`p=${shellQuotePath(remotePath, encoding)}`,
'if [ ! -e "$p" ] && [ ! -L "$p" ]; then echo broken; exit 0; fi',
'if [ -d "$p" ]; then echo directory; else echo file; fi',
].join("; ");
const result = await runOrThrow(cmd, { allowNonZero: true, signal });
const kind = (result.stdout || "").trim().split(/\r?\n/).pop();
if (kind === "directory") return "directory";
if (kind === "file") return "file";
return null;
} catch {
return null;
}
}
function toListEntry(rec) {
const sizeNum = Number(rec.size) || 0;
return {
name: rec.name,
type: rec.type,
linkTarget: rec.type === "symlink" ? "file" : null,
size: `${sizeNum} bytes`,
lastModified: new Date(rec.modifyTime || Date.now()).toISOString(),
permissions: rec.permissions,
...(rec.owner ? { owner: rec.owner } : {}),
// raw fields for internal use
_size: sizeNum,
_modifyTime: rec.modifyTime,
};
}
async function stat(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
const result = await runOrThrow(buildStatCommand(remotePath, encoding), { signal });
return parseStatRecord(result.stdout);
}
async function mkdir(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
await runOrThrow(buildMkdirCommand(remotePath, {
recursive: options.recursive !== false,
encoding,
}), { signal });
return true;
}
async function remove(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
let recursive = !!options.recursive;
if (!recursive) {
try {
const st = await stat(remotePath, { signal, encoding });
recursive = st.isDirectory;
} catch {
// best-effort delete
}
}
await runOrThrow(buildDeleteCommand(remotePath, { recursive, encoding }), { signal });
return true;
}
async function unlink(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
await runOrThrow(buildUnlinkCommand(remotePath, encoding), { signal });
return true;
}
async function rename(oldPath, newPath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
await runOrThrow(buildRenameCommand(oldPath, newPath, encoding), { signal });
return true;
}
async function chmod(remotePath, mode, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
let modeStr;
if (typeof mode === "number") {
modeStr = (mode & 0o7777).toString(8).padStart(3, "0");
} else {
modeStr = String(mode);
}
await runOrThrow(buildChmodCommand(remotePath, modeStr, encoding), { signal });
return true;
}
/**
* Decode remote path bytes using the session encoding. When encoding is utf-8
* (including auto→utf-8 first open), invalid UTF-8 sequences fall back to
* gb18030 so Chinese $HOME paths open instead of becoming mojibake that then
* fails to list. Returns the decoded string and the encoding that produced it.
*/
function decodeRemotePathBytes(raw, encoding = "utf-8") {
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || "");
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 { text: iconv.decode(buf, "gb18030"), encoding: "gb18030" };
} catch {
return { text: buf.toString("utf8"), encoding: "utf-8" };
}
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
return { text, encoding: "utf-8" };
} catch {
try {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
return { text: iconv.decode(buf, "gb18030"), encoding: "gb18030" };
} catch {
return { text: buf.toString("utf8"), encoding: "utf-8" };
}
}
}
async function homeDir(options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
// Prefer a marker so we know base64 succeeded (raw $HOME may also contain '/').
const result = await runOrThrow(
'if command -v base64 >/dev/null 2>&1; then printf "B64:"; printf "%s" "$HOME" | base64 | tr -d "\\r\\n"; '
+ 'elif command -v openssl >/dev/null 2>&1; then printf "B64:"; printf "%s" "$HOME" | openssl base64 | tr -d "\\r\\n"; '
+ 'else printf "RAW:%s" "$HOME"; fi',
{ signal },
);
let home = (result.stdout || "").trim();
let usedEncoding = String(encoding || "utf-8").toLowerCase();
if (home.startsWith("B64:")) {
try {
const raw = Buffer.from(home.slice(4), "base64");
const decoded = decodeRemotePathBytes(raw, encoding);
home = decoded.text;
usedEncoding = decoded.encoding;
} catch {
home = "";
}
} else if (home.startsWith("RAW:")) {
home = home.slice(4);
}
home = (home || "").trim();
if (typeof options.onDetectedEncoding === "function" && usedEncoding) {
try {
options.onDetectedEncoding(usedEncoding);
} catch {
// ignore observer errors
}
}
if (home) return home;
throw new ScpShellError("Could not determine home directory");
}
async function realpath(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
const result = await runOrThrow(buildRealpathCommand(remotePath || ".", encoding), { signal });
const abs = (result.stdout || "").trim().split(/\r?\n/)[0];
if (!abs) throw new ScpShellError("Could not resolve remote path");
return abs;
}
async function readFile(remotePath, options = {}) {
// Prefer scp -f for binary fidelity; also works when cat is restricted.
const chunks = [];
await downloadToWritable(remotePath, {
write: (buf) => {
chunks.push(Buffer.from(buf));
},
end: () => {},
}, {
fileSize: null,
transfer: options.transfer || null,
onProgress: null,
encoding: options.encoding || "utf-8",
signal: options.signal || null,
maxBytes: options.maxBytes,
});
return Buffer.concat(chunks);
}
async function writeFile(remotePath, content, options = {}) {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
const tmpLocal = options.tempLocalPath;
if (tmpLocal) {
await fsModule.promises.writeFile(tmpLocal, buffer);
try {
await uploadFile(tmpLocal, remotePath, {
fileSize: buffer.length,
transfer: options.transfer || null,
onProgress: options.onProgress || null,
mode: options.mode,
encoding: options.encoding || "utf-8",
signal: options.signal || null,
});
} finally {
try { await fsModule.promises.unlink(tmpLocal); } catch { /* ignore */ }
}
return true;
}
// Stream from memory via a temp-less path: write through scp sink using buffer
await uploadBuffer(buffer, remotePath, {
transfer: options.transfer || null,
onProgress: options.onProgress || null,
mode: options.mode || 0o0644,
encoding: options.encoding || "utf-8",
signal: options.signal || null,
});
return true;
}
async function uploadFile(localPath, remotePath, options = {}) {
// Always use a fresh size for the SCP wire header so a shrinking/growing
// file between enqueue and open cannot desync the remote scp -t peer.
const st = await fsModule.promises.stat(localPath);
const hasProvidedReadStream = typeof options.openReadStream === "function";
const fileSize = hasProvidedReadStream ? Number(options.fileSize) : st.size;
if (!Number.isSafeInteger(fileSize) || fileSize < 0) {
throw new Error("Verified SCP uploads require a valid file size");
}
const mode = options.mode != null ? options.mode : (st.mode & 0o777);
const transfer = options.transfer || null;
const onProgress = options.onProgress || null;
const encoding = options.encoding || "utf-8";
const signal = options.signal || null;
const remoteParent = pathModule.posix.dirname(remotePath) || ".";
const baseName = pathModule.posix.basename(remotePath);
assertSafeRemotePath(remoteParent === "." ? "." : remoteParent);
assertSafeRemotePath(remotePath);
// Ensure parent exists
if (remoteParent && remoteParent !== ".") {
await mkdir(remoteParent, { recursive: true, encoding, signal });
}
const command = buildScpSinkCommand(remoteParent, encoding);
const stream = await execStream(command, { signal: options.signal || transfer?.signal || null });
const abort = () => {
try { stream.close?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
};
if (transfer) {
const prev = transfer.abort;
transfer.abort = () => {
abort();
if (typeof prev === "function") prev();
};
}
try {
// Register for ACK before the remote can reply (and before we write).
await waitForAck(stream, transfer, signal);
const control = buildFileControlLine({
mode,
size: fileSize,
name: baseName,
encoding,
});
// Arm ACK before write so a fast remote cannot race; if write fails, swallow
// the orphaned ACK rejection (stream close would otherwise go unhandled).
await writeAndWaitAck(stream, control, transfer, signal);
let transferred = 0;
// Arm the final ACK listener before the trailing NUL so a fast remote cannot
// race past waitForAck and hang the upload. Race it with the stream so a
// mid-stream remote error/cancel does not leave an unhandled rejection.
const finalAck = waitForAck(stream, transfer, signal);
let activeReadStream = null;
let activeReadCompletion = Promise.resolve();
const streamDone = new Promise((resolve, reject) => {
const openedReadStream = hasProvidedReadStream
? options.openReadStream()
: fsModule.createReadStream(localPath, { highWaterMark: 256 * 1024 });
const readStream = openedReadStream?.stream || openedReadStream;
activeReadCompletion = Promise.resolve(openedReadStream?.completed);
activeReadStream = readStream;
if (transfer) transfer.readStream = readStream;
let settled = false;
const finish = (err) => {
if (settled) return;
settled = true;
readStream.removeAllListeners();
if (err) {
try { readStream.destroy(); } catch { /* ignore */ }
reject(err);
} else {
resolve();
}
};
readStream.on("data", (chunk) => {
if (transfer?.cancelled || signal?.aborted) {
finish(new Error("Transfer cancelled"));
return;
}
if (transferred + chunk.length > fileSize) {
finish(new Error("Verified SCP upload stream exceeded its declared size"));
return;
}
const ok = stream.write(chunk);
transferred += chunk.length;
if (typeof onProgress === "function") {
onProgress(transferred, fileSize);
}
if (!ok) {
readStream.pause();
stream.once("drain", () => readStream.resume());
}
});
readStream.on("error", finish);
readStream.on("end", () => {
if (transferred !== fileSize) {
finish(new Error(
`Verified SCP upload stream ended early: expected ${fileSize} bytes, got ${transferred}`,
));
return;
}
stream.write(Buffer.from([0x00]));
finish(null);
});
stream.on("error", finish);
});
try {
await Promise.all([streamDone, finalAck]);
} catch (err) {
// Whichever side loses first must not leave the other as unhandled rejection,
// and must stop the local reader (including a drain-paused stream).
void streamDone.catch(() => {});
void finalAck.catch(() => {});
try { activeReadStream?.destroy(); } catch { /* ignore */ }
await activeReadCompletion.catch(() => {});
throw err;
}
await activeReadCompletion;
await closeStream(stream);
if (transfer?.cancelled || signal?.aborted) throw new Error("Transfer cancelled");
if (typeof onProgress === "function") onProgress(fileSize, fileSize);
return true;
} catch (err) {
abort();
try { transfer?.readStream?.destroy(); } catch { /* ignore */ }
throw err;
}
}
async function uploadBuffer(buffer, remotePath, options = {}) {
const fileSize = buffer.length;
const mode = options.mode != null ? options.mode : 0o0644;
const transfer = options.transfer || null;
const onProgress = options.onProgress || null;
const remoteParent = pathModule.posix.dirname(remotePath) || ".";
const baseName = pathModule.posix.basename(remotePath);
const encoding = options.encoding || "utf-8";
const signal = options.signal || null;
if (remoteParent && remoteParent !== ".") {
await mkdir(remoteParent, { recursive: true, encoding, signal });
}
const command = buildScpSinkCommand(remoteParent, encoding);
const stream = await execStream(command, { signal: signal || transfer?.signal || null });
const abort = () => {
try { stream.close?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
};
if (transfer) {
const prev = transfer.abort;
transfer.abort = () => {
abort();
if (typeof prev === "function") prev();
};
}
try {
await waitForAck(stream, transfer, signal);
await writeAndWaitAck(stream, buildFileControlLine({
mode,
size: fileSize,
name: baseName,
encoding,
}), transfer, signal);
const chunkSize = 256 * 1024;
let offset = 0;
while (offset < buffer.length) {
if (transfer?.cancelled || signal?.aborted) throw new Error("Transfer cancelled");
const end = Math.min(offset + chunkSize, buffer.length);
await writeAll(stream, buffer.subarray(offset, end));
offset = end;
if (typeof onProgress === "function") onProgress(offset, fileSize);
}
// Arm final ACK before trailing NUL; swallow orphan if write fails.
await writeAndWaitAck(stream, Buffer.from([0x00]), transfer, signal);
await closeStream(stream);
return true;
} catch (err) {
abort();
throw err;
}
}
async function downloadFile(remotePath, localPath, options = {}) {
const transfer = options.transfer || null;
const onProgress = options.onProgress || null;
let fileSize = Number.isFinite(options.fileSize) ? options.fileSize : null;
if (fileSize == null) {
try {
const st = await stat(remotePath, {
encoding: options.encoding || "utf-8",
signal: options.signal || null,
});
fileSize = st.size;
} catch {
fileSize = 0;
}
}
await fsModule.promises.mkdir(pathModule.dirname(localPath), { recursive: true });
const writeStream = fsModule.createWriteStream(localPath);
if (transfer) transfer.writeStream = writeStream;
// Attach error listener immediately — createWriteStream can emit before we open SCP.
let earlyWriteError = null;
const onEarlyWriteError = (err) => {
earlyWriteError = err || new Error("Local write stream failed");
};
writeStream.on("error", onEarlyWriteError);
try {
if (earlyWriteError) throw earlyWriteError;
const downloadResult = await downloadToWritable(remotePath, writeStream, {
fileSize,
transfer,
onProgress,
encoding: options.encoding || "utf-8",
signal: options.signal || null,
});
if (earlyWriteError) throw earlyWriteError;
await new Promise((resolve, reject) => {
writeStream.end((err) => (err ? reject(err) : resolve()));
});
return downloadResult;
} catch (err) {
try { writeStream.destroy(); } catch { /* ignore */ }
try { await fsModule.promises.unlink(localPath); } catch { /* ignore */ }
throw err;
} finally {
try { writeStream.removeListener("error", onEarlyWriteError); } catch { /* ignore */ }
}
}
async function downloadToWritable(remotePath, writable, {
fileSize,
transfer,
onProgress,
encoding = "utf-8",
signal = null,
maxBytes = null,
} = {}) {
assertSafeRemotePath(remotePath);
const command = buildScpSourceCommand(remotePath, encoding);
const stream = await execStream(command, {
signal: signal || transfer?.signal || null,
});
const isCancelled = () => transfer?.cancelled || signal?.aborted;
let settleDownload = null;
const abort = () => {
try { stream.close?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
};
if (transfer) {
const prev = transfer.abort;
transfer.abort = () => {
settleDownload?.(new Error("Transfer cancelled"));
abort();
if (typeof prev === "function") prev();
};
}
const parser = createSourceStreamParser({ encoding });
let transferred = 0;
let expectedSize = fileSize;
let gotFile = false;
const readLimit = Number.isSafeInteger(maxBytes) && maxBytes >= 0 ? maxBytes : null;
const createReadLimitError = (actualBytes) => {
const error = new Error(
`Remote file is too large to read into memory (${actualBytes} bytes; maximum ${readLimit} bytes). `
+ "Use SFTP download for large files.",
);
error.code = "SFTP_READ_TOO_LARGE";
error.maxBytes = readLimit;
error.actualBytes = actualBytes;
return error;
};
await new Promise((resolve, reject) => {
let settled = false;
const finish = (err) => {
if (settled) return;
settled = true;
settleDownload = null;
// Explicit untrack before removeAllListeners (which would drop once handlers).
try { stream.__netcattyUntrack?.(); } catch { /* ignore */ }
try { stream.__netcattyCleanupAbort?.(); } catch { /* ignore */ }
stream.removeAllListeners();
if (err) reject(err);
else resolve();
};
settleDownload = finish;
let processing = false;
const queue = [];
const processQueue = async () => {
if (processing || settled) return;
processing = true;
try {
while (queue.length > 0 && !settled) {
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
return;
}
const chunk = queue.shift();
try {
const events = parser.feed(chunk);
for (const ev of events) {
if (settled) return;
if (ev.type === "file-start") {
gotFile = true;
expectedSize = ev.size;
if (readLimit != null && ev.size > readLimit) {
finish(createReadLimitError(ev.size));
abort();
return;
}
stream.write(buildAck());
} else if (ev.type === "file-data") {
if (readLimit != null && transferred + ev.data.length > readLimit) {
finish(createReadLimitError(transferred + ev.data.length));
abort();
return;
}
const canContinue = writable.write(ev.data);
transferred += ev.data.length;
if (typeof onProgress === "function") {
onProgress(transferred, expectedSize || transferred);
}
// Backpressure: wait for drain, or fail if the writable errors.
if (canContinue === false) {
await new Promise((resolveDrain, rejectDrain) => {
const onDrain = () => {
cleanup();
resolveDrain();
};
const onWritableError = (err) => {
cleanup();
rejectDrain(err);
};
const cleanup = () => {
writable.removeListener("drain", onDrain);
writable.removeListener("error", onWritableError);
};
writable.once("drain", onDrain);
writable.once("error", onWritableError);
});
}
} else if (ev.type === "file-end") {
stream.write(buildAck());
if (typeof onProgress === "function") {
onProgress(transferred, expectedSize || transferred);
}
finish(null);
try { stream.close?.(); } catch { /* ignore */ }
return;
} else if (ev.type === "directory") {
stream.write(buildAck());
} else if (ev.type === "end-directory") {
stream.write(buildAck());
} else if (ev.type === "time") {
stream.write(buildAck());
}
}
} catch (err) {
finish(err);
return;
}
}
} finally {
processing = false;
if (!settled && queue.length > 0) {
void processQueue();
} else if (!settled && typeof stream.resume === "function") {
try { stream.resume(); } catch { /* ignore */ }
}
}
};
stream.on("data", (chunk) => {
if (settled) return;
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
return;
}
queue.push(Buffer.from(chunk));
// Pause the remote stream while we apply local backpressure.
try { stream.pause?.(); } catch { /* ignore */ }
void processQueue();
});
stream.on("error", (err) => {
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
return;
}
finish(err);
});
if (typeof writable?.on === "function") {
writable.on("error", (err) => {
finish(err || new Error("Local write stream failed"));
try { stream.close?.(); } catch { /* ignore */ }
});
}
stream.on("close", () => {
if (settled) return;
// Abort closes the stream; surface cancel rather than protocol incompleteness.
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
return;
}
try {
parser.finish();
} catch (err) {
finish(err);
return;
}
if (!gotFile) {
finish(new ScpProtocolError("SCP source closed without a file"));
return;
}
finish(null);
});
stream.stderr?.on?.("data", () => {
// ignore stderr chatter unless we fail
});
// execStream may return a stream that its AbortSignal handler already
// closed synchronously. Settle from cancellation state instead of
// depending on a close event that may have happened before listeners.
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
abort();
return;
}
if (stream.__netcattyEarlyError) {
finish(stream.__netcattyEarlyError);
return;
}
if (
stream.__netcattyClosed === true
|| stream.closed === true
|| stream.destroyed === true
|| stream.readableEnded === true
) {
finish(new ScpProtocolError("SCP source closed before protocol setup"));
return;
}
void writeAll(stream, buildAck()).then(() => {
if (isCancelled()) {
finish(new Error("Transfer cancelled"));
}
}, finish);
});
if (isCancelled()) throw new Error("Transfer cancelled");
return { fileSize: expectedSize, transferred };
}
/**
* Write a buffer then wait for the peer ACK. Arms the ACK listener before the
* write so a fast remote cannot race past waitForAck. If the write fails (or
* anything throws before the ACK settles), the orphaned ACK rejection is
* swallowed so process-level unhandledRejection does not fire when the
* stream later closes under abort().
*/
async function writeAndWaitAck(stream, buffer, transfer, signal = null) {
const ack = waitForAck(stream, transfer, signal);
try {
await writeAll(stream, buffer);
await ack;
} catch (err) {
void ack.catch(() => {});
throw err;
}
}
function waitForAck(stream, transfer, signal = null) {
return new Promise((resolve, reject) => {
let buf = Buffer.alloc(0);
let settled = false;
let poll = null;
const onData = (chunk) => {
if (settled) return;
if (transfer?.cancelled || signal?.aborted) {
cleanup();
reject(new Error("Transfer cancelled"));
return;
}
buf = Buffer.concat([buf, Buffer.from(chunk)]);
const result = consumeAck(buf);
if (result.status === "incomplete") return;
cleanup();
if (result.status === "ok") resolve();
else reject(new ScpProtocolError(result.message || "SCP remote error", result.status));
};
const onError = (err) => {
if (settled) return;
cleanup();
reject(err);
};
const onClose = () => {
if (settled) return;
cleanup();
if (transfer?.cancelled || signal?.aborted) {
reject(new Error("Transfer cancelled"));
return;
}
reject(new ScpProtocolError("SCP stream closed while waiting for ACK"));
};
const cleanup = () => {
settled = true;
if (poll) {
clearInterval(poll);
poll = null;
}
stream.removeListener("data", onData);
stream.removeListener("error", onError);
stream.removeListener("close", onClose);
};
stream.on("data", onData);
stream.on("error", onError);
stream.on("close", onClose);
// Poll cancel flag so aborts work even when the remote sends no bytes.
if (transfer || signal) {
poll = setInterval(() => {
if (transfer?.cancelled || signal?.aborted) {
cleanup();
reject(new Error("Transfer cancelled"));
}
}, 25);
if (typeof poll.unref === "function") poll.unref();
}
if (transfer?.cancelled || signal?.aborted) {
cleanup();
reject(new Error("Transfer cancelled"));
return;
}
if (stream.__netcattyEarlyError) {
const earlyError = stream.__netcattyEarlyError;
cleanup();
reject(earlyError);
return;
}
if (
stream.__netcattyClosed === true
|| stream.closed === true
|| stream.destroyed === true
|| stream.readableEnded === true
) {
cleanup();
reject(new ScpProtocolError("SCP stream closed before waiting for ACK"));
}
});
}
function writeAll(stream, buffer) {
return new Promise((resolve, reject) => {
if (!stream || typeof stream.write !== "function") {
reject(new Error("Invalid SCP stream"));
return;
}
let settled = false;
const done = (err) => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve();
};
try {
let invokedCallback = false;
const ok = stream.write(buffer, (err) => {
invokedCallback = true;
done(err);
});
if (ok === false) {
stream.once("drain", () => done());
return;
}
// Some mocks ignore the write callback; resolve on next tick if still pending.
queueMicrotask(() => {
if (!settled && !invokedCallback) done();
});
} catch (err) {
done(err);
}
});
}
function closeStream(stream) {
return new Promise((resolve) => {
try {
if (typeof stream.end === "function") {
stream.end(() => resolve());
return;
}
} catch { /* ignore */ }
try { stream.close?.(); } catch { /* ignore */ }
resolve();
});
}
return {
list,
stat,
mkdir,
remove,
unlink,
rename,
chmod,
homeDir,
realpath,
readFile,
writeFile,
uploadFile,
uploadBuffer,
downloadFile,
downloadToWritable,
};
}
/**
* Build exec/execStream adapters from an ssh2 Client.
*/
function createSshExecAdapters(sshClient) {
function exec(command, options = {}) {
return executeBoundedSshCommand(sshClient, command, {
signal: options.signal || null,
openingTimeoutMs: options.openingTimeoutMs,
runTimeoutMs: options.runTimeoutMs,
maxOutputBytes: options.maxOutputBytes,
});
}
function execStream(command, options = {}) {
const signal = options.signal || null;
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Transfer cancelled"));
return;
}
let settled = false;
let streamRef = null;
let openingTimer = null;
let abortCleaned = false;
const cleanupAbort = () => {
if (abortCleaned || !signal) return;
abortCleaned = true;
try { signal.removeEventListener("abort", onAbort); } catch { /* ignore */ }
if (streamRef?.__netcattyCleanupAbort === cleanupAbort) {
delete streamRef.__netcattyCleanupAbort;
}
if (openingTimer) clearTimeout(openingTimer);
openingTimer = null;
};
const onAbort = () => {
try { streamRef?.close?.(); } catch { /* ignore */ }
try { streamRef?.destroy?.(); } catch { /* ignore */ }
cleanupAbort();
if (!settled) {
settled = true;
reject(new Error("Transfer cancelled"));
if (!streamRef) invalidateSshTransport(sshClient);
}
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return;
}
}
openingTimer = setTimeout(() => {
if (settled) return;
settled = true;
cleanupAbort();
terminateSshExecStream(streamRef);
reject(new Error("SSH exec channel open timed out after 15000 ms"));
invalidateSshTransport(sshClient);
}, 15_000);
openingTimer.unref?.();
try {
sshClient.exec(command, (err, stream) => {
if (openingTimer) clearTimeout(openingTimer);
openingTimer = null;
if (settled) {
try { stream?.close?.(); } catch { /* ignore */ }
try { stream?.destroy?.(); } catch { /* ignore */ }
return;
}
if (err) {
settled = true;
cleanupAbort();
reject(err);
return;
}
streamRef = stream;
// Keep terminal stream state sticky across the async handoff. A close
// or error can otherwise occur after this promise resolves but before
// the SCP parser installs its own listeners.
stream.__netcattyClosed = false;
stream.__netcattyEarlyError = null;
stream.on("close", () => {
stream.__netcattyClosed = true;
});
stream.on("error", (streamErr) => {
stream.__netcattyEarlyError = stream.__netcattyEarlyError || streamErr;
});
if (signal) {
stream.__netcattyCleanupAbort = cleanupAbort;
stream.on("close", cleanupAbort);
// AbortSignal does not replay an abort that happened just before the
// listener was installed. Recheck during the exec handoff.
if (signal.aborted) {
cleanupAbort();
onAbort();
reject(new Error("Transfer cancelled"));
return;
}
}
settled = true;
resolve(stream);
});
} catch (err) {
if (settled) return;
settled = true;
cleanupAbort();
reject(err);
}
});
}
return { exec, execStream };
}
/**
* Get or create a cached SCP backend for a client object.
*/
function getScpBackendForClient(client) {
if (!client) throw new Error("SFTP session not found");
if (client.__netcattyScpBackend) return client.__netcattyScpBackend;
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== "function") {
throw new Error("SCP mode requires an SSH session with exec support");
}
// Track live exec streams so closeSftp can abort in-flight ops without
// tearing down a shared terminal SSH socket.
if (!client.__netcattyScpActiveStreams) {
client.__netcattyScpActiveStreams = new Set();
}
const base = createSshExecAdapters(sshClient);
const track = (stream) => {
if (!stream) return stream;
client.__netcattyScpActiveStreams.add(stream);
const untrack = () => {
try { client.__netcattyScpActiveStreams.delete(stream); } catch { /* ignore */ }
};
stream.__netcattyUntrack = untrack;
stream.once?.("close", untrack);
stream.once?.("end", untrack);
stream.once?.("error", untrack);
return stream;
};
// Wrap base.exec so shell list/stat/mkdir streams are also abortable on closeSftp.
const trackedExec = (command, options = {}) => new Promise((resolve, reject) => {
executeBoundedSshCommand(sshClient, command, {
signal: options.signal || null,
openingTimeoutMs: options.openingTimeoutMs,
runTimeoutMs: options.runTimeoutMs,
maxOutputBytes: options.maxOutputBytes,
onStream: track,
}).then(resolve, reject);
});
const adapters = {
exec: trackedExec,
execStream: async (command, options = {}) => {
const stream = await base.execStream(command, options);
return track(stream);
},
};
client.__netcattyScpTrackedExec = trackedExec;
const backend = createScpBackend(adapters);
client.__netcattyScpBackend = backend;
return backend;
}
/**
* Abort all in-flight SCP exec streams for a client without ending the SSH socket.
* Used when closeSftp is called for session-backed handles (agent Stop/timeout).
*/
function abortScpClientStreams(client) {
const streams = client?.__netcattyScpActiveStreams;
if (!streams || typeof streams.forEach !== "function") return;
for (const stream of [...streams]) {
try { stream.close?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
try { streams.delete(stream); } catch { /* ignore */ }
}
}
module.exports = {
createScpBackend,
createSshExecAdapters,
createTransferFromAbortSignal,
getScpBackendForClient,
abortScpClientStreams,
isScpModeClient,
shellQuote,
modeToPermissionsString,
};