mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e6b25f72 | ||
|
|
5da7940a03 | ||
|
|
a4baf9e1fd | ||
|
|
7537bbaa89 | ||
|
|
bca2498b9f | ||
|
|
5af31c02c9 | ||
|
|
b302ff1f84 | ||
|
|
5cc7bf803d |
No files matched your search
@@ -55,6 +55,7 @@ let sftpClients = null;
|
||||
let electronModule = null;
|
||||
let sessions = null;
|
||||
let reportOpenedSessionActivity = null;
|
||||
let reportSuppressedError = null;
|
||||
const rendererSftpSourceSessions = new Map();
|
||||
const REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS = 15_000;
|
||||
const REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
|
||||
@@ -734,6 +735,9 @@ function init(deps) {
|
||||
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
|
||||
? deps.reportOpenedSessionActivity
|
||||
: null;
|
||||
reportSuppressedError = typeof deps.reportSuppressedError === "function"
|
||||
? deps.reportSuppressedError
|
||||
: null;
|
||||
rendererSftpSourceSessions.clear();
|
||||
}
|
||||
|
||||
@@ -2015,10 +2019,14 @@ async function openSftpForSession(_event, payload) {
|
||||
}
|
||||
|
||||
try {
|
||||
await requireSftpChannel(client, {
|
||||
// This is a fresh client: retain the initial channel-open error instead
|
||||
// of passing through recovery, which replaces it with a generic error.
|
||||
const channel = await tryOpenSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
timeoutMs: payload?.timeoutMs,
|
||||
});
|
||||
if (!hasSftpChannelApi(channel)) throw new Error("SFTP channel unavailable");
|
||||
client.sftp = channel;
|
||||
client.__netcattyFileProtocol = "sftp";
|
||||
} catch (sftpErr) {
|
||||
if (fileProtocol === "sftp") throw sftpErr;
|
||||
@@ -2027,6 +2035,23 @@ async function openSftpForSession(_event, payload) {
|
||||
`[SFTP] openSftpForSession SFTP channel failed for ${sessionId}; falling back to SCP mode:`,
|
||||
sftpErr?.message || String(sftpErr),
|
||||
);
|
||||
// The terminal worker has no Electron app: forward to main's logger.
|
||||
try {
|
||||
const source = "sftpBridge.openSftpForSession";
|
||||
const message = `SFTP channel failed for ${sourceSessionId}; falling back to SCP mode (${fileProtocol})`;
|
||||
if (reportSuppressedError) {
|
||||
reportSuppressedError(source, sftpErr, message);
|
||||
} else {
|
||||
require("./crashLogBridge.cjs").captureDiagnostic(source, message, {
|
||||
sessionId: sourceSessionId,
|
||||
fileProtocol,
|
||||
reason: sftpErr?.message || String(sftpErr),
|
||||
reasonCode: sftpErr?.code ?? sftpErr?.level ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Diagnostic failures must not prevent the fallback.
|
||||
}
|
||||
client.__netcattyFileProtocol = "scp";
|
||||
client.sftp = null;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
|
||||
for (const loggerThrows of [false, true]) {
|
||||
test(`Auto fallback reports the channel failure; loggerThrows=${loggerThrows}`, async () => {
|
||||
const failure = Object.assign(new Error("subsystem request rejected"), { code: "CHANNEL_OPEN_FAILURE" });
|
||||
const reports = [];
|
||||
const conn = {
|
||||
sftp(callback) { callback(failure); },
|
||||
exec(command, callback) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
callback(null, stream);
|
||||
queueMicrotask(() => {
|
||||
if (!command.includes("command -v scp")) {
|
||||
stream.emit("data", Buffer.from("RAW:/home/test\n"));
|
||||
}
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
},
|
||||
};
|
||||
bridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["fallback-session", { conn }]]),
|
||||
sftpClients: new Map(),
|
||||
reportSuppressedError(...args) {
|
||||
reports.push(args);
|
||||
if (loggerThrows) throw new Error("logger unavailable");
|
||||
},
|
||||
});
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "fallback-session" });
|
||||
try {
|
||||
assert.equal(opened.fileProtocol, "scp");
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0][0], "sftpBridge.openSftpForSession");
|
||||
assert.equal(reports[0][1], failure);
|
||||
assert.match(reports[0][2], /fallback-session.*SCP mode \(auto\)/);
|
||||
} finally {
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -190,8 +190,16 @@ function createScpBackend(deps = {}) {
|
||||
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);
|
||||
// allowNonZero: parseStatRecord needs the exit code + stderr to tell a real
|
||||
// ENOENT (exit 2) apart from an empty exec-channel response.
|
||||
const result = await runOrThrow(buildStatCommand(remotePath, encoding), {
|
||||
allowNonZero: true,
|
||||
signal,
|
||||
});
|
||||
return parseStatRecord(result.stdout, {
|
||||
stderr: result.stderr,
|
||||
exitCode: result.code,
|
||||
});
|
||||
}
|
||||
|
||||
async function mkdir(remotePath, options = {}) {
|
||||
|
||||
@@ -175,6 +175,137 @@ describe("scpBackend browse/manage with fake exec", () => {
|
||||
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
|
||||
});
|
||||
|
||||
it("reports ENOENT when the stat command exits 2 with an ENOENT marker", async () => {
|
||||
const missingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => missingBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when a shell banner precedes the marker on stderr", async () => {
|
||||
// Codex: non-interactive shells may print warnings before the command, so
|
||||
// the ENOENT marker can appear on a later stderr line.
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "warning: env cleared\nENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when stdout contains a login banner", async () => {
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "Welcome\n", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mask an empty exec response as ENOENT in stat", async () => {
|
||||
const emptyBackend = createScpBackend({
|
||||
// Simulates the reporter's case: the exec channel returns no output at
|
||||
// all (e.g. channel negotiation failure) — not a missing file.
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => emptyBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /empty response/);
|
||||
assert.ok(!(err instanceof Error && err.message === "No such file"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces stderr and exit code when the stat command fails without output", async () => {
|
||||
const failingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 1 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => failingBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 1/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat exit 2 without an ENOENT marker as a missing file", async () => {
|
||||
// Codex: a remote shell failure (e.g. syntax/usage error) can also exit 2;
|
||||
// without the ENOENT marker it must surface the real stderr, not ENOENT.
|
||||
const shellFailBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => shellFailBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 2/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 2);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a stdout ENOENT marker with a nonzero, non-2 exit as missing", async () => {
|
||||
// Codex: a forced-command wrapper can print "ENOENT" on stdout before
|
||||
// failing with another status; it must not be reported as ENOENT.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "ENOENT\n", stderr: "denied\n", code: 126 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.notEqual(err.code, "ENOENT");
|
||||
assert.equal(err.exitCode, 126);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stat metadata forwarded by a wrapper that exits nonzero", async () => {
|
||||
// Codex: a forced-command wrapper can forward the stat record but exit
|
||||
// nonzero; the metadata is untrustworthy and must not be returned.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({
|
||||
stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt|12345\n",
|
||||
stderr: "forced command failed\n",
|
||||
code: 1,
|
||||
}),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.match(err.message, /exited 1/);
|
||||
assert.match(err.message, /forced command failed/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "forced command failed");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves home directory", async () => {
|
||||
const home = await backend.homeDir();
|
||||
assert.equal(home, "/home/test");
|
||||
|
||||
@@ -327,16 +327,57 @@ function parseLsLaOutput(stdout, { basePath = "" } = {}) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseStatRecord(stdout) {
|
||||
function parseStatRecord(stdout, { stderr = "", exitCode = null } = {}) {
|
||||
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
|
||||
const stderrText = String(stderr || "").trim();
|
||||
// A missing path emits an ENOENT marker and exits 2. Shell startup
|
||||
// messages may precede it on either stream; exit status alone is not enough.
|
||||
const hasMissingMarker = line === "ENOENT"
|
||||
|| stderrText.split(/\r?\n/).some((entry) => entry.trim() === "ENOENT");
|
||||
if (hasMissingMarker && (exitCode == null || exitCode === 2)) {
|
||||
throw new ScpShellError("No such file", "ENOENT");
|
||||
}
|
||||
if (!line || line === "ENOENT") {
|
||||
const err = new ScpShellError("No such file", "ENOENT");
|
||||
err.code = "ENOENT";
|
||||
const detail = [
|
||||
"exec channel returned empty response",
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail, "EMPTY_RESPONSE");
|
||||
err.code = "EMPTY_RESPONSE";
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
// Any other nonzero status is a remote execution failure — even when stdout
|
||||
// looks like a valid record (e.g. a forced-command wrapper that forwards the
|
||||
// stat record before failing). Such metadata is untrustworthy, so reject it
|
||||
// instead of letting the caller proceed on it.
|
||||
if (exitCode != null && exitCode !== 0) {
|
||||
const detail = [
|
||||
`stat command exited ${exitCode}`,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 5) {
|
||||
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
|
||||
// Non-empty stdout that isn't a stat record: the remote command may have
|
||||
// failed (nonzero exit, e.g. a restricted shell printing a banner) while
|
||||
// still writing to stdout. Preserve exit code and stderr so failures with
|
||||
// output aren't reduced to a bare "malformed" message.
|
||||
const detail = [
|
||||
`Malformed stat record: ${line.slice(0, 80)}`,
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
|
||||
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
|
||||
|
||||
@@ -301,6 +301,7 @@ function main() {
|
||||
selectZmodemUploadFiles,
|
||||
selectZmodemDownloadDirectory,
|
||||
terminalDataPipeline,
|
||||
reportSuppressedError: reportWorkerError,
|
||||
};
|
||||
|
||||
runtime = createTerminalWorkerRuntime({
|
||||
|
||||
Reference in new issue
Block a user