pmacs/docs/package-manager-hardening-f...

164 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-009F-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.