# 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/magit` and `other/magit` collapse 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_timeout` returns before joining the stdout/stderr drain threads (they're joined only on the normal path). - **F-011** (Low) — `ResolvedPackage.commit` is documented "Full 40-character commit hash" but `commit_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 `indegree` map, 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 `sha2` already.** `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 `ResolvePlan` is never serialized (only feeds `Lockfile::from_plan`), so a rename is serde-safe. Field access is concrete-typed, so a compiler-driven rename touches exactly the `ResolvedPackage` sites, not the other `.commit` fields (`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 `break`s with a `Result` — 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. `sha2` is already in the tree. - **A rename is worth it over a doc patch (F-011).** The name is the trap ("trusts `.commit` as 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`): new `ResolveError::BasenameCollision { basename, names }`; a `pub(crate)` `find_basename_collision(names)` groups the plan's names by `package_basename` and returns the first basename with >1 distinct name. **Guards *both* plan-construction paths** (review follow-up): `into_plan` (fresh / `UpdateOne`) *and* `Lockfile::to_resolve_plan` (the frozen / lockfile-derived path, which returns its own `LockfileError::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 existing `roster_lookup_picks_most_recent_on_basename_collision` loader 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_url` unchanged in front. Hash test asserts stability, normalization, 64-hex shape, and distinctness. - **F-010** (`fetcher.rs`): `run_with_timeout` now `break`s the wait loop with a `Result`, reaping the child (`kill` + `wait`) on both the timeout and `try_wait`-error paths, then joins both drain threads at a single point before propagating — no detached readers survive a timeout. The existing `timeout_kills_long_running_command` test 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 the `ResolvedPackage` sites; the Lua-visible record key stays `"commit"`, and the other structs' `commit` fields (`ChosenTag`, internal entry, `InstalledPackage`, `LockedPackage`) were correctly left alone. - **F-012** (`resolver.rs`): deleted the dead first `indegree` map + 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`, later `other/magit`) needs the canonical name recorded in `.pmacs-install` and 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 `//`, require by canonical name) is a larger design change; rejecting is the interim floor.