Files
NetMesh/electron/bridges/sftpBridge/scpProtocol.cjs

349 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 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,
};