From 54b420bb9985bbe137831ca6bf84e4adbc0d94a1 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 14:43:36 -0500 Subject: [PATCH 1/4] 3b increment 5a: pkgstate verification predicates Add .verify(preview, reader) (R/verify.R): check every RESOLVED RECORD of a committed preview against native dpkg/apt ground truth, per verb (4.7 / 6.3). The next increment wires this into .commit_session step 6. transaction verbs (install/remove/purge/upgrade/dist_upgrade): each record checked by ITS OWN action via pkgstate::dpkg_installed() -- install/upgrade/downgrade must be `installed` at to_version; remove leaves config-files/not-installed/an absent row; purge must be absent or not-installed (a surviving config-files is a FAILED purge, since dpkg has no purged status word -- purge is the absence of a DB entry). configure: each record's package must be fully `installed`. hold/unhold: the pkgstate::dpkg_selections() want reads back (install vs hold), matched by package name (hold records carry no arch). update: no observable post-state -> verified NA. Verification is INDEPENDENT of the helper's self-report (4.7): .verify reads only the plan (preview) and the ground truth (reader), so a clean status with a disagreeing post-state is a failure. It returns (verified TRUE/FALSE/NA, detail) and NEVER raises. The record grammar the 3a preview slice carried advisory-only is now load-bearing, pinned to shipped pkgexec 0.0.3 (the five transaction actions install/remove/purge/upgrade/downgrade; the configure/hold state words) and pkgstate 0.0.1.9 (dpkg_installed `status` = the state word; dpkg_selections `selection` = the want word). The pkgstate reads go through an injectable reader seam (pkgstate_reader()/set_pkgstate_reader), so the 50 new tests run against canned data frames and never query dpkg. CI installs pkgstate (pure R, public sibling). Still deferred: wiring .verify() into .commit_session (capturing the verdict into the outcome, never raising), then the exported apt_(). --- .github/workflows/ci.yaml | 10 ++ CLAUDE.md | 49 ++++---- R/verify.R | 242 ++++++++++++++++++++++++++++++++++++ inst/tinytest/test_verify.R | 183 +++++++++++++++++++++++++++ 4 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 R/verify.R create mode 100644 inst/tinytest/test_verify.R diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 873555a..f943c44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,6 +65,16 @@ jobs: R CMD INSTALL "${RUNNER_TEMP}/runix" Rscript -e 'install.packages("tinytest")' + # pkgstate is pkgops's third Import (read-only dpkg/apt state; the commit + # lifecycle's verification reads it). Pure R (imports runix, installed + # above), so a source install from the public sibling repo suffices; the + # suite injects a fake reader, so dpkg is never queried on the runner. + - name: Install pkgstate (public sibling Import) + run: | + git clone --depth 1 https://github.com/cornball-ai/pkgstate.git \ + "${RUNNER_TEMP}/pkgstate" + R CMD INSTALL "${RUNNER_TEMP}/pkgstate" + - name: Install pkgops run: R CMD INSTALL . diff --git a/CLAUDE.md b/CLAUDE.md index fd5c66a..f339114 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,28 +39,33 @@ reviewed increments** — hold at each increment before the next. approval_required / else check_failed, integer-valued-guarded), and `.authorize(verb_spec, interactive)` (interactive mode defers to the pkexec prompt and skips pkcheck). -- **Increment 4b (this): wire authorization into `.commit_session`** — step 2 now - runs `.authorize(verb_spec, interactive)` after capability and before open. - Authorized proceeds to the effect intent; a machine-mode refusal - (`unauthorized`/`approval_required`) goes through `.refuse()`, which opens a - **plain intent** via the seam's `refuse` op (`runix::broker_audit_sink()` + - `audit_two_phase()` with a no-op effect) and writes the terminal outcome - (`effect_issued=FALSE`) under one broker cid, then signals — no effect intent is - ever opened for a refusal. `check_failed` fails closed with **nothing recorded** - (`pkgops_polkit_check_failed`). `interactive` is a caller-supplied parameter - (runix exposes no TTY probe; default `FALSE` = machine mode). Added: - `.verb_spec_for()` (verbs.R), the `refuse` seam op (session_ops.R), and the - `approval_required` → `runix_approval_required` outcome status - (`.PKGOPS_POLKIT_CONDITION`, outcome.R). -- **Still NOT started** (later increments, each its own review): **`pkgstate` - verification** (§4.3 step 6 — `pkgstate` becomes an `Imports` only when that - increment lands, not before, or it is an unused-Import NOTE; it also supplies - the outcome record's `observed`/`changed` post-state fields), and then the - **exported per-verb `apt_()` API** (with the preview - `{verb,resource,plan_hash}` match check). The durable outcome-record grammar in - `.outcome_record()`, the plain-intent refusal record grammar, and the exact - pkcheck rc→outcome split are pinned against a real broker/polkit in the VM-gated - increment. +- **Increment 4b (merged): wire authorization into `.commit_session`** — step 2 + runs `.authorize(verb_spec, interactive)`; authorized proceeds, a machine-mode + refusal opens a **plain intent** via the seam's `refuse` op and writes the + terminal outcome (only signaled as closed when `audit_persisted == TRUE` with a + valid broker cid, `.valid_broker_cid`); `check_failed` fails closed with nothing + recorded. +- **Increment 5a (this): pkgstate verification predicates** (`R/verify.R`) — + `.verify(preview, reader)` checks every **resolved record** of a committed + preview against native ground truth, per verb (§6.3): transaction verbs by each + record's `action` (install/upgrade/downgrade → installed at `to_version`; remove + → `config-files`/`not-installed`/absent; purge → absent/`not-installed`, a + surviving `config-files` is a *failed* purge) via `dpkg_installed()`; configure → + fully `installed`; hold/unhold → the `dpkg_selections()` want reads back; update + → `NA`. **Independent of the helper's status** (reads only the plan + ground + truth); returns `(verified TRUE/FALSE/NA, detail)`. pkgstate reads go through an + injectable reader seam (`pkgstate_reader()`/`set_pkgstate_reader`, + hermetic-test-only). **`pkgstate` is now an `Imports`** (the default reader uses + it). Record grammar/post-state pinned to pkgexec 0.0.3 + pkgstate 0.0.1.9. +- **Still NOT started** (later increments, each its own review): **5b — wire + `.verify()` into `.commit_session` step 6** (for a success status, verify and + **capture** the verdict into the outcome's `verified`/`verify_detail` — never + raise; a verification failure still writes the outcome), and then the **exported + per-verb `apt_()` API** (with the preview `{verb,resource,plan_hash}` match + check). The durable outcome-record grammar (incl. the `observed`/`changed` + post-state fields verification now produces), the plain-intent refusal record + grammar, and the exact pkcheck rc→outcome split are pinned against a real + broker/polkit in the VM-gated increment. The authoritative design is `runix/docs/pkgops-plan.md` (the approved contract) and `runix/docs/pkgops-implementation-plan.md` (rev 2, the build sequence). diff --git a/R/verify.R b/R/verify.R new file mode 100644 index 0000000..b02c3dc --- /dev/null +++ b/R/verify.R @@ -0,0 +1,242 @@ +## pkgstate verification (contract 4.7 / 6.3): read native dpkg/apt ground truth +## and check every RESOLVED RECORD of the committed preview against its planned +## post-state. Verification is INDEPENDENT of the helper's self-report -- a clean +## helper status with a disagreeing post-state is a verification FAILURE, not a +## success (4.7). This layer is pure: the pkgstate reads go through an injectable +## seam (default = pkgstate::dpkg_installed / dpkg_selections), so the suite runs +## against canned data frames and never queries dpkg. The next increment wires +## .verify() into .commit_session step 6, CAPTURING a failure into the outcome +## (never raising -- the outcome must still be written, 4.3 step 6). +## +## Record grammar + post-state semantics are pinned to shipped pkgexec 0.0.3 +## (tools/preview.cc, src/apt_common.cc) and pkgstate 0.0.1.9 (dpkg_installed's +## `status` = dpkg's state word verbatim; dpkg_selections' `selection` = the want +## word). The 3a preview slice carried records advisory-only; here they become +## load-bearing. + +## The verb -> record family map. Transaction verbs share one record grammar +## (package/architecture/action/from_version/to_version/flags); configure, hold, +## and update each have their own. update has no observable post-state. +.VERIFY_FAMILY <- c(apt.install = "txn", apt.remove = "txn", + apt.purge = "txn", apt.upgrade = "txn", + apt.dist_upgrade = "txn", apt.configure = "configure", + apt.hold = "hold", apt.unhold = "hold", + apt.update = "update") + +.verify_family <- function(request_verb) { + if (.is_scalar_str(request_verb) && + request_verb %in% names(.VERIFY_FAMILY)) { + unname(.VERIFY_FAMILY[request_verb]) + } else { + NULL + } +} + +## The pkgstate reader seam. installed() -> data.frame(package, version, +## architecture, status); selections(packages) -> data.frame(package, +## architecture, selection). Default delegates to pkgstate (the honest Import); +## a hermetic test injects canned frames via set_pkgstate_reader(). +installed_default <- function() { + pkgstate::dpkg_installed() +} +selections_default <- function(packages = NULL) { + pkgstate::dpkg_selections(packages) +} +.PKGOPS_PKGSTATE_READER_DEFAULT <- list(installed = installed_default, + selections = selections_default) + +.pkgops_pkgstate_reader <- local({ + state <- new.env(parent = emptyenv()) + pkgstate_reader <- function() { + if (is.null(state$reader)) { + return(.PKGOPS_PKGSTATE_READER_DEFAULT) + } + merged <- .PKGOPS_PKGSTATE_READER_DEFAULT + merged[names(state$reader)] <- state$reader + merged + } + set_pkgstate_reader <- function(reader = NULL) { + old <- state$reader + state$reader <- reader + invisible(old) + } + list(pkgstate_reader = pkgstate_reader, + set_pkgstate_reader = set_pkgstate_reader) +}) +pkgstate_reader <- .pkgops_pkgstate_reader$pkgstate_reader +set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader + +## A scalar string from a decoded record field, or NA if absent/malformed. +.rec_str <- function(rec, field) { + v <- rec[[field]] + if (.is_scalar_str(v)) { + v + } else { + NA_character_ + } +} + +## Fold per-record failure messages into a (verified, detail) verdict: any +## failure -> verified FALSE with the joined reasons; none -> verified TRUE. +.verify_result <- function(fails) { + if (length(fails) > 0L) { + list(verified = FALSE, detail = paste(fails, collapse = "; ")) + } else { + list(verified = TRUE, detail = NA_character_) + } +} + +## Check one transaction record's action against the installed row (0 or 1 row, +## package:architecture being unique in dpkg). dpkg has no "removed"/"purged" +## status word: removed-with-configs is `config-files`, purged is an absent row +## (or the `not-installed` stub), installed-and-configured is `installed`. The +## five actions are the complete set pkgexec emits (install/remove/purge/upgrade/ +## downgrade; no configure/reinstall here). +.check_txn_state <- function(action, to_version, row) { + present <- nrow(row) > 0L + if (present) { + status <- as.character(row$status[1L]) + } else { + status <- NA_character_ + } + if (present) { + version <- as.character(row$version[1L]) + } else { + version <- NA_character_ + } + switch(action, + install =, + upgrade =, + downgrade = { + if (!present) { + return(sprintf("absent, expected installed %s", to_version)) + } + if (!identical(status, "installed")) { + return(sprintf("status %s, expected installed", status)) + } + if (!identical(version, to_version)) { + return(sprintf("version %s, expected %s", version, to_version)) + } + NA_character_ + }, + remove = { + ## removed leaves config files (or drops the row); "installed" or any + ## incomplete state means the removal did not take. + if (present && !status %in% c("config-files", "not-installed")) { + return(sprintf("status %s, expected removed", status)) + } + NA_character_ + }, + purge = { + ## purge deletes configs too: a surviving `config-files` row is a + ## failed purge; only an absent row / `not-installed` stub passes. + if (present && !identical(status, "not-installed")) { + return(sprintf("status %s, expected purged", status)) + } + NA_character_ + }, + sprintf("unrecognised action %s", action)) +} + +## install/remove/purge/upgrade/dist_upgrade -> transaction records, each checked +## by ITS OWN action (a verb pulls in dependency installs/removals/upgrades). +.verify_txn <- function(records, reader) { + inst <- reader$installed() + fails <- character(0) + for (rec in records) { + pkg <- .rec_str(rec, "package") + arch <- .rec_str(rec, "architecture") + action <- .rec_str(rec, "action") + to_version <- .rec_str(rec, "to_version") + if (is.na(pkg) || is.na(action)) { + fails <- c(fails, "malformed transaction record") + next + } + row <- inst[!is.na(inst$package) & inst$package == pkg & + (is.na(arch) | inst$architecture == arch),, drop = FALSE] + why <- .check_txn_state(action, to_version, row) + if (!is.na(why)) { + fails <- c(fails, sprintf("%s: %s", pkg, why)) + } + } + .verify_result(fails) +} + +## configure -> each record's package must now be fully configured (dpkg status +## `installed`); the record's `state` is the PRE-state that needed configuring. +.verify_configure <- function(records, reader) { + inst <- reader$installed() + fails <- character(0) + for (rec in records) { + pkg <- .rec_str(rec, "package") + arch <- .rec_str(rec, "architecture") + if (is.na(pkg)) { + fails <- c(fails, "malformed configure record") + next + } + row <- inst[!is.na(inst$package) & inst$package == pkg & + (is.na(arch) | inst$architecture == arch),, drop = FALSE] + if (nrow(row) > 0L) { + status <- as.character(row$status[1L]) + } else { + status <- "absent" + } + if (!identical(status, "installed")) { + fails <- c(fails, sprintf("%s: %s, expected fully configured", + pkg, status)) + } + } + .verify_result(fails) +} + +## hold/unhold -> each target must read back in the intended dpkg selection +## (`to_state` in {install, hold}). Matched by package name only: hold records +## carry no architecture, so a multi-arch package is verified across all its +## selection rows (all must agree -- fail closed). +.verify_hold <- function(records, reader) { + pkgs <- vapply(records, function(r) .rec_str(r, "package"), character(1)) + sel <- reader$selections(pkgs[!is.na(pkgs)]) + fails <- character(0) + for (rec in records) { + pkg <- .rec_str(rec, "package") + want <- .rec_str(rec, "to_state") + if (is.na(pkg) || is.na(want)) { + fails <- c(fails, "malformed hold record") + next + } + row <- sel[!is.na(sel$package) & sel$package == pkg,, drop = FALSE] + if (nrow(row) == 0L) { + fails <- c(fails, + sprintf("%s: no selection, expected %s", pkg, want)) + } else if (!all(row$selection == want)) { + fails <- c(fails, sprintf("%s: selection %s, expected %s", pkg, + paste(unique(row$selection), collapse = "/"), + want)) + } + } + .verify_result(fails) +} + +## Verify a committed preview's resolved records against native ground truth, +## returning list(verified = TRUE/FALSE/NA, detail). verified is NA when there is +## nothing to check (an index refresh, or no records); FALSE when any record's +## post-state disagrees with the plan; TRUE when all agree. Independent of the +## helper's status by construction -- it reads only the plan and the ground truth. +.verify <- function(preview, reader = pkgstate_reader()) { + fam <- .verify_family(preview$verb) + if (is.null(fam) || identical(fam, "update")) { + detail <- if (identical(fam, "update")) { + "no post-state to verify (index refresh)" + } else { + sprintf("no verification for verb %s", preview$verb) + } + return(list(verified = NA, detail = detail)) + } + records <- preview$records + if (!is.list(records) || length(records) == 0L) { + return(list(verified = NA, detail = "no resolved records to verify")) + } + switch(fam, txn = .verify_txn(records, reader), + configure = .verify_configure(records, reader), + hold = .verify_hold(records, reader)) +} diff --git a/inst/tinytest/test_verify.R b/inst/tinytest/test_verify.R new file mode 100644 index 0000000..305179a --- /dev/null +++ b/inst/tinytest/test_verify.R @@ -0,0 +1,183 @@ +# pkgstate verification (R/verify.R): check a committed preview's resolved +# records against native dpkg/apt ground truth, per verb (contract 6.3). +# Hermetic: the pkgstate reads go through a fake reader (canned data frames), so +# dpkg is never queried. Verification is independent of the helper's status -- +# .verify() sees only the plan (preview) and the ground truth (reader). + +verify <- pkgops:::.verify +fam <- pkgops:::.verify_family +set_reader <- pkgops:::set_pkgstate_reader + +## builders ------------------------------------------------------------------- +prevv <- function(verb, records) list(verb = verb, records = records) +txn <- function(package, action, to_version = "", architecture = "amd64", + from_version = "", flags = list()) { + list(package = package, architecture = architecture, action = action, + from_version = from_version, to_version = to_version, flags = flags) +} +cfg <- function(package, architecture = "amd64", current_version = "1.0", + state = "half-configured") { + list(package = package, architecture = architecture, + current_version = current_version, state = state) +} +hld <- function(package, from_state, to_state) { + list(package = package, from_state = from_state, to_state = to_state) +} +inst_df <- function(package = character(), version = character(), + architecture = character(), status = character()) { + data.frame(package = package, version = version, + architecture = architecture, status = status, + stringsAsFactors = FALSE) +} +sel_df <- function(package = character(), architecture = character(), + selection = character()) { + data.frame(package = package, architecture = architecture, + selection = selection, stringsAsFactors = FALSE) +} +reader <- function(installed = inst_df(), selections = sel_df()) { + list(installed = function() installed, + selections = function(packages = NULL) { + if (is.null(packages)) { + selections + } else { + selections[selections$package %in% packages, , drop = FALSE] + } + }) +} + +## ---- the verb -> family map ------------------------------------------------- +expect_equal(fam("apt.install"), "txn") +expect_equal(fam("apt.remove"), "txn") +expect_equal(fam("apt.purge"), "txn") +expect_equal(fam("apt.upgrade"), "txn") +expect_equal(fam("apt.dist_upgrade"), "txn") +expect_equal(fam("apt.configure"), "configure") +expect_equal(fam("apt.hold"), "hold") +expect_equal(fam("apt.unhold"), "hold") +expect_equal(fam("apt.update"), "update") +expect_null(fam("apt.bogus")) + +## ---- transaction: install --------------------------------------------------- +p_inst <- prevv("apt.install", list(txn("nginx", "install", "1.2"))) +r <- verify(p_inst, reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_identical(r$verified, TRUE) +expect_true(is.na(r$detail)) + +# wrong version installed -> FALSE +r <- verify(p_inst, reader(inst_df("nginx", "1.1", "amd64", "installed"))) +expect_identical(r$verified, FALSE) +expect_true(grepl("version 1.1", r$detail)) + +# absent -> FALSE +r <- verify(p_inst, reader(inst_df())) +expect_identical(r$verified, FALSE) +expect_true(grepl("absent", r$detail)) + +# installed but only unpacked/half-configured -> FALSE +r <- verify(p_inst, reader(inst_df("nginx", "1.2", "amd64", "half-configured"))) +expect_identical(r$verified, FALSE) +expect_true(grepl("half-configured", r$detail)) + +# wrong architecture -> not the same row -> FALSE (absent for amd64) +r <- verify(p_inst, reader(inst_df("nginx", "1.2", "i386", "installed"))) +expect_identical(r$verified, FALSE) + +## ---- transaction: remove ---------------------------------------------------- +p_rm <- prevv("apt.remove", list(txn("nginx", "remove"))) +expect_identical(verify(p_rm, reader(inst_df("nginx", "1.2", "amd64", + "config-files")))$verified, TRUE) +expect_identical(verify(p_rm, reader(inst_df()))$verified, TRUE) # dropped row ok +# still installed -> the removal did not take +r <- verify(p_rm, reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_identical(r$verified, FALSE) + +## ---- transaction: purge (config-files is a FAILED purge) -------------------- +p_pg <- prevv("apt.purge", list(txn("nginx", "purge"))) +expect_identical(verify(p_pg, reader(inst_df()))$verified, TRUE) +expect_identical(verify(p_pg, reader(inst_df("nginx", "", "amd64", + "not-installed")))$verified, TRUE) +r <- verify(p_pg, reader(inst_df("nginx", "1.2", "amd64", "config-files"))) +expect_identical(r$verified, FALSE) # configs remain -> not purged +expect_true(grepl("config-files", r$detail)) + +## ---- transaction: upgrade / downgrade check to_version ---------------------- +p_up <- prevv("apt.upgrade", list(txn("nginx", "upgrade", "2.0", + from_version = "1.2"))) +expect_identical(verify(p_up, reader(inst_df("nginx", "2.0", "amd64", + "installed")))$verified, TRUE) +expect_identical(verify(p_up, reader(inst_df("nginx", "1.2", "amd64", + "installed")))$verified, FALSE) +p_dn <- prevv("apt.install", list(txn("nginx", "downgrade", "1.0", + from_version = "1.2"))) +expect_identical(verify(p_dn, reader(inst_df("nginx", "1.0", "amd64", + "installed")))$verified, TRUE) + +## ---- transaction: mixed records, one failing dependency --------------------- +p_mix <- prevv("apt.install", list(txn("nginx", "install", "1.2"), + txn("libfoo", "install", "3.4"), + txn("oldbar", "remove"))) +good <- reader(inst_df(c("nginx", "libfoo", "oldbar"), + c("1.2", "3.4", "9"), rep("amd64", 3L), + c("installed", "installed", "config-files"))) +expect_identical(verify(p_mix, good)$verified, TRUE) +# libfoo failed to install -> FALSE, and the detail names libfoo not nginx +bad <- reader(inst_df(c("nginx", "oldbar"), c("1.2", "9"), + c("amd64", "amd64"), c("installed", "config-files"))) +r <- verify(p_mix, bad) +expect_identical(r$verified, FALSE) +expect_true(grepl("libfoo", r$detail)) +expect_false(grepl("nginx", r$detail)) + +## ---- configure: package must be fully configured post-commit ---------------- +p_cfg <- prevv("apt.configure", list(cfg("nginx", state = "half-configured"))) +expect_identical(verify(p_cfg, reader(inst_df("nginx", "1.2", "amd64", + "installed")))$verified, TRUE) +r <- verify(p_cfg, reader(inst_df("nginx", "1.2", "amd64", "half-configured"))) +expect_identical(r$verified, FALSE) +expect_identical(verify(p_cfg, reader(inst_df()))$verified, FALSE) # absent + +## ---- hold / unhold: the dpkg selection reads back --------------------------- +p_hold <- prevv("apt.hold", list(hld("nginx", "install", "hold"))) +expect_identical(verify(p_hold, reader(selections = sel_df("nginx", "amd64", + "hold")))$verified, TRUE) +r <- verify(p_hold, reader(selections = sel_df("nginx", "amd64", "install"))) +expect_identical(r$verified, FALSE) # still on install +expect_true(grepl("expected hold", r$detail)) +# no selection row at all -> FALSE +expect_identical(verify(p_hold, reader())$verified, FALSE) + +p_unhold <- prevv("apt.unhold", list(hld("nginx", "hold", "install"))) +expect_identical(verify(p_unhold, + reader(selections = sel_df("nginx", "amd64", + "install")))$verified, TRUE) + +# multi-arch: both rows must agree with the intended selection +p_h2 <- prevv("apt.hold", list(hld("nginx", "install", "hold"))) +expect_identical(verify(p_h2, reader(selections = sel_df(c("nginx", "nginx"), + c("amd64", "i386"), + c("hold", "hold"))))$verified, + TRUE) +r <- verify(p_h2, reader(selections = sel_df(c("nginx", "nginx"), + c("amd64", "i386"), + c("hold", "install")))) +expect_identical(r$verified, FALSE) # one arch not held + +## ---- update: no observable post-state -> NA --------------------------------- +r <- verify(prevv("apt.update", list()), reader()) +expect_true(is.na(r$verified)) +expect_true(grepl("index refresh", r$detail)) + +## ---- no records / unknown verb -> NA (nothing to verify) -------------------- +expect_true(is.na(verify(prevv("apt.install", list()), reader())$verified)) +expect_true(is.na(verify(prevv("apt.bogus", list(txn("x", "install"))), + reader())$verified)) + +## ---- a malformed record is a verification FAILURE, not a pass --------------- +r <- verify(prevv("apt.install", list(list(architecture = "amd64"))), reader()) +expect_identical(r$verified, FALSE) +expect_true(grepl("malformed", r$detail)) + +## ---- the reader seam: set_pkgstate_reader installs/restores ----------------- +old <- set_reader(reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_identical(verify(p_inst)$verified, TRUE) # uses the injected reader +set_reader(old) # restore (default pkgstate) From 88a3471cd83e9b8fec647874fd43f81d0d72ae3e Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 14:43:36 -0500 Subject: [PATCH 2/4] Bump version to 0.0.1.6 (pkgstate Imports) --- DESCRIPTION | 4 ++-- NEWS.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index ba78aad..4de9029 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: pkgops Type: Package Title: Unprivileged Issuer for 'APT' Package-State Mutations -Version: 0.0.1.5 +Version: 0.0.1.6 Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", @@ -19,7 +19,7 @@ License: MIT + file LICENSE OS_type: unix SystemRequirements: pkgexec (provides the unprivileged 'runix-apt-preview' planner on PATH; only needed for live previews, not for the test suite) -Imports: runix (>= 0.0.1.12), janssonr (>= 0.0.1.1) +Imports: runix (>= 0.0.1.12), janssonr (>= 0.0.1.1), pkgstate (>= 0.0.1.9) Suggests: tinytest Additional_repositories: https://cornball-ai.github.io/drat Encoding: UTF-8 diff --git a/NEWS.md b/NEWS.md index eacfc24..f19afe0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,31 @@ +# pkgops 0.0.1.6 + +Commit lifecycle (slice 3b): the **pkgstate verification predicates** (§4.7 / +§6.3). Pure, hermetic (the dpkg/apt reads go through an injectable reader seam); +the next increment wires them into `.commit_session`. + +* `.verify(preview, reader)` checks every **resolved record** of a committed + preview against native ground truth, per verb: transaction verbs by each + record's action (install/upgrade/downgrade must be installed at `to_version`; + remove leaves `config-files`/`not-installed`/an absent row; purge must be + absent, a surviving `config-files` is a **failed** purge) via + `pkgstate::dpkg_installed()`; configure must be fully `installed`; hold/unhold + read the `pkgstate::dpkg_selections()` want back; update has no post-state + (`NA`). +* Verification is **independent of the helper's self-report** (§4.7): it reads + only the plan and the ground truth, so a clean status with a disagreeing + post-state is a verification failure. It returns `(verified TRUE/FALSE/NA, + detail)` and never raises. +* The record grammar the preview slice carried advisory-only is now + **load-bearing**, pinned to shipped pkgexec 0.0.3 (the five transaction actions, + the configure/hold state words) and pkgstate 0.0.1.9 (the `status` state word vs + the `selection` want word). +* `pkgstate` is now an `Imports` (the default reader uses it); the suite injects a + fake reader, so dpkg is never queried. +* Still deferred: wiring `.verify()` into `.commit_session` step 6 (capturing the + verdict into the outcome, never raising), and the exported per-verb + `apt_()` API. + # pkgops 0.0.1.5 Commit lifecycle (slice 3b): **wire the polkit authorization into From 8618c6b0738f337b68ccfbc63b60d726af3cc4d4 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 14:58:40 -0500 Subject: [PATCH 3/4] review: arch-qualified hold identity + .verify never raises on bad data Two 5a review blockers. 1. Multi-arch hold now preserves target identity. .split_pkg_arch() splits a hold record's `pkg:arch` package (hold records carry no separate architecture field) and matches BOTH package and architecture against dpkg_selections; an unqualified name still matches across all its rows (every row must agree, conservative). Tests: qualified success/failure, wrong-arch no-match, and two qualified targets on distinct arches. 2. .verify() is now genuinely non-raising on malformed reader/record data: - .rec_str() returns NA for a non-list record instead of erroring on `rec[[field]]`; - the hold selection comparison is NA-safe (an NA selection is a mismatch, not `if (NA)`); - .require_cols() rejects a reader frame missing an expected column, and an outer tryCatch in .verify() normalizes ANY residual error (a throwing reader, a malformed frame) into verified = FALSE with a detail -- a post-state pkgops cannot read is fail-closed, never a pass. Regression tests: NA selection, non-list records, a column-short frame, a throwing reader, and a hostile-input batch asserting .verify always returns a well-formed (verified, detail) list. --- R/verify.R | 95 ++++++++++++++++++++++++++++++------- inst/tinytest/test_verify.R | 66 +++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/R/verify.R b/R/verify.R index b02c3dc..1fc2deb 100644 --- a/R/verify.R +++ b/R/verify.R @@ -66,8 +66,13 @@ selections_default <- function(packages = NULL) { pkgstate_reader <- .pkgops_pkgstate_reader$pkgstate_reader set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader -## A scalar string from a decoded record field, or NA if absent/malformed. +## A scalar string from a decoded record field, or NA if absent/malformed. Robust +## to a non-list record (a malformed/hand-built preview): a non-list rec yields NA +## for every field, so the record is treated as malformed rather than erroring. .rec_str <- function(rec, field) { + if (!is.list(rec)) { + return(NA_character_) + } v <- rec[[field]] if (.is_scalar_str(v)) { v @@ -76,6 +81,35 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader } } +## Split a record's `package` into (package, architecture). A hold record carries +## no separate architecture field, so an arch-qualified target arrives inline as +## `pkg:arch` -- split it and match both, preserving target identity. An +## unqualified name has NA architecture (match by name across all its rows). A +## malformed value (no scalar, or not exactly one `pkg:arch` split) yields NA +## package, so the record fails as malformed rather than matching the wrong row. +.split_pkg_arch <- function(x) { + if (!.is_scalar_str(x)) { + list(package = NA_character_, architecture = NA_character_) + } else if (grepl("^[^:]+:[^:]+$", x)) { + list(package = sub(":.*$", "", x), architecture = sub("^[^:]+:", "", x)) + } else if (!grepl(":", x, fixed = TRUE)) { + list(package = x, architecture = NA_character_) + } else { + list(package = NA_character_, architecture = NA_character_) + } +} + +## A reader frame must carry the columns the predicate reads; a frame that does +## not is malformed and stops (the outer belt in .verify() turns that into a +## verification FAILURE, never a wrong pass from a silently-absent column). +.require_cols <- function(df, cols, what) { + if (!is.data.frame(df) || !all(cols %in% names(df))) { + stop(sprintf("%s reader frame is missing required columns (need %s)", + what, paste(cols, collapse = ", "))) + } + invisible(df) +} + ## Fold per-record failure messages into a (verified, detail) verdict: any ## failure -> verified FALSE with the joined reasons; none -> verified TRUE. .verify_result <- function(fails) { @@ -142,6 +176,8 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader ## by ITS OWN action (a verb pulls in dependency installs/removals/upgrades). .verify_txn <- function(records, reader) { inst <- reader$installed() + .require_cols(inst, c("package", "version", "architecture", "status"), + "installed") fails <- character(0) for (rec in records) { pkg <- .rec_str(rec, "package") @@ -166,6 +202,7 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader ## `installed`); the record's `state` is the PRE-state that needed configuring. .verify_configure <- function(records, reader) { inst <- reader$installed() + .require_cols(inst, c("package", "architecture", "status"), "installed") fails <- character(0) for (rec in records) { pkg <- .rec_str(rec, "package") @@ -190,28 +227,43 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader } ## hold/unhold -> each target must read back in the intended dpkg selection -## (`to_state` in {install, hold}). Matched by package name only: hold records -## carry no architecture, so a multi-arch package is verified across all its -## selection rows (all must agree -- fail closed). +## (`to_state` in {install, hold}). An arch-qualified target (`pkg:arch`) matches +## both package AND architecture, preserving its identity; an unqualified target +## is verified across ALL its selection rows (every row must agree -- fail +## closed). Comparisons are NA-safe: an NA selection is a mismatch, not an error. .verify_hold <- function(records, reader) { - pkgs <- vapply(records, function(r) .rec_str(r, "package"), character(1)) + parsed <- lapply(records, function(rec) { + pa <- .split_pkg_arch(.rec_str(rec, "package")) + list(package = pa$package, architecture = pa$architecture, + want = .rec_str(rec, "to_state")) + }) + pkgs <- unique(vapply(parsed, function(p) p$package, character(1))) sel <- reader$selections(pkgs[!is.na(pkgs)]) + .require_cols(sel, c("package", "architecture", "selection"), "selections") fails <- character(0) - for (rec in records) { - pkg <- .rec_str(rec, "package") - want <- .rec_str(rec, "to_state") - if (is.na(pkg) || is.na(want)) { + for (p in parsed) { + if (is.na(p$package) || is.na(p$want)) { fails <- c(fails, "malformed hold record") next } - row <- sel[!is.na(sel$package) & sel$package == pkg,, drop = FALSE] + hit <- !is.na(sel$package) & sel$package == p$package + if (!is.na(p$architecture)) { + hit <- hit & !is.na(sel$architecture) & + sel$architecture == p$architecture + } + row <- sel[hit,, drop = FALSE] + label <- if (is.na(p$architecture)) { + p$package + } else { + paste0(p$package, ":", p$architecture) + } if (nrow(row) == 0L) { fails <- c(fails, - sprintf("%s: no selection, expected %s", pkg, want)) - } else if (!all(row$selection == want)) { - fails <- c(fails, sprintf("%s: selection %s, expected %s", pkg, + sprintf("%s: no selection, expected %s", label, p$want)) + } else if (!all(!is.na(row$selection) & row$selection == p$want)) { + fails <- c(fails, sprintf("%s: selection %s, expected %s", label, paste(unique(row$selection), collapse = "/"), - want)) + p$want)) } } .verify_result(fails) @@ -236,7 +288,16 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader if (!is.list(records) || length(records) == 0L) { return(list(verified = NA, detail = "no resolved records to verify")) } - switch(fam, txn = .verify_txn(records, reader), - configure = .verify_configure(records, reader), - hold = .verify_hold(records, reader)) + ## Belt over the per-record fixes: any residual error from malformed reader + ## frames or records is normalized to a verification FAILURE (never raised), + ## so .verify's "never raises" contract holds against untrusted inputs. A + ## post-state pkgops cannot read is not a pass -- it fails closed. + tryCatch( + switch(fam, txn = .verify_txn(records, reader), + configure = .verify_configure(records, reader), + hold = .verify_hold(records, reader)), + error = function(e) { + list(verified = FALSE, + detail = paste0("verification error: ", conditionMessage(e))) + }) } diff --git a/inst/tinytest/test_verify.R b/inst/tinytest/test_verify.R index 305179a..62a7ca3 100644 --- a/inst/tinytest/test_verify.R +++ b/inst/tinytest/test_verify.R @@ -151,7 +151,7 @@ expect_identical(verify(p_unhold, reader(selections = sel_df("nginx", "amd64", "install")))$verified, TRUE) -# multi-arch: both rows must agree with the intended selection +# unqualified multi-arch: ALL rows must agree with the intended selection p_h2 <- prevv("apt.hold", list(hld("nginx", "install", "hold"))) expect_identical(verify(p_h2, reader(selections = sel_df(c("nginx", "nginx"), c("amd64", "i386"), @@ -162,6 +162,30 @@ r <- verify(p_h2, reader(selections = sel_df(c("nginx", "nginx"), c("hold", "install")))) expect_identical(r$verified, FALSE) # one arch not held +## ---- arch-QUALIFIED hold preserves target identity (pkg:arch) --------------- +mixed <- reader(selections = sel_df(c("nginx", "nginx"), c("amd64", "i386"), + c("hold", "install"))) +# nginx:amd64 -> hold: matches ONLY the amd64 row (which is hold) -> TRUE +expect_identical(verify(prevv("apt.hold", list(hld("nginx:amd64", "install", + "hold"))), mixed)$verified, TRUE) +# nginx:i386 -> hold: matches ONLY the i386 row (which is install) -> FALSE +r <- verify(prevv("apt.hold", list(hld("nginx:i386", "install", "hold"))), mixed) +expect_identical(r$verified, FALSE) +expect_true(grepl("nginx:i386", r$detail)) # the qualified identity in the detail +# nginx:amd64 but only an i386 selection row exists -> no match -> FALSE +r <- verify(prevv("apt.hold", list(hld("nginx:amd64", "install", "hold"))), + reader(selections = sel_df("nginx", "i386", "hold"))) +expect_identical(r$verified, FALSE) +expect_true(grepl("no selection", r$detail)) +# two qualified targets, each matched to its own arch +p_q2 <- prevv("apt.hold", list(hld("nginx:amd64", "install", "hold"), + hld("nginx:i386", "install", "hold"))) +expect_identical(verify(p_q2, reader(selections = sel_df(c("nginx", "nginx"), + c("amd64", "i386"), + c("hold", "hold"))))$verified, + TRUE) +expect_identical(verify(p_q2, mixed)$verified, FALSE) # i386 is install + ## ---- update: no observable post-state -> NA --------------------------------- r <- verify(prevv("apt.update", list()), reader()) expect_true(is.na(r$verified)) @@ -177,6 +201,46 @@ r <- verify(prevv("apt.install", list(list(architecture = "amd64"))), reader()) expect_identical(r$verified, FALSE) expect_true(grepl("malformed", r$detail)) +## ---- .verify NEVER raises on malformed reader/record data ------------------- +## (contract: a post-state pkgops cannot read is not a pass -- it fails closed.) + +# an NA selection is a mismatch, not an if(NA) error +r <- verify(p_hold, reader(selections = sel_df("nginx", "amd64", NA_character_))) +expect_identical(r$verified, FALSE) + +# a non-list record (e.g. a stray scalar) -> malformed FAILURE, no error +r <- verify(prevv("apt.install", list("not-a-record")), reader()) +expect_identical(r$verified, FALSE) +expect_true(grepl("malformed", r$detail)) +r <- verify(prevv("apt.hold", list("not-a-record")), reader()) +expect_identical(r$verified, FALSE) + +# a reader frame missing an expected column -> normalized to FALSE, not an error +bad_reader <- list(installed = function() data.frame(package = "nginx", + stringsAsFactors = FALSE), + selections = function(packages = NULL) sel_df()) +r <- verify(p_inst, bad_reader) +expect_identical(r$verified, FALSE) +expect_true(grepl("verification error", r$detail)) + +# a reader whose installed() itself throws -> FALSE, not propagated +boom_reader <- list(installed = function() stop("dpkg exploded"), + selections = function(packages = NULL) sel_df()) +r <- verify(p_inst, boom_reader) +expect_identical(r$verified, FALSE) +expect_true(grepl("verification error", r$detail)) + +# across a batch of hostile inputs, .verify always returns a well-formed list +hostile <- list( + verify(prevv("apt.install", list(NA)), reader()), + verify(prevv("apt.hold", list(list(package = 42L))), reader()), + verify(prevv("apt.configure", list("x")), reader()), + verify(p_inst, boom_reader)) +for (h in hostile) { + expect_true(is.list(h) && all(c("verified", "detail") %in% names(h))) + expect_true(is.logical(h$verified) && length(h$verified) == 1L) +} + ## ---- the reader seam: set_pkgstate_reader installs/restores ----------------- old <- set_reader(reader(inst_df("nginx", "1.2", "amd64", "installed"))) expect_identical(verify(p_inst)$verified, TRUE) # uses the injected reader From 2caeea049eecac73d313ddb9f18670498f859699 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 15:10:47 -0500 Subject: [PATCH 4/4] verify: architecture is required for txn/configure records, never a wildcard The pinned pkgexec 0.0.3 record grammar makes architecture a required field on transaction and configure records (digest.h). The verifier previously treated a missing architecture as a wildcard (is.na(arch) | inst$architecture == arch), which matched every arch row and inspected the first -- a missing/malformed arch could pass against an unrelated arch's post-state. Require architecture in both .verify_txn and .verify_configure (a missing or malformed arch is now a malformed record), and filter the installed frame by an exact package+architecture match with no wildcard fallback. Adds a multi-arch regression test: the same package on two arches is matched to its own arch row (not the first), and a record with no architecture fails as malformed. --- R/verify.R | 14 ++++++++++---- inst/tinytest/test_verify.R | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/R/verify.R b/R/verify.R index 1fc2deb..67a6bd9 100644 --- a/R/verify.R +++ b/R/verify.R @@ -184,12 +184,15 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader arch <- .rec_str(rec, "architecture") action <- .rec_str(rec, "action") to_version <- .rec_str(rec, "to_version") - if (is.na(pkg) || is.na(action)) { + ## architecture is REQUIRED by the pinned txn record grammar (digest.h); + ## a missing/malformed arch must fail, never wildcard-match every arch. + if (is.na(pkg) || is.na(action) || is.na(arch)) { fails <- c(fails, "malformed transaction record") next } row <- inst[!is.na(inst$package) & inst$package == pkg & - (is.na(arch) | inst$architecture == arch),, drop = FALSE] + !is.na(inst$architecture) & + inst$architecture == arch,, drop = FALSE] why <- .check_txn_state(action, to_version, row) if (!is.na(why)) { fails <- c(fails, sprintf("%s: %s", pkg, why)) @@ -207,12 +210,15 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader for (rec in records) { pkg <- .rec_str(rec, "package") arch <- .rec_str(rec, "architecture") - if (is.na(pkg)) { + ## architecture is REQUIRED by the pinned configure record grammar; + ## a missing/malformed arch fails, never wildcard-matches every arch. + if (is.na(pkg) || is.na(arch)) { fails <- c(fails, "malformed configure record") next } row <- inst[!is.na(inst$package) & inst$package == pkg & - (is.na(arch) | inst$architecture == arch),, drop = FALSE] + !is.na(inst$architecture) & + inst$architecture == arch,, drop = FALSE] if (nrow(row) > 0L) { status <- as.character(row$status[1L]) } else { diff --git a/inst/tinytest/test_verify.R b/inst/tinytest/test_verify.R index 62a7ca3..b51ffe6 100644 --- a/inst/tinytest/test_verify.R +++ b/inst/tinytest/test_verify.R @@ -82,6 +82,33 @@ expect_true(grepl("half-configured", r$detail)) r <- verify(p_inst, reader(inst_df("nginx", "1.2", "i386", "installed"))) expect_identical(r$verified, FALSE) +## ---- transaction: architecture is per-record, never a wildcard -------------- +# same package on two arches: each record is matched to ITS OWN arch row +two_arch <- reader(inst_df(c("nginx", "nginx"), c("1.2", "1.0"), + c("amd64", "i386"), c("installed", "installed"))) +p_2a <- prevv("apt.install", list(txn("nginx", "install", "1.2", "amd64"), + txn("nginx", "install", "1.2", "i386"))) +r <- verify(p_2a, two_arch) +expect_identical(r$verified, FALSE) # i386 is at 1.0, not 1.2 +expect_true(grepl("version 1.0", r$detail)) +# both records match their arch's version -> TRUE +ok_2a <- reader(inst_df(c("nginx", "nginx"), c("1.2", "1.2"), + c("amd64", "i386"), c("installed", "installed"))) +expect_identical(verify(p_2a, ok_2a)$verified, TRUE) + +# a transaction record MISSING architecture is malformed, not a wildcard match +r <- verify(prevv("apt.install", + list(list(package = "nginx", action = "install", + to_version = "1.2"))), + reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_identical(r$verified, FALSE) +expect_true(grepl("malformed", r$detail)) +# a configure record MISSING architecture is likewise malformed +r <- verify(prevv("apt.configure", list(list(package = "nginx"))), + reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_identical(r$verified, FALSE) +expect_true(grepl("malformed", r$detail)) + ## ---- transaction: remove ---------------------------------------------------- p_rm <- prevv("apt.remove", list(txn("nginx", "remove"))) expect_identical(verify(p_rm, reader(inst_df("nginx", "1.2", "amd64",