Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
017f6e1
fix sudo handling and pkgx-under-/root reachability
tannevaled May 11, 2026
3d8f6a1
Potential fix for pull request finding
tannevaled May 11, 2026
3c85e6d
Potential fix for pull request finding
tannevaled May 11, 2026
373b100
Potential fix for pull request finding
tannevaled May 11, 2026
bfcf4a7
Potential fix for pull request finding
tannevaled May 11, 2026
3470f52
Potential fix for pull request finding
tannevaled May 11, 2026
069770d
Potential fix for pull request finding
tannevaled May 12, 2026
e3530f1
Potential fix for pull request finding
tannevaled May 12, 2026
e1104ea
address Copilot review comments on #86
tannevaled May 18, 2026
4264c8b
fix temporal-dead-zone on PKGX_MIN_VERSION
tannevaled May 18, 2026
1c8cda3
ci: fix sudo-install test 1 to not assert on /root/.pkgx
tannevaled May 18, 2026
ea4dab6
ci: emit diagnostic ::warning:: annotations from sudo-install
tannevaled May 18, 2026
18e0471
ci: check directories, not files, under \$HOME/.pkgx
tannevaled May 18, 2026
5b8fa2a
address remaining Copilot remark: verify versioned pkgx candidate
tannevaled May 18, 2026
5b51867
ci: run sudo-install on macOS too
tannevaled May 18, 2026
c700b49
ci: add diagnostic warnings to sudo-install (debugging macOS)
tannevaled May 18, 2026
cd3133b
ci: expand sudo-install stderr diagnostic (head+tail, strip ANSI)
tannevaled May 18, 2026
b94c2ce
ci: invoke sudo with -H so HOME is reset on macOS
tannevaled May 18, 2026
a36e06c
warn when sudo preserved HOME (macOS shebang/cache footgun)
tannevaled May 18, 2026
a09c34e
self-heal macOS sudo cache pollution instead of needing -H
tannevaled May 18, 2026
77b78c9
ci: diagnose macOS reclaim — show pre/post root-owned files under $HO…
tannevaled May 18, 2026
2939be8
reclaim: chown -h to operate on the symlink, not its target
tannevaled May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,26 +124,34 @@ jobs:
- name: sudo install drops privileges and overrides HOME
run: |
set -eux
# marker to scope ownership checks to files created by this install
# marker to scope checks to entries created by this install
touch /tmp/pkgm-sudo-marker
sudo ./pkgm.ts i hyperfine
test -x /usr/local/bin/hyperfine
# HOME override: pkgx must not have created anything under /root/.pkgx
# during this install. We scope the check to paths newer than the
# marker so a pre-existing /root/.pkgx from the runner image or
# setup action does not cause a false failure.
created_under_root=$(sudo find /root/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null || true)
if [ -n "$created_under_root" ]; then
echo "::error::pkgx cached under /root/.pkgx — HOME override failed"
echo "$created_under_root"

# HOME override + privilege drop are validated via the pkg cache
# under $HOME/.pkgx. We deliberately do NOT assert that
# /root/.pkgx is empty: the shebang's `pkgx --quiet deno^2.1 run …`
# runs as root before any pkgm.ts code executes, and that outer
Comment on lines +145 to +147
# pkgx caches under $HOME/.pkgx which resolves to /root/.pkgx
# under sudo. That cache is unavoidable and unrelated to whether
# pkgm's *inner* pkgx call dropped privileges.
#
# We check for newly created entries (directories specifically),
# not files: tar -x preserves the archive's original mtimes on
# extracted files, so file mtimes are typically *older* than the
# marker. Directories are created fresh by `mkdir` during
# extraction and reliably have a current mtime.

new_dirs=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -type d -print 2>/dev/null | head -1 || true)
if [ -z "$new_dirs" ]; then
echo "::error::no new directories under \$HOME/.pkgx — inner pkgx did not cache to invoking user's tree"
exit 1
fi
# Privilege drop: nothing newly created under ~/.pkgx should be
# owned by root. Any root-owned file here means pkgx ran as root
# despite SUDO_USER being set.

owned_by_root=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -user root -print 2>/dev/null || true)
if [ -n "$owned_by_root" ]; then
echo "::error::pkgx cache files created as root under \$HOME/.pkgx:"
echo "::error::pkgx cache entries created as root under \$HOME/.pkgx — privilege drop failed:"
echo "$owned_by_root"
exit 1
fi
Expand Down
206 changes: 185 additions & 21 deletions pkgm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import { ensureDir, existsSync, walk } from "jsr:@std/fs@^1";
import { parseArgs } from "jsr:@std/cli@^1";
const { hydrate } = plumbing;

// Module-scope SemVer literal: must be defined before any function that
// reads it can be called from top-level code below. `const` declarations
// are hoisted in name only (TDZ), so placing this further down the file
// triggered "Cannot access 'PKGX_MIN_VERSION' before initialization" once
// install()/get_pkgx() ran at module-init time.
const PKGX_MIN_VERSION = new SemVer("2.4.0");

function standardPath() {
let path = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";

Expand Down Expand Up @@ -297,25 +304,43 @@ async function query_pkgx(
set("PKGX_DIST_URL");
set("XDG_DATA_HOME");

const needs_sudo_backwards = install_prefix().string == "/usr/local";
let cmd = needs_sudo_backwards ? "/usr/bin/sudo" : pkgx;
if (needs_sudo_backwards) {
if (!Deno.env.get("SUDO_USER")) {
if (Deno.uid() == 0) {
const isRoot = Deno.uid() == 0;
const sudoUser = Deno.env.get("SUDO_USER");
const prefix = install_prefix().string;
const isSystemPrefix = prefix == "/usr/local";

let cmd = pkgx;
let cmd_args = args;

if (isSystemPrefix) {
if (isRoot && sudoUser) {
// Drop privileges so pkgx writes its cache as the invoking user, not root.
// But only if pkgx is reachable from sudoUser — otherwise the inner sudo
// aborts with "unable to execute …: Permission denied" (pkgxdev/pkgm#68).
const reachable = pkgx_reachable_as(pkgx, sudoUser);
if (reachable) {
cmd = "/usr/bin/sudo";
cmd_args = ["-u", sudoUser, "--", reachable, ...args];
// Override HOME, or pkgx will cache back under /root/ where sudoUser
// can't reach it on the next invocation.
const home = user_home(sudoUser);
if (home) env.HOME = home;
} else if (Deno.env.get("PKGM_DEBUG")) {
console.error(
"%cwarning",
"color:yellow",
"installing as root; installing via `sudo` is preferred",
`pkgm: \`pkgx\` at ${pkgx} is not reachable as ${sudoUser}; running it as root`,
);
}
Comment on lines +341 to 345
cmd = pkgx;
} else {
args.unshift("-u", Deno.env.get("SUDO_USER")!, pkgx);
} else if (isRoot) {
console.error(
"%cwarning",
"color:yellow",
"installing as root; installing via `sudo` is preferred",
);
}
}

const proc = new Deno.Command(cmd, {
args: [...args, "--json=v1"],
args: [...cmd_args, "--json=v1"],
stdout: "piped",
env,
clearEnv: true,
Expand Down Expand Up @@ -517,18 +542,26 @@ function symlink_with_overwrite(src: string, dst: string) {
Deno.symlinkSync(src, dst);
}

function pkgx_meets_minimum(path: string): boolean {
try {
const out = new Deno.Command(path, { args: ["--version"] }).outputSync();
if (!out.success) return false;
const match = new TextDecoder().decode(out.stdout).match(
/^pkgx (\d+\.\d+\.\d+)/,
);
if (!match) return false;
return new SemVer(match[1]).gte(PKGX_MIN_VERSION);
} catch {
return false;
}
}

function get_pkgx() {
for (const path of Deno.env.get("PATH")!.split(":")) {
const pkgx = join(path, "pkgx");
if (existsSync(pkgx)) {
const out = new Deno.Command(pkgx, { args: ["--version"] }).outputSync();
const stdout = new TextDecoder().decode(out.stdout);
const match = stdout.match(/^pkgx (\d+\.\d+\.\d+)/);
if (!match || new SemVer(match[1]).lt(new SemVer("2.4.0"))) {
Deno.exit(1);
}
return pkgx;
}
if (!existsSync(pkgx)) continue;
if (!pkgx_meets_minimum(pkgx)) Deno.exit(1);
return pkgx;
}
throw new Error("no `pkgx` found in `$PATH`");
}
Expand Down Expand Up @@ -766,6 +799,137 @@ function install_prefix() {
}
}

function user_home_from_passwd(user: string): string | undefined {
try {
const passwd = Deno.readTextFileSync("/etc/passwd");
for (const line of passwd.split("\n")) {
if (!line || line.startsWith("#")) continue;
const fields = line.split(":");
if (fields[0] === user) return fields[5] || undefined;
}
} catch {
// Ignore unreadable or absent passwd database and fall back to other lookups.
}

return undefined;
}

function user_home_from_dscl(user: string): string | undefined {
if (!existsSync("/usr/bin/dscl")) return undefined;

try {
const out = new Deno.Command("/usr/bin/dscl", {
args: [".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
}).outputSync();
if (!out.success) return undefined;

const line = new TextDecoder().decode(out.stdout).trim();
const prefix = "NFSHomeDirectory:";
if (!line.startsWith(prefix)) return undefined;

const home = line.slice(prefix.length).trim();
return home || undefined;
} catch {
return undefined;
}
}

function user_home(user: string): string | undefined {
// Prefer getent where available, but fall back to passwd parsing and macOS
// dscl so HOME can still be resolved when dropping privileges on systems
// without getent.
const getent = existsSync("/usr/bin/getent")
? "/usr/bin/getent"
: existsSync("/bin/getent")
? "/bin/getent"
: undefined;

if (getent) {
try {
const out = new Deno.Command(getent, {
args: ["passwd", user],
}).outputSync();
if (out.success) {
const fields = new TextDecoder().decode(out.stdout).trim().split(":");
if (fields[5]) return fields[5];
}
} catch {
// Ignore getent lookup failures and try portable fallbacks below.
}
}

return user_home_from_passwd(user) ?? user_home_from_dscl(user);
}

function pkgx_reachable_as(current: string, user: string): string | undefined {
// The caller has already enforced PKGX_MIN_VERSION for `current` via
// get_pkgx(); fallback candidates have not, so each return path below
// re-checks with pkgx_meets_minimum() to avoid handing back an
// unsupported binary (per #86 review).
if (reachable_as(current, user)) return current;

const home = user_home(user);
if (home) {
// Versioned pkgx.sh layout: ~/.pkgx/pkgx.sh/v<x.y.z>/bin/pkgx — pick the
// highest version that meets the minimum.
const root = join(home, ".pkgx/pkgx.sh");
if (existsSync(root)) {
let best: { v: SemVer; path: string } | undefined;
try {
if (Deno.statSync(root).isDirectory) {
for (const entry of Deno.readDirSync(root)) {
if (!entry.isDirectory || !entry.name.startsWith("v")) continue;
try {
const v = new SemVer(entry.name.slice(1));
if (v.lt(PKGX_MIN_VERSION)) continue;
const path = join(root, entry.name, "bin/pkgx");
if (!existsSync(path)) continue;
if (!best || v.gt(best.v)) best = { v, path };
} catch {
// skip malformed version dir
}
}
}
} catch {
// Ignore unreadable/non-directory pkgx.sh roots and fall back to other locations.
}
if (best) return best.path;
}
const local = join(home, ".local/bin/pkgx");
if (existsSync(local) && pkgx_meets_minimum(local)) return local;
Comment on lines +940 to +941
}
if (
existsSync("/usr/local/bin/pkgx") &&
pkgx_meets_minimum("/usr/local/bin/pkgx")
) {
return "/usr/local/bin/pkgx";
}
return undefined;
}

function reachable_as(p: string, user: string): boolean {
// Conservative heuristic: private home dirs are typically mode 700, so a
// path under another user's home is unreachable. System paths and the
// user's own home are assumed reachable.
const home = user_home(user);
if (home && (p === home || p.startsWith(`${home}/`))) return true;

// Shared Linuxbrew prefix lives under /home but is world-traversable and
// is treated as a system pkgx location by standardPath(). Without this
// exemption a pkgx installed via Linuxbrew would force the root-execution
// fallback, recreating the root-owned cache problem this code avoids
// (per #86 review). Honour $HOMEBREW_PREFIX in case it's elsewhere.
const brew = Deno.env.get("HOMEBREW_PREFIX") ?? "/home/linuxbrew/.linuxbrew";
if (p === brew || p.startsWith(`${brew}/`)) return true;

if (p === "/root" || p.startsWith("/root/")) return false;
if (p === "/var/root" || p.startsWith("/var/root/")) return false;

if (p.match(/^\/(home|Users)\/([^/]+)(?:\/|$)/)) return false;

return true;
}

function dev_stub_text(selfpath: string, bin_prefix: string, name: string) {
if (selfpath.startsWith("/usr/local") && selfpath != "/usr/local/bin/dev") {
return `
Expand Down
Loading