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

444 lines
16 KiB
JavaScript

/**
* Shell quoting + remote browse/manage command builders for SCP-mode sessions.
* Pure helpers — no SSH I/O. Commands are designed for POSIX / BusyBox shells.
*/
"use strict";
class ScpShellError extends Error {
constructor(message, code = "SCP_SHELL_ERROR") {
super(message);
this.name = "ScpShellError";
this.code = code;
}
}
/**
* Single-quote a remote path/argument for POSIX sh.
* Rejects NUL and rejects empty strings for path ops (callers may allow empty for flags).
*/
function shellQuote(value, { allowEmpty = false } = {}) {
if (value == null) {
throw new ScpShellError("Shell argument is required");
}
const str = String(value);
if (str.includes("\0")) {
throw new ScpShellError("Shell argument must not contain NUL");
}
if (!allowEmpty && str.length === 0) {
throw new ScpShellError("Shell argument must not be empty");
}
// ' -> '\'' (end quote, escaped quote, re-open)
return `'${str.replace(/'/g, `'\\''`)}'`;
}
/**
* Validate a remote path used in shell commands.
* Blocks obvious injection patterns beyond quoting (newlines).
*/
function assertSafeRemotePath(remotePath) {
if (typeof remotePath !== "string" || !remotePath) {
throw new ScpShellError("Remote path is required");
}
if (remotePath.includes("\0") || /[\r\n]/.test(remotePath)) {
throw new ScpShellError("Remote path must not contain NUL or newlines");
}
return remotePath;
}
/**
* Build `scp -t` sink command (upload to remote directory parent).
* Destination is the directory that will receive the file named in the C line.
*/
function buildScpSinkCommand(remoteDir, encoding = "utf-8") {
const dir = assertSafeRemotePath(remoteDir || ".");
return `scp -t -- ${shellQuotePath(dir, encoding)}`;
}
/**
* Build `scp -f` source command (download from remote path).
*/
function buildScpSourceCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
return `scp -f -- ${shellQuotePath(p, encoding)}`;
}
/**
* Portable directory listing that emits one record per line:
* T|MODE|SIZE|MTIME|B64NAME
* where T is f/d/l (file/dir/link), MODE is octal perms, MTIME unix seconds,
* B64NAME is base64 of the basename (handles spaces/unicode safely).
*
* Uses a POSIX-oriented shell loop; avoids GNU-only find -printf.
*/
function buildListCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
// Join the for-loop body with newlines so we never emit invalid `do;` (`;`
// after `do` is a syntax error on POSIX sh and would force the ls -la fallback).
const loop = [
"for f in * .[!.]* ..?*; do",
// Include broken symlinks: -e is false for dangling links, but -L is true.
' { [ -e "$f" ] || [ -L "$f" ]; } || continue',
' [ "$f" = "." ] && continue',
' [ "$f" = ".." ] && continue',
' if [ -L "$f" ]; then t=l',
' elif [ -d "$f" ]; then t=d',
' else t=f; fi',
' lsline=$(ls -ld -- "$f" 2>/dev/null)',
' mode=$(printf "%s\\n" "$lsline" | awk \'{print $1}\')',
' owner=$(printf "%s\\n" "$lsline" | awk \'{print $3}\')',
// Use metadata only — never open the file (FIFOs/special files would hang on wc -c).
' size=$(stat -c %s -- "$f" 2>/dev/null || stat -f %z -- "$f" 2>/dev/null || echo 0)',
' [ -z "$size" ] && size=0',
' mtime=$(date -r "$f" +%s 2>/dev/null || stat -c %Y -- "$f" 2>/dev/null || stat -f %m -- "$f" 2>/dev/null || echo 0)',
// basename base64: prefer base64 -w0 (GNU), fallback base64, then od
// Prefer `base64` when present; otherwise openssl. Avoid `cmd | tr || fallback`
// because a missing base64 still lets tr succeed with empty input.
' if command -v base64 >/dev/null 2>&1; then b64=$(printf "%s" "$f" | base64 2>/dev/null | tr -d "\\r\\n")',
' elif command -v openssl >/dev/null 2>&1; then b64=$(printf "%s" "$f" | openssl base64 2>/dev/null | tr -d "\\r\\n")',
' else b64=; fi',
' printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "$size" "$mtime" "$b64" "${owner:-}"',
"done",
].join("\n");
return `cd ${q} || exit 1; ${loop}`;
}
/**
* Simpler listing using `ls -la` as a fallback parse path (for tests / remotes
* where the loop above is too heavy). Primary runtime still prefers buildListCommand.
*/
function buildListCommandLs(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return `LC_ALL=C ls -la -- ${q} 2>/dev/null || LC_ALL=C ls -la ${q}`;
}
function buildStatCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
// Emit: T|MODE_OCT|SIZE|MTIME|ABS|INO
// INO is optional identity for staged-upload race checks (SFTP v3 has none).
return [
`p=${q}`,
'if [ ! -e "$p" ] && [ ! -L "$p" ]; then echo "ENOENT" >&2; exit 2; fi',
'if [ -L "$p" ]; then t=l',
'elif [ -d "$p" ]; then t=d',
'else t=f; fi',
'mode=$(ls -ld -- "$p" 2>/dev/null | awk \'{print $1}\')',
'size=$(stat -c %s -- "$p" 2>/dev/null || stat -f %z -- "$p" 2>/dev/null || echo 0)',
'mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y -- "$p" 2>/dev/null || stat -f %m -- "$p" 2>/dev/null || echo 0)',
'abs=$(cd "$(dirname -- "$p")" 2>/dev/null && printf "%s/%s\\n" "$(pwd -P 2>/dev/null || pwd)" "$(basename -- "$p")" || printf "%s\\n" "$p")',
'ino=$(stat -c %i -- "$p" 2>/dev/null || stat -f %i -- "$p" 2>/dev/null || echo)',
'printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "${size:-0}" "${mtime:-0}" "$abs" "${ino}"',
].join("; ");
}
function buildMkdirCommand(remotePath, { recursive = true, encoding = "utf-8" } = {}) {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return recursive
? `mkdir -p -- ${q}`
: `mkdir -- ${q}`;
}
function buildDeleteCommand(remotePath, { recursive = false, encoding = "utf-8" } = {}) {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
if (recursive) {
return `rm -rf -- ${q}`;
}
return `rm -f -- ${q} 2>/dev/null || rmdir -- ${q}`;
}
function buildUnlinkCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return `rm -f -- ${q}`;
}
function buildRenameCommand(oldPath, newPath, encoding = "utf-8") {
const a = shellQuotePath(assertSafeRemotePath(oldPath), encoding);
const b = shellQuotePath(assertSafeRemotePath(newPath), encoding);
return `mv -- ${a} ${b}`;
}
function buildChmodCommand(remotePath, modeOctal, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const mode = String(modeOctal);
if (!/^[0-7]{3,4}$/.test(mode)) {
throw new ScpShellError(`Invalid chmod mode: ${mode}`);
}
// Mode is validated digits-only; path is shell-quoted.
return `chmod ${mode} -- ${shellQuotePath(p, encoding)}`;
}
function buildHomeCommand() {
return 'printf "%s\\n" "$HOME"';
}
function buildRealpathCommand(remotePath, encoding = "utf-8") {
const p = assertSafeRemotePath(remotePath);
const q = shellQuotePath(p, encoding);
return (
`realpath -- ${q} 2>/dev/null || ` +
`readlink -f -- ${q} 2>/dev/null || ` +
`(cd ${q} 2>/dev/null && pwd -P) || ` +
`(cd "$(dirname -- ${q})" 2>/dev/null && printf "%s/%s\\n" "$(pwd -P 2>/dev/null || pwd)" "$(basename -- ${q})")`
);
}
/**
* Decode a base64-encoded remote basename using the session filename encoding.
* @param {string} b64
* @param {string} [encoding] utf-8 | gb18030 | auto
*/
function decodeListBasename(b64, encoding = "utf-8") {
const raw = Buffer.from(b64, "base64");
const enc = String(encoding || "utf-8").toLowerCase();
if (enc === "gb18030" || enc === "gbk" || enc === "gb2312") {
try {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
return iconv.decode(raw, "gb18030");
} catch {
return raw.toString("utf8");
}
}
return raw.toString("utf8");
}
/**
* Quote a remote path for shell, encoding non-UTF-8 path bytes when needed.
* For gb18030 hosts the remote filesystem expects gb18030 bytes, not UTF-8.
*/
function shellQuotePath(remotePath, encoding = "utf-8") {
assertSafeRemotePath(remotePath);
const enc = String(encoding || "utf-8").toLowerCase();
if (enc === "gb18030" || enc === "gbk" || enc === "gb2312") {
// eslint-disable-next-line global-require
const iconv = require("iconv-lite");
const b64 = iconv.encode(remotePath, "gb18030").toString("base64");
// Expand base64 to raw bytes on the remote. Prefer base64, then openssl
// (same fallback used when listing names on minimal NAS hosts).
return `"$(printf '%s' '${b64}' | base64 -d 2>/dev/null || printf '%s' '${b64}' | base64 -D 2>/dev/null || printf '%s' '${b64}' | openssl base64 -d 2>/dev/null || printf '%s' '${b64}' | openssl enc -base64 -d 2>/dev/null)"`;
}
return shellQuote(remotePath);
}
/**
* Parse a username from an SFTP longname / `ls -l` line.
* Example: "-rwxr-xr-x 1 root root 4096 Jan 1 00:00 filename"
*/
function ownerFromSftpLongname(longname) {
if (!longname) return undefined;
const match = String(longname).match(/^[dlbcps\-][rwxsStT\-]{9}[+.@]?\s+\d+\s+(\S+)\s+\S+\s+/);
const owner = match?.[1]?.trim();
return owner || undefined;
}
function ownerFromUid(uid) {
if (typeof uid !== "number" || !Number.isFinite(uid)) return undefined;
return String(uid);
}
function normalizeListingOwner(value) {
if (typeof value !== "string") return undefined;
const owner = value.trim();
if (!owner || owner === "?" || owner === "UNKNOWN") return undefined;
return owner;
}
function resolveListingOwner({ owner, longname, uid } = {}) {
return normalizeListingOwner(owner)
|| ownerFromSftpLongname(longname)
|| ownerFromUid(uid);
}
/**
* Parse records from buildListCommand output.
* @returns {Array<{ name: string, type: 'file'|'directory'|'symlink', size: number, modifyTime: number, permissions?: string, owner?: string }>}
*/
function parseListRecords(stdout, encoding = "utf-8") {
const lines = String(stdout || "").split(/\r?\n/).filter(Boolean);
const results = [];
for (const line of lines) {
const parts = line.split("|");
if (parts.length < 5) continue;
const [t, modeStr, sizeStr, mtimeStr, b64, ownerRaw] = parts;
let name;
try {
name = decodeListBasename(b64, encoding);
} catch {
continue;
}
if (!name || name === "." || name === "..") continue;
const type = t === "d" ? "directory" : t === "l" ? "symlink" : "file";
const size = Number(sizeStr) || 0;
const modifyTime = (Number(mtimeStr) || 0) * 1000;
const permissions = parseLsModeToPermissions(modeStr);
const owner = resolveListingOwner({ owner: ownerRaw });
results.push({ name, type, size, modifyTime, permissions, ...(owner ? { owner } : {}) });
}
return results;
}
/**
* Parse classic `ls -la` output as a fallback.
*/
function parseLsLaOutput(stdout, { basePath = "" } = {}) {
const lines = String(stdout || "").split(/\r?\n/);
const results = [];
for (const line of lines) {
if (!line || line.startsWith("total ")) continue;
// permissions links owner group size month day time/year name
const match = line.match(
/^([dlbcps\-])([rwxsStT\-]{9})[+.@]?\s+\d+\s+(\S+)\s+\S+\s+(\d+)\s+(\S+\s+\S+\s+\S+)\s+(.+)$/,
);
if (!match) continue;
const typeChar = match[1];
const perm = match[2];
const owner = resolveListingOwner({ owner: match[3] });
const size = Number(match[4]) || 0;
let name = match[6];
// strip " -> target" only for symlink rows (backslash filenames may contain " -> ")
if (typeChar === "l") {
const arrow = name.indexOf(" -> ");
if (arrow >= 0) name = name.slice(0, arrow);
}
name = name.trim();
if (!name || name === "." || name === "..") continue;
// when listing a directory, ls -la path shows path prefix sometimes — use basename
if (name.includes("/") && basePath) {
const base = name.endsWith("/") ? name.slice(0, -1) : name;
const idx = base.lastIndexOf("/");
if (idx >= 0) name = base.slice(idx + 1);
}
const type = typeChar === "d" ? "directory" : typeChar === "l" ? "symlink" : "file";
results.push({
name,
type,
size,
modifyTime: Date.now(),
permissions: perm,
...(owner ? { owner } : {}),
});
}
return results;
}
function parseStatRecord(stdout) {
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
if (!line || line === "ENOENT") {
const err = new ScpShellError("No such file", "ENOENT");
err.code = "ENOENT";
throw err;
}
const parts = line.split("|");
if (parts.length < 5) {
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
}
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
? String(inoStr).trim()
: undefined;
return {
type: t === "d" ? "directory" : t === "l" ? "symlink" : "file",
isDirectory: t === "d",
isSymbolicLink: t === "l",
size: Number(sizeStr) || 0,
modifyTime: (Number(mtimeStr) || 0) * 1000,
mode: lsModeToNumber(modeStr),
permissions: parseLsModeToPermissions(modeStr),
path: abs,
...(ino ? { ino } : {}),
};
}
function parseLsModeToPermissions(modeStr) {
if (!modeStr || modeStr === "?") return undefined;
// -rwxr-xr-x or drwxr-xr-x → rwxr-xr-x
if (modeStr.length >= 10) return modeStr.slice(1, 10);
if (modeStr.length === 9) return modeStr;
return undefined;
}
function lsModeToNumber(modeStr) {
const perm = parseLsModeToPermissions(modeStr);
if (!perm || perm.length < 9) return 0;
const bit = (ch, w, r, x) => {
if (ch === "r") return r;
if (ch === "w") return w;
// lowercase s/t include execute; uppercase S/T are special-bit-only
if (ch === "x" || ch === "s" || ch === "t") return x;
return 0;
};
let mode = 0;
mode |= bit(perm[0], 0, 0o400, 0);
mode |= bit(perm[1], 0o200, 0, 0);
mode |= bit(perm[2], 0, 0, 0o100);
mode |= bit(perm[3], 0, 0o040, 0);
mode |= bit(perm[4], 0o020, 0, 0);
mode |= bit(perm[5], 0, 0, 0o010);
mode |= bit(perm[6], 0, 0o004, 0);
mode |= bit(perm[7], 0o002, 0, 0);
mode |= bit(perm[8], 0, 0, 0o001);
// Special bits: setuid / setgid / sticky (s/S in user/group exec, t/T in other)
if (perm[2] === "s" || perm[2] === "S") mode |= 0o4000;
if (perm[5] === "s" || perm[5] === "S") mode |= 0o2000;
if (perm[8] === "t" || perm[8] === "T") mode |= 0o1000;
return mode;
}
function modeToPermissionsString(mode) {
if (typeof mode !== "number") return undefined;
const toTriplet = (bits) =>
`${bits & 4 ? "r" : "-"}${bits & 2 ? "w" : "-"}${bits & 1 ? "x" : "-"}`;
return `${toTriplet((mode >> 6) & 7)}${toTriplet((mode >> 3) & 7)}${toTriplet(mode & 7)}`;
}
/**
* Normalize file protocol preference from host/open options.
* @returns {'auto'|'sftp'|'scp'}
*/
function normalizeFileProtocol(value) {
const v = String(value || "auto").toLowerCase().trim();
if (v === "sftp" || v === "scp") return v;
return "auto";
}
function isScpModeClient(client) {
return !!(client && client.__netcattyFileProtocol === "scp");
}
module.exports = {
ScpShellError,
shellQuote,
assertSafeRemotePath,
buildScpSinkCommand,
buildScpSourceCommand,
buildListCommand,
buildListCommandLs,
buildStatCommand,
buildMkdirCommand,
buildDeleteCommand,
buildUnlinkCommand,
buildRenameCommand,
buildChmodCommand,
buildHomeCommand,
buildRealpathCommand,
parseListRecords,
parseLsLaOutput,
ownerFromSftpLongname,
ownerFromUid,
resolveListingOwner,
parseStatRecord,
parseLsModeToPermissions,
lsModeToNumber,
modeToPermissionsString,
decodeListBasename,
shellQuotePath,
normalizeFileProtocol,
isScpModeClient,
};