Skip to content

Add package-ownership probe (#301) - #308

Merged
Staphylococcus merged 9 commits into
devfrom
feat/301-ownership-probe
Oct 8, 2026
Merged

Staphylococcus merged 9 commits into
devfrom
feat/301-ownership-probe

Conversation

@Staphylococcus

@Staphylococcus Staphylococcus commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

#301 — application-side prework for package-managed installs.

Adds PackageDatabase, the observation layer that answers "who owns an
installed 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 missing
    executable is Unavailable, any other spawn error is a probe Failed,
    and a non-"unowned" answer is Err, never a silent Unowned. Aggregate: none
    claim -> Unowned, one -> Owned, more -> Conflicting, any failure
    -> Err.
  • PathOwnership { Unowned, Owned, Conflicting } — conflicting packages
    claiming one file is a first-class result, distinct from a probe failure.
  • InstallationOwnership { Bundle, Package { family }, Unknown } —
    layout-gated: an unowned executable is Bundle only when the
    installed 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, which
    consumers refuse.
  • PackageFamily { Dpkg, Rpm, Pacman } is data, not code: all three
    are 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.
  • Each database's verdict is content-based, not exit-code-based (the
    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 Unowned only when stderr is exactly one documented
    no-match line about the queried path (error: file <path>: No such file or directory or file <path> is not owned by any package); a bare
    substring is not enough, so a DB/config failure that merely mentions
    No such file or directory still fails closed. -q --path queries the
    DB 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 owns
    marker; dpkg via its record form with 1 = definitively unowned.
  • Injectable probe runner (with_probe) so each database's exit-code
    and 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_ADMINDIR and rpm's per-user macro layer
    (HOME/XDG_CONFIG_HOME/RPM_CONFIGDIR, which would re-point %_dbpath)).

Scope notes

Verification

  • cargo check -p lg-buddy --all-targets and cargo clippy clean
  • 35 tests in package_ownership (parsing, exit-code and content
    semantics, cross-database aggregation, fail-closed mapping, layout-gated
    install classification, probe-env sanitization)
  • Full lg-buddy lib suite: 1509 passed, 0 failed

#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
Staphylococcus force-pushed the feat/301-ownership-probe branch from 5881c0c to 4a9fdbe Compare October 6, 2026 14:57
Staphylococcus and others added 2 commits October 6, 2026 18:04
…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.
@Staphylococcus
Staphylococcus merged commit a8f34c6 into dev Oct 8, 2026
14 checks passed
@Staphylococcus
Staphylococcus deleted the feat/301-ownership-probe branch October 8, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant