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
444 lines
16 KiB
JavaScript
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,
|
|
};
|