diff --git a/CLAUDE.md b/CLAUDE.md index f172c67..d64911a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ reviewed increments** — hold at each increment before the next. (`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. -- **Increment 5b (this): wire verification into `.commit_session` step 6** — +- **Increment 5b (merged): wire verification into `.commit_session` step 6** — after commit + classify, on the **success path only** (`is.null(condition)`: an `ok`/`no_op` that will be returned), `.verify_and_capture()` runs `.verify()` and captures `verified`/`verify_detail` onto the returned outcome. **Observational**: @@ -68,12 +68,33 @@ reviewed increments** — hold at each increment before the next. (step 7) and outcome-before-signal holds. A known failure / left-open effect is not verified. The reader stays behind the seam, so `.commit_session` is fully hermetic (fake session-ops + fake pkcheck + fake reader). -- **Still NOT started** (later increments, each its own review): 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 the verdict feeds), 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 6 (draft PR #9, HELD): exported per-verb `apt_()` API** — the + nine `apt_(preview, ...)` commit wrappers with the preview + `{verb,resource,plan_hash}` match check; interactive defaults to + `base::interactive()`. This makes pkgops **mutation-capable**. Held pending the + VM proof (Part B) that the complete public path + durable record shape hold + against a real broker/polkit. +- **VM-gate increment Part A (this): durable audit record grammar** — enrich the + committed outcome with the broker `RECORD_SCHEMA` fields so the exported API + writes a complete record. `.observe()` reads the resolved records' post-state + into the record's `observed` object, keyed by `package:arch` (`{status,version}` + for txn/configure, `{selection}` for hold -- one entry per matched arch, since an + unqualified hold target can span arches); `.freeze_reader()` gives verdict + + observe one shared post-read. `state_changed` is a real pre/post `.observe()` diff + (D7 = S-B), `NA` when either side is unavailable, never inferred from + `effect_issued`; `apt.update` observes nothing, so observed/changed/state_changed + are all omitted. + `.authorized_via()` records `pkexec`/`autonomous`/`pkcheck` at the authorization + site (`.authorize()` now returns `list(decision, via)`). `.outcome_record()` + maps onto the broker's 16-field allow-list (omitting `NA`/`NULL` optionals; + `verified` → `changed` only when the post-state was read), and + `.validate_record()` mirrors the broker guard (rejects non-allow-list field, + reserved key, wrong type). Observation is **success-path only**; the failure-path + `observed` shape stays deferred. Plan: `runix/docs/pkgops-vm-gate-plan.md`. +- **Still NOT started** (later increments, each its own review): **Part B** — the + disposable-VM proof that pins the durable record grammar, the plain-intent + refusal record grammar, and the exact pkcheck rc→outcome split against a real + broker/polkit; then the `rctl apt.*` surface. 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/DESCRIPTION b/DESCRIPTION index 851e757..369a482 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.7 +Version: 0.0.1.8 Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 7697c0e..03853de 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,37 @@ +# pkgops 0.0.1.8 + +Durable audit record grammar (VM-gate increment, Part A). The committed outcome +now carries the fields the broker's `RECORD_SCHEMA` records, so the mutation-capable +public API (held in PR #9) will write a complete, replayable audit record instead +of the earlier minimal placeholder. Hermetic: the positive allow-list is enforced +broker-side and proven in the disposable-VM proof (Part B); this increment mirrors +the guard locally and adds no new dependency. + +* **Observer.** `.observe()` reads the committed preview's resolved records into + the record's `observed` object (the schema's only object field): `{status, + version}` per `package:arch` for transaction/configure, `{selection}` per matched + `package:arch` for hold. Hold records carry no architecture, so an unqualified + target records **one entry per matched architecture** (deterministic radix order) + rather than collapsing to a single row that could hide a change confined to one + arch. `.freeze_reader()` caches the post-commit read so the verdict and the + observed snapshot come from **one** read (no verify/observe TOCTOU). +* **`state_changed` is an observed diff.** A pre-commit `.observe()` snapshot is + compared with the post-commit state; `state_changed` is `NA` (omitted) whenever + either side is unavailable. It is **never** inferred from `effect_issued`, which + means the effect was issued, not that on-disk state moved. `apt.update` reads no + observable post-state, so `observed`, `changed`, and `state_changed` are all + omitted (never a fabricated transition). +* **`authorized_via` provenance.** Decided at the authorization site as `"pkexec"` + / `"autonomous"` / `"pkcheck"` and recorded onto the outcome, never reconstructed + downstream from partial state. +* **Record grammar + guard.** `.outcome_record()` maps the outcome onto the broker + allow-list, omitting `NA`/`NULL` optionals; `verified` becomes `changed` only when + the post-state was actually read (a read failure omits `changed` rather than + asserting a false "did not change"). `.validate_record()` rejects any + non-allow-list field, broker-reserved key, or wrong type before a record is built. +* Observation is **success-path only** (matching the wired-verification scope); the + failure-path `observed` shape stays deferred. + # pkgops 0.0.1.7 Commit lifecycle (slice 3b): **wire verification into `.commit_session`** (§4.3 diff --git a/R/commit.R b/R/commit.R index 2fdca56..fd0208e 100644 --- a/R/commit.R +++ b/R/commit.R @@ -14,30 +14,117 @@ ## default). A caller may override for a test/staging broker. .PKGOPS_BROKER_SOCKET <- "/run/runix-audit.sock" -## The durable outcome record pkgops hands to write_outcome. runix's C wraps it as +## The durable audit record pkgops hands to write_outcome. runix's C wraps it as ## {type:"write_outcome", binding, record} and the broker appends `record` to the -## audit line, filling correlation_id (from the binding), phase, host, pid, actor -## and time itself (durable-audit-contract.md 112). `effect_issued` is the -## broker's gate and is ALWAYS a real boolean here: the effect-unknown path leaves -## the intent open and never reaches write_outcome, so this builder rejects an NA. +## audit line, stamping correlation_id (from the binding), phase, host, pid, actor, +## time and schema_version itself (durable-audit-contract.md 112). ## -## BOUNDARY [REVIEW]: this record is intentionally MINIMAL and carries only the -## audit-schema fields pkgops authoritatively owns before verification -## (`operation`, `resource`, `effect_issued`). The post-state fields -## (`observed`/`changed`/`state_changed`) are the pkgstate-verification -## increment's OUTPUT and are added there; `authorized_via` is the polkit -## increment's. The exact grammar and its alignment with the broker's -## RECORD_SCHEMA / record_schema_version (which rejects unknown fields) is pinned -## in the VM-gated increment, where write_outcome meets a real broker. +## The record's field grammar is pinned to the broker's RECORD_SCHEMA +## (runix-audit-broker/src/json.c): a CLOSED allow-list of 16 domain fields, each +## with a fixed type, and the broker HARD-REJECTS (schema_invalid) any field not on +## the list or any broker-reserved key a client sends. The R adapter only rejects +## the reserved keys locally, so `.validate_record()` below is pkgops's own guard +## that the record it builds conforms -- the real allow-list + type enforcement is +## broker-side and proven in the VM-gated increment. +.PKGOPS_RECORD_FIELDS <- c(operation = "string", outcome = "string", + resource = "string", scope = "string", + audit_scope = "string", authorized_via = "string", + completion_method = "string", + job_result = "string", observed_reason = "string", + preview = "bool", effect_issued = "bool", + changed = "bool", state_changed = "bool", + observed_failed = "bool", elapsed = "number", + observed = "object") + +## The broker-stamped keys a client must NEVER send (audit_broker_sink.R:200-202); +## including one is a runix_broker_reserved_field error at the broker. +.PKGOPS_RECORD_RESERVED <- c("schema_version", "record_type", + "correlation_id", "phase", "host", "pid", + "actor", "time", "binding", "broker") + +## Whether a value matches its allow-list type: a scalar non-NA string/bool/number +## (number >= 0), or a named-list object. +.record_type_ok <- function(value, type) { + switch(type, string = is.character(value) && length(value) == 1L && + !is.na(value), bool = is.logical(value) && length(value) == 1L && + !is.na(value), number = is.numeric(value) && length(value) == 1L && + !is.na(value) && value >= 0, object = is.list(value) && + (length(value) == 0L || !is.null(names(value))), FALSE) +} + +## Assert a built record conforms BEFORE it is sent: fully named, no reserved key, +## every field on the allow-list, every value the right type. A non-conforming +## record is a pkgops bug -> fail closed, never sent (the real broker would reject +## it as schema_invalid; this catches it hermetically). +.validate_record <- function(rec) { + nm <- names(rec) + if (length(rec) > 0L && (is.null(nm) || any(!nzchar(nm)))) { + stop_pkgops("internal: outcome record must be a fully-named list", + class = "pkgops_bad_request") + } + bad <- intersect(nm, .PKGOPS_RECORD_RESERVED) + if (length(bad) > 0L) { + stop_pkgops("internal: outcome record carries a broker-reserved field: ", + bad[1L], class = "pkgops_bad_request") + } + unknown <- setdiff(nm, names(.PKGOPS_RECORD_FIELDS)) + if (length(unknown) > 0L) { + stop_pkgops("internal: outcome record carries a non-allow-list field: ", + unknown[1L], class = "pkgops_bad_request") + } + for (f in nm) { + if (!.record_type_ok(rec[[f]], unname(.PKGOPS_RECORD_FIELDS[f]))) { + stop_pkgops("internal: outcome record field `", f, + "` has the wrong type", class = "pkgops_bad_request") + } + } + rec +} + +## Build the durable record from a closed effect-intent outcome (VM-gate plan 2.2). +## Maps pkgops's outcome fields onto the allow-list: `verified` -> `changed` (but +## unknown, hence omitted, when the post-read failed -- pkgops cannot then claim the +## state did not change), `verify_detail` -> `observed_reason`, the captured +## post-state -> `observed`, plus `authorized_via` / `scope` / `preview` / +## `observed_failed` / `state_changed`. An optional field is OMITTED (never an +## explicit key) when NA/NULL: the broker treats an absent optional field as null, +## and this keeps the record deterministic. `effect_issued` is always a known +## boolean here (the effect-unknown path never reaches write_outcome). +## NB exact `[[` access throughout: `$` partial-matches, and `observed` is a strict +## prefix of `observed_failed`/`observed_reason`, so `outcome$observed` would +## resolve to the wrong field when the `observed` element is absent. .outcome_record <- function(outcome) { - ei <- outcome$effect_issued + ei <- outcome[["effect_issued"]] if (!(length(ei) == 1L && is.logical(ei) && !is.na(ei))) { ## defensive: write_outcome is only reached on a KNOWN-effect close stop_pkgops("internal: outcome record needs a known effect_issued", class = "pkgops_bad_request") } - list(operation = outcome$verb, resource = outcome$resource, - effect_issued = ei) + ## a read failure leaves the functional verdict unknown -> omit `changed`, + ## never write a false "did not change". + changed <- if (isTRUE(outcome[["observed_failed"]])) { + NA + } else { + outcome[["verified"]] + } + add <- function(rec, key, value) { + if (is.null(value) || (is.atomic(value) && length(value) == 1L && + is.na(value))) { + rec + } else { + rec[[key]] <- value + rec + } + } + rec <- list(operation = outcome[["verb"]], resource = outcome[["resource"]], + effect_issued = ei, scope = "system", preview = FALSE) + rec <- add(rec, "authorized_via", outcome[["authorized_via"]]) + rec <- add(rec, "observed", outcome[["observed"]]) + rec <- add(rec, "changed", changed) + rec <- add(rec, "state_changed", outcome[["state_changed"]]) + rec <- add(rec, "observed_reason", outcome[["verify_detail"]]) + rec <- add(rec, "observed_failed", outcome[["observed_failed"]]) + .validate_record(rec) } ## Map a persist failure (write_outcome status other than "ok") to a fail-closed @@ -63,27 +150,34 @@ persist_status = wr$status, detail = detail)) } -## step 6 (contract 4.7): cross-check the committed preview's resolved records -## against native ground truth and CAPTURE the verdict into the outcome. This is -## OBSERVATIONAL -- it never changes the close/open decision and never raises: a -## disagreeing post-state is `verified = FALSE` + a detail on the outcome, not a -## signal. So the outcome is still written (step 7) and outcome-before-signal -## holds. .verify() reads only the plan (preview) and the ground truth, never the -## helper's self-report (4.7), and by the time this runs the commit has applied, -## so the reader observes the POST-state. +## step 6 (contract 4.7 / VM-gate plan 2.2-2.4): cross-check the committed preview's +## resolved records against native ground truth and CAPTURE onto the outcome the +## verdict (verified/verify_detail), the observed POST-state, the post-read-failure +## flag, and the pre/post diff (state_changed, from the pre-commit `before` +## snapshot). OBSERVATIONAL -- it never changes the close/open decision and never +## raises: a disagreeing post-state is verified = FALSE + a detail, not a signal, so +## the outcome is still written (step 7) and outcome-before-signal holds. .verify() +## reads only the plan and the ground truth, never the helper's self-report (4.7); +## by the time this runs the commit has applied, so the reader sees the POST-state. ## -## .verify() already normalizes malformed reader/record data to verified = FALSE +## Both .verify() and .observe() already normalize malformed reader/record data ## without raising; the extra tryCatch is belt-and-suspenders so a residual error -## can never abort before write_outcome. The durable-record post-state fields -## (observed/changed) that carry this verdict to the broker are the VM-gated -## increment's; here the verdict lands on the returned outcome object only. -.verify_and_capture <- function(outcome, preview) { - v <- tryCatch(.verify(preview), error = function(e) { +## can never abort before write_outcome. +.capture_post <- function(outcome, preview, before) { + ## the verdict and the observed post-state derive from ONE cached read, so they + ## describe the same snapshot (no double dpkg read, no post-lock TOCTOU). + post_reader <- .freeze_reader(pkgstate_reader()) + v <- tryCatch(.verify(preview, post_reader), error = function(e) { list(verified = FALSE, detail = paste0("verification error: ", conditionMessage(e))) }) + after <- tryCatch(.observe(preview, post_reader), + error = function(e) list(state = NULL, read_failed = TRUE)) outcome$verified <- v$verified outcome$verify_detail <- v$detail + outcome$observed <- after$state + outcome$observed_failed <- isTRUE(after$read_failed) + outcome$state_changed <- .state_changed(before, after) outcome } @@ -239,9 +333,9 @@ ## effect intent: it records a PLAIN intent + terminal outcome and stops, so no ## unused effect receipt is minted. A failed check fails closed with nothing ## opened. - decision <- .authorize(verb_spec, interactive) - if (!identical(decision, "authorized")) { - return(.refuse(ops, socket_path, preview, decision)) + dec <- .authorize(verb_spec, interactive) + if (!identical(dec$decision, "authorized")) { + return(.refuse(ops, socket_path, preview, dec$decision)) } ## step 3 -- open the effect-required intent. The receipt + outcome binding @@ -253,22 +347,35 @@ plan_schema = preview$plan_schema, plan_hash = preview$plan_hash) + ## step 3.5 -- PRE-COMMIT snapshot (D7 = S-B): read the resolved records' state + ## BEFORE the commit applies, so state_changed can be a real observed diff and + ## is never inferred from effect_issued. On the same reader seam as verification + ## (hermetic); never raises (a pre-read failure -> state_changed NA later). + before <- .observe(preview) + ## From here the intent is OPEN and every path must attempt to close it before ## returning or signaling. steps 4-5 (commit + classify) yield an ## (outcome, condition, leave_open) even if the commit itself raised. decided <- .commit_and_classify(ops, session, preview, lock_timeout, deadline_ms) - ## step 6 -- pkgstate VERIFICATION (contract 4.7). Only on the success path - ## (is.null(condition): an ok/no_op that will be RETURNED, not signaled): - ## cross-check the committed preview against native ground truth and capture - ## the verdict onto the outcome. A known failure already carries its own - ## condition and a left-open effect is unknown, so neither has a trustworthy - ## post-state to check. This is observational -- never raises, never changes - ## close/open -- so the outcome is still written below and the - ## outcome-before-signal order holds. + ## authorization provenance is known independent of success/failure (the intent + ## was authorized before it opened), so it is recorded on any classified outcome. + ## effect-unknown (leave_open, no outcome to close) needs no record. + if (!is.null(decided$outcome)) { + decided$outcome$authorized_via <- dec$via + } + + ## step 6 -- pkgstate VERIFICATION + post-state capture (contract 4.7 / VM-gate + ## plan 2.2-2.4). Only on the success path (is.null(condition): an ok/no_op that + ## will be RETURNED, not signaled): cross-check the committed preview against + ## native ground truth and capture the verdict + the observed post-state + the + ## pre/post diff onto the outcome. A known failure carries its own condition and + ## a left-open effect is unknown, so neither has a trustworthy post-state. This + ## is observational -- never raises, never changes close/open -- so the outcome + ## is still written below and the outcome-before-signal order holds. if (is.null(decided$condition)) { - decided$outcome <- .verify_and_capture(decided$outcome, preview) + decided$outcome <- .capture_post(decided$outcome, preview, before) } ## step 7 -- close the durable intent, UNLESS the effect is genuinely unknown diff --git a/R/outcome.R b/R/outcome.R index 586fb85..201c056 100644 --- a/R/outcome.R +++ b/R/outcome.R @@ -145,18 +145,33 @@ ## object never carries an unmapped or unknown status. `effect_issued` is the ## helper's tri-state (untrusted -> normalized); `verified` is pkgops's own ## tri-state verdict (enforced, not normalized); `condition` is the runix -## condition object for a non-success outcome, or NULL. `verified`/`verify_detail` -## are NA in this increment -- pkgstate verification lands in a later one. +## condition object for a non-success outcome, or NULL. +## +## The durable post-state fields feed the audit record the VM-gate increment writes +## (VM-gate plan 2.2): `authorized_via` (the polkit provenance, 2.5), `observed` +## (the post-state pkgstate read, a named object or NULL, 2.4), `observed_failed` +## (the post-read threw), and `state_changed` (the observed pre/post diff, D7=S-B). +## All default to the "not captured" value so the classifier's callers are +## unchanged; the commit lifecycle sets them (authorized_via always, the rest on the +## success path). `observed` NULL is dropped from the list -- `$observed` still +## reads back NULL. new_pkgops_outcome <- function(correlation_id, verb, resource, plan_hash, status, effect_issued = NA, verified = NA, verify_detail = NA_character_, - condition = NULL) { + condition = NULL, + authorized_via = NA_character_, + observed = NULL, observed_failed = NA, + state_changed = NA) { structure(list(schema_version = 1L, correlation_id = correlation_id, verb = verb, resource = resource, plan_hash = plan_hash, status = .status_condition(status), effect_issued = .norm_effect_issued(effect_issued), verified = .check_tristate(verified, "verified"), - verify_detail = verify_detail, condition = condition), + verify_detail = verify_detail, condition = condition, + authorized_via = authorized_via, observed = observed, + observed_failed = .check_tristate(observed_failed, + "observed_failed"), + state_changed = .check_tristate(state_changed, "state_changed")), class = "pkgops_outcome") } diff --git a/R/polkit.R b/R/polkit.R index d96eb59..fcfb735 100644 --- a/R/polkit.R +++ b/R/polkit.R @@ -108,19 +108,48 @@ set_pkcheck <- .pkgops_pkcheck$set_pkcheck "3" = "approval_required", "check_failed") } -## Decide whether a commit may proceed, per contract 4.4. In INTERACTIVE mode the -## check is deferred to the pkexec prompt at the entrypoint spawn, so this returns -## "authorized" WITHOUT running pkcheck (a cancelled prompt becomes a known-false -## unauthorized outcome on the effect intent later). In MACHINE mode it runs the -## non-interactive pkcheck for the verb's action and maps the result. The +## The `authorized_via` provenance carried into the durable record (section 2.5 of +## the VM-gate plan). It is DECIDED here, where the authorization is decided, from state +## that is complete at this point -- never reconstructed downstream from partial +## state. "pkexec": the interactive prompt authenticates at the privileged spawn. +## "autonomous": a machine-mode grant (rc 0) of an autonomous-class verb +## (apt.update/apt.hold) -- it identifies the authorization CLASS, not which polkit +## rule fired, since pkcheck does not reveal the rule (the runix-apt-autonomous rule +## is the only non-interactive grant for those verbs). "pkcheck": a machine-mode +## grant of a normally-admin verb. +.authorized_via <- function(verb_spec, interactive) { + if (isTRUE(interactive)) { + "pkexec" + } else if (isTRUE(verb_spec$autonomous)) { + "autonomous" + } else { + "pkcheck" + } +} + +## Decide whether a commit may proceed, per contract 4.4, returning +## list(decision, via): `decision` is the machine-mode decision vocabulary +## (.POLKIT_DECISIONS); `via` is the authorized_via provenance, set on an +## authorized decision and NA on a refusal (nothing was authorized). In INTERACTIVE +## mode the check is deferred to the pkexec prompt at the entrypoint spawn, so this +## returns "authorized" WITHOUT running pkcheck (a cancelled prompt becomes a +## known-false unauthorized outcome on the effect intent later). In MACHINE mode it +## runs the non-interactive pkcheck for the verb's action and maps the result. The ## autonomous verbs (apt.update/apt.hold) need no special-casing: the -## runix-apt-autonomous polkit rule grants members rc 0 through the SAME check, -## and a non-member falls through to a refusal like any other verb. +## runix-apt-autonomous polkit rule grants members rc 0 through the SAME check, and +## a non-member falls through to a refusal like any other verb. .authorize <- function(verb_spec, interactive) { if (isTRUE(interactive)) { - return("authorized") + return(list(decision = "authorized", + via = .authorized_via(verb_spec, interactive = TRUE))) } action <- .polkit_action(verb_spec$request_verb) rc <- pkcheck_fn()(action) - .pkcheck_decision(rc) + decision <- .pkcheck_decision(rc) + via <- if (identical(decision, "authorized")) { + .authorized_via(verb_spec, interactive = FALSE) + } else { + NA_character_ + } + list(decision = decision, via = via) } diff --git a/R/verify.R b/R/verify.R index 67a6bd9..65cee5b 100644 --- a/R/verify.R +++ b/R/verify.R @@ -1,12 +1,15 @@ -## 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). +## pkgstate verification (contract 4.7 / 6.3) + the observed post-state for the +## durable record (VM-gate plan 2.4). .verify() reads native dpkg/apt ground truth +## and checks every RESOLVED RECORD of the committed preview against its planned +## post-state, 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). +## .observe() reads the same records' state into the record's `observed` object; +## .commit_session snapshots it pre- and post-commit so `state_changed` is a real +## observed diff (D7 = S-B), never inferred from effect_issued. 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. .commit_session step 6 CAPTURES the verdict + observed +## 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 @@ -66,6 +69,28 @@ selections_default <- function(packages = NULL) { pkgstate_reader <- .pkgops_pkgstate_reader$pkgstate_reader set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader +## Wrap a reader so its underlying installed()/selections() is read AT MOST ONCE +## and cached. The commit lifecycle's post-commit verdict (.verify) and observed +## post-state (.observe) then derive from the SAME snapshot -- one dpkg read, and no +## TOCTOU window (the dpkg lock is released after the commit, so two live reads +## could disagree and make `changed` and `observed` describe different states). +## selections() caches on first call; the two callers derive the same package set +## from the same preview, so the ignored later `packages` arg is not a hazard. +.freeze_reader <- function(reader) { + cache <- new.env(parent = emptyenv()) + list(installed = function() { + if (is.null(cache$inst)) { + cache$inst <- reader$installed() + } + cache$inst + }, selections = function(packages = NULL) { + if (is.null(cache$sel)) { + cache$sel <- reader$selections(packages) + } + cache$sel + }) +} + ## 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. @@ -307,3 +332,119 @@ set_pkgstate_reader <- .pkgops_pkgstate_reader$set_pkgstate_reader detail = paste0("verification error: ", conditionMessage(e))) }) } + +## Read the OBSERVED post-state of a preview's resolved records into the durable +## record's `observed` object (VM-gate plan 2.4), independent of the verdict. Used +## twice by the commit lifecycle -- a pre-commit snapshot and the post-commit read +## -- so `state_changed` is a real before/after diff (D7 = S-B), never inferred from +## effect_issued. Returns list(state, read_failed): `state` is a named list keyed by +## `package:arch` (one entry per resolved record), or NULL when there is nothing to +## observe (update / no records); `read_failed` is TRUE when the pkgstate read threw +## (the belt catches it, so .observe never raises). `state` uses only the same +## reader seam as .verify, so the lifecycle stays hermetic. +.observe <- function(preview, reader = pkgstate_reader()) { + fam <- .verify_family(preview$verb) + if (is.null(fam) || identical(fam, "update")) { + return(list(state = NULL, read_failed = FALSE)) + } + records <- preview$records + if (!is.list(records) || length(records) == 0L) { + return(list(state = NULL, read_failed = FALSE)) + } + tryCatch(if (identical(fam, "hold")) { + .observe_hold(records, reader) + } else { + .observe_installed(records, reader) + }, error = function(e) list(state = NULL, read_failed = TRUE)) +} + +## The observed installed state (txn + configure): {status, version} per record, +## keyed by `package:arch`. An absent package reads back as not-installed/"" so the +## key is still present (documenting what was checked). A malformed record (no +## package/arch) is skipped -- .verify already fails it; the observation only +## records what it can read. +.observe_installed <- function(records, reader) { + inst <- reader$installed() + .require_cols(inst, c("package", "version", "architecture", "status"), + "installed") + state <- list() + for (rec in records) { + pkg <- .rec_str(rec, "package") + arch <- .rec_str(rec, "architecture") + if (is.na(pkg) || is.na(arch)) { + next + } + key <- paste0(pkg, ":", arch) + row <- inst[!is.na(inst$package) & inst$package == pkg & + !is.na(inst$architecture) & + inst$architecture == arch,, drop = FALSE] + if (nrow(row) > 0L) { + state[[key]] <- list(status = as.character(row$status[1L]), + version = as.character(row$version[1L])) + } else { + state[[key]] <- list(status = "not-installed", version = "") + } + } + list(state = if (length(state) > 0L) state else NULL, read_failed = FALSE) +} + +## The observed selection state (hold/unhold): {selection} per matched row, keyed by +## `package:arch`. A hold record carries no architecture, so an UNQUALIFIED target can +## match several architecture rows (e.g. pkg:amd64 + pkg:i386). Each matched row is +## recorded under its own `package:arch` key, ordered deterministically (radix on +## architecture, locale-independent for a stable pre/post diff) -- collapsing to one +## row would hide a change confined to a second architecture, making `state_changed` +## read FALSE while .verify_hold (which checks EVERY row) correctly fails. A target +## that matches no row is documented under its identity (`package:arch` if it named an +## arch, else the bare `package`) with an NA selection. +.observe_hold <- function(records, reader) { + parsed <- lapply(records, function(rec) { + .split_pkg_arch(.rec_str(rec, "package")) + }) + 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") + state <- list() + for (p in parsed) { + if (is.na(p$package)) { + next + } + 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] + if (nrow(row) == 0L) { + key <- if (is.na(p$architecture)) { + p$package + } else { + paste0(p$package, ":", p$architecture) + } + state[[key]] <- list(selection = NA_character_) + next + } + ord <- order(as.character(row$architecture), method = "radix") + for (i in ord) { + key <- paste0(p$package, ":", as.character(row$architecture[i])) + state[[key]] <- list(selection = as.character(row$selection[i])) + } + } + list(state = if (length(state) > 0L) state else NULL, read_failed = FALSE) +} + +## The observed pre/post diff for `state_changed` (D7 = S-B): TRUE iff the observed +## state actually differed between the pre-commit and post-commit snapshots, FALSE +## iff it did not, and NA (JSON null) whenever EITHER snapshot is unavailable -- a +## read failure on either side, or nothing observable (update). NEVER derived from +## effect_issued: an issued effect (or an operation_failed / dpkg_broken that began) +## need not have changed the observed state. +.state_changed <- function(before, after) { + if (isTRUE(before$read_failed) || isTRUE(after$read_failed)) { + return(NA) + } + if (is.null(before$state) || is.null(after$state)) { + return(NA) + } + !identical(before$state, after$state) +} diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 5b75020..e25063a 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -410,7 +410,9 @@ expect_inherits(r, "pkgops_outcome") expect_identical(r$verified, TRUE) expect_true(is.na(r$verify_detail)) expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome")) -expect_equal(cr_ok$n, 1L) +## the reader is read twice on a success txn: the pre-commit snapshot +## (state_changed), and ONE cached post-read shared by the verdict + observation +expect_equal(cr_ok$n, 2L) ## success + a DISAGREEING post-state -> verified FALSE + a detail, but the ## outcome is STILL returned (not a raised condition) and STILL written: a failed @@ -455,7 +457,9 @@ lg <- newlog() r <- run_commit(ops_for(lg, cr("ok", "operation_failed", TRUE)), preview = prev1, reader = cr_fail$reader) expect_inherits(r, "runix_operation_failed") -expect_equal(cr_fail$n, 0L) # verification skipped +## only the pre-commit snapshot reads on a failure path; the verdict + post-state +## observation are success-path only, so no post-commit read happens +expect_equal(cr_fail$n, 1L) expect_true("write_outcome" %in% lg$seq) # known close still written ## a LEFT-OPEN effect_unknown is not verified either (the effect is unknown) and @@ -465,7 +469,7 @@ lg <- newlog() r <- run_commit(ops_for(lg, cr("effect_unknown", effect_issued = NA)), preview = prev1, reader = cr_open$reader) expect_inherits(r, "runix_helper_bad_result") -expect_equal(cr_open$n, 0L) +expect_equal(cr_open$n, 1L) # only the pre-commit snapshot expect_false("write_outcome" %in% lg$seq) ## verification is INDEPENDENT of the helper status (4.7): a clean `ok` whose @@ -476,3 +480,94 @@ r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, reader = cr_lie$reader) expect_identical(r$verified, FALSE) # helper said ok; the host disagrees expect_true(grepl("half-configured", r$verify_detail)) + +## ============================================================================ +## Part A -- the durable record the commit writes (observed/changed/state_changed/ +## authorized_via), and state_changed from a real pre/post diff. +## ============================================================================ + +## a matching success commit: the record carries the full grammar -------------- +cr_match <- counting_reader(status = "installed", version = "1.2") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = cr_match$reader) +expect_inherits(r, "pkgops_outcome") +rec <- lg$record +expect_equal(rec$operation, "apt.install") +expect_equal(rec$scope, "system") +expect_identical(rec$preview, FALSE) +expect_equal(rec$authorized_via, "pkcheck") # machine, non-autonomous +expect_identical(rec$changed, TRUE) # verified matched +expect_identical(rec$observed_failed, FALSE) +expect_equal(rec$observed, list("nginx:amd64" = list(status = "installed", + version = "1.2"))) +expect_false(any(names(rec) %in% pkgops:::.PKGOPS_RECORD_RESERVED)) +expect_true(all(names(rec) %in% names(pkgops:::.PKGOPS_RECORD_FIELDS))) + +## a DISAGREEING post-state: changed=FALSE + observed_reason, still written ------ +cr_dis <- counting_reader(status = "installed", version = "1.1") +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = cr_dis$reader) +expect_identical(lg$record$changed, FALSE) +expect_true(grepl("version 1.1", lg$record$observed_reason)) + +## state_changed from a REAL pre/post diff: a stateful reader returns the +## pre-state on the first read and the post-state after, so the pre-commit +## snapshot differs from the post-state -> state_changed TRUE. +stateful_reader <- function(pre, post) { + e <- new.env(parent = emptyenv()) + e$n <- 0L + list(installed = function() { + e$n <- e$n + 1L + if (e$n == 1L) pre else post + }, selections = empty_sel) +} +absent <- data.frame(package = character(), version = character(), + architecture = character(), status = character(), + stringsAsFactors = FALSE) +installed <- data.frame(package = "nginx", version = "1.2", + architecture = "amd64", status = "installed", + stringsAsFactors = FALSE) +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = stateful_reader(absent, installed)) +expect_identical(lg$record$state_changed, TRUE) # absent -> installed +expect_identical(lg$record$changed, TRUE) # verified against post + +## no on-disk change (pre == post) -> state_changed FALSE ----------------------- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = stateful_reader(installed, installed)) +expect_identical(lg$record$state_changed, FALSE) + +## a read failure -> observed_failed TRUE, changed + observed OMITTED ----------- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = boom_reader) +expect_identical(lg$record$observed_failed, TRUE) +expect_false("changed" %in% names(lg$record)) +expect_false("observed" %in% names(lg$record)) + +## authorized_via reflects the mode: interactive -> pkexec ---------------------- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), preview = prev1, + reader = counting_reader()$reader, interactive = TRUE) +expect_equal(lg$record$authorized_via, "pkexec") + +## an autonomous verb authorized in machine mode -> authorized_via = autonomous -- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), + preview = mkprev(verb = "apt.update", resource = "@indexes", + packages = character(0)), + reader = counting_reader()$reader, + pkcheck = function(a) if (grepl("update$", a)) 0L else 1L) +expect_equal(lg$record$authorized_via, "autonomous") + +## a known FAILURE record still carries authorized_via (the intent was authorized) +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "operation_failed", TRUE)), preview = prev1, + reader = counting_reader()$reader) +expect_inherits(r, "runix_operation_failed") +expect_equal(lg$record$authorized_via, "pkcheck") # authorized before it failed +expect_false("changed" %in% names(lg$record)) # not verified on a failure diff --git a/inst/tinytest/test_polkit.R b/inst/tinytest/test_polkit.R index ebf7f94..c3a45c9 100644 --- a/inst/tinytest/test_polkit.R +++ b/inst/tinytest/test_polkit.R @@ -53,14 +53,19 @@ run_authorize <- function(pkfn, verb_spec, interactive) { authorize(verb_spec, interactive) } -## interactive mode defers to the pkexec prompt: pkcheck is NOT run ------------ +## .authorize returns list(decision, via): `via` is the authorized_via provenance +## on an authorized decision, NA on a refusal (nothing authorized). + +## interactive mode defers to the pkexec prompt: pkcheck is NOT run; via = pkexec - calls <- new.env(parent = emptyenv()) calls$n <- 0L never <- function(action) { calls$n <- calls$n + 1L 0L } -expect_equal(run_authorize(never, verbs$install, TRUE), "authorized") +r <- run_authorize(never, verbs$install, TRUE) +expect_equal(r$decision, "authorized") +expect_equal(r$via, "pkexec") expect_equal(calls$n, 0L) # never queried polkit ## machine mode runs pkcheck for the verb's action and maps the result -------- @@ -71,22 +76,40 @@ mk <- function(rc) { rc } } -expect_equal(run_authorize(mk(0L), verbs$install, FALSE), "authorized") +## a non-autonomous verb authorized in machine mode -> via = pkcheck +r <- run_authorize(mk(0L), verbs$install, FALSE) +expect_equal(r$decision, "authorized") +expect_equal(r$via, "pkcheck") expect_equal(seen$action, "ai.cornball.runix.apt.install") # verb -> action -expect_equal(run_authorize(mk(1L), verbs$remove, FALSE), "unauthorized") +## refusals carry decision + via = NA (nothing was authorized) +r <- run_authorize(mk(1L), verbs$remove, FALSE) +expect_equal(r$decision, "unauthorized") +expect_true(is.na(r$via)) expect_equal(seen$action, "ai.cornball.runix.apt.remove") -expect_equal(run_authorize(mk(2L), verbs$purge, FALSE), "approval_required") -expect_equal(run_authorize(mk(127L), verbs$install, FALSE), "check_failed") +r <- run_authorize(mk(2L), verbs$purge, FALSE) +expect_equal(r$decision, "approval_required") +expect_true(is.na(r$via)) +r <- run_authorize(mk(127L), verbs$install, FALSE) +expect_equal(r$decision, "check_failed") +expect_true(is.na(r$via)) ## autonomous needs no special-casing: the SAME check, the rule grants members - ## a fake standing in for "member of runix-apt-autonomous" (update/hold -> rc 0). +## An authorized autonomous-class verb records via = autonomous. autofn <- function(action) { if (grepl("\\.(update|hold)$", action)) 0L else 1L } -expect_equal(run_authorize(autofn, verbs$update, FALSE), "authorized") -expect_equal(run_authorize(autofn, verbs$hold, FALSE), "authorized") -expect_equal(run_authorize(autofn, verbs$unhold, FALSE), "unauthorized") # not autonomous -expect_equal(run_authorize(autofn, verbs$install, FALSE), "unauthorized") +r <- run_authorize(autofn, verbs$update, FALSE) +expect_equal(r$decision, "authorized") +expect_equal(r$via, "autonomous") +r <- run_authorize(autofn, verbs$hold, FALSE) +expect_equal(r$decision, "authorized") +expect_equal(r$via, "autonomous") +r <- run_authorize(autofn, verbs$unhold, FALSE) +expect_equal(r$decision, "unauthorized") # not autonomous +expect_true(is.na(r$via)) +r <- run_authorize(autofn, verbs$install, FALSE) +expect_equal(r$decision, "unauthorized") ## ---- the subject builder is pid,start-time,uid (real /proc on Linux) -------- if (file.exists("/proc/self/stat")) { diff --git a/inst/tinytest/test_record.R b/inst/tinytest/test_record.R new file mode 100644 index 0000000..c334d75 --- /dev/null +++ b/inst/tinytest/test_record.R @@ -0,0 +1,137 @@ +# The durable audit record grammar (R/commit.R): .outcome_record() maps a closed +# effect-intent outcome onto the broker's RECORD_SCHEMA allow-list, and +# .validate_record() guards that the built record conforms (only allow-list fields, +# correct types, no broker-reserved key) before it is ever sent. Hermetic: the real +# allow-list enforcement is broker-side, proven in the VM-gated increment. + +rec_of <- pkgops:::.outcome_record +validate <- pkgops:::.validate_record +FIELDS <- pkgops:::.PKGOPS_RECORD_FIELDS +RESERVED <- pkgops:::.PKGOPS_RECORD_RESERVED + +## An outcome with the post-state fields set (as .capture_post would). +mkout <- function(verb = "apt.install", resource = "nginx", status = "ok", + effect_issued = TRUE, verified = NA, verify_detail = NA_character_, + authorized_via = "pkcheck", observed = NULL, + observed_failed = NA, state_changed = NA) { + o <- pkgops:::new_pkgops_outcome(correlation_id = "c", verb = verb, + resource = resource, plan_hash = "h", + status = status, effect_issued = effect_issued, + verified = verified, + verify_detail = verify_detail) + o$authorized_via <- authorized_via + o$observed <- observed + o$observed_failed <- observed_failed + o$state_changed <- state_changed + o +} + +## ---- every built record conforms to the allow-list --------------------------- +r <- rec_of(mkout()) +expect_true(all(names(r) %in% names(FIELDS))) # only allow-list fields +expect_false(any(names(r) %in% RESERVED)) # never a reserved key +expect_equal(r$operation, "apt.install") +expect_equal(r$resource, "nginx") +expect_identical(r$effect_issued, TRUE) +expect_equal(r$scope, "system") # apt is system scope +expect_identical(r$preview, FALSE) # a commit, not a preview +expect_equal(r$authorized_via, "pkcheck") + +## ---- verified -> changed (only when the post-state was read) ------------------ +# a matched verification -> changed TRUE +expect_identical(rec_of(mkout(verified = TRUE, observed_failed = FALSE))$changed, + TRUE) +# a mismatch (read OK, disagreed) -> changed FALSE + observed_reason +r <- rec_of(mkout(verified = FALSE, observed_failed = FALSE, + verify_detail = "version 1.1, expected 1.2")) +expect_identical(r$changed, FALSE) +expect_equal(r$observed_reason, "version 1.1, expected 1.2") +expect_identical(r$observed_failed, FALSE) +# a READ FAILURE -> changed is OMITTED (unknown, never a false "did not change") +r <- rec_of(mkout(verified = FALSE, observed_failed = TRUE, + verify_detail = "verification error: dpkg exploded")) +expect_false("changed" %in% names(r)) +expect_identical(r$observed_failed, TRUE) +expect_equal(r$observed_reason, "verification error: dpkg exploded") +# verified NA (no post-state, e.g. update) -> changed omitted +expect_false("changed" %in% names(rec_of(mkout(verified = NA)))) + +## ---- observed carries the per-package post-state object ---------------------- +obs <- list("nginx:amd64" = list(status = "installed", version = "1.2")) +r <- rec_of(mkout(verified = TRUE, observed_failed = FALSE, observed = obs)) +expect_equal(r$observed, obs) # the object, verbatim +expect_true(is.list(r$observed) && !is.null(names(r$observed))) + +## ---- state_changed is a scalar boolean or omitted ---------------------------- +expect_identical(rec_of(mkout(state_changed = TRUE))$state_changed, TRUE) +expect_identical(rec_of(mkout(state_changed = FALSE))$state_changed, FALSE) +expect_false("state_changed" %in% names(rec_of(mkout(state_changed = NA)))) + +## ---- update: no post-state read -> observed/changed/state_changed all OMITTED - +# apt.update reads no package post-state and no pre/post index state, so .observe +# returns NULL (read_failed FALSE) and .state_changed returns NA. The record must +# then carry NONE of the three -- never a fabricated "did change" (blocker 2). +r <- rec_of(mkout(verb = "apt.update", resource = "*", verified = NA, + observed = NULL, observed_failed = FALSE, state_changed = NA)) +expect_false("observed" %in% names(r)) +expect_false("changed" %in% names(r)) +expect_false("state_changed" %in% names(r)) +expect_identical(r$observed_failed, FALSE) # a real bool (no read failed); never NA here +expect_equal(r$operation, "apt.update") + +## ---- NA/NULL optional fields are OMITTED, not sent as null ------------------- +# a bare known-failure-style outcome: no verification captured +r <- rec_of(mkout(verified = NA, authorized_via = "pkexec", observed = NULL, + observed_failed = NA, state_changed = NA, + verify_detail = NA_character_)) +expect_equal(sort(names(r)), + sort(c("operation", "resource", "effect_issued", "scope", "preview", + "authorized_via"))) +expect_equal(r$authorized_via, "pkexec") + +## ---- effect_issued must be a known boolean (never NA at write time) ---------- +expect_error(rec_of(mkout(effect_issued = NA)), class = "pkgops_bad_request") + +## ============================================================================ +## .validate_record: the schema guard +## ============================================================================ + +## a minimal valid record passes +expect_silent(validate(list(operation = "apt.install", resource = "nginx", + effect_issued = TRUE, scope = "system", + preview = FALSE))) + +## a broker-RESERVED key is rejected +for (k in RESERVED) { + bad <- list(operation = "apt.install", resource = "nginx", + effect_issued = TRUE) + bad[[k]] <- "x" + expect_error(validate(bad), class = "pkgops_bad_request") +} + +## a NON-allow-list field is rejected (the smuggled-field case) +expect_error(validate(list(operation = "apt.install", resource = "nginx", + effect_issued = TRUE, verified = TRUE)), + class = "pkgops_bad_request") # `verified` is NOT a broker field +expect_error(validate(list(operation = "apt.install", request_id = "42")), + class = "pkgops_bad_request") + +## wrong TYPES are rejected (the broker validates T_BOOL / T_OBJECT / ...) +expect_error(validate(list(changed = "yes")), class = "pkgops_bad_request") # bool +expect_error(validate(list(observed = "nginx")), class = "pkgops_bad_request") # object +expect_error(validate(list(state_changed = list(a = 1))), + class = "pkgops_bad_request") # bool +expect_error(validate(list(elapsed = -1)), class = "pkgops_bad_request") # >= 0 +expect_error(validate(list(effect_issued = NA)), class = "pkgops_bad_request") # non-NA + +## a correct object / bool / number pass +expect_silent(validate(list(observed = list("nginx:amd64" = + list(status = "installed"))))) +expect_silent(validate(list(changed = TRUE, state_changed = FALSE, + observed_failed = FALSE, elapsed = 0))) + +## ---- the allow-list is exactly the broker's 16 domain fields ----------------- +expect_equal(length(FIELDS), 16L) +expect_true(all(c("observed", "changed", "state_changed", "observed_failed", + "observed_reason", "authorized_via", "operation", "resource", + "effect_issued", "scope", "preview") %in% names(FIELDS))) diff --git a/inst/tinytest/test_verify.R b/inst/tinytest/test_verify.R index b51ffe6..01df42d 100644 --- a/inst/tinytest/test_verify.R +++ b/inst/tinytest/test_verify.R @@ -272,3 +272,93 @@ for (h in hostile) { 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) + +## ============================================================================ +## .observe (R/verify.R): the observed post-state object for the durable record, +## and .state_changed: the pre/post diff (D7 = S-B). +## ============================================================================ +observe <- pkgops:::.observe +state_changed <- pkgops:::.state_changed + +## ---- transaction: {status, version} keyed by package:arch -------------------- +o <- observe(p_inst, reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_false(o$read_failed) +expect_equal(o$state, list("nginx:amd64" = list(status = "installed", + version = "1.2"))) +## an absent package reads back not-installed/"" (the key is still present) +o <- observe(p_inst, reader(inst_df())) +expect_equal(o$state, list("nginx:amd64" = list(status = "not-installed", + version = ""))) +## multi-record: one entry per resolved record, each to its own arch row +p2 <- prevv("apt.install", list(txn("nginx", "install", "1.2", "amd64"), + txn("nginx", "install", "1.0", "i386"))) +o <- observe(p2, reader(inst_df(c("nginx", "nginx"), c("1.2", "1.0"), + c("amd64", "i386"), c("installed", "installed")))) +expect_equal(names(o$state), c("nginx:amd64", "nginx:i386")) +expect_equal(o$state[["nginx:i386"]], list(status = "installed", version = "1.0")) + +## ---- hold: {selection} keyed by package:arch (one entry per matched row) ------ +## an unqualified target matching one arch row keys by that arch (package:arch), +## consistent with the installed observations and the plan's qualified identity. +o <- observe(prevv("apt.hold", list(hld("nginx", "install", "hold"))), + reader(selections = sel_df("nginx", "amd64", "hold"))) +expect_equal(o$state, list("nginx:amd64" = list(selection = "hold"))) +o <- observe(prevv("apt.hold", list(hld("nginx:amd64", "install", "hold"))), + reader(selections = sel_df("nginx", "amd64", "hold"))) +expect_equal(o$state, list("nginx:amd64" = list(selection = "hold"))) + +## an UNQUALIFIED target matching MULTIPLE arches records EVERY arch, keyed by +## package:arch in a deterministic radix order (input i386-first -> output +## amd64-first), so no architecture is silently dropped. +h_un <- prevv("apt.hold", list(hld("nginx", "install", "hold"))) +o <- observe(h_un, reader(selections = sel_df(c("nginx", "nginx"), + c("i386", "amd64"), + c("hold", "hold")))) +expect_equal(names(o$state), c("nginx:amd64", "nginx:i386")) +expect_equal(o$state[["nginx:i386"]], list(selection = "hold")) + +## REGRESSION (blocker 1): only the SECOND architecture's selection changes. A +## single collapsed row missed it (before == after -> state_changed FALSE); one +## entry per arch makes the pre/post diff correctly TRUE. +before <- observe(h_un, reader(selections = sel_df(c("nginx", "nginx"), + c("amd64", "i386"), + c("install", "install")))) +after <- observe(h_un, reader(selections = sel_df(c("nginx", "nginx"), + c("amd64", "i386"), + c("install", "hold")))) +expect_equal(names(before$state), c("nginx:amd64", "nginx:i386")) +expect_false(identical(before$state, after$state)) +expect_true(state_changed(before, after)) + +## a target that matches NO selection row is documented under its identity (bare +## package when unqualified, package:arch when it named an arch) with NA selection. +o <- observe(prevv("apt.hold", list(hld("nginx", "install", "hold"))), reader()) +expect_equal(o$state, list("nginx" = list(selection = NA_character_))) +o <- observe(prevv("apt.hold", list(hld("nginx:i386", "install", "hold"))), + reader(selections = sel_df("nginx", "amd64", "hold"))) +expect_equal(o$state, list("nginx:i386" = list(selection = NA_character_))) + +## ---- update / no records: nothing observable (state NULL, not a read failure) - +o <- observe(prevv("apt.update", list()), reader()) +expect_null(o$state) +expect_false(o$read_failed) +o <- observe(prevv("apt.install", list()), reader()) +expect_null(o$state) +expect_false(o$read_failed) + +## ---- a reader that throws -> read_failed TRUE, state NULL, never raises ------- +boom <- list(installed = function() stop("dpkg exploded"), + selections = function(packages = NULL) sel_df()) +o <- observe(p_inst, boom) +expect_true(o$read_failed) +expect_null(o$state) + +## ---- .state_changed: the observed pre/post diff ------------------------------ +before <- observe(p_inst, reader(inst_df())) # absent +after <- observe(p_inst, reader(inst_df("nginx", "1.2", "amd64", "installed"))) +expect_true(state_changed(before, after)) # absent -> installed: changed +expect_false(state_changed(after, after)) # identical: no change +## either snapshot unavailable -> NA (never inferred) +expect_true(is.na(state_changed(list(state = NULL, read_failed = FALSE), after))) +expect_true(is.na(state_changed(list(state = NULL, read_failed = TRUE), after))) +expect_true(is.na(state_changed(before, list(state = NULL, read_failed = TRUE))))