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