From 44dd17db1559c93e35e675dfc724a245f52fce74 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 3 Oct 2026 04:00:14 +0100 Subject: [PATCH 1/2] feat(linux): stage native desktop packages with declared tray dependencies --- docs/linux-native-packages.md | 24 +++++++++++ scripts/build-deb.sh | 17 +++----- scripts/build-linux-native.sh | 74 ++++++++++++++++++++++++++++++++++ scripts/stage-linux-package.sh | 39 ++++++++++++++++++ test/build-deb.test.js | 22 +++++++++- test/linux-native.test.js | 74 ++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 docs/linux-native-packages.md create mode 100644 scripts/build-linux-native.sh create mode 100644 scripts/stage-linux-package.sh create mode 100644 test/linux-native.test.js diff --git a/docs/linux-native-packages.md b/docs/linux-native-packages.md new file mode 100644 index 00000000..8a76aff6 --- /dev/null +++ b/docs/linux-native-packages.md @@ -0,0 +1,24 @@ +# Native Linux desktop packages + +The builders consume our own CI's generated x86_64 Node bundle. That input +must be trusted and must not change while packaging runs. Symlink validation +rejects accidental links outside the bundle, then flattens internal links. +It is not a race-safe boundary for an attacker editing the tree concurrently. + +Staged directories are 755 and files 644, with only the app launcher, bundled +Node and /usr/bin wrapper executable (755). The desktop entry opens the app +without a terminal; the tray opens the existing loopback WebUI. + +Build commands, after `bash scripts/build-posix.sh linux`: + +``` +bash scripts/build-linux-native.sh deb dist/linux/nowplaying 0.2.1+dev out +bash scripts/build-linux-native.sh rpm dist/linux/nowplaying 0.2.1+dev out +bash scripts/build-linux-native.sh arch dist/linux/nowplaying 0.2.1+dev out +``` + +Run each builder on its target distro with the corresponding tooling. Arch's +makepkg requires an unprivileged builder. The scripts build files only; they +do not install, publish, enable autostart or cut stable releases. Versions use +letters, digits, dot, plus and dash; RPM/Arch translate dash to underscore. +This slice rejects non-x86_64 hosts and bundles rather than mislabeling them. diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index 69a9e4af..db4afbaa 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -27,14 +27,9 @@ esac stage="$(mktemp -d)" trap 'rm -rf "$stage"' EXIT -mkdir -p "$stage/opt" "$stage/usr/bin" "$stage/DEBIAN" "$out" -chmod 755 "$stage" "$stage/opt" "$stage/usr" "$stage/usr/bin" -cp -a "$bundle" "$stage/opt/nowplaying" -cat > "$stage/usr/bin/nowplaying" <<'WRAP' -#!/bin/sh -exec /opt/nowplaying/nowplaying "$@" -WRAP -chmod 755 "$stage/usr/bin/nowplaying" +mkdir -p "$stage/DEBIAN" "$out" +chmod 755 "$stage" +bash "$(dirname "$0")/stage-linux-package.sh" "$bundle" "$stage" size_kb=$(du -sk "$stage/opt" "$stage/usr" | awk '{ sum += $1 } END { print sum }') cat > "$stage/DEBIAN/control" </dev/null" +fakeroot dpkg-deb --root-owner-group -Zxz --build "$stage" "$file" >/dev/null echo "$file" diff --git a/scripts/build-linux-native.sh b/scripts/build-linux-native.sh new file mode 100644 index 00000000..8fe45b15 --- /dev/null +++ b/scripts/build-linux-native.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Build only. Installing/releasing is an explicit next step. Source app version +# is normalised for package managers; a dev package must use a dev version. +set -euo pipefail +kind="${1:?deb|rpm|arch required}" +case "$kind" in + deb|rpm|arch) ;; + *) echo 'Unknown package format' >&2; exit 2 ;; +esac +bundle="$(cd "${2:?bundle directory required}" && pwd)" +version="${3:?package version required}" +out="${4:?output directory required}" +[[ "$version" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo 'Invalid package version' >&2; exit 2; } +# This desktop slice ships only x86_64, matching the current dev artifact. +[[ "$(uname -m)" = x86_64 ]] || { echo 'Native desktop packages currently require x86_64' >&2; exit 2; } +[[ "$("$bundle/runtime/node" -p 'process.arch')" = x64 ]] || { echo 'Bundle architecture must be x64' >&2; exit 2; } +mkdir -p "$out"; out="$(cd "$out" && pwd)" +root="$(cd "$(dirname "$0")/.." && pwd)" +case "$kind" in + deb) exec bash "$root/scripts/build-deb.sh" "$bundle" "$version" amd64 "$out" ;; + rpm|arch) ;; +esac +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT +bash "$root/scripts/stage-linux-package.sh" "$bundle" "$stage/payload" +if [[ "$kind" = rpm ]]; then + mkdir -p "$stage/rpm/"{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + tar -czf "$stage/rpm/SOURCES/payload.tar.gz" -C "$stage/payload" . + cat > "$stage/rpm/SPECS/nowplaying.spec" < "$stage/PKGBUILD" <&2; exit 2; } +[[ -f "$bundle/app/assets/brand/png/icon-512.png" ]] || { echo 'Bundle branding missing' >&2; exit 2; } +mkdir -p "$stage/opt" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/512x512/apps" +# npm dependencies may contain symlinks. Resolve each before copying, and +# reject absolute/relative links that escape the built bundle. Then flatten +# links in the staged copy so permission normalization cannot touch outside. +bundle="$(cd "$bundle" && pwd)" +while IFS= read -r -d '' link; do + target="$(realpath -e "$link")" || { echo 'Broken bundle symlink' >&2; exit 2; } + [[ "$target" = "$bundle/"* ]] || { echo 'Bundle symlink escapes package' >&2; exit 2; } +done < <(find "$bundle" -type l -print0) +cp -aL "$bundle" "$stage/opt/nowplaying" +cat > "$stage/usr/bin/nowplaying" <<'WRAP' +#!/bin/sh +exec /opt/nowplaying/nowplaying "$@" +WRAP +chmod 755 "$stage/usr/bin/nowplaying" +cat > "$stage/usr/share/applications/nowplaying.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Version=1.0 +Name=NowPlaying +Comment=Media presence and README cards +Exec=/usr/bin/nowplaying start +Icon=nowplaying +Terminal=false +Categories=AudioVideo;Audio; +DESKTOP +cp "$bundle/app/assets/brand/png/icon-512.png" "$stage/usr/share/icons/hicolor/512x512/apps/nowplaying.png" +find "$stage/opt" "$stage/usr" -type d -exec chmod 755 {} + +find "$stage/opt" "$stage/usr" -type f -exec chmod 644 {} + +chmod 755 "$stage/usr/bin/nowplaying" "$stage/opt/nowplaying/nowplaying" "$stage/opt/nowplaying/runtime/node" diff --git a/test/build-deb.test.js b/test/build-deb.test.js index 2522c1e4..953f0568 100644 --- a/test/build-deb.test.js +++ b/test/build-deb.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { copyFileSync, chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,6 +16,8 @@ function fakeBundle() { writeFileSync(join(bundle, "nowplaying"), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, "nowplaying"), 0o755); writeFileSync(join(bundle, "runtime", "node"), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, "runtime", "node"), 0o755); writeFileSync(join(bundle, "app", "package.json"), "{}"); + mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true }); + copyFileSync(new URL("../assets/brand/png/icon-512.png", import.meta.url), join(bundle, "app/assets/brand/png/icon-512.png")); return { dir, bundle }; } @@ -38,11 +40,27 @@ test("builds a .deb with the bundle under /opt, a wrapper in /usr/bin and root o assert.match(info, /^Package: nowplaying$/m); assert.match(info, /^Version: 0\.2\.0$/m); assert.match(info, /^Architecture: arm64$/m); - assert.match(info, /^Depends: libc6, libstdc\+\+6, libgcc-s1$/m); + assert.match(info, /^Depends: libc6, libstdc\+\+6, libgcc-s1, python3-gi, .*libsecret-tools.*xdg-utils$/m); assert.doesNotMatch(info, /evil/); + assert.match(listingDesktop(file), /Exec=\/usr\/bin\/nowplaying start/); const listing = execFileSync("dpkg-deb", ["-c", file], { encoding: "utf8" }); assert.match(listing, /\.\/opt\/nowplaying\/runtime\/node$/m); assert.match(listing, /^-rwxr-xr-x root\/root .*\.\/usr\/bin\/nowplaying$/m); assert.doesNotMatch(listing, /^\S+ (?!root\/root)\S+/m); assert.match(listing, /^drwxr-xr-x root\/root .* \.\/$/m); }); + +function listingDesktop(file) { + const dir = mkdtempSync(join(tmpdir(), "np-deb-extract-")); + execFileSync("dpkg-deb", ["-x", file, dir]); + return execFileSync("cat", [join(dir, "usr/share/applications/nowplaying.desktop")], { encoding: "utf8" }); +} + +test("output paths containing shell syntax are literal argv, not commands", { skip: !haveDpkg }, () => { + const { dir, bundle } = fakeBundle(); + const out = join(dir, "out'; touch INJECTED; #"); + const result = execFileSync("bash", [script, bundle, "0.2.1+dev", "amd64", out], { encoding: "utf8" }).trim(); + assert.equal(result, join(out, "nowplaying_0.2.1+dev_amd64.deb")); + assert.equal(spawnSync("test", ["-e", join(dir, "INJECTED")]).status, 1); + assert.match(execFileSync("dpkg-deb", ["-f", result], { encoding: "utf8" }), /Package: nowplaying/); +}); diff --git a/test/linux-native.test.js b/test/linux-native.test.js new file mode 100644 index 00000000..3e20f914 --- /dev/null +++ b/test/linux-native.test.js @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, readFileSync, statSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("../", import.meta.url).pathname; +test("common staging contains a non-terminal desktop entry and branding", () => { + const dir = mkdtempSync(join(tmpdir(), "np-native-")); + const bundle = join(dir, "bundle"); + mkdirSync(join(bundle, "runtime"), { recursive: true }); + mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true }); + for (const file of ["nowplaying", "runtime/node"]) { + writeFileSync(join(bundle, file), "#!/bin/sh\nexit 0\n"); + chmodSync(join(bundle, file), 0o777); + } + writeFileSync(join(bundle, "app/assets/brand/png/icon-512.png"), "test"); + chmodSync(join(bundle, "app/assets/brand/png/icon-512.png"), 0o777); + chmodSync(join(bundle, "app/assets/brand/png"), 0o777); + const stage = join(dir, "stage"); + execFileSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, stage]); + const entry = readFileSync(join(stage, "usr/share/applications/nowplaying.desktop"), "utf8"); + assert.equal(statSync(join(stage, "opt/nowplaying/runtime/node")).mode & 0o777, 0o755); + assert.equal(statSync(join(stage, "opt/nowplaying/app/assets/brand/png/icon-512.png")).mode & 0o777, 0o644); + assert.equal(statSync(join(stage, "opt/nowplaying/app/assets/brand/png")).mode & 0o777, 0o755); + assert.match(entry, /^Terminal=false$/m); + assert.match(entry, /^Exec=\/usr\/bin\/nowplaying start$/m); + assert.match(entry, /^Icon=nowplaying$/m); + assert.match(readFileSync(join(stage, "usr/bin/nowplaying"), "utf8"), /exec \/opt\/nowplaying\/nowplaying "\$@"/); +}); + +test("native builder rejects invalid package version and unknown format", () => { + const bundle = mkdtempSync(join(tmpdir(), "np-package-bad-")); + const out = join(bundle, "out"); + mkdirSync(join(bundle, "runtime")); + writeFileSync(join(bundle, "runtime/node"), "#!/bin/sh\ntouch FORMAT_PROBE_RAN\nprintf x64\n"); + chmodSync(join(bundle, "runtime/node"), 0o755); + for (const [kind, version] of [["deb", "0.2\nRequires: injected"], ["other", "0.2.1"]]) { + const result = spawnSync("bash", [join(root, "scripts/build-linux-native.sh"), kind, bundle, version, out], { encoding: "utf8" }); + assert.equal(result.status, 2); + if (kind === "other") assert.match(result.stderr, /Unknown package format/); + } +}); + +test("native staging accepts internal links but rejects paths escaping the bundle", () => { + const dir = mkdtempSync(join(tmpdir(), "np-native-links-")); + const bundle = join(dir, "bundle"); + mkdirSync(join(bundle, "runtime"), { recursive: true }); + mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true }); + for (const file of ["nowplaying", "runtime/node"]) { writeFileSync(join(bundle, file), "#!/bin/sh\nexit 0\n"); chmodSync(join(bundle, file), 0o755); } + writeFileSync(join(bundle, "app/assets/brand/png/icon-512.png"), "test"); + symlinkSync("icon-512.png", join(bundle, "app/assets/brand/png/alias.png")); + execFileSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, join(dir, "good")]); + writeFileSync(join(dir, "outside"), "outside"); + symlinkSync(join(dir, "outside"), join(bundle, "escape")); + const result = spawnSync("bash", [join(root, "scripts/stage-linux-package.sh"), bundle, join(dir, "bad")], { encoding: "utf8" }); + assert.equal(result.status, 2); + assert.match(result.stderr, /escapes/); +}); + +test("native builder rejects unsupported versions and bundled arm64 before packaging", { skip: process.platform !== "linux" }, () => { + const dir = mkdtempSync(join(tmpdir(), "np-native-arch-")); + mkdirSync(join(dir, "runtime")); + writeFileSync(join(dir, "runtime/node"), "#!/bin/sh\nprintf arm64\n"); + chmodSync(join(dir, "runtime/node"), 0o755); + for (const format of ["deb", "rpm", "arch"]) { + for (const version of ["0.2~dev", "0.2_dev", "0.2.1+dev"]) { + const result = spawnSync("bash", [join(root, "scripts/build-linux-native.sh"), format, dir, version, join(dir, "out")], { encoding: "utf8" }); + assert.equal(result.status, 2); + assert.match(result.stderr, /Invalid package version|Bundle architecture|require x86_64/); + } + } +}); From 18e29f7ef77229de89cdc7c0ac44502a2799acc2 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 3 Oct 2026 04:03:40 +0100 Subject: [PATCH 2/2] fix(linux): constrain native packaging scripts and tests to Linux --- scripts/build-deb.sh | 1 + scripts/build-linux-native.sh | 1 + scripts/stage-linux-package.sh | 1 + test/build-deb.test.js | 4 ++-- test/linux-native.test.js | 7 ++++--- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index db4afbaa..07d6d2d1 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -8,6 +8,7 @@ # usage: build-deb.sh # The Maintainer field comes from NOWPLAYING_DEB_MAINTAINER. set -euo pipefail +[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; } bundle="${1:?usage: build-deb.sh }" version="${2:?missing version}" diff --git a/scripts/build-linux-native.sh b/scripts/build-linux-native.sh index 8fe45b15..9d1fe97c 100644 --- a/scripts/build-linux-native.sh +++ b/scripts/build-linux-native.sh @@ -2,6 +2,7 @@ # Build only. Installing/releasing is an explicit next step. Source app version # is normalised for package managers; a dev package must use a dev version. set -euo pipefail +[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; } kind="${1:?deb|rpm|arch required}" case "$kind" in deb|rpm|arch) ;; diff --git a/scripts/stage-linux-package.sh b/scripts/stage-linux-package.sh index c1afd5a6..f53c03b8 100644 --- a/scripts/stage-linux-package.sh +++ b/scripts/stage-linux-package.sh @@ -3,6 +3,7 @@ # Input is trusted, immutable build output from our own CI. Link validation # rejects accidental escapes; it is not race-safe against concurrent edits. set -euo pipefail +[[ "$(uname -s)" = Linux ]] || { echo "Linux only: native Linux packaging" >&2; exit 2; } bundle="${1:?bundle directory required}" stage="${2:?staging directory required}" [[ -x "$bundle/nowplaying" && -x "$bundle/runtime/node" ]] || { echo 'Not a built bundle' >&2; exit 2; } diff --git a/test/build-deb.test.js b/test/build-deb.test.js index 953f0568..7d63cb1f 100644 --- a/test/build-deb.test.js +++ b/test/build-deb.test.js @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const script = new URL("../scripts/build-deb.sh", import.meta.url).pathname; -const haveDpkg = spawnSync("dpkg-deb", ["--version"]).status === 0 && spawnSync("fakeroot", ["--version"]).status === 0; +const haveDpkg = process.platform === "linux" && spawnSync("dpkg-deb", ["--version"]).status === 0 && spawnSync("fakeroot", ["--version"]).status === 0; function fakeBundle() { const dir = mkdtempSync(join(tmpdir(), "np-deb-")); @@ -21,7 +21,7 @@ function fakeBundle() { return { dir, bundle }; } -test("rejects a bad version or architecture before building", () => { +test("rejects a bad version or architecture before building", { skip: process.platform !== "linux" }, () => { const { dir, bundle } = fakeBundle(); const bad = [[bundle, "0.2.0\nSection: evil", "amd64", dir, /invalid version/], [bundle, "v0.2", "amd64", dir, /invalid version/], [bundle, "0.2.0", "i386", dir, /unsupported architecture/]]; for (const [bundleDir, version, arch, out, message] of bad) { diff --git a/test/linux-native.test.js b/test/linux-native.test.js index 3e20f914..1f135cda 100644 --- a/test/linux-native.test.js +++ b/test/linux-native.test.js @@ -5,8 +5,9 @@ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, readFileSync, statSyn import { tmpdir } from "node:os"; import { join } from "node:path"; +const linuxOnly = { skip: process.platform !== "linux" }; const root = new URL("../", import.meta.url).pathname; -test("common staging contains a non-terminal desktop entry and branding", () => { +test("common staging contains a non-terminal desktop entry and branding", linuxOnly, () => { const dir = mkdtempSync(join(tmpdir(), "np-native-")); const bundle = join(dir, "bundle"); mkdirSync(join(bundle, "runtime"), { recursive: true }); @@ -30,7 +31,7 @@ test("common staging contains a non-terminal desktop entry and branding", () => assert.match(readFileSync(join(stage, "usr/bin/nowplaying"), "utf8"), /exec \/opt\/nowplaying\/nowplaying "\$@"/); }); -test("native builder rejects invalid package version and unknown format", () => { +test("native builder rejects invalid package version and unknown format", linuxOnly, () => { const bundle = mkdtempSync(join(tmpdir(), "np-package-bad-")); const out = join(bundle, "out"); mkdirSync(join(bundle, "runtime")); @@ -43,7 +44,7 @@ test("native builder rejects invalid package version and unknown format", () => } }); -test("native staging accepts internal links but rejects paths escaping the bundle", () => { +test("native staging accepts internal links but rejects paths escaping the bundle", linuxOnly, () => { const dir = mkdtempSync(join(tmpdir(), "np-native-links-")); const bundle = join(dir, "bundle"); mkdirSync(join(bundle, "runtime"), { recursive: true });