164 lines
8.9 KiB
Markdown
164 lines
8.9 KiB
Markdown
# 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<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. `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 `<owner>/<name>/`, require by canonical name) is a larger
|
||
design change; rejecting is the interim floor.
|