233 lines
8.7 KiB
JavaScript
233 lines
8.7 KiB
JavaScript
|
|
"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");
|
||
|
|
});
|
||
|
|
});
|