Repository navigation
Add package-ownership probe (#301) - #308
Merged
Merged
Conversation
#301: PackageDatabase answers path-level ownership via dpkg -S, fail-closed on a missing database, an unparseable result, or a tool failure (Err, never a silent Unowned). rpm -qf stays out of scope; the Rpm family is forward data for #135. InstallationOwnership is layout-gated: an unowned executable is a Bundle only when the installed layout satisfies the release-bundle contract; otherwise (and on conflicting reports or probe failure) it is Unknown, which consumers must refuse to act on. The probe runner is injectable so exit-code semantics and dpkg output parsing are unit-testable without a package database.
Fixes #308 review blockers: 1. Successful-but-malformed output fails closed. parse_dpkg_owners now returns a Result; owner_of maps any parse failure to ProbeFailed instead of a silent Unowned. Unowned remains the exclusive answer of exit code 1. 2. Parse the documented dpkg -S grammar instead of one-owner-per-line: multiple owners on one record are comma-separated (pkgname1, pkgname2: pathname), and file-diversion records are recognized and skipped. The probe pins LC_ALL=C.UTF-8 and clears LANGUAGE, per the man page's machine-parsing hint, since diversion prefixes are otherwise localized. Any other line, blank line, or an ownership record naming a different path is an error. Also corrects the docs that overstated the RPM forward-compatibility: the rpm probe path in #135 is a new runner shape, not data on the existing one.
Fixes #308 follow-ups: 1. Don't trust the inherited process environment. The probe now runs /usr/bin/dpkg-query by absolute path (not PATH-resolved) and removes DPKG_ROOT / DPKG_ADMINDIR, so an attacker-controlled environment can neither substitute a fake dpkg (which would forge Unowned via exit 1) nor redirect which database is queried. 2. Accept architecture-qualified package ids. dpkg -S reports owners like libc6:amd64; is_package_name rejected ':' so those failed as ProbeFailed. is_package_id now takes a package name plus an optional :<arch> qualifier (lowercase-alphanumeric, no second ':'), and treats same-name/different-arch owners as distinct (a real conflict when one record carries both).
PackageDatabase was dpkg-only and treated a missing /usr/bin/dpkg-query as ProbeFailed. On Arch (no dpkg) that made every ownership question a fail-closed Err, so #304's clobber guard would refuse bundle upgrades that #133 explicitly keeps supported. Generalize the probe to the three host package databases (dpkg-query, rpm, pacman — the same triple the KWin safety code already probes in setup/kwin/native.rs) and aggregate: none claim the path -> Unowned exactly one claims -> Owned more than one claims -> Conflicting present-but-unanswerable -> Err (fail closed) no database available -> Err (never a silent Unowned) So an Arch host with working pacman and no owner now establishes a clean Unowned instead of treating dpkg's absence as a database failure. - rpm path queries %{NAME} only (default NEVRA is unreliable: names contain '-', and a single file's output may omit release/arch); exit 0 = owned, 1 = unowned, 2 = db error. - pacman path is content-based (match the documented "is owned by" / "No package owns" strings) so an unverified exit code can't be mistaken for a verdict; anything else fails closed. - allow '-' in the architecture qualifier (hurd-i386, kfreebsd-amd64 are valid dpkg arch tokens the prior grammar rejected). Replaces the dpkg-specific DpkgProbeOutcome with a database-agnostic ProbeOutcome { Ran{code,stdout,stderr}, Unavailable } plus a Database descriptor and an injectable ProbeFn. 24 module tests.
Three fixes from review of the three-database model:
1. rpm empty-success: parse_rpm_names now errors on empty/all-blank
stdout from a *successful* `rpm -qf` (was: zero owners -> would read
as `Unowned`). Renamed from parse_rpm_nevra; it parses %{NAME} lines.
2. Spawn-error classification: the system runner maps only a genuinely
missing executable (NotFound) to ProbeOutcome::Unavailable. Any other
spawn error is ProbeOutcome::Failed (the database exists but couldn't
run), which aggregation records and folds into an Err — so a present
but unrunnable database can no longer let a secondary db's "unowned"
stand alone.
3. pacman -Qq --owns: replaces the human-readable `is owned by` sentence
parsing. --quiet makes --owns emit only package names (one per line),
removing the unverified path-echo and the ignored exit status. Owned
= exit 0 + names on stdout; unowned = non-zero exit carrying the
documented `No package owns` marker (stderr); any other non-zero
output fails closed (we do not trust a specific unowned exit code we
cannot confirm on this host).
+4 tests (present-db-unrunnable, pacman empty-success, pacman unverified
exit, rpm empty-success). 28 module tests, 1502 lib tests, clippy clean.
rpm loads a per-user macro layer (~/.config/rpm/macros, via HOME / XDG_CONFIG_HOME; RPM_CONFIGDIR re-points it) after the vendor/host settings, and %_dbpath is a runtime macro. So an unprivileged caller could redirect `rpm -qf` at a different database despite the trusted absolute executable — the same class of problem as DPKG_ADMINDIR. Remove HOME, XDG_CONFIG_HOME and RPM_CONFIGDIR for the rpm probe. This neutralizes the user layer while leaving /etc/rpm + /usr/lib/rpm and the default db path intact. Mirrors the dpkg DPKG_ROOT/DPKG_ADMINDIR sanitization. +1 regression test (system_probe_neutralizes_user_database_config). 29 module tests, 1503 lib tests, clippy clean, rustfmt clean.
`rpm -qf` exits 1 both when a file is genuinely unowned and when it simply does not exist on disk (the public contract only says non-zero = failure). #304 will preflight destination paths that don't exist yet, so treating exit 1 as "unowned" is a real misread, and `-qf` also misses `%ghost`/deleted-but-installed files. `rpm -q --path --qf '%{NAME}\n'` queries the *database* for the owning package whether or not the file is installed. The verdict is now content-based, not exit-code-based: * a valid package NAME on stdout = owned * `No such file or directory` (lstat fail) or `is not owned by any package` (lstat ok) on stderr = unowned * anything else (incl. a broken-DB message) = Err (fail closed) Exit 1 alone is no longer the "unowned" signal. Verified against the upstream rpm source (query.cc / rpmts.cc / poptQV.cc). +4 tests (rpm unowned via both markers, unexpected-output fail-closed, exit-1-with-unrecognized-stderr not conflated with broken DB). 33 module, 1507 lib, clippy clean, rustfmt clean.
Staphylococcus
force-pushed
the
feat/301-ownership-probe
branch
from
October 6, 2026 14:57
5881c0c to
4a9fdbe
Compare
…d path) `rpm -q --path` emits `No such file or directory` for *other* failures too (e.g. `open of <x>.rpm failed:` on a missing `.rpm`), so the bare substring match could accept a DB/config failure as `Unowned`. The negative verdict now requires stderr to be *exactly one line* and exactly one of the two documented no-match messages *about the queried path*: error: file <queried>: No such file or directory (lstat failed) file <queried> is not owned by any package (lstat ok) Any extra line, a different path, or an unrecognized message is an `Err` (fail closed), not `Unowned`. The owned case is unchanged (a valid NAME on stdout, even for a `%ghost`/deleted/excluded file). Tests: the lstat-fail marker now carries the `error:` prefix it really has; added three fail-closed cases (unrelated `No such file`, an extra stderr line, and a no-match message about a *different* path) and dropped the now-subsumed `some unexpected failure` case. 35 module tests; full lib 1509 passed, 0 failed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#301 — application-side prework for package-managed installs.
Adds
PackageDatabase, the observation layer that answers "who owns aninstalled file, if anyone", so the clobber guard (#304), package-aware
provisioning (#302), and the updater can tell a package-managed install
from a plain release-bundle install.
What lands here
PackageDatabase::owner_of(path) -> Result<PathOwnership, PackageDatabaseError>—three host package databases probed as one query —
dpkg -S,rpm -q --path,pacman -Qq --owns— fail-closed: a missingexecutable is
Unavailable, any other spawn error is a probeFailed,and a non-"unowned" answer is
Err, never a silentUnowned. Aggregate: noneclaim ->
Unowned, one ->Owned, more ->Conflicting, any failure->
Err.PathOwnership { Unowned, Owned, Conflicting }— conflicting packagesclaiming one file is a first-class result, distinct from a probe failure.
InstallationOwnership { Bundle, Package { family }, Unknown }—layout-gated: an unowned executable is
Bundleonly when theinstalled layout satisfies the release-bundle contract (the caller
supplies that signal from preflight). Unowned-but-unrecognized layout,
conflicting reports, and probe failure all map to
Unknown, whichconsumers refuse.
PackageFamily { Dpkg, Rpm, Pacman }is data, not code: all threeare probed; Build and smoke-test the LG Buddy RPM package #135 adds the RPM delivery and family-specific update logic,
not a new probe path.
documented C-locale output is the signal, so a non-zero exit with
unrecognized output always fails closed): rpm via a package NAME on
stdout, else
Unownedonly when stderr is exactly one documentedno-match line about the queried path (
error: file <path>: No such file or directoryorfile <path> is not owned by any package); a baresubstring is not enough, so a DB/config failure that merely mentions
No such file or directorystill fails closed.-q --pathqueries theDB whether or not the file is installed, so Prework: clobber guard (bundle installer never overwrites package-owned / non-writable files) #304 can preflight a
destination that doesn't exist yet; pacman via the
No package ownsmarker; dpkg via its record form with
1= definitively unowned.with_probe) so each database's exit-codeand output semantics are unit-testable without a package database (this
dev host is NixOS; the probes are pinned to absolute, trusted executable
paths and a C locale, with the per-database env knobs sanitized — dpkg's
DPKG_ROOT/DPKG_ADMINDIRand rpm's per-user macro layer(
HOME/XDG_CONFIG_HOME/RPM_CONFIGDIR, which would re-point%_dbpath)).Scope notes
a mutation of a package-owned path is Prework: clobber guard (bundle installer never overwrites package-owned / non-writable files) #304's job. Mirrors the preflight
observation/judgment split.
caller. RPM delivery and the package-manager dispatch stay in Build and smoke-test the LG Buddy RPM package #135; a
pacman-owned (Arch) install is treated as externally managed (no pacman
delivery in v2 scope).
Verification
cargo check -p lg-buddy --all-targetsandcargo clippycleanpackage_ownership(parsing, exit-code and contentsemantics, cross-database aggregation, fail-closed mapping, layout-gated
install classification, probe-env sanitization)
lg-buddylib suite: 1509 passed, 0 failed