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
189 lines
7.7 KiB
JavaScript
189 lines
7.7 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
|
|
const {
|
|
getArchiveKind,
|
|
isExtractableArchive,
|
|
posixParentDir,
|
|
stripCompressionSuffix,
|
|
computeExtractTimeoutMs,
|
|
buildExtractCommand,
|
|
buildLocalExtractPlan,
|
|
EXTRACT_MAX_TIMEOUT_MS,
|
|
} = require("./archiveExtract.cjs");
|
|
|
|
test("detects compound archive suffixes before single-file compression", () => {
|
|
assert.equal(getArchiveKind("backup.tar.gz"), "tar.gz");
|
|
assert.equal(getArchiveKind("/var/a.tgz"), "tar.gz");
|
|
assert.equal(getArchiveKind("logs.tar.bz2"), "tar.bz2");
|
|
assert.equal(getArchiveKind("src.tar.xz"), "tar.xz");
|
|
assert.equal(getArchiveKind("app.tar"), "tar");
|
|
assert.equal(getArchiveKind("payload.zip"), "zip");
|
|
assert.equal(getArchiveKind("notes.txt.gz"), "gz");
|
|
assert.equal(getArchiveKind("notes.txt"), null);
|
|
assert.equal(getArchiveKind(".gz"), null);
|
|
assert.equal(isExtractableArchive("bundle.tgz"), true);
|
|
assert.equal(isExtractableArchive("readme.md"), false);
|
|
});
|
|
|
|
test("posix parent and gzip output stay in the archive directory", () => {
|
|
assert.equal(posixParentDir("/home/app/a.tar.gz"), "/home/app");
|
|
assert.equal(posixParentDir("/a.zip"), "/");
|
|
assert.equal(stripCompressionSuffix("/home/app/notes.txt.gz", "gz"), "/home/app/notes.txt");
|
|
assert.equal(stripCompressionSuffix("/notes.txt.gz", "gz"), "/notes.txt");
|
|
});
|
|
|
|
test("extract commands quote spaces and single quotes", () => {
|
|
const tarCmd = buildExtractCommand("/tmp/my files/app's.tgz");
|
|
assert.match(tarCmd, /tar -xzf '/);
|
|
assert.match(tarCmd, /'\/tmp\/my files\/app'\\''s\.tgz'/);
|
|
assert.match(tarCmd, /-C '\/tmp\/my files'/);
|
|
|
|
const zipCmd = buildExtractCommand("/opt/build/out.zip");
|
|
assert.match(zipCmd, /unzip -qo '\/opt\/build\/out\.zip' -d '\/opt\/build' >\/dev\/null/);
|
|
assert.match(zipCmd, /tar -xf '\/opt\/build\/out\.zip' -C '\/opt\/build'/);
|
|
|
|
const gzCmd = buildExtractCommand("/var/log/syslog.gz");
|
|
assert.match(gzCmd, /out='\/var\/log\/syslog'/);
|
|
assert.match(gzCmd, /archive='\/var\/log\/syslog\.gz'/);
|
|
assert.match(gzCmd, /trap 'rm -f -- "\$stage"' EXIT/);
|
|
assert.match(gzCmd, /gzip -dc -- "\$archive" > "\$stage"/);
|
|
assert.match(gzCmd, /if \[ -d "\$out" \]/);
|
|
assert.match(gzCmd, /mv -f -- "\$stage" "\$out"/);
|
|
|
|
const spacedGz = buildExtractCommand("/tmp/my file.txt.gz");
|
|
assert.match(spacedGz, /out='\/tmp\/my file\.txt'/);
|
|
assert.match(spacedGz, /archive='\/tmp\/my file\.txt\.gz'/);
|
|
assert.doesNotMatch(spacedGz, /trap 'rm -f -- '/);
|
|
});
|
|
|
|
test("generated gzip extract script can install its EXIT trap with spaces in the path", () => {
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawnSync } = require("node:child_process");
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-trap-"));
|
|
const archive = path.join(dir, "my file.txt.gz");
|
|
fs.writeFileSync(archive, "x");
|
|
const cmd = buildExtractCommand(archive);
|
|
const prefix = [];
|
|
for (const line of cmd.split("\n")) {
|
|
prefix.push(line);
|
|
if (line.startsWith("trap ")) break;
|
|
}
|
|
prefix.push("trap - EXIT");
|
|
prefix.push("rm -f -- \"$stage\"");
|
|
const ran = spawnSync("sh", ["-c", prefix.join("\n")], { encoding: "utf8" });
|
|
assert.equal(ran.status, 0, ran.stderr);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("extract command rejects newlines and unknown types", () => {
|
|
assert.throws(() => buildExtractCommand("/tmp/bad\n.tar.gz"), /NUL or newlines/);
|
|
assert.throws(() => buildExtractCommand("/tmp/notes.txt"), /Unsupported archive type/);
|
|
});
|
|
|
|
test("local extract plan uses unzip with tar fallback off Windows", () => {
|
|
const unixZip = buildLocalExtractPlan("/tmp/a.zip", "linux");
|
|
assert.deepEqual(unixZip.command, "unzip");
|
|
assert.deepEqual(unixZip.args, ["-qo", "/tmp/a.zip", "-d", "/tmp"]);
|
|
assert.deepEqual(unixZip.fallback, { command: "tar", args: ["-xf", "/tmp/a.zip", "-C", "/tmp"] });
|
|
|
|
const winZip = buildLocalExtractPlan("C:\\tmp\\a.zip", "win32");
|
|
assert.equal(winZip.command, "tar");
|
|
assert.ok(winZip.args.includes("-xf"));
|
|
|
|
const gz = buildLocalExtractPlan("/tmp/notes.txt.gz", "win32");
|
|
assert.equal(gz.builtin, "gunzip");
|
|
assert.equal(gz.archivePath, "/tmp/notes.txt.gz");
|
|
assert.equal(gz.stdoutFile, "/tmp/notes.txt");
|
|
});
|
|
|
|
test("unknown archive size uses the maximum extract timeout", () => {
|
|
assert.equal(computeExtractTimeoutMs(undefined), EXTRACT_MAX_TIMEOUT_MS);
|
|
assert.ok(computeExtractTimeoutMs(1024) < EXTRACT_MAX_TIMEOUT_MS);
|
|
});
|
|
|
|
test("extractLocalArchiveFile unpacks a tar.gz next to the archive", async () => {
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawnSync } = require("node:child_process");
|
|
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-"));
|
|
const source = path.join(dir, "hello.txt");
|
|
const archive = path.join(dir, "hello.tgz");
|
|
fs.writeFileSync(source, "hello-extract");
|
|
const packed = spawnSync("tar", ["-czf", archive, "-C", dir, "hello.txt"], { encoding: "utf8" });
|
|
assert.equal(packed.status, 0, packed.stderr);
|
|
fs.unlinkSync(source);
|
|
|
|
await extractLocalArchiveFile(archive);
|
|
assert.equal(fs.readFileSync(source, "utf8"), "hello-extract");
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("failed gzip extract leaves an existing sibling file intact", async () => {
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-keep-"));
|
|
const dest = path.join(dir, "notes.txt");
|
|
const archive = path.join(dir, "notes.txt.gz");
|
|
fs.writeFileSync(dest, "keep-me");
|
|
fs.writeFileSync(archive, "this-is-not-gzip");
|
|
|
|
await assert.rejects(() => extractLocalArchiveFile(archive), /Local extraction failed/);
|
|
assert.equal(fs.readFileSync(dest, "utf8"), "keep-me");
|
|
assert.deepEqual(
|
|
fs.readdirSync(dir).filter((name) => name.includes(".netcatty-extract")),
|
|
[],
|
|
);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("extractLocalArchiveFile gunzips with Node zlib and keeps a sibling staging name", async () => {
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const zlib = require("node:zlib");
|
|
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-gz-"));
|
|
const dest = path.join(dir, "notes.txt");
|
|
const archive = path.join(dir, "notes.txt.gz");
|
|
const sibling = `${dest}.netcatty-extract`;
|
|
fs.writeFileSync(sibling, "do-not-touch");
|
|
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
|
|
|
|
await extractLocalArchiveFile(archive);
|
|
assert.equal(fs.readFileSync(dest, "utf8"), "hello-gzip");
|
|
assert.equal(fs.readFileSync(sibling, "utf8"), "do-not-touch");
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("failed gzip extract leaves a sibling directory target intact", async () => {
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const zlib = require("node:zlib");
|
|
const { extractLocalArchiveFile } = require("./archiveExtract.cjs");
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-extract-dir-"));
|
|
const dest = path.join(dir, "notes.txt");
|
|
const archive = path.join(dir, "notes.txt.gz");
|
|
fs.mkdirSync(dest);
|
|
fs.writeFileSync(path.join(dest, "keep.txt"), "inside");
|
|
fs.writeFileSync(archive, zlib.gzipSync("hello-gzip"));
|
|
|
|
await assert.rejects(() => extractLocalArchiveFile(archive), /directory/);
|
|
assert.equal(fs.readFileSync(path.join(dest, "keep.txt"), "utf8"), "inside");
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|