Files
NetMesh/electron/bridges/sftpBridge.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

2656 lines
93 KiB
JavaScript

/**
* SFTP Bridge - Handles SFTP connections and file operations
* Extracted from main.cjs for single responsibility
*/
const fs = require("node:fs");
const path = require("node:path");
const net = require("node:net");
const { createHash, randomUUID } = require("node:crypto");
const { pipeline } = require("node:stream/promises");
const { TextDecoder } = require("node:util");
const { StringDecoder } = require("node:string_decoder");
const { executeBoundedSshCommand } = require("./boundedSshExec.cjs");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
const iconv = require("iconv-lite");
let SFTPWrapper;
try {
// Try to load SFTPWrapper from ssh2 internals for sudo support
const sftpModule = require("ssh2/lib/protocol/SFTP");
SFTPWrapper = sftpModule.SFTP || sftpModule;
} catch (e) {
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
}
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
shouldSkipKiPasswordAutoFill,
safeSend: authSafeSend,
isKeyEncrypted,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
prepareSystemSshAgentForAuth,
preparePrivateKeyForAuth,
loadFirstIdentityFileForAuth,
} = require("./sshAuthHelper.cjs");
const {
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
} = require("./sshAlgorithms.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
let electronModule = null;
let sessions = null;
let reportOpenedSessionActivity = null;
const rendererSftpSourceSessions = new Map();
const REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS = 15_000;
const REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
const REMOTE_DELETE_EXEC_MAX_OUTPUT_BYTES = 64 * 1024;
const REMOTE_BACKUP_DELETE_ATTEMPTS = 3;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
// Track requested/resolved filename encoding per SFTP session
const sftpEncodingState = new Map(); // stateKey -> { requested: 'auto'|'utf-8'|'gb18030', resolved: 'utf-8'|'gb18030' }
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const cloneEncodingState = (value) => (
value && typeof value === "object"
? { requested: value.requested || "auto", resolved: value.resolved || "utf-8" }
: null
);
function copySftpEncodingState(sourceKey, targetKey) {
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const state = cloneEncodingState(sftpEncodingState.get(sourceKey));
if (state) {
sftpEncodingState.set(targetKey, state);
} else {
sftpEncodingState.delete(targetKey);
}
}
function clearSftpEncodingState(stateKey) {
if (!stateKey) return;
sftpEncodingState.delete(stateKey);
}
function clearSftpEncodingStateByPrefix(prefix) {
if (!prefix) return;
for (const key of sftpEncodingState.keys()) {
if (key.startsWith(prefix)) {
sftpEncodingState.delete(key);
}
}
}
const normalizeEncoding = (encoding) => {
if (!encoding) return "auto";
const normalized = String(encoding).toLowerCase();
if (normalized === "utf8") return "utf-8";
return normalized;
};
const isValidUtf8 = (buffer) => {
try {
utf8Decoder.decode(buffer);
return true;
} catch {
return false;
}
};
const detectEncodingFromList = (items) => {
// Return null if we can't definitively detect encoding (empty list or all valid UTF-8)
// This allows the caller to preserve the previous encoding instead of defaulting to UTF-8
if (!items || items.length === 0) {
return null;
}
for (const item of items) {
const raw = item?.filenameRaw || (item?.filename ? Buffer.from(item.filename, "utf8") : null);
if (raw && !isValidUtf8(raw)) {
return "gb18030";
}
}
// All filenames are valid UTF-8, but we can't prove they're not GB18030-encoded ASCII
// Return null to preserve previous encoding rather than forcing UTF-8
return null;
};
const resolveEncodingForRequest = (sftpId, requestedEncoding) => {
const requested = normalizeEncoding(requestedEncoding);
if (requested && requested !== "auto") {
sftpEncodingState.set(sftpId, { requested, resolved: requested });
return requested;
}
const existing = sftpEncodingState.get(sftpId);
const resolved = existing?.resolved || "utf-8";
sftpEncodingState.set(sftpId, { requested: "auto", resolved });
return resolved;
};
const updateResolvedEncoding = (sftpId, requestedEncoding, resolvedEncoding) => {
const requested = normalizeEncoding(requestedEncoding);
const resolved = normalizeEncoding(resolvedEncoding);
const finalResolved = resolved === "auto" ? "utf-8" : resolved;
sftpEncodingState.set(sftpId, {
requested: requested || "auto",
resolved: finalResolved,
});
return finalResolved;
};
const isAsciiString = (value) =>
typeof value === "string" && /^[\x00-\x7F]*$/.test(value);
const encodePath = (input, encoding) => {
if (input === undefined || input === null) return input;
if (Buffer.isBuffer(input)) return input;
if (encoding === "utf-8") return input;
// Avoid Buffer paths when ASCII-only; keeps compatibility with unpatched ssh2
if (isAsciiString(input)) return input;
return iconv.encode(input, encoding);
};
const decodeName = (raw, encoding) => {
if (!raw) return "";
if (Buffer.isBuffer(raw)) {
return encoding === "utf-8" ? raw.toString("utf8") : iconv.decode(raw, encoding);
}
return raw;
};
const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
if (!sftpId) return inputPath;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
return encodePath(inputPath, encoding);
};
/** Resolve the effective filename encoding for a live SFTP/SCP session. */
const getResolvedFilenameEncoding = (sftpId, requestedEncoding) => {
if (!sftpId) return requestedEncoding || "utf-8";
return resolveEncodingForRequest(sftpId, requestedEncoding);
};
const hasSftpChannelApi = (value) =>
!!value &&
typeof value.readdir === "function" &&
typeof value.stat === "function" &&
typeof value.mkdir === "function" &&
typeof value.unlink === "function";
function createAbortError(signal, fallbackMessage = "The operation was aborted.") {
const reason = signal?.reason;
if (reason instanceof Error) {
return reason;
}
if (typeof reason === "string" && reason) {
return new Error(reason);
}
return new Error(fallbackMessage);
}
function isAbortError(error) {
if (!error) return false;
if (error.name === "AbortError") return true;
const message = String(error.message || error);
return /abort|cancel/i.test(message);
}
const tryOpenSftpChannel = (client, options = {}) =>
openBoundedSftpChannel(client?.client, options);
const getSftpChannel = async (client, options = {}) => {
if (!client) return null;
if (client.__netcattyDisposed) return null;
if (hasSftpChannelApi(client.sftp)) {
return client.sftp;
}
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
// Reopening with sshClient.sftp() would silently downgrade permissions.
if (client.__netcattySudoMode) {
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
return null;
}
// Do not treat ssh2's "client.sftp" method as a channel object.
// Re-open a fresh channel when the cached channel is stale.
if (!client.client || typeof client.client.sftp !== "function") {
return null;
}
// Deduplicate per-client: avoid concurrent channel re-open attempts
if (client._reopeningPromise) {
return waitForSharedSftpReopen(client._reopeningPromise, options.signal || null);
}
const reopeningPromise = (async () => {
try {
// Reopening belongs to the connection, not to whichever transfer reached
// it first. Each waiter cancels only its own wait below.
const reopened = await tryOpenSftpChannel(client, { timeoutMs: options.timeoutMs });
if (hasSftpChannelApi(reopened)) {
if (client.__netcattyDisposed) {
try { reopened.end?.(); } catch {}
try { reopened.close?.(); } catch {}
return null;
}
client.sftp = reopened;
return reopened;
}
} catch (err) {
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
}
return null;
})();
client._reopeningPromise = reopeningPromise;
void reopeningPromise.finally(() => {
if (client._reopeningPromise === reopeningPromise) {
client._reopeningPromise = null;
}
});
return waitForSharedSftpReopen(reopeningPromise, options.signal || null);
};
const requireSftpChannel = async (client, options = {}) => {
const sftp = await getSftpChannel(client, options);
if (!sftp) {
throw new Error("SFTP session lost. Please reconnect.");
}
return sftp;
};
const realpathAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
});
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
});
const lstatAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
const inspect = typeof sftp.lstat === "function" ? sftp.lstat.bind(sftp) : sftp.stat.bind(sftp);
inspect(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
});
const readlinkAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
if (typeof sftp.readlink !== "function") {
const error = new Error("SFTP readlink is not available");
error.code = "ENOTSUP";
reject(error);
return;
}
sftp.readlink(targetPath, (err, linkPath) => (err ? reject(err) : resolve(linkPath)));
});
const readdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.readdir(targetPath, (err, items) => (err ? reject(err) : resolve(items || [])));
});
const mkdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.mkdir(targetPath, (err) => (err ? reject(err) : resolve()));
});
const raceReadAgainstAbort = async (operation, signal) => {
throwIfAborted(signal);
if (!signal) return operation;
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(createAbortError(signal, "SFTP directory setup was aborted"));
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
signal.removeEventListener("abort", onAbort);
}
};
const waitForSharedSftpReopen = async (reopeningPromise, signal) => {
try {
return await raceReadAgainstAbort(reopeningPromise, signal);
} catch (error) {
if (signal?.aborted) throw error;
return null;
}
};
const rmdirAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.rmdir(targetPath, (err) => (err ? reject(err) : resolve()));
});
const unlinkAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.unlink(targetPath, (err) => (err ? reject(err) : resolve()));
});
const openFileAsync = (sftp, targetPath, flags = "w") =>
new Promise((resolve, reject) => {
sftp.open(targetPath, flags, (err, handle) => (err ? reject(err) : resolve(handle)));
});
const writeFileChunkAsync = (sftp, handle, buffer, offset, length, position) =>
new Promise((resolve, reject) => {
sftp.write(handle, buffer, offset, length, position, (err) => (err ? reject(err) : resolve()));
});
const closeFileAsync = (sftp, handle) =>
new Promise((resolve, reject) => {
sftp.close(handle, (err) => (err ? reject(err) : resolve()));
});
const normalizeRemotePathString = async (client, inputPath) => {
if (typeof inputPath !== "string") return inputPath;
if (inputPath === "..") {
const root = await client.realPath("..");
return `${root}/`;
}
if (inputPath.startsWith("../") || inputPath.startsWith("..\\")) {
const root = await client.realPath("..");
return `${root}/${inputPath.slice(3)}`;
}
if (inputPath === ".") {
const root = await client.realPath(".");
return `${root}/`;
}
if (inputPath.startsWith("./") || inputPath.startsWith(".\\")) {
const root = await client.realPath(".");
return `${root}/${inputPath.slice(2)}`;
}
return inputPath;
};
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
const normalizeRemoteDirPath = (dirPath) => {
if (isWindowsRemotePath(dirPath)) {
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
}
return path.posix.normalize(dirPath);
};
const ensureRemoteDirInternal = async (sftp, dirPath, encoding, options = {}) => {
const signal = options.signal || null;
throwIfAborted(signal);
if (!dirPath || dirPath === ".") return;
const normalized = normalizeRemoteDirPath(dirPath);
if (!normalized || normalized === ".") return;
// Optimization: Check if the full path already exists to avoid O(N) round trips
// This is the common case (e.g. uploading multiple files to the same directory)
const encodedFull = encodePath(normalized, encoding);
throwIfAborted(signal);
try {
const stats = await raceReadAgainstAbort(statAsync(sftp, encodedFull), signal);
throwIfAborted(signal);
if (stats.isDirectory()) {
return;
}
} catch (err) {
throwIfAborted(signal);
// If path doesn't exist or other error, proceed to recursive check
}
const isWindowsPath = isWindowsRemotePath(normalized);
const isAbsolute = normalized.startsWith("/");
const parts = isWindowsPath
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
: normalized.split("/").filter(Boolean);
let current = isWindowsPath
? `${normalized.slice(0, 2)}\\`
: (isAbsolute ? "/" : "");
for (const part of parts) {
throwIfAborted(signal);
if (isWindowsPath) {
const base = current.replace(/[\\]+$/, "");
current = `${base}\\${part}`;
} else {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
}
const encodedCurrent = encodePath(current, encoding);
try {
const stats = await raceReadAgainstAbort(statAsync(sftp, encodedCurrent), signal);
throwIfAborted(signal);
if (!stats.isDirectory()) {
throw new Error(`Remote path is not a directory: ${current}`);
}
} catch (err) {
throwIfAborted(signal);
if (err && (err.code === 2 || err.code === 4)) {
throwIfAborted(signal);
await mkdirAsync(sftp, encodedCurrent);
throwIfAborted(signal);
continue;
}
throw err;
}
}
};
const removeRemotePathInternal = async (sftp, targetPath, encoding, signal = null) => {
throwIfAborted(signal);
const encodedTarget = encodePath(targetPath, encoding);
let stats;
try {
stats = await lstatAsync(sftp, encodedTarget);
} catch (err) {
if (err && err.code === 2) return;
throw err;
}
throwIfAborted(signal);
if (stats.isSymbolicLink?.()) {
await unlinkAsync(sftp, encodedTarget);
} else if (stats.isDirectory()) {
throwIfAborted(signal);
const items = await readdirAsync(sftp, encodedTarget);
throwIfAborted(signal);
for (const item of items) {
throwIfAborted(signal);
const rawName =
item?.filenameRaw ||
(item?.filename ? Buffer.from(item.filename, "utf8") : null);
const name = decodeName(rawName, encoding);
if (!name || name === "." || name === "..") continue;
const childPath = path.posix.join(targetPath, name);
await removeRemotePathInternal(sftp, childPath, encoding, signal);
throwIfAborted(signal);
}
throwIfAborted(signal);
await rmdirAsync(sftp, encodedTarget);
} else {
throwIfAborted(signal);
await unlinkAsync(sftp, encodedTarget);
}
throwIfAborted(signal);
};
/**
* Run a one-shot remote shell command on the SSH connection underlying an SFTP client.
* Used for fast directory delete (`rm -rf`) when SFTP-protocol recursion would be slow.
*/
function terminateRemoteShellStream(stream) {
if (!stream) return;
try { stream.once?.("error", () => {}); } catch { /* ignore */ }
try { stream.stderr?.once?.("error", () => {}); } catch { /* ignore */ }
try { stream.close?.(); } catch { /* ignore */ }
try { stream.end?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
}
async function execRemoteShellCommand(sshClient, command, optionsOrSignal = null) {
if (!sshClient || typeof sshClient.exec !== "function") {
throw new Error("SSH exec unavailable");
}
const isAbortSignal = optionsOrSignal
&& typeof optionsOrSignal === "object"
&& typeof optionsOrSignal.addEventListener === "function"
&& typeof optionsOrSignal.aborted === "boolean";
const options = isAbortSignal ? { signal: optionsOrSignal } : (optionsOrSignal || {});
const signal = options.signal || null;
const openingTimeoutMs = Math.max(
1,
Number(options.openingTimeoutMs) || REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS,
);
const runTimeoutMs = Math.max(
1,
Number(options.runTimeoutMs) || REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS,
);
const maxOutputBytes = Math.max(
1,
Number(options.maxOutputBytes) || REMOTE_DELETE_EXEC_MAX_OUTPUT_BYTES,
);
const discardStdout = options.discardStdout === true;
return await new Promise((resolve, reject) => {
let settled = false;
let streamRef = null;
let openingTimer = null;
let runTimer = null;
let outputBytes = 0;
let stdout = "";
let stderr = "";
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let decodersEnded = false;
const cleanup = () => {
if (openingTimer) clearTimeout(openingTimer);
if (runTimer) clearTimeout(runTimer);
openingTimer = null;
runTimer = null;
if (signal) {
try { signal.removeEventListener("abort", onAbort); } catch { /* ignore */ }
}
};
const finish = (error, code = 0) => {
if (settled) return;
settled = true;
cleanup();
if (error) {
terminateRemoteShellStream(streamRef);
reject(error);
return;
}
if (!decodersEnded) {
decodersEnded = true;
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
}
if (code === 0) {
resolve({ stdout, stderr, code });
return;
}
reject(new Error(
`Remote command failed (code ${code})${stderr ? `: ${String(stderr).trim()}` : ""}`,
));
};
const onAbort = () => {
finish(createAbortError(signal, "Remote command was aborted"));
if (!streamRef) invalidateSshTransport(sshClient);
};
const appendOutput = (target, chunk) => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
if (target === "stdout" && discardStdout) return;
outputBytes += buffer.length;
if (outputBytes > maxOutputBytes) {
finish(new Error(`Remote command output exceeded ${maxOutputBytes} bytes`));
return;
}
if (target === "stdout") stdout += stdoutDecoder.write(buffer);
else stderr += stderrDecoder.write(buffer);
};
if (signal) {
if (signal.aborted) {
finish(createAbortError(signal, "Remote command was aborted"));
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
openingTimer = setTimeout(() => {
finish(new Error(`Remote command open timed out after ${openingTimeoutMs} ms`));
invalidateSshTransport(sshClient);
}, openingTimeoutMs);
try {
sshClient.exec(command, (error, stream) => {
if (openingTimer) clearTimeout(openingTimer);
openingTimer = null;
if (settled) {
terminateRemoteShellStream(stream);
return;
}
if (error) {
finish(error);
return;
}
streamRef = stream;
runTimer = setTimeout(() => {
finish(new Error(`Remote command execution timed out after ${runTimeoutMs} ms`));
}, runTimeoutMs);
stream.on("data", (chunk) => { appendOutput("stdout", chunk); });
if (stream.stderr && typeof stream.stderr.on === "function") {
stream.stderr.on("data", (chunk) => { appendOutput("stderr", chunk); });
stream.stderr.on("error", (streamError) => finish(streamError));
}
stream.on("close", (code) => finish(null, code ?? 0));
stream.on("error", (streamError) => finish(streamError));
});
} catch (error) {
finish(error);
}
});
}
/**
* Fast directory delete via shell `rm -rf`, verified through the SFTP channel.
* Returns true only when SFTP confirms the path is gone. Never trust shell exit
* alone — shell cwd/root can diverge from the SFTP view of the same path.
*
* Non-UTF-8 encodings skip this path (shell quoting of legacy encodings is unsafe).
*/
async function tryFastShellDirectoryDelete(client, remotePath, encoding = "utf-8", signal = null) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.exec !== "function") return false;
const enc = !encoding || encoding === "auto" ? "utf-8" : encoding;
if (enc !== "utf-8") return false;
if (typeof remotePath !== "string" || !remotePath || remotePath === "/" || remotePath === ".") {
return false;
}
let command;
try {
const { buildDeleteCommand } = require("./sftpBridge/scpShell.cjs");
command = buildDeleteCommand(remotePath, { recursive: true, encoding: "utf-8" });
} catch {
return false;
}
try {
await execRemoteShellCommand(sshClient, command, { signal });
} catch {
return false;
}
throwIfAborted(signal);
// Confirm via the same SFTP channel the browser uses.
try {
const sftp = await requireSftpChannel(client, { signal });
const encoded = encodePath(remotePath, "utf-8");
await lstatAsync(sftp, encoded);
// Still present — shell did not remove the SFTP-visible path.
return false;
} catch (err) {
if (err && (err.code === 2 || err.code === "ENOENT")) return true;
if (err && /no such file/i.test(String(err.message || ""))) return true;
return false;
}
}
/**
* Remove a remote directory: prefer verified shell `rm -rf`, fall back to
* protocol-level recursive walk when shell is unavailable or unverified.
*/
async function removeRemoteDirectory(client, remotePath, encoding = "utf-8", signal = null) {
throwIfAborted(signal);
const enc = !encoding || encoding === "auto" ? "utf-8" : encoding;
if (await tryFastShellDirectoryDelete(client, remotePath, enc, signal)) {
return;
}
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal });
const normalized = await normalizeRemotePathString(client, remotePath);
throwIfAborted(signal);
await removeRemotePathInternal(sftp, normalized, enc, signal);
}
const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding, options = {}) => {
const client = sftpClients.get(sftpId);
if (!client) throw new Error("SFTP session not found");
if (!dirPath || dirPath === ".") return true;
const { isScpModeClient, getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
if (isScpModeClient(client)) {
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
await getScpBackendForClient(client).mkdir(dirPath, {
recursive: true,
encoding: encoding === "auto" ? "utf-8" : encoding,
signal: options.signal || null,
});
return true;
}
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
const signal = options.signal || null;
const sftp = await requireSftpChannel(client, { signal });
throwIfAborted(signal);
// Always walk the path segment-by-segment. This lets sftp.stat() follow
// symlinked directory segments before deciding whether the next mkdir is
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
const normalizedPath = await raceReadAgainstAbort(
normalizeRemotePathString(client, dirPath),
signal,
);
throwIfAborted(signal);
await ensureRemoteDirInternal(sftp, normalizedPath, encoding, { signal });
return true;
};
const { safeSend } = require("./ipcUtils.cjs");
/**
* Initialize the SFTP bridge with dependencies
*/
function init(deps) {
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
sessions = deps.sessions;
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
? deps.reportOpenedSessionActivity
: null;
rendererSftpSourceSessions.clear();
}
function ensureRemoteSftpSupport(sessionId) {
const session = sessions?.get(sessionId);
if (!session) {
throw new Error(`Session "${sessionId}" not found`);
}
const sshClient = session.conn || session.sshClient;
if (!sshClient || typeof sshClient.sftp !== "function") {
throw new Error("SFTP is only supported for SSH sessions with an active SSH connection.");
}
return { session, sshClient };
}
function findRemoteSftpSourceByEndpoint(sourceSessionId, expectedEndpoint, requireExactSession = false) {
const requested = sessions?.get(sourceSessionId);
const matchesExpectedEndpoint = (session) => {
const actualEndpoint = session?._reuseEndpoint || session?.connRef?.endpoint;
return Boolean(
actualEndpoint
&& endpointAllowsReuse(expectedEndpoint, actualEndpoint, "channel"),
);
};
const hasLiveSftpConnection = (session) => {
const sshClient = session?.conn || session?.sshClient;
return Boolean(sshClient && typeof sshClient.sftp === "function");
};
if (requested && matchesExpectedEndpoint(requested) && hasLiveSftpConnection(requested)) {
return { sessionId: sourceSessionId, session: requested, sshClient: requested.conn || requested.sshClient };
}
if (requireExactSession) return null;
// The renderer only has endpoint fields when it chooses a sourceSessionId;
// route, proxy, credential and host-key fingerprints live in the main
// process. Treat its id as a hint, then choose the newest live session whose
// authoritative transport identity matches in full.
let matchingSource = null;
for (const [candidateId, candidate] of sessions || []) {
if (candidateId === sourceSessionId) continue;
if (!matchesExpectedEndpoint(candidate) || !hasLiveSftpConnection(candidate)) continue;
matchingSource = {
sessionId: candidateId,
session: candidate,
sshClient: candidate.conn || candidate.sshClient,
};
}
return matchingSource;
}
// Common remote NAME_MAX; keep stage/backup basenames within this budget.
const REMOTE_BASENAME_MAX = 255;
function clipRemoteBaseName(baseName, overhead) {
const raw = baseName || "upload";
const maxBase = Math.max(8, REMOTE_BASENAME_MAX - overhead);
if (Buffer.byteLength(raw, "utf8") <= maxBase) return raw;
// Prefer character-safe clip: shrink until utf8 bytes fit.
let clipped = raw;
while (clipped.length > 1 && Buffer.byteLength(clipped, "utf8") > maxBase) {
clipped = clipped.slice(0, -1);
}
return clipped || "upload";
}
function buildStagedRemotePath(remotePath) {
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
// ".netcatty-upload-" (17) + 8 hex + "-" (1) + ".part" (5) = 31
const safeBaseName = clipRemoteBaseName(baseName, 31);
const stagedName = `.netcatty-upload-${randomUUID().slice(0, 8)}-${safeBaseName}.part`;
return dir ? `${dir}${stagedName}` : stagedName;
}
function buildBackupRemotePath(remotePath) {
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
// One stable recovery path per destination prevents repeated retries from
// leaving a new random backup each time cleanup is temporarily unavailable.
// ".netcatty-backup-" (17) + 16 hex + "-" (1) + ".bak" (4) = 38
const safeBaseName = clipRemoteBaseName(baseName, 38);
const targetHash = createHash("sha256").update(String(remotePath)).digest("hex").slice(0, 16);
const backupName = `.netcatty-backup-${targetHash}-${safeBaseName}.bak`;
return dir ? `${dir}${backupName}` : backupName;
}
function createRemoteBackupCleanupError(cleanupError, paths = {}) {
const error = new Error(
`Remote upload completed, but the original backup could not be removed. `
+ `Final: ${String(paths.finalPath || "unknown")}; backup: ${String(paths.backupPath || "unknown")}: `
+ `${cleanupError?.message || String(cleanupError)}`,
{ cause: cleanupError },
);
error.recoverable = true;
error.remoteBackupPath = paths.backupPath || null;
error.remoteFinalPath = paths.finalPath || null;
return error;
}
async function deleteRemoteBackupWithRetry(deleteBackup, paths = {}, options = {}) {
const attempts = Math.max(1, Number(options.attempts) || REMOTE_BACKUP_DELETE_ATTEMPTS);
const delay = options.delay || ((attempt) => new Promise((resolve) => {
setTimeout(resolve, attempt * 25);
}));
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await deleteBackup();
return;
} catch (error) {
lastError = error;
if (attempt < attempts) await delay(attempt);
}
}
throw createRemoteBackupCleanupError(lastError, paths);
}
async function readRemotePathIfPresent(statPath) {
try {
return await statPath();
} catch (error) {
if (isRemoteMissingError(error)) return null;
throw error;
}
}
async function reconcileRemoteUploadBackup(options) {
const backup = await readRemotePathIfPresent(options.statBackup);
if (!backup) return;
const target = await readRemotePathIfPresent(options.statTarget);
if (!target) {
// An interrupted promotion left the backup as the only known-good copy.
await options.restoreBackup();
return;
}
// The final target is live, so the stable backup belongs to an earlier
// committed upload. Remove it before this attempt creates a new backup.
await deleteRemoteBackupWithRetry(options.deleteBackup, {
finalPath: options.finalPath,
backupPath: options.backupPath,
});
}
function isRemoteMissingError(err) {
const code = err?.code;
return code === 2
|| code === "ENOENT"
|| code === "NO_SUCH_FILE"
|| code === "SSH_FX_NO_SUCH_FILE"
|| String(err?.message || "").trim() === "ENOENT";
}
function isRemoteUnsupportedError(err) {
const code = err?.code;
return code === 8
|| code === "ENOTSUP"
|| code === "EOPNOTSUPP"
|| code === "SSH_FX_OP_UNSUPPORTED";
}
async function inspectRemoteRecoveryEntry(sftp, targetPath) {
try {
return await lstatAsync(sftp, targetPath);
} catch (error) {
// Some SFTP v3 servers expose lstat but reject the request at runtime.
// A Netcatty-created recovery artifact is always a regular file, so stat
// remains a safe compatibility fallback for this narrow inspection.
if (typeof sftp?.lstat === "function" && isRemoteUnsupportedError(error)) {
return statAsync(sftp, targetPath);
}
throw error;
}
}
function attrsIndicateSymlink(attrs) {
if (!attrs) return false;
if (typeof attrs.isSymbolicLink === "function") return !!attrs.isSymbolicLink();
if (typeof attrs.isSymbolicLink === "boolean") return attrs.isSymbolicLink;
const mode = Number(attrs.mode);
return Number.isFinite(mode) && (mode & 0o170000) === 0o120000;
}
async function distinguishMissingTargetFromBrokenSymlink(sftp, encodedPath) {
try {
await readlinkAsync(sftp, encodedPath);
return { writeInPlace: true, existingMode: null, destinationExisted: true };
} catch (error) {
if (isRemoteMissingError(error)) {
return { writeInPlace: false, existingMode: null, destinationExisted: false };
}
const unsafeError = new Error(
"Cannot safely distinguish a missing upload target from a broken symbolic link",
{ cause: error },
);
unsafeError.unsafeUploadTarget = true;
throw unsafeError;
}
}
/**
* Plan overwrite strategy for a remote upload target.
* - Confirmed symlinks: write in-place so the server follows the link.
* - When lstat is unavailable but the path exists: write in-place so we never
* replace an unknown link node via stage+rename.
* - Confirmed regular files (new or existing): stage + rename so cancel cannot
* keep mutating the final destination. Restore mode bits after promotion
* (SFTP v3 cannot portably preserve owner/ACL/xattr/hard-links).
*/
async function planRemoteUploadReplace(client, encodedPath, remotePath, signal = null) {
try {
const sftp = await requireSftpChannel(client);
const hasNativeLstat = typeof sftp?.lstat === "function";
if (hasNativeLstat) {
let attrs = null;
try {
attrs = await lstatAsync(sftp, encodedPath);
} catch (lstatError) {
if (isRemoteMissingError(lstatError)) {
return { writeInPlace: false, existingMode: null, destinationExisted: false, destinationSnapshot: null };
}
// Some SFTP servers expose lstat client-side but reject it at runtime.
// A successful stat proves the destination exists, but cannot tell us
// whether it is a symlink, so preserve it with an in-place write.
try {
attrs = await statAsync(sftp, encodedPath);
if (attrs) return { writeInPlace: true, existingMode: null, destinationExisted: true };
} catch (statError) {
if (isRemoteMissingError(statError)) {
return distinguishMissingTargetFromBrokenSymlink(sftp, encodedPath);
}
// Unknown inspection failure: do not risk rename-replacing a link.
return { writeInPlace: true, existingMode: null, destinationExisted: null };
}
}
if (!attrs) return { writeInPlace: false, existingMode: null, destinationExisted: false, destinationSnapshot: null };
if (attrsIndicateSymlink(attrs)) {
return { writeInPlace: true, existingMode: null, destinationExisted: true };
}
const mode = Number(attrs.mode);
const existingMode = Number.isFinite(mode) && mode > 0
? (mode & 0o7777)
: null;
return {
writeInPlace: false,
existingMode,
destinationExisted: true,
destinationSnapshot: await finalizeDestinationSnapshot(
client,
encodedPath,
remotePath,
snapshotRemoteTarget(attrs),
{ scpMode: false, signal },
),
};
}
// No lstat: if the path exists via stat, write in-place so a symlink is not
// replaced by rename when we cannot inspect the link node.
try {
const attrs = await statAsync(sftp, encodedPath);
if (attrs) {
const mode = Number(attrs.mode);
const existingMode = Number.isFinite(mode) && mode > 0
? (mode & 0o7777)
: null;
return { writeInPlace: true, existingMode, destinationExisted: true };
}
} catch (statError) {
if (!isRemoteMissingError(statError)) {
// Unknown existing-path state: preserve a possible symlink.
return { writeInPlace: true, existingMode: null, destinationExisted: null };
}
return distinguishMissingTargetFromBrokenSymlink(sftp, encodedPath);
}
} catch (error) {
if (error?.unsafeUploadTarget) throw error;
// Unknown target state: preserve a possible symlink instead of replacing it.
return { writeInPlace: true, existingMode: null, destinationExisted: null };
}
return { writeInPlace: false, existingMode: null, destinationExisted: false, destinationSnapshot: null };
}
function attrsIndicateDirectory(attrs) {
if (!attrs) return false;
if (typeof attrs.isDirectory === "function") return !!attrs.isDirectory();
if (typeof attrs.isDirectory === "boolean") return attrs.isDirectory;
const mode = Number(attrs.mode);
return Number.isFinite(mode) && (mode & 0o170000) === 0o040000;
}
function snapshotRemoteTarget(attrs) {
if (!attrs) return null;
const snapshot = {};
for (const field of ["size", "mode", "uid", "gid", "mtime", "modifyTime", "ino", "dev", "fileId", "permissions", "type"]) {
const value = attrs[field];
if (["number", "string", "bigint", "boolean"].includes(typeof value)) {
snapshot[field] = String(value);
}
}
return snapshot;
}
function remoteTargetMatchesSnapshot(attrs, snapshot) {
if (!snapshot) return true;
return Object.entries(snapshot).every(([field, expected]) => {
// contentDigest is verified separately via a re-hash of the destination.
if (field === "contentDigest") return true;
return attrs[field] != null && String(attrs[field]) === expected;
});
}
async function hashReadableForDigest(readable, signal = null) {
throwIfAborted(signal);
const hash = createHash("sha256");
const onAbort = () => {
try {
readable.destroy?.(createAbortError(signal, "Remote target verification was aborted"));
} catch {
/* ignore */
}
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
try {
for await (const chunk of readable) {
throwIfAborted(signal);
hash.update(chunk);
}
throwIfAborted(signal);
return hash.digest("hex");
} finally {
if (signal) {
try { signal.removeEventListener("abort", onAbort); } catch { /* ignore */ }
}
if (signal?.aborted) {
try { readable.destroy?.(); } catch { /* ignore */ }
}
}
}
async function tryRemoteSha256Sum(sshClient, remotePath, signal = null) {
if (!sshClient || typeof sshClient.exec !== "function") return null;
const escapedPath = String(remotePath).replace(/'/g, "'\\''");
try {
const result = await executeBoundedSshCommand(
sshClient,
`sha256sum -- '${escapedPath}'`,
{
signal,
openingTimeoutMs: 15_000,
runTimeoutMs: 10 * 60_000,
maxOutputBytes: 64 * 1024,
},
);
const match = result.stdout.match(/^([a-fA-F0-9]{64})\s/);
return result.code === 0 && match ? match[1].toLowerCase() : null;
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw createAbortError(signal, "Remote target verification was aborted");
}
return null;
}
}
/**
* Prefer server-side sha256sum; fall back to streaming the destination over SFTP.
* Returns null when neither path is available (metadata-only residual risk).
*/
async function computeRemoteContentDigest(client, encodedPath, remotePath, options = {}) {
const signal = options.signal || null;
throwIfAborted(signal);
const digest = await tryRemoteSha256Sum(client?.client, remotePath, signal);
if (digest) {
throwIfAborted(signal);
return digest;
}
try {
throwIfAborted(signal);
const sftp = client?.sftp || await requireSftpChannel(client);
if (typeof sftp?.createReadStream === "function") {
return await hashReadableForDigest(sftp.createReadStream(encodedPath), signal);
}
if (typeof client?.get === "function") {
const buffer = await client.get(encodedPath);
throwIfAborted(signal);
return createHash("sha256")
.update(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer))
.digest("hex");
}
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw createAbortError(signal, "Remote target verification was aborted");
return null;
}
return null;
}
/**
* Always prefer a content digest when possible. Metadata (including ino/fileId)
* alone cannot detect same-size in-place rewrites on SFTP v3 or coarse SCP mtime.
*/
async function finalizeDestinationSnapshot(client, encodedPath, remotePath, snapshot, options = {}) {
if (!snapshot) return snapshot;
const digest = await computeRemoteContentDigest(client, encodedPath, remotePath, options);
if (!digest) return snapshot;
return { ...snapshot, contentDigest: digest };
}
async function assertDestinationContentUnchanged(client, encodedPath, remotePath, snapshot, options = {}) {
if (!snapshot?.contentDigest) return;
const digest = await computeRemoteContentDigest(client, encodedPath, remotePath, options);
if (!digest || digest !== snapshot.contentDigest) {
throw new Error(`Remote destination changed during upload: ${remotePath}`);
}
}
async function assertTargetStillMissingWithoutLstat(sftp, encodedPath, remotePath) {
try {
const attrs = await statAsync(sftp, encodedPath);
if (attrs) throw new Error(`Remote destination appeared during upload: ${remotePath}`);
} catch (error) {
if (!isRemoteMissingError(error)) throw error;
try {
await readlinkAsync(sftp, encodedPath);
throw new Error(`Remote destination changed to a symlink during upload: ${remotePath}`);
} catch (readlinkError) {
if (isRemoteMissingError(readlinkError)) return;
throw readlinkError;
}
}
}
async function assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
expectedExisted,
expectedSnapshot,
signal = null,
) {
throwIfAborted(signal);
const sftp = await requireSftpChannel(client);
if (typeof sftp?.lstat !== "function") {
if (expectedExisted !== false) {
throw new Error(`Cannot safely recheck remote destination before replace: ${remotePath}`);
}
await assertTargetStillMissingWithoutLstat(sftp, encodedPath, remotePath);
return;
}
let attrs = null;
try {
attrs = await lstatAsync(sftp, encodedPath);
} catch (err) {
if (isRemoteMissingError(err)) {
attrs = null;
} else if (expectedExisted === false) {
// Runtime lstat may be unsupported despite the method existing. A plain
// stat may only authorize promotion when it still proves true absence.
await assertTargetStillMissingWithoutLstat(sftp, encodedPath, remotePath);
return;
} else {
throw err;
}
}
const existsNow = !!attrs;
if (expectedExisted === false && existsNow) {
throw new Error(`Remote destination appeared during upload: ${remotePath}`);
}
if (expectedExisted === true && !existsNow) {
throw new Error(`Remote destination disappeared during upload: ${remotePath}`);
}
if (attrsIndicateSymlink(attrs)) {
throw new Error(`Remote destination changed to a symlink during upload: ${remotePath}`);
}
if (attrsIndicateDirectory(attrs)) {
throw new Error(`Remote path is a directory: ${remotePath}`);
}
if (existsNow && !remoteTargetMatchesSnapshot(attrs, expectedSnapshot)) {
throw new Error(`Remote destination changed during upload: ${remotePath}`);
}
if (existsNow) {
await assertDestinationContentUnchanged(
client,
encodedPath,
remotePath,
expectedSnapshot,
{ signal },
);
}
}
async function restoreRemoteMode(client, encodedPath, mode, options = {}) {
if (mode == null || !Number.isFinite(mode)) return;
const bestEffort = options?.bestEffort !== false;
try {
const { isScpModeClient, getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
if (isScpModeClient(client)) {
// SCP-only servers have no SFTP channel; chmod via shell backend.
const encoding = options?.encoding || "utf-8";
const remotePath = options?.remotePath
|| (Buffer.isBuffer(encodedPath) ? decodeName(encodedPath, encoding) : String(encodedPath));
await getScpBackendForClient(client).chmod(remotePath, mode, {
encoding,
signal: options?.signal || null,
});
return;
}
if (typeof client.chmod === "function") {
await client.chmod(encodedPath, mode);
return;
}
const sftp = await requireSftpChannel(client);
await new Promise((resolve, reject) => {
if (typeof sftp.chmod === "function") {
sftp.chmod(encodedPath, mode, (err) => (err ? reject(err) : resolve()));
return;
}
if (typeof sftp.setstat === "function") {
sftp.setstat(encodedPath, { mode }, (err) => (err ? reject(err) : resolve()));
return;
}
reject(new Error("Remote server does not support restoring file mode"));
});
} catch (err) {
if (!bestEffort) throw err;
}
}
function createRemoteRecoveryError(promotionError, restoreError, paths = {}) {
const recoveryLocations = [
paths.backupPath ? `backup=${String(paths.backupPath)}` : null,
paths.stagePath ? `staged=${String(paths.stagePath)}` : null,
].filter(Boolean).join(", ");
const error = new Error(
`Remote upload promotion failed and the original destination could not be restored (${recoveryLocations}): ${restoreError?.message || String(restoreError)}`,
{ cause: promotionError },
);
error.preserveStagedUpload = true;
error.remoteStagePath = paths.stagePath || null;
error.remoteBackupPath = paths.backupPath || null;
error.remoteFinalPath = paths.finalPath || null;
return error;
}
async function planScpRemoteUploadReplace(client, remotePath, encoding, signal = null) {
const { getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
const backend = getScpBackendForClient(client);
let attrs = null;
try {
attrs = await backend.stat(remotePath, { encoding, signal });
} catch (error) {
if (!isRemoteMissingError(error)) throw error;
}
if (!attrs) {
return { writeInPlace: false, existingMode: null, destinationExisted: false, destinationSnapshot: null };
}
if (attrs.isDirectory || attrs.type === "directory") {
throw new Error(`Remote path is a directory: ${remotePath}`);
}
if (attrs.isSymbolicLink || attrs.isSymlink || attrs.type === "symlink") {
return { writeInPlace: true, existingMode: null, destinationExisted: true };
}
const encodedPath = encodePath(remotePath, encoding);
return {
writeInPlace: false,
// `permissions` distinguishes a real mode 000 from an unparseable mode,
// both of which otherwise appear as numeric zero.
existingMode: attrs.permissions && Number.isFinite(attrs.mode)
? (attrs.mode & 0o7777)
: null,
destinationExisted: true,
destinationSnapshot: await finalizeDestinationSnapshot(
client,
encodedPath,
remotePath,
snapshotRemoteTarget(attrs),
{ scpMode: true, encoding, signal },
),
};
}
async function promoteScpStagedUpload(
client,
stagedPath,
targetPath,
backupPath,
encoding,
expectedExisted,
expectedSnapshot,
existingMode,
assertCanPromote,
commitPromotion,
runCancelablePreflight,
signal,
) {
const { getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
const backend = getScpBackendForClient(client);
const encodedTargetPath = encodePath(targetPath, encoding);
const assertTargetSafe = async () => {
let latestTarget = null;
try {
latestTarget = await runCancelablePreflight(
() => backend.stat(targetPath, { encoding, signal }),
);
} catch (error) {
if (!isRemoteMissingError(error)) throw error;
}
if (latestTarget?.isDirectory || latestTarget?.type === "directory") {
throw new Error(`Remote path is a directory: ${targetPath}`);
}
if (latestTarget?.isSymbolicLink || latestTarget?.isSymlink || latestTarget?.type === "symlink") {
throw new Error(`Remote destination changed to a symlink during upload: ${targetPath}`);
}
if (expectedExisted === false && latestTarget) {
throw new Error(`Remote destination appeared during upload: ${targetPath}`);
}
if (expectedExisted === true && !latestTarget) {
throw new Error(`Remote destination disappeared during upload: ${targetPath}`);
}
if (latestTarget && !remoteTargetMatchesSnapshot(latestTarget, expectedSnapshot)) {
throw new Error(`Remote destination changed during upload: ${targetPath}`);
}
if (latestTarget) {
await assertDestinationContentUnchanged(
client,
encodedTargetPath,
targetPath,
expectedSnapshot,
{ scpMode: true, encoding, signal },
);
}
return latestTarget;
};
let latest = await assertTargetSafe();
assertCanPromote();
if (Number.isFinite(existingMode)) {
await backend.chmod(stagedPath, existingMode, { encoding, signal });
assertCanPromote();
latest = await assertTargetSafe();
assertCanPromote();
}
// Promotion starts mutating the destination at the first rename. Mark the
// commit boundary before that point so a cancel arriving during either
// rename cannot be accepted and then reported after publication.
commitPromotion();
let movedExisting = false;
if (latest) {
await backend.rename(targetPath, backupPath, { encoding });
movedExisting = true;
}
try {
assertCanPromote();
await backend.rename(stagedPath, targetPath, { encoding });
} catch (promotionError) {
if (movedExisting) {
try {
await backend.rename(backupPath, targetPath, { encoding });
} catch (restoreError) {
throw createRemoteRecoveryError(promotionError, restoreError, {
stagePath: stagedPath,
backupPath,
finalPath: targetPath,
});
}
}
throw promotionError;
}
if (movedExisting) {
await deleteRemoteBackupWithRetry(
() => backend.remove(backupPath, { recursive: false, encoding }),
{ finalPath: targetPath, backupPath },
);
}
}
/**
* Pipelined upload with optional stage+rename.
* - Confirmed regular files: stage then rename (cancel-safe finals) + mode restore.
* - Symlinks / unknown-existing (no lstat): write in-place.
* - Parent-dir permission on stage: fall back to in-place (code-based only).
*
* `remotePath` must be the logical (pre-encode) path string. Encoding is applied
* here so staged/backup names are not built from Buffer path bytes.
*/
async function runRemoteUploadTransaction(client, localPath, remotePath, options = {}) {
const signal = options?.signal || null;
const expectedSize = options?.expectedSize;
const encoding = options?.encoding || "utf-8";
const customUpload = typeof options?.uploadFile === "function" ? options.uploadFile : null;
const assertCanPromote = typeof options?.assertCanPromote === "function"
? options.assertCanPromote
: () => throwIfAborted(signal);
const commitPromotion = typeof options?.commitPromotion === "function"
? options.commitPromotion
: () => {};
const runCancelablePreflight = typeof options?.runCancelablePreflight === "function"
? options.runCancelablePreflight
: (operation) => operation();
const preserveStageOnUploadError = options?.preserveStageOnUploadError === true;
const { isScpModeClient, getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
const scpMode = isScpModeClient(client);
const encodedPath = encodePath(remotePath, encoding);
// Build stable recovery paths before inspecting the destination. A previous
// interrupted/unclean promotion must be reconciled before we snapshot the
// target for this upload.
const stagedLogical = options?.stagedPath || buildStagedRemotePath(remotePath);
const backupLogical = options?.backupPath || buildBackupRemotePath(remotePath);
const encodedStagedPath = encodePath(stagedLogical, encoding);
const encodedBackupPath = encodePath(backupLogical, encoding);
if (scpMode) {
const backend = getScpBackendForClient(client);
await reconcileRemoteUploadBackup({
finalPath: remotePath,
backupPath: backupLogical,
statTarget: () => runCancelablePreflight(() => backend.stat(remotePath, { encoding, signal })),
statBackup: () => runCancelablePreflight(() => backend.stat(backupLogical, { encoding, signal })),
restoreBackup: () => backend.rename(backupLogical, remotePath, { encoding }),
deleteBackup: () => backend.remove(backupLogical, { recursive: false, encoding }),
});
} else {
// Inspect recovery artifacts through the raw channel. Every production
// client has one, while `client.stat` is only a convenience supplied by
// some adapters. Recovery must not make otherwise-valid raw SFTP clients
// fail before the normal upload plan can run.
const recoverySftp = await requireSftpChannel(client, { signal });
await reconcileRemoteUploadBackup({
finalPath: remotePath,
backupPath: backupLogical,
statTarget: () => runCancelablePreflight(() => inspectRemoteRecoveryEntry(recoverySftp, encodedPath)),
statBackup: () => runCancelablePreflight(() => inspectRemoteRecoveryEntry(recoverySftp, encodedBackupPath)),
restoreBackup: () => client.rename(encodedBackupPath, encodedPath),
deleteBackup: () => (
typeof client.delete === "function"
? client.delete(encodedBackupPath)
: unlinkAsync(recoverySftp, encodedBackupPath)
),
});
}
const plan = await runCancelablePreflight(() => (
scpMode
? planScpRemoteUploadReplace(client, remotePath, encoding, signal)
: planRemoteUploadReplace(client, encodedPath, remotePath, signal)
));
const fastPutOptions = { ...options };
delete fastPutOptions.expectedSize;
delete fastPutOptions.encoding;
delete fastPutOptions.uploadFile;
delete fastPutOptions.assertCanPromote;
delete fastPutOptions.commitPromotion;
delete fastPutOptions.runCancelablePreflight;
delete fastPutOptions.allowInPlaceFallback;
delete fastPutOptions.stagedPath;
delete fastPutOptions.backupPath;
delete fastPutOptions.preserveStageOnUploadError;
const uploadTo = async (logicalPath, encodedUploadPath, generatedStagePath) => {
if (customUpload) {
// SCP shell commands encode logical string paths themselves. Passing an
// SFTP-style Buffer here loses non-UTF-8 path information.
await customUpload(scpMode ? logicalPath : encodedUploadPath, {
logicalPath,
generatedStagePath,
plan,
});
return;
}
await pipelinedUploadLocalFile(client, localPath, encodedUploadPath, {
...fastPutOptions,
generatedStagePath,
});
};
const uploadDirect = async () => {
await uploadTo(remotePath, encodedPath, false);
assertCanPromote();
// An in-place upload has already published its bytes and cannot be rolled
// back. Stop accepting cancellation before the final size verification so
// a late request cannot report the completed overwrite as cancelled.
commitPromotion();
// Some servers recreate the inode on OPEN|CREAT|TRUNC. Restore captured
// permission bits after an in-place overwrite (incl. stage→in-place
// fallback) so executable/setuid bits are not left at umask defaults.
// Do not forward the transfer AbortSignal: promotion already committed, and
// a late cancel must not abort required mode restoration (best-effort would
// otherwise swallow the failure and leave umask defaults).
if (Number.isFinite(plan.existingMode)) {
await restoreRemoteMode(client, encodedPath, plan.existingMode, {
bestEffort: false,
remotePath,
encoding,
});
}
// SCP stat reports the link node itself. After an in-place symlink upload,
// it cannot reliably verify the followed target's byte count.
if (Number.isFinite(expectedSize) && expectedSize >= 0 && !(scpMode && plan.writeInPlace)) {
const st = scpMode
? await getScpBackendForClient(client).stat(remotePath, { encoding })
: typeof client.stat === "function" ? await client.stat(encodedPath) : null;
const size = Number(st?.size);
if (Number.isFinite(size) && size !== expectedSize) {
throw new Error(
`Upload size mismatch for ${remotePath}: expected ${expectedSize} bytes, got ${size}`,
);
}
}
return { staged: false };
};
if (plan.writeInPlace) {
return uploadDirect();
}
const cleanupStage = async () => {
try {
if (scpMode) {
await getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
});
} else if (typeof client.delete === "function") {
await client.delete(encodedStagedPath);
}
} catch {
// Best-effort cleanup of a partial stage.
}
};
try {
await uploadTo(stagedLogical, encodedStagedPath, true);
} catch (err) {
if (!preserveStageOnUploadError) {
await cleanupStage();
}
throw err;
}
try {
assertCanPromote();
if (Number.isFinite(expectedSize) && expectedSize >= 0) {
const stagedStat = await runCancelablePreflight(() => (
scpMode
? getScpBackendForClient(client).stat(stagedLogical, { encoding, signal })
: typeof client.stat === "function" ? client.stat(encodedStagedPath) : null
));
const stagedSize = Number(stagedStat?.size);
if (Number.isFinite(stagedSize) && stagedSize !== expectedSize) {
throw new Error(
`Upload size mismatch for ${remotePath}: expected ${expectedSize} bytes, got ${stagedSize}`,
);
}
}
// Cancel may arrive during the awaited size verify; recheck before promote.
assertCanPromote();
if (scpMode) {
await promoteScpStagedUpload(
client,
stagedLogical,
remotePath,
backupLogical,
encoding,
plan.destinationExisted,
plan.destinationSnapshot,
plan.existingMode,
assertCanPromote,
commitPromotion,
runCancelablePreflight,
signal,
);
} else {
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
// Apply the old mode to the stage before promotion. A failed chmod must not
// replace an executable final with a non-executable file and report success.
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
bestEffort: false,
});
assertCanPromote();
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
commitPromotion();
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath, {
stagePath: stagedLogical,
backupPath: backupLogical,
finalPath: remotePath,
});
}
return { staged: true };
} catch (err) {
if (!err?.preserveStagedUpload) {
await cleanupStage();
}
throw err;
}
}
const posixRenameAsync = (sftp, fromPath, toPath) =>
new Promise((resolve, reject) => {
if (typeof sftp?.ext_openssh_rename !== "function") {
reject(new Error("POSIX rename is not supported by this SFTP channel."));
return;
}
sftp.ext_openssh_rename(fromPath, toPath, (err) => (err ? reject(err) : resolve()));
});
async function renameRemotePath(client, fromPath, toPath, backupPath = null, recoveryPaths = {}) {
const sftp = await requireSftpChannel(client);
if (typeof sftp?.ext_openssh_rename === "function") {
try {
await posixRenameAsync(sftp, fromPath, toPath);
return;
} catch {
// Fall back to plain rename when the OpenSSH extension is unavailable or rejected.
}
}
try {
await client.rename(fromPath, toPath);
return;
} catch (renameErr) {
if (!backupPath) throw renameErr;
const destinationStat = await client.stat(toPath)
.then((stat) => stat || null)
.catch(() => false);
if (!destinationStat || destinationStat.isDirectory) {
throw renameErr;
}
let movedExistingTarget = false;
try {
await client.rename(toPath, backupPath);
movedExistingTarget = true;
await client.rename(fromPath, toPath);
} catch (fallbackErr) {
if (movedExistingTarget) {
try {
await client.rename(backupPath, toPath);
} catch (restoreErr) {
throw createRemoteRecoveryError(fallbackErr, restoreErr, {
stagePath: recoveryPaths.stagePath || fromPath,
backupPath: recoveryPaths.backupPath || backupPath,
finalPath: recoveryPaths.finalPath || toPath,
});
}
}
throw fallbackErr;
}
if (movedExistingTarget) {
await deleteRemoteBackupWithRetry(
() => client.delete(backupPath),
{
finalPath: recoveryPaths.finalPath || toPath,
backupPath: recoveryPaths.backupPath || backupPath,
},
);
}
}
}
function collectReadable(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
stream.once("error", reject);
stream.once("end", () => resolve(Buffer.concat(chunks)));
});
}
function writeToWritable(stream, content) {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
stream.removeListener("error", onError);
stream.removeListener("finish", onSuccess);
stream.removeListener("close", onSuccess);
};
const onError = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onSuccess = () => {
if (settled) return;
settled = true;
cleanup();
resolve();
};
stream.once("error", onError);
stream.once("finish", onSuccess);
stream.once("close", onSuccess);
stream.end(content);
});
}
function throwIfAborted(signal) {
if (!signal?.aborted) return;
const reason = signal.reason;
if (reason instanceof Error) {
throw reason;
}
if (typeof reason === "string" && reason) {
throw new Error(reason);
}
throw new Error("The operation was aborted.");
}
async function pipeStreams(source, destination, signal = null) {
if (signal) {
return await pipeline(source, destination, { signal });
}
return await pipeline(source, destination);
}
function statResultFromAttrs(attrs) {
const mode = attrs?.mode || 0;
const fileTypeMask = mode & 0o170000;
return {
size: attrs?.size || 0,
modifyTime: (attrs?.mtime || 0) * 1000,
mode,
isDirectory: typeof attrs?.isDirectory === "function"
? attrs.isDirectory()
: fileTypeMask === 0o040000,
isSymbolicLink: typeof attrs?.isSymbolicLink === "function"
? attrs.isSymbolicLink()
: fileTypeMask === 0o120000,
};
}
function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
const refHolder = options?.refHolder || null;
let ended = false;
const client = {
client: sshClient,
sftp: null,
__netcattySessionBacked: true,
__netcattySourceSessionId: options?.sourceSessionId,
__netcattyRefHolder: refHolder,
__netcattyDisposed: false,
_reopeningPromise: null,
async get(remotePath) {
const sftp = await requireSftpChannel(client);
const stream = sftp.createReadStream(remotePath);
return await collectReadable(stream);
},
async put(content, remotePath, options = {}) {
const sftp = await requireSftpChannel(client);
const signal = options?.signal || null;
throwIfAborted(signal);
if (content && typeof content.pipe === "function") {
const stream = sftp.createWriteStream(remotePath);
await pipeStreams(content, stream, signal);
return true;
}
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
const handle = await openFileAsync(sftp, remotePath, "w");
try {
let offset = 0;
while (offset < buffer.length) {
throwIfAborted(signal);
const length = Math.min(256 * 1024, buffer.length - offset);
await writeFileChunkAsync(sftp, handle, buffer, offset, length, offset);
offset += length;
}
} finally {
await closeFileAsync(sftp, handle);
}
return true;
},
/**
* Pipelined local→remote upload via the raw ssh2 SFTP channel.
* Session-backed clients are not ssh2-sftp-client instances and do not
* inherit client.fastPut — expose the channel method so uploadLocal keeps
* the high-throughput path (#2449).
*
* When `options.signal` is provided, open a disposable SFTP channel so
* abort can end the transfer without killing the browse session.
*/
async fastPut(localPath, remotePath, options = {}) {
return runAbortableFastPut(client, localPath, remotePath, options);
},
async stat(remotePath) {
const sftp = await requireSftpChannel(client);
const attrs = await statAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async realPath(remotePath) {
const sftp = await requireSftpChannel(client);
return await realpathAsync(sftp, remotePath);
},
async rename(oldPath, newPath) {
const sftp = await requireSftpChannel(client);
await new Promise((resolve, reject) => {
sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve()));
});
},
async delete(remotePath, options = {}) {
const signal = options?.signal || null;
throwIfAborted(signal);
const sftp = await requireSftpChannel(client, { signal });
throwIfAborted(signal);
await unlinkAsync(sftp, remotePath);
throwIfAborted(signal);
},
async rmdir(remotePath, recursive = false, options = {}) {
const signal = options?.signal || null;
throwIfAborted(signal);
if (recursive) {
// Prefer verified shell `rm -rf` (fast); fall back to SFTP walk.
await removeRemoteDirectory(client, remotePath, "utf-8", signal);
return;
}
const sftp = await requireSftpChannel(client, { signal });
throwIfAborted(signal);
await rmdirAsync(sftp, remotePath);
throwIfAborted(signal);
},
async chmod(remotePath, mode) {
const sftp = await requireSftpChannel(client);
await new Promise((resolve, reject) => {
if (typeof sftp.chmod === "function") {
sftp.chmod(remotePath, mode, (err) => (err ? reject(err) : resolve()));
return;
}
sftp.setstat(remotePath, { mode }, (err) => (err ? reject(err) : resolve()));
});
},
async end() {
if (ended) return;
ended = true;
client.__netcattyDisposed = true;
try {
if (client.sftp && typeof client.sftp.end === "function") {
client.sftp.end();
} else if (client.sftp && typeof client.sftp.close === "function") {
client.sftp.close();
}
} catch {
// Ignore channel close failures for session-backed clients.
} finally {
client.sftp = null;
if (refHolder && typeof releaseConnectionRef === "function") {
releaseConnectionRef(refHolder);
}
}
},
};
return client;
}
async function openSftpForSession(_event, payload) {
const { sessionId } = payload || {};
if (!sessionId) throw new Error("sessionId is required");
throwIfAborted(payload?.abortSignal);
let sourceSessionId = sessionId;
let source;
if (payload?.expectedEndpoint) {
const expectedEndpoint = buildConnectionReuseEndpoint(payload.expectedEndpoint);
source = findRemoteSftpSourceByEndpoint(
sessionId,
expectedEndpoint,
payload?.requireExactSourceSession === true,
);
if (!source) {
const err = new Error("Source session SSH route does not match the requested SFTP endpoint");
err.code = "ERR_SFTP_SOURCE_ROUTE_MISMATCH";
throw err;
}
sourceSessionId = source.sessionId;
} else {
source = { sessionId, ...ensureRemoteSftpSupport(sessionId) };
}
const { session, sshClient } = source;
const actualEndpoint = session._reuseEndpoint || session.connRef?.endpoint;
const sftpId = `${sourceSessionId}-sftp-${randomUUID()}`;
const refHolder = { id: sftpId, __sshLeaseKind: "sftp" };
if (session.connRef && typeof acquireConnectionRef === "function") {
acquireConnectionRef(refHolder, session.connRef);
}
const client = createSessionBackedSftpClient(sourceSessionId, sshClient, {
refHolder,
sourceSessionId,
});
client.__netcattyEndpointKey = session.connRef?.endpointKey || buildEndpointKey(actualEndpoint);
const { normalizeFileProtocol } = require("./sftpBridge/scpShell.cjs");
const { getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
// Prefer explicit payload, then the host preference stored when the SSH
// session started (Catty/MCP/clipboard open without fileProtocol).
const fileProtocol = normalizeFileProtocol(
payload?.fileProtocol
?? session?.sftpFileProtocol
?? session?.fileProtocol,
);
const {
createBoundedProbeSignal,
SCP_PROBE_TIMEOUT_MS,
} = require("./sftpBridge/openConnection.cjs");
const probeTimeoutMs = Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0
? payload.timeoutMs
: SCP_PROBE_TIMEOUT_MS;
const sudoRequested = Boolean(payload?.sudo);
async function probeScpCapability() {
const bounded = createBoundedProbeSignal(payload?.abortSignal || null, probeTimeoutMs);
try {
await getScpBackendForClient(client).homeDir({ signal: bounded.signal });
const { createSshExecAdapters } = require("./sftpBridge/scpBackend.cjs");
const adapters = createSshExecAdapters(sshClient);
const scpProbe = await adapters.exec(
"command -v scp >/dev/null 2>&1 || which scp >/dev/null 2>&1",
{ signal: bounded.signal },
);
if (scpProbe.code !== 0) {
throw new Error("SCP binary not available on remote host");
}
} catch (err) {
if (bounded.timedOut && !payload?.abortSignal?.aborted) {
throw new Error(`SCP mode probe timed out after ${probeTimeoutMs}ms`);
}
throw err;
} finally {
bounded.dispose();
}
}
try {
// Forced SCP cannot provide sudo elevation; reject before touching the channel
// so session reuse matches the fresh openSftp contract.
if (sudoRequested && fileProtocol === "scp") {
throw new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
);
}
if (sudoRequested) {
const sftpWrapper = await connectSudoSftp(sshClient, payload?.password || "");
client.sftp = sftpWrapper;
client.__netcattyFileProtocol = "sftp";
client.__netcattySudoMode = true;
sftpWrapper.on?.("close", () => client.end());
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId, fileProtocol: "sftp", sourceSessionId };
}
if (fileProtocol === "scp") {
client.__netcattyFileProtocol = "scp";
client.sftp = null;
// Probe must succeed: SCP mode requires working SSH exec + scp binary.
await probeScpCapability();
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId, fileProtocol: "scp", sourceSessionId };
}
try {
await requireSftpChannel(client, {
signal: payload?.abortSignal,
timeoutMs: payload?.timeoutMs,
});
client.__netcattyFileProtocol = "sftp";
} catch (sftpErr) {
if (fileProtocol === "sftp") throw sftpErr;
// Auto: SCP-mode fallback for hosts without SFTP subsystem (e.g. some NAS/root)
console.warn(
`[SFTP] openSftpForSession SFTP channel failed for ${sessionId}; falling back to SCP mode:`,
sftpErr?.message || String(sftpErr),
);
client.__netcattyFileProtocol = "scp";
client.sftp = null;
try {
await probeScpCapability();
} catch (probeErr) {
throw new Error(
`SFTP unavailable and SCP-mode probe failed: ${probeErr?.message || String(probeErr)}`,
);
}
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId, fileProtocol: "scp", sourceSessionId };
}
throwIfAborted(payload?.abortSignal);
copySftpEncodingState(payload?.encodingStateKey, sftpId);
sftpClients.set(sftpId, client);
return { ok: true, sftpId, fileProtocol: "sftp", sourceSessionId };
} catch (err) {
try {
await client.end();
} catch {
// Ignore cleanup failures while discarding a one-off session-backed handle.
}
throw err;
}
}
async function runUnifiedSftpTransfer(payload, direction) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
throwIfAborted(payload.abortSignal);
// High-level file transfers have one implementation. sftpBridge remains the
// session/filesystem adapter; transferBridge owns scheduling, integrity,
// staging, promotion, recovery, progress and cancellation.
const transferBridge = require("./transferBridge.cjs");
transferBridge.init({ sftpClients });
const transferId = payload.transferId || `sftp-${direction}-${randomUUID()}`;
const sender = {
send(channel, eventPayload) {
payload.onTransferEvent?.(channel, eventPayload);
},
};
const transferPayload = direction === "upload"
? {
transferId,
sourcePath: payload.localPath,
targetPath: payload.remotePath,
sourceType: "local",
targetType: "sftp",
targetSftpId: payload.sftpId,
targetEncoding: payload.encoding,
resumable: payload.resumable === true,
sourceIsOwnedTemp: payload.sourceIsOwnedTemp === true,
abortSignal: payload.abortSignal || null,
}
: {
transferId,
sourcePath: payload.remotePath,
targetPath: payload.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: payload.sftpId,
sourceEncoding: payload.encoding,
resumable: payload.resumable !== false,
abortSignal: payload.abortSignal || null,
};
const cancel = () => {
void transferBridge.cancelTransfer(null, { transferId });
};
payload.abortSignal?.addEventListener?.("abort", cancel, { once: true });
try {
const result = await transferBridge.startTransfer({ sender }, transferPayload);
if (result?.error) {
if (result.cancelled || result.error === "Transfer cancelled") {
throw createAbortError(payload.abortSignal, "Transfer cancelled");
}
throw new Error(result.error);
}
return direction === "upload"
? { success: true, transferId, remotePath: payload.remotePath }
: { success: true, transferId, localPath: payload.localPath };
} finally {
payload.abortSignal?.removeEventListener?.("abort", cancel);
}
}
async function downloadSftpToLocal(_event, payload) {
return runUnifiedSftpTransfer(payload, "download");
}
/**
* Open a disposable SFTP channel for cancelable pipelined uploads when possible.
* Falls back to the shared browse channel (not disposable) for sudo / missing SSH client.
*/
async function acquireUploadSftpChannel(client, options = {}) {
if (client?.__netcattySudoMode) {
const sftp = await requireSftpChannel(client, options);
return { sftp, dispose: false };
}
const sshClient = client?.client;
if (sshClient && typeof sshClient.sftp === "function") {
// Prefer a disposable channel for cancel, but never fail the whole upload
// when MaxSessions / server policy refuses another subsystem — fall back to
// the existing browse channel (Codex PR review).
try {
throwIfAborted(options?.signal);
const sftp = await tryOpenSftpChannel(client, options);
if (sftp && typeof sftp.fastPut === "function") {
return { sftp, dispose: true };
}
try { sftp?.end?.(); } catch { /* ignore */ }
} catch (err) {
if (options?.signal?.aborted) throw err;
console.warn(
"[SFTP] Disposable upload channel unavailable, using shared SFTP channel:",
err?.message || String(err),
);
}
}
const shared = await requireSftpChannel(client, options);
return { sftp: shared, dispose: false };
}
/**
* Run ssh2 SFTP fastPut with optional AbortSignal.
* Always waits for the fastPut callback (or a short dispose grace period) before
* settling so local temp files are not unlinked while ssh2 still holds them.
* Disposable channels are ended on abort; shared channels only mark cancelled.
*/
function runFastPutOnChannel(sftp, localPath, remotePath, options = {}, channelControl = {}) {
const { dispose = false, signal = null, generatedStagePath = false } = channelControl;
throwIfAborted(signal);
if (typeof sftp?.fastPut !== "function") {
throw new Error(
"SFTP pipelined upload (fastPut) is not available on this session",
);
}
const { signal: _ignoredSignal, onChannel, ...fastPutOptions } = options || {};
return new Promise((resolve, reject) => {
let settled = false;
let abortRequested = false;
let pendingError = null;
let forceFinishTimer = null;
const clearForceFinish = () => {
if (forceFinishTimer) {
clearTimeout(forceFinishTimer);
forceFinishTimer = null;
}
};
const scheduleForceFinish = (err) => {
clearForceFinish();
forceFinishTimer = setTimeout(() => {
// Shared channel: best-effort unlink only paths explicitly created by
// our staging planner. A caller's final name may resemble a stage path.
if (
!dispose
&& (abortRequested || signal?.aborted || pendingError)
&& generatedStagePath
) {
try { sftp.unlink?.(remotePath, () => {}); } catch { /* ignore */ }
}
finish(err || new Error("SFTP channel closed"));
}, 2000);
};
const finish = (err) => {
if (settled) return;
settled = true;
clearForceFinish();
if (signal && onAbort) {
try { signal.removeEventListener("abort", onAbort); } catch { /* ignore */ }
}
try { sftp.removeListener?.("error", onChannelError); } catch { /* ignore */ }
if (dispose) {
try { sftp.end?.(); } catch { /* ignore */ }
}
if (err) reject(err);
else resolve();
};
// Channel errors must not finish immediately: wait for fastPut callback (or
// force timeout) so local temp files are not unlinked while still open.
// Shared channels also get force-settle (without sftp.end) so a stalled
// callback after error cannot hang the upload forever.
const onChannelError = (err) => {
pendingError = err || new Error("SFTP channel error");
if (dispose) {
try { sftp.end?.(); } catch { /* ignore */ }
}
scheduleForceFinish(pendingError);
};
const onAbort = () => {
abortRequested = true;
if (dispose) {
try { sftp.end?.(); } catch { /* ignore */ }
scheduleForceFinish(createAbortError(signal, "Upload cancelled"));
return;
}
// Shared browse/sudo channel: do not sftp.end() (would kill the session).
// Still bound cancellation so a stalled fastPut cannot hang forever.
scheduleForceFinish(createAbortError(signal, "Upload cancelled"));
};
try { sftp.on?.("error", onChannelError); } catch { /* ignore */ }
if (typeof onChannel === "function") {
try { onChannel(sftp, { dispose, abort: onAbort }); } catch { /* ignore */ }
}
if (signal) {
if (signal.aborted) {
finish(createAbortError(signal, "Upload cancelled"));
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
try {
sftp.fastPut(localPath, remotePath, fastPutOptions, (err) => {
if (abortRequested || signal?.aborted) {
finish(createAbortError(signal, "Upload cancelled"));
return;
}
if (pendingError) {
finish(pendingError);
return;
}
finish(err || null);
});
} catch (err) {
finish(err);
}
});
}
async function runAbortableFastPut(client, localPath, remotePath, options = {}) {
const signal = options?.signal || null;
const generatedStagePath = options?.generatedStagePath === true;
const fastPutOptions = { ...options };
delete fastPutOptions.generatedStagePath;
throwIfAborted(signal);
const { sftp, dispose } = await acquireUploadSftpChannel(client, { signal });
return runFastPutOnChannel(sftp, localPath, remotePath, fastPutOptions, {
dispose,
signal,
generatedStagePath,
});
}
/**
* Pipelined local→remote upload.
* - Prefer disposable-channel fastPut when abortable / session-backed
* - ssh2-sftp-client.fastPut when no signal and method exists
* Never falls back to serial createWriteStream/put (#2449).
*/
async function pipelinedUploadLocalFile(client, localPath, remotePath, options = {}) {
const signal = options?.signal || null;
// Always use abortable channel path when a signal is present, or when the
// client is session-backed (wrapper fastPut → disposable channel).
if (signal || client?.__netcattySessionBacked || typeof client?.fastPut !== "function") {
return runAbortableFastPut(client, localPath, remotePath, options);
}
// ssh2-sftp-client without abort: native fastPut on the shared connection.
const fastPutOptions = { ...options };
delete fastPutOptions.generatedStagePath;
return client.fastPut(localPath, remotePath, fastPutOptions);
}
async function uploadLocalToSftp(_event, payload) {
return runUnifiedSftpTransfer(payload, "upload");
}
/**
* Send SFTP connection progress to the renderer for user-visible logging
*/
function sendSftpProgress(sender, sessionId, label, status, detail) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
} catch {
// Ignore destroyed webContents
}
}
/**
* Connect through a chain of jump hosts for SFTP
*/
const { createOpenConnectionApi } = require("./sftpBridge/openConnection.cjs");
const {
acquireConnectionRef,
releaseConnectionRef,
findReusableSession,
createTransport,
borrowTransport,
findTransportByEndpoint,
beginTransportDial,
waitForTransportDial,
completeTransportDial,
failTransportDial,
buildEndpointKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
endpointAllowsReuse,
} = require("./sshConnectionPool.cjs");
const openConnectionApi = createOpenConnectionApi({
get sftpClients() { return sftpClients; },
get sessions() { return sessions; },
get electronModule() { return electronModule; },
jumpConnectionsMap, SftpClient, SSHClient, NetcattyAgent, keyboardInteractiveHandler, passphraseHandler,
hostKeyVerifier,
fs, path, net, Buffer, process, console, setTimeout, clearTimeout,
SFTPWrapper, createProxySocket, buildSftpAlgorithms, getAvailableAgentSocket,
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, prepareSystemSshAgentForAuth, findAllDefaultPrivateKeysFromHelper,
buildAuthHandler, applyAuthToConnOpts, createKeyboardInteractiveHandler, shouldSkipKiPasswordAutoFill, passphraseHandler,
isKeyEncrypted, randomUUID,
sendSftpProgress, safeSend, authSafeSend, copySftpEncodingState, clearSftpEncodingState, normalizeEncoding,
resolveEncodingForRequest, updateResolvedEncoding, requireSftpChannel, realpathAsync,
connectSudoSftp: undefined,
acquireConnectionRef, releaseConnectionRef, findReusableSession, createSessionBackedSftpClient,
createTransport, borrowTransport, findTransportByEndpoint,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildEndpointKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
});
const { connectThroughChainForSftp, connectSudoSftp, openSftp } = openConnectionApi;
const { createFileOpsApi } = require("./sftpBridge/fileOps.cjs");
const fileOpsApi = createFileOpsApi({
get sftpClients() { return sftpClients; },
get electronModule() { return electronModule; },
fileWatcherBridge, fs, path, Buffer, console, setTimeout, clearTimeout,
jumpConnectionsMap, sftpEncodingState, normalizeEncoding, isAsciiString,
requireSftpChannel, resolveEncodingForRequest, updateResolvedEncoding, encodePath, decodeName,
detectEncodingFromList, statResultFromAttrs, normalizeRemotePathString, collectReadable, writeToWritable,
throwIfAborted, pipeStreams, ensureRemoteDirForSession, removeRemotePathInternal, removeRemoteDirectory,
tryFastShellDirectoryDelete, execRemoteShellCommand, renameRemotePath,
buildStagedRemotePath, buildBackupRemotePath,
realpathAsync, statAsync, lstatAsync, readdirAsync, mkdirAsync, rmdirAsync, unlinkAsync, openFileAsync,
writeFileChunkAsync, closeFileAsync, createAbortError, copySftpEncodingState, clearSftpEncodingState,
safeSend, tempDirBridge, randomUUID,
runUnifiedSftpTransfer,
});
const {
listSftp,
realpathSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinary,
closeSftp,
mkdirSftp,
deleteSftp,
renameSftp,
statSftp,
lstatSftp,
chmodSftp,
extractSftpArchive,
getSftpHomeDir,
} = fileOpsApi;
function resolveRendererSftpSourceSession(channel, payload = {}) {
if (channel === "netcatty:sftp:openForSession") return payload.sessionId || null;
if (channel === "netcatty:sftp:open") return payload.sourceSessionId || null;
const sftpId = payload.sftpId;
if (!sftpId) return null;
return rendererSftpSourceSessions.get(sftpId)
|| sftpClients?.get?.(sftpId)?.__netcattySourceSessionId
|| null;
}
function reportSftpActivity(sessionId, phase) {
if (!sessionId) return;
try {
reportOpenedSessionActivity?.({ sessionId, phase });
} catch {
// Activity tracking must not interfere with SFTP operations.
}
}
function createRendererSftpOwnership(closeOwnedSftp) {
const owners = new Map();
const isOpenChannel = (channel) => (
channel === "netcatty:sftp:open" || channel === "netcatty:sftp:openForSession"
);
const cleanupOwnerIfDone = (entry) => {
if (!entry?.destroyed || entry.pendingOpens > 0 || entry.sftpIds.size > 0) return;
owners.delete(entry.senderId);
};
const closeForOwner = async (entry, sftpId) => {
entry.sftpIds.delete(sftpId);
try {
await closeOwnedSftp(sftpId, entry.senderId);
} catch (error) {
console.warn(`[SFTP] Failed to close renderer-owned session ${sftpId}:`, error?.message || error);
} finally {
cleanupOwnerIfDone(entry);
}
};
const getOwner = (sender) => {
const senderId = sender?.id;
if (!Number.isSafeInteger(senderId)) return null;
let entry = owners.get(senderId);
if (entry) return entry;
entry = {
senderId,
sender,
destroyed: sender?.isDestroyed?.() === true,
pendingOpens: 0,
sftpIds: new Set(),
};
owners.set(senderId, entry);
if (!entry.destroyed && typeof sender?.once === "function") {
sender.once("destroyed", () => {
entry.destroyed = true;
for (const sftpId of [...entry.sftpIds]) {
void closeForOwner(entry, sftpId);
}
cleanupOwnerIfDone(entry);
});
}
return entry;
};
return {
shouldRememberOpenResult(sender, sftpId) {
const senderId = sender?.id;
if (!Number.isSafeInteger(senderId)) return true;
return owners.get(senderId)?.sftpIds.has(sftpId) === true;
},
async run(channel, event, payload, handler) {
if (isOpenChannel(channel)) {
const entry = getOwner(event?.sender);
if (!entry) return handler();
entry.pendingOpens += 1;
try {
const result = await handler();
const sftpId = result?.sftpId;
if (sftpId) {
if (entry.destroyed || entry.sender?.isDestroyed?.() === true) {
entry.destroyed = true;
await closeForOwner(entry, sftpId);
} else {
entry.sftpIds.add(sftpId);
}
}
return result;
} finally {
entry.pendingOpens -= 1;
cleanupOwnerIfDone(entry);
}
}
if (channel === "netcatty:sftp:close" && payload?.sftpId) {
const claimedEntries = [];
for (const entry of owners.values()) {
if (entry.sftpIds.delete(payload.sftpId)) {
claimedEntries.push(entry);
}
cleanupOwnerIfDone(entry);
}
try {
return await handler();
} catch (error) {
await Promise.all(claimedEntries.map(async (entry) => {
if (entry.destroyed || entry.sender?.isDestroyed?.() === true) {
entry.destroyed = true;
await closeForOwner(entry, payload.sftpId);
} else {
entry.sftpIds.add(payload.sftpId);
}
}));
throw error;
}
}
return handler();
},
};
}
function registerActivityHandle(ipcMain, channel, handler, ownership = null) {
ipcMain.handle(channel, async (event, payload) => {
const sourceSessionId = resolveRendererSftpSourceSession(channel, payload);
reportSftpActivity(sourceSessionId, "begin");
try {
const invoke = () => handler(event, payload);
const result = ownership
? await ownership.run(channel, event, payload, invoke)
: await invoke();
const sftpId = result?.sftpId;
const resolvedSourceSessionId = result?.sourceSessionId
|| sftpClients?.get?.(sftpId)?.__netcattySourceSessionId
|| sourceSessionId;
if (
resolvedSourceSessionId
&& sftpId
&& (!ownership || ownership.shouldRememberOpenResult(event?.sender, sftpId))
) {
rendererSftpSourceSessions.set(sftpId, resolvedSourceSessionId);
if (resolvedSourceSessionId !== sourceSessionId) {
reportSftpActivity(resolvedSourceSessionId, "touch");
}
}
return result;
} finally {
if (channel === "netcatty:sftp:close" && payload?.sftpId) {
rendererSftpSourceSessions.delete(payload.sftpId);
}
reportSftpActivity(sourceSessionId, "end");
}
});
}
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel, ownership) {
registerActivityHandle(ipcMain, channel, (event, payload) => (
terminalWorkerManager.request(channel, payload, {
webContentsId: event?.sender?.id,
})
), ownership);
}
/**
* Register IPC handlers for SFTP operations
*/
function registerHandlers(ipcMain, options = {}) {
const terminalWorkerManager = options.terminalWorkerManager || null;
const ownership = createRendererSftpOwnership((sftpId, webContentsId) => {
rendererSftpSourceSessions.delete(sftpId);
return terminalWorkerManager
? terminalWorkerManager.request(
"netcatty:sftp:close",
{ sftpId },
{ webContentsId },
)
: closeSftp(null, { sftpId });
});
if (terminalWorkerManager) {
[
"netcatty:sftp:open",
"netcatty:sftp:openForSession",
"netcatty:sftp:list",
"netcatty:sftp:realpath",
"netcatty:sftp:read",
"netcatty:sftp:readBinary",
"netcatty:sftp:write",
"netcatty:sftp:writeBinary",
"netcatty:sftp:downloadToLocal",
"netcatty:sftp:uploadLocal",
"netcatty:sftp:close",
"netcatty:sftp:mkdir",
"netcatty:sftp:delete",
"netcatty:sftp:rename",
"netcatty:sftp:stat",
"netcatty:sftp:lstat",
"netcatty:sftp:chmod",
"netcatty:sftp:extract",
"netcatty:sftp:homeDir",
].forEach((channel) => registerWorkerHandle(ipcMain, terminalWorkerManager, channel, ownership));
return;
}
[
["netcatty:sftp:open", openSftp],
["netcatty:sftp:openForSession", openSftpForSession],
["netcatty:sftp:list", listSftp],
["netcatty:sftp:realpath", realpathSftp],
["netcatty:sftp:read", readSftp],
["netcatty:sftp:readBinary", readSftpBinary],
["netcatty:sftp:write", writeSftp],
["netcatty:sftp:writeBinary", writeSftpBinary],
["netcatty:sftp:downloadToLocal", downloadSftpToLocal],
["netcatty:sftp:uploadLocal", uploadLocalToSftp],
["netcatty:sftp:close", closeSftp],
["netcatty:sftp:mkdir", mkdirSftp],
["netcatty:sftp:delete", deleteSftp],
["netcatty:sftp:rename", renameSftp],
["netcatty:sftp:stat", statSftp],
["netcatty:sftp:lstat", lstatSftp],
["netcatty:sftp:chmod", chmodSftp],
["netcatty:sftp:extract", extractSftpArchive],
["netcatty:sftp:homeDir", getSftpHomeDir],
].forEach(([channel, handler]) => registerActivityHandle(ipcMain, channel, handler, ownership));
}
/**
* Get the SFTP clients map (for external access)
*/
function getSftpClients() {
return sftpClients;
}
module.exports = {
init,
registerHandlers,
getSftpClients,
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
requireSftpChannel,
encodePathForSession,
getResolvedFilenameEncoding,
ensureRemoteDirForSession,
clearSftpEncodingState,
clearSftpEncodingStateByPrefix,
openSftpForSession,
openSftp,
_createRendererSftpOwnershipForTests: createRendererSftpOwnership,
listSftp,
realpathSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinary,
downloadSftpToLocal,
uploadLocalToSftp,
pipelinedUploadLocalFile,
runRemoteUploadTransaction,
_renameRemotePathForTests: renameRemotePath,
_buildBackupRemotePathForTests: buildBackupRemotePath,
_deleteRemoteBackupWithRetryForTests: deleteRemoteBackupWithRetry,
_reconcileRemoteUploadBackupForTests: reconcileRemoteUploadBackup,
closeSftp,
mkdirSftp,
deleteSftp,
renameSftp,
statSftp,
lstatSftp,
chmodSftp,
extractSftpArchive,
getSftpHomeDir,
resolveEncodingForRequest,
_execRemoteShellCommandForTests: execRemoteShellCommand,
_tryRemoteSha256SumForTests: tryRemoteSha256Sum,
};