mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(package): preserve daemon asset modes and relative links (#2511)
The CLI package build now stages `packages/daemon/assets` with Node's `fs.cp` (recursive, `verbatimSymlinks`) instead of `cp -r`. Staged assets keep their source modes, so a build run under a narrower umask than the checkout no longer packs them as 0600 or 0700 (npm keeps owner-only bits when packing). A relative link stays a link in staging and is omitted by npm, rather than being followed and copied in on macOS. Copied bytes and sources are unchanged, and a failed copy still stops the build before the staged root is recorded. Only the assets step changes; the other copy steps still use `cp -r`. Contributed by @Aummadour.
This commit is contained in:
1 parent
87297f0f8e
commit
863c0f58f2
3 files changed
+159
-2
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
|
||||
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
|
||||
const inventory = script.match(/^node -e '\n[\s\S]*?^' "\$CLI_DIR\/daemon\/substance-surfaces.json" "\$\{SUBSTANCE_SURFACE_ROOTS\[@\]\}"$/m)?.[0];
|
||||
assert.ok(assetsBranch, "actual daemon assets staging branch must exist");
|
||||
assert.ok(inventory, "actual staged substance-root writer must exist");
|
||||
|
||||
const modes = { "": 0o755, "tool.sh": 0o755, "data.bin": 0o644, private: 0o700, "private/secret.txt": 0o400, shared: 0o750 };
|
||||
const contents = { "tool.sh": Buffer.from("#!/bin/sh\necho fixture\n"), "data.bin": Buffer.from([0, 255, 13, 10, 128]), "private/secret.txt": Buffer.from("synthetic fixture\n") };
|
||||
const links = { "local-link": "./data.bin", "outside-link": "../../../outside/sentinel.bin" };
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), "package-assets-"));
|
||||
const daemon = join(root, "packages/daemon");
|
||||
const cli = join(root, "packages/cli");
|
||||
const assets = join(daemon, "assets");
|
||||
for (const dir of [assets, join(assets, "private"), join(assets, "shared"), join(cli, "daemon"), join(root, "outside")]) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
for (const [name, bytes] of Object.entries(contents)) fs.writeFileSync(join(assets, name), bytes);
|
||||
for (const [name, mode] of Object.entries(modes)) fs.chmodSync(join(assets, name), mode);
|
||||
for (const [name, target] of Object.entries(links)) fs.symlinkSync(target, join(assets, name));
|
||||
fs.writeFileSync(join(root, "outside/sentinel.bin"), "outside target, not asset content\n", { mode: 0o600 });
|
||||
return { root, daemon, cli, assets, staged: join(cli, "daemon/assets") };
|
||||
}
|
||||
|
||||
function snapshot(root) {
|
||||
const entries = [];
|
||||
function walk(relative) {
|
||||
const file = join(root, relative);
|
||||
const stat = fs.lstatSync(file);
|
||||
if (stat.isSymbolicLink()) entries.push([relative, "link", fs.readlinkSync(file)]);
|
||||
else if (stat.isDirectory()) {
|
||||
entries.push([relative, "directory", stat.mode & 0o777]);
|
||||
for (const name of fs.readdirSync(file).sort()) walk(join(relative, name));
|
||||
} else entries.push([relative, "file", stat.mode & 0o777, fs.readFileSync(file).toString("hex")]);
|
||||
}
|
||||
walk("");
|
||||
return entries;
|
||||
}
|
||||
|
||||
function stage(f, mask) {
|
||||
// Run only the real staging branch and its inventory writer, without builds or installs.
|
||||
return spawnSync("bash", ["-c", `set -euo pipefail
|
||||
umask "$1"
|
||||
DAEMON_DIR="$2"
|
||||
CLI_DIR="$3"
|
||||
SUBSTANCE_SURFACE_ROOTS=()
|
||||
umask
|
||||
${assetsBranch}
|
||||
${inventory}
|
||||
umask
|
||||
`, "package-assets", mask, f.daemon, f.cli], { encoding: "utf8", timeout: 30_000 });
|
||||
}
|
||||
|
||||
function assertStaged(f) {
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
assert.ok(fs.lstatSync(join(f.staged, name)).isFile(), name);
|
||||
assert.deepEqual(fs.readFileSync(join(f.staged, name)), bytes, `${name} bytes`);
|
||||
}
|
||||
for (const [name, target] of Object.entries(links)) {
|
||||
assert.ok(fs.lstatSync(join(f.staged, name)).isSymbolicLink(), name);
|
||||
assert.equal(fs.readlinkSync(join(f.staged, name)), target, `${name} verbatim target`);
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(f.staged).sort(), ["data.bin", "local-link", "outside-link", "private", "shared", "tool.sh"]);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(join(f.cli, "daemon/substance-surfaces.json"), "utf8")), { schemaVersion: 1, roots: ["daemon/assets"] });
|
||||
}
|
||||
|
||||
for (const mask of ["0077", "0022"]) {
|
||||
test(`asset staging preserves source modes, bytes and relative links under umask ${mask}`, { skip: process.platform === "win32" }, () => {
|
||||
const f = fixture();
|
||||
const source = snapshot(f.assets);
|
||||
const outside = snapshot(join(f.root, "outside"));
|
||||
const callerMask = process.umask();
|
||||
try {
|
||||
const copied = stage(f, mask);
|
||||
assert.equal(copied.error, undefined, copied.stderr);
|
||||
assert.equal(copied.status, 0, copied.stderr);
|
||||
assert.deepEqual(copied.stdout.trim().split("\n"), [mask, mask], "copy must leave the caller's umask unchanged");
|
||||
assert.deepEqual(snapshot(f.assets), source, "source bytes, modes and links must remain unchanged");
|
||||
assert.deepEqual(snapshot(join(f.root, "outside")), outside, "outside target must remain untouched");
|
||||
for (const parent of [f.root, f.cli, join(f.cli, "daemon")]) assert.equal(fs.statSync(parent).mode & 0o777, 0o700, "private parent mode");
|
||||
assertStaged(f);
|
||||
const stagedModes = Object.fromEntries(Object.keys(modes).map(name => [name, fs.statSync(join(f.staged, name)).mode & 0o777]));
|
||||
assert.deepEqual(stagedModes, modes, `source modes under umask ${mask}`);
|
||||
} finally {
|
||||
assert.equal(process.umask(), callerMask, "test process umask must remain unchanged");
|
||||
fs.rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a real assets copy error stops before recording the substance root", { skip: process.platform === "win32" }, () => {
|
||||
assert.match(script, /^set -euo pipefail$/m, "the real build script must stop on an assets copy error");
|
||||
const f = fixture();
|
||||
const source = snapshot(f.assets);
|
||||
const outside = snapshot(join(f.root, "outside"));
|
||||
try {
|
||||
fs.writeFileSync(f.staged, "blocking regular file\n", { mode: 0o600 });
|
||||
const copied = stage(f, "0077");
|
||||
assert.equal(copied.error, undefined, copied.stderr);
|
||||
assert.equal(copied.signal, null);
|
||||
assert.notEqual(copied.status, 0, "copying a directory over a regular file must fail");
|
||||
assert.ok(copied.stderr.length > 0, "copy failure must be visible");
|
||||
assert.equal(fs.readFileSync(f.staged, "utf8"), "blocking regular file\n");
|
||||
assert.equal(fs.statSync(f.staged).mode & 0o777, 0o600);
|
||||
assert.equal(fs.existsSync(join(f.cli, "daemon/substance-surfaces.json")), false);
|
||||
assert.deepEqual(snapshot(f.assets), source);
|
||||
assert.deepEqual(snapshot(join(f.root, "outside")), outside);
|
||||
} finally {
|
||||
fs.rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("offline npm pack carries staged asset bytes and executability, with npm's own link and mode policy", { skip: process.platform === "win32" }, () => {
|
||||
const f = fixture();
|
||||
const source = snapshot(f.assets);
|
||||
const outside = snapshot(join(f.root, "outside"));
|
||||
try {
|
||||
const copied = stage(f, "0022");
|
||||
assert.equal(copied.error, undefined, copied.stderr);
|
||||
assert.equal(copied.status, 0, copied.stderr);
|
||||
assertStaged(f);
|
||||
const staged = snapshot(f.staged);
|
||||
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
|
||||
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
|
||||
const files = new Map(packed.files.map(file => [file.path, file]));
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
const entry = `daemon/assets/${name}`;
|
||||
assert.ok(files.has(entry), `${entry} must be packed`);
|
||||
assert.deepEqual(execFileSync("tar", ["-xOf", join(f.root, packed.filename), `package/${entry}`]), bytes);
|
||||
}
|
||||
assert.equal(files.get("daemon/assets/tool.sh").mode, 0o755);
|
||||
assert.equal(files.get("daemon/assets/data.bin").mode, 0o644);
|
||||
assert.equal(files.get("daemon/assets/private/secret.txt").mode, 0o600, "npm adds owner write permission without adding group/other access");
|
||||
for (const name of Object.keys(links)) assert.equal(files.has(`daemon/assets/${name}`), false, "npm omits symlinks; staging retention is a separate contract");
|
||||
assert.deepEqual(snapshot(f.staged), staged, "packing must not mutate staged modes or links");
|
||||
assert.deepEqual(snapshot(f.assets), source);
|
||||
assert.deepEqual(snapshot(join(f.root, "outside")), outside);
|
||||
} finally {
|
||||
fs.rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -86,7 +86,13 @@ mkdir -p "$CLI_DIR/daemon/dist"
|
||||
cp -r "$DAEMON_DIR/dist/"* "$CLI_DIR/daemon/dist/"
|
||||
|
||||
if [ -d "$DAEMON_DIR/assets" ]; then
|
||||
cp -r "$DAEMON_DIR/assets" "$CLI_DIR/daemon/assets"
|
||||
node --input-type=module -e '
|
||||
import { cp } from "node:fs/promises";
|
||||
await cp(process.argv[1], process.argv[2], {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
' "$DAEMON_DIR/assets" "$CLI_DIR/daemon/assets"
|
||||
SUBSTANCE_SURFACE_ROOTS+=("daemon/assets")
|
||||
fi
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ test("build-package scans the complete daemon specs tree before LP-7 copies it",
|
||||
test("build-package emits the substance roots from the staging branches that actually ran", () => {
|
||||
const buildScript = readFileSync("scripts/build-package.sh", "utf8");
|
||||
assert.match(buildScript, /SUBSTANCE_SURFACE_ROOTS=\(\)/);
|
||||
assert.match(buildScript, /cp -r "\$DAEMON_DIR\/assets"[\s\S]*SUBSTANCE_SURFACE_ROOTS\+=\("daemon\/assets"\)/);
|
||||
const assetsBranch = buildScript.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0] ?? "";
|
||||
assert.match(assetsBranch, /"\$DAEMON_DIR\/assets" "\$CLI_DIR\/daemon\/assets"/);
|
||||
assert.match(assetsBranch, /SUBSTANCE_SURFACE_ROOTS\+=\("daemon\/assets"\)/);
|
||||
assert.match(buildScript, /cp -r "\$DAEMON_DIR\/specs"[\s\S]*SUBSTANCE_SURFACE_ROOTS\+=\("daemon\/specs"\)/);
|
||||
assert.match(buildScript, /cp -r "\$DAEMON_DIR\/context-packs"[\s\S]*SUBSTANCE_SURFACE_ROOTS\+=\("daemon\/context-packs"\)/);
|
||||
assert.match(buildScript, /substance-surfaces\.json/);
|
||||
|
||||
Reference in new issue
Block a user