8.9 KiB
Package-manager hardening — framing + as-built
Five audit findings in src/packages/, taken as one sweep: one Medium
correctness issue (F-005) and four Lows (F-009–F-012) that range from a
security-adjacent cache-key upgrade to dead-code deletion. All in the
pmacs core crate; no wire-protocol or frontend change.
- F-005 (Medium) — install dirs are named by package basename, and
require-lookup routes by basename. Two distinct packages
owner/magitandother/magitcollapse to one install dir; most-recent-install silently wins. - F-009 (Low) — the fetch bare-mirror cache dir is keyed by 64-bit FNV-1a of the URL — trivially collidable, and URLs are attacker-adjacent.
- F-010 (Low) — on a git subprocess timeout,
run_with_timeoutreturns before joining the stdout/stderr drain threads (they're joined only on the normal path). - F-011 (Low) —
ResolvedPackage.commitis documented "Full 40-character commit hash" butcommit_for_tag()puts a tag string there; the resolver deliberately works against commit-ishes and defers SHA resolution to the installer. - F-012 (Low) — the resolver's topological sort builds an
indegreemap, reasons in comments that it's backwards, and rebuilds it — leaving the first block dead.
What the recon established
- F-005 must not break the loader's intended override. The loader
documents that a project-scope install may share a basename with a
user-scope one, and the Lua searcher picks most-recent-first on
purpose (
loader.rs:43). So the collision to reject is the one with no override semantics: two distinct package names inside a single resolve plan sharing a basename — they'd collide on disk with no way to tell which the caller meant.into_plan(resolver.rs:678) builds the plan and holds every name at once — the one place that can see the collision. The install marker (.pmacs-install) records only the commit, not the canonical name, so cross-resolve install-time detection would need a marker-format change — deferred. - F-009 has
sha2already.sha2 = "0.10"is a direct dep (M7.6 lockfile hashing,lockfile.rs:84), so SHA-256 is a swap, not a new dependency. The cache key is a dir name; changing it re-clones once (it's a cache) — acceptable. - F-010 is reap-then-join. After
kill()+wait()the child's pipes close, so the drain threads finish promptly and are safe to join. The fix is to reap on every exit path and join at a single point. - F-011's field genuinely holds a commit-ish and
ResolvePlanis never serialized (only feedsLockfile::from_plan), so a rename is serde-safe. Field access is concrete-typed, so a compiler-driven rename touches exactly theResolvedPackagesites, not the other.commitfields (ChosenTag, the internal entry,InstalledPackage,LockedPackage).
The rules
Q#PM1 — F-005: reject same-basename distinct names in one plan. After
into_plan assembles packages, group by package_basename(name)
(installer.rs:1046, already pub(crate)); if any basename maps to more
than one distinct PackageName, return a new
ResolveError::BasenameCollision { basename, names }. This blocks the
install before it can silently shadow, without touching the loader's
cross-scope override behavior. Install-time (cross-resolve) detection is
named-deferred (needs the canonical name in the marker).
Q#PM2 — F-009: SHA-256 the cache key. Replace fnv1a_hex with
sha256_hex (sha2::Sha256, full 64-char hex) for the bare-mirror dir /
lock names. Keep normalize_url in front (case/.git/trailing-slash
folding) so the same repo still maps to one entry; only the hash
function changes. Update the three hash unit tests.
Q#PM3 — F-010: reap on every path, join once. Restructure
run_with_timeout so the wait loop breaks with a Result<ExitStatus, FetchError> — killing + reaping the child on the timeout and try_wait-
error paths — then joins both drain threads unconditionally and only then
propagates the error. No detached readers survive a timeout.
Q#PM4 — F-011: rename commit → revision, tell the truth. Rename
the field and document it as "a commit-ish (a SHA, or a tag/branch name);
the installer/lockfile resolves it to a concrete SHA." Compiler-driven:
fix each flagged ResolvedPackage read (resolver/lockfile/
lua_bindings). The Lua-visible record key stays "commit" (user API
unchanged); only the Rust contract gets honest.
Q#PM5 — F-012: delete the dead indegree block. Remove the first
indegree construction (resolver.rs:707-715) and collapse the
three-paragraph "reset / no, backwards / cleaner" narration into one
comment stating the final algorithm (outgoing-edge counts + a dependents
reverse-adjacency, peeled by Kahn's). Pure cleanup — the existing
topo-sort tests are the guard.
Categorical bets
- Reject, don't guess (F-005). With two same-basename packages in one plan and no override semantics to disambiguate, a clear error beats silently installing whichever lands last. A namespace-preserving layout is the real answer later; failing loud is the right v0.1 floor.
- Crypto hash for an adversary-adjacent key (F-009). URLs are
effectively attacker-controlled; only a cryptographic digest resists a
deliberate cache-path collision.
sha2is already in the tree. - A rename is worth it over a doc patch (F-011). The name is the trap
("trusts
.commitas a SHA"); renaming removes it at the type level, and the compiler makes the change safe and exhaustive.
Validation implication
All five are unit-testable in-crate (no GPU, no daemon): a resolve plan
with a synthesized basename collision errors (F-005); sha256_hex is
stable/normalized (F-009); a timeout leaves no unjoined threads and still
returns Timeout (F-010 — assert via the existing timeout test path);
rename is compile-checked + existing resolve/lockfile tests (F-011); topo
order unchanged (F-012, existing tests). Runs under both Lua flavors in
CI.
As-built
Landed as framed; all five in src/packages/ (F-011 also touches
lua_bindings.rs + acceptance tests via the rename). No serde/wire change.
- F-005 (
resolver.rs+lockfile.rs): newResolveError::BasenameCollision { basename, names }; apub(crate)find_basename_collision(names)groups the plan's names bypackage_basenameand returns the first basename with >1 distinct name. Guards both plan-construction paths (review follow-up):into_plan(fresh /UpdateOne) andLockfile::to_resolve_plan(the frozen / lockfile-derived path, which returns its ownLockfileError::BasenameCollision). The frozen check runs up front, before any fetch, so a hand-edited or pre-existing lockfile with two same-basename packages fails fast. Three unit tests (detection, negative cases, and the frozen-path rejection); the existingroster_lookup_picks_most_recent_on_basename_collisionloader test still passes, confirming the intended cross-scope override is untouched. - F-009 (
fetcher.rs):fnv1a_hex→sha256_hex(sha2::Sha256, 64-char hex) for the bare-mirror dir/lock key;normalize_urlunchanged in front. Hash test asserts stability, normalization, 64-hex shape, and distinctness. - F-010 (
fetcher.rs):run_with_timeoutnowbreaks the wait loop with aResult, reaping the child (kill+wait) on both the timeout andtry_wait-error paths, then joins both drain threads at a single point before propagating — no detached readers survive a timeout. The existingtimeout_kills_long_running_commandtest covers the path. - F-011 (
resolver.rs+lockfile.rs+lua_bindings.rs+ m7_5/m7_6/ m7_10 tests):ResolvedPackage.commit→revision, documented as a commit-ish. Compiler-driven rename hit exactly theResolvedPackagesites; the Lua-visible record key stays"commit", and the other structs'commitfields (ChosenTag, internal entry,InstalledPackage,LockedPackage) were correctly left alone. - F-012 (
resolver.rs): deleted the dead firstindegreemap + the three-paragraph "reset/backwards/cleaner" narration; one comment now states the actual algorithm.
Validated: cargo fmt clean; clippy --all-targets clean under both
Lua flavors (luajit + lua54); 1437 lib unit tests pass, incl. the new
F-005 tests (fresh + frozen paths), the F-009 hash test, the F-010 timeout
test, and the loader override test.
Deferred (named)
- F-005 install-time collision detection. Catching a collision across
separate resolves (install
owner/magit, laterother/magit) needs the canonical name recorded in.pmacs-installand an installer check — a marker-format change out of this batch's scope. - Namespace-preserving install layout. The structural fix for F-005
(install under
<owner>/<name>/, require by canonical name) is a larger design change; rejecting is the interim floor.