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

233 lines
8.7 KiB
JavaScript
Raw Normal View History

"use strict";
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
buildFileControlLine,
buildDirectoryControlLine,
buildEndDirectoryLine,
buildAck,
parseControlLine,
consumeAck,
createSourceStreamParser,
sanitizeScpBasename,
ScpProtocolError,
SCP_OK,
SCP_ERROR,
SCP_FATAL,
} = require("./scpProtocol.cjs");
const {
shellQuote,
assertSafeRemotePath,
buildScpSinkCommand,
buildScpSourceCommand,
buildListCommand,
buildMkdirCommand,
buildDeleteCommand,
buildUnlinkCommand,
buildRenameCommand,
buildChmodCommand,
parseListRecords,
parseLsLaOutput,
ownerFromSftpLongname,
resolveListingOwner,
normalizeFileProtocol,
} = require("./scpShell.cjs");
describe("scpProtocol control lines", () => {
it("builds a file control line with mode size and basename", () => {
const line = buildFileControlLine({ mode: 0o644, size: 11, name: "hello.txt" });
assert.equal(line.toString("utf8"), "C0644 11 hello.txt\n");
});
it("builds directory and end markers", () => {
assert.equal(buildDirectoryControlLine({ mode: 0o755, name: "dir" }).toString(), "D0755 0 dir\n");
assert.equal(buildEndDirectoryLine().toString(), "E\n");
});
it("parses file and directory control lines including spaces in names", () => {
const file = parseControlLine("C0644 3 my file.txt");
assert.deepEqual(file, { kind: "file", mode: 0o644, size: 3, name: "my file.txt" });
const dir = parseControlLine("D0755 0 nested");
assert.equal(dir.kind, "directory");
assert.equal(dir.name, "nested");
assert.equal(parseControlLine("E").kind, "end");
});
it("rejects path separators in basenames", () => {
assert.throws(() => sanitizeScpBasename("../x"), ScpProtocolError);
assert.throws(() => buildFileControlLine({ size: 1, name: "a/b" }), ScpProtocolError);
assert.throws(() => parseControlLine("C0644 1 a/b"), ScpProtocolError);
// Backslash is valid on POSIX; do not reject.
assert.equal(sanitizeScpBasename("a\\b"), "a\\b");
});
it("consumeAck handles ok error and fatal", () => {
assert.deepEqual(consumeAck(Buffer.from([SCP_OK])), { status: "ok", consumed: 1 });
const err = consumeAck(Buffer.from([SCP_ERROR, ...Buffer.from("nope\n")]));
assert.equal(err.status, "error");
assert.equal(err.message, "nope");
const fatal = consumeAck(Buffer.from([SCP_FATAL, ...Buffer.from("dead\n")]));
assert.equal(fatal.status, "fatal");
assert.equal(consumeAck(Buffer.alloc(0)).status, "incomplete");
});
it("buildAck is a single NUL", () => {
assert.deepEqual(buildAck(), Buffer.from([0]));
});
});
describe("scpProtocol source stream parser", () => {
it("parses handshake file metadata data and completion", () => {
const parser = createSourceStreamParser();
const body = Buffer.from("hi\n");
const stream = Buffer.concat([
Buffer.from("C0644 3 hi.txt\n"),
body,
Buffer.from([0x00]),
]);
const events = parser.feed(stream);
assert.equal(events[0].type, "file-start");
assert.equal(events[0].name, "hi.txt");
assert.equal(events[0].size, 3);
assert.equal(events[1].type, "file-data");
assert.deepEqual(events[1].data, body);
assert.equal(events[2].type, "file-end");
parser.finish();
});
it("handles chunked feeds and recursive directory markers", () => {
const parser = createSourceStreamParser();
const part1 = Buffer.from("D0755 0 d\nC0644 4 ");
const part2 = Buffer.from("a.txt\nabcd\0E\n");
const e1 = parser.feed(part1);
assert.equal(e1[0].type, "directory");
assert.equal(e1[0].name, "d");
const e2 = parser.feed(part2);
assert.equal(e2.find((e) => e.type === "file-start")?.name, "a.txt");
assert.ok(e2.some((e) => e.type === "file-data"));
assert.ok(e2.some((e) => e.type === "file-end"));
assert.ok(e2.some((e) => e.type === "end-directory"));
parser.finish();
});
});
describe("scpShell quoting and commands", () => {
it("quotes paths with single quotes safely", () => {
assert.equal(shellQuote("simple"), "'simple'");
assert.equal(shellQuote("a'b"), `'a'\\''b'`);
assert.throws(() => shellQuote("a\0b"), /NUL/);
assert.throws(() => assertSafeRemotePath("a\nb"), /newline/i);
});
it("builds scp -t/-f commands with -- and quoted paths", () => {
assert.equal(buildScpSinkCommand("/tmp/out"), "scp -t -- '/tmp/out'");
assert.equal(buildScpSourceCommand("/var/a b"), "scp -f -- '/var/a b'");
assert.match(buildListCommand("/home/user"), /cd '\/home\/user'/);
// Must not emit invalid `do;` which breaks POSIX sh for-loops.
assert.doesNotMatch(buildListCommand("/home/user"), /do;/);
assert.match(buildListCommand("/home/user"), /do\n/);
assert.match(buildMkdirCommand("/x/y"), /mkdir -p -- '\/x\/y'/);
assert.match(buildDeleteCommand("/x", { recursive: true }), /rm -rf -- '\/x'/);
assert.equal(buildUnlinkCommand("/x"), "rm -f -- '/x'");
assert.match(buildRenameCommand("/a", "/b"), /mv -- '\/a' '\/b'/);
assert.match(buildChmodCommand("/a", "755"), /chmod 755 -- '\/a'/);
});
it("lsModeToNumber preserves setuid/setgid/sticky bits", () => {
const { lsModeToNumber } = require("./scpShell.cjs");
// -rwsr-sr-t : setuid+setgid+sticky with execute (lowercase s/t)
assert.equal(lsModeToNumber("-rwsr-sr-t"), 0o7755);
// -rwSrwSrwT : special bits without execute (uppercase S/T)
assert.equal(lsModeToNumber("-rwSrwSrwT"), 0o7666);
});
it("rejects unsafe remote paths for shell ops", () => {
assert.throws(() => buildScpSourceCommand("x\0y"));
assert.throws(() => buildDeleteCommand(""));
});
it("parses list records with base64 names", () => {
const name = "你好 world.txt";
const b64 = Buffer.from(name, "utf8").toString("base64");
const stdout = `f|-rw-r--r--|12|1700000000|${b64}\nd|drwxr-xr-x|0|1700000001|${Buffer.from("sub").toString("base64")}\n`;
const rows = parseListRecords(stdout);
assert.equal(rows.length, 2);
assert.equal(rows[0].name, name);
assert.equal(rows[0].type, "file");
assert.equal(rows[0].size, 12);
assert.equal(rows[1].type, "directory");
assert.equal(rows[1].name, "sub");
});
it("parses list records with gb18030 basenames", () => {
const iconv = require("iconv-lite");
const name = "测试.txt";
const b64 = iconv.encode(name, "gb18030").toString("base64");
const rows = parseListRecords(`f|-rw-r--r--|1|1700000000|${b64}\n`, "gb18030");
assert.equal(rows[0]?.name, name);
});
it("parses optional owner from list records and ls -la fallback", () => {
const b64 = Buffer.from("notes.txt", "utf8").toString("base64");
const rows = parseListRecords(`f|-rw-r--r--|12|1700000000|${b64}|www-data\n`);
assert.equal(rows[0]?.owner, "www-data");
const legacy = parseListRecords(`f|-rw-r--r--|12|1700000000|${b64}\n`);
assert.equal(legacy[0]?.owner, undefined);
const lsRows = parseLsLaOutput(
"total 4\n-rw-r--r-- 1 root wheel 12 Jan 1 00:00 notes.txt\n",
);
assert.equal(lsRows[0]?.name, "notes.txt");
assert.equal(lsRows[0]?.owner, "root");
const aclRows = parseLsLaOutput(
"-rw-r--r--+ 1 alice staff 12 Jan 1 00:00 notes.txt\n",
);
assert.equal(aclRows[0]?.owner, "alice");
});
it("resolves listing owner from longname, then uid", () => {
assert.equal(
ownerFromSftpLongname("-rwxr-xr-x 1 alice staff 4096 Jan 1 00:00 bin"),
"alice",
);
assert.equal(
resolveListingOwner({ longname: "drwxr-xr-x 2 www-data www-data 4096 Jan 1 00:00 html" }),
"www-data",
);
assert.equal(resolveListingOwner({ uid: 1000 }), "1000");
assert.equal(resolveListingOwner({ owner: "UNKNOWN", uid: 0 }), "0");
assert.equal(
ownerFromSftpLongname("-rw-r--r--+ 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
assert.equal(
ownerFromSftpLongname("-rw-r--r--@ 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
assert.equal(
ownerFromSftpLongname("-rw-r--r--. 1 alice staff 12 Jan 1 00:00 notes.txt"),
"alice",
);
});
it("list command includes owner in the record", () => {
assert.match(buildListCommand("/tmp"), /awk '\{print \$3\}'/);
assert.match(buildListCommand("/tmp"), /%s\\|%s\\|%s\\|%s\\|%s\\|%s/);
});
it("list command keeps broken symlinks", () => {
assert.match(buildListCommand("/tmp"), /-L "\$f"/);
assert.match(buildListCommand("/tmp"), /-e "\$f"/);
});
it("normalizes file protocol preference", () => {
assert.equal(normalizeFileProtocol(undefined), "auto");
assert.equal(normalizeFileProtocol("SFTP"), "sftp");
assert.equal(normalizeFileProtocol("scp"), "scp");
assert.equal(normalizeFileProtocol("other"), "auto");
});
});