diff --git a/CLAUDE.md b/CLAUDE.md index 9c06c2c..4b6a7c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,42 +68,53 @@ 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). -- **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`. - - **Part B follow-up (the coarse `outcome`)**: `RECORD_SCHEMA` marks `operation` - and `outcome` REQUIRED, so a record without `outcome` is `schema_invalid` at - `write_outcome` — a Part B finding the hermetic fake broker could not surface. - `.outcome_record()` now derives the coarse `outcome` from the closed status - (`ok` for `ok`/`no_op`, `error` for every closed failure/refusal), keeping the - detailed per-package result in `observed`. `.validate_record()` enforces the - required pair locally (`.PKGOPS_RECORD_REQUIRED`). The R-level refuse path - (`session_ops.R`) already carried `outcome` (`intent` + status); the native - effect-session open_intent gained the field in runix (`effect_session.c`). -- **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. +- **VM-gate increment Part A (merged, `ee013da`, 0.0.1.8): 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. Observation is **success-path only**. + Plan: `runix/docs/pkgops-vm-gate-plan.md`. +- **Coarse `outcome` conformance (merged, 0.0.1.9)**: `RECORD_SCHEMA` marks + `operation` and `outcome` REQUIRED, so a record without `outcome` is + `schema_invalid` at `write_outcome` — a Part B finding the hermetic fake broker + could not surface. `.outcome_record()` derives the coarse `outcome` from the closed + status (`ok` for `ok`/`no_op`, `error` for every closed failure/refusal), keeping + the detailed per-package result in `observed`; `.validate_record()` enforces the + required pair (`.PKGOPS_RECORD_REQUIRED`). The R-level refuse path + (`session_ops.R`) already carried `outcome`; the native effect-session + `open_intent` gained it in runix (`effect_session.c`). +- **Increment 6 (this branch, PR #9, 0.0.1.10, held draft): the exported per-verb + `apt_()` commit API** (`R/commit_api.R`) — nine public entrypoints, each + committing the `pkgops_preview` its `apt__preview()` twin produced. + `.commit_verb()` (in `R/commit.R`) adds the two checks `.commit_session` can't: the + arg is a `pkgops_preview`, and its verb is the one this fn commits (a verb/preview + mismatch is a `pkgops_bad_request`), both before anything opens; then delegates. The + `plan_hash` stays the integrity authority (the helper re-validates it under the lock; + pkgops does not re-derive it). `interactive` defaults to `base::interactive()`. + **This is the increment that makes pkgops mutation-capable** — the `DESCRIPTION` no + longer says mutation is out of scope. Rebased onto Part A + the coarse-`outcome` + fix (0.0.1.9); held draft pending the Part B VM proof. Also: `.ensure_cid()` + attaches the session + `correlation_id` to every left-open / effect-unknown condition that reaches the + caller (a mid-flight kill or a lost result), preserving its class/fields, so an + open intent is reconcilable -- needed by the Part B G-INT gate. +- **Part B (disposable-VM proof): PASSED** — the real broker/polkit VM run drove the + whole public path on a fresh disposable guest (every functional gate via + `pkgops::apt_()`; G12-G14 + G15 via the `rab-exercise` broker oracle; + G11a/G11b via direct `pkexec`): polkit matrix 23/23, §7 gates 68/68. It surfaced + and fixed the runix native `open_intent`/empty-resource gaps, the coarse-`outcome` + omission (0.0.1.9), and a pkgexec pre-redemption cid bug (G10, `apt_locked`). + G9/G-OWN are pkgops preview-side refusals (no intent opened), not + broker-redemption refusals. Next: 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 d07f012..2db6dd3 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.9 +Version: 0.0.1.10 Date: 2026-08-20 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", @@ -10,11 +10,12 @@ Authors@R: c( Description: The unprivileged R issuer for authorized 'APT' package-state mutations in the Runix system-administration framework. It plans a change with the read-only 'runix-apt-preview' helper, returning a typed, advisory - preview whose plan digest binds exactly what a later commit would apply. - Mutation itself is out of scope of this release: previews open no intent, - take no lock, and mint nothing. Reads of installed state live in the sibling - 'pkgstate'; the privileged effectors and the preview helper are shipped - separately by 'pkgexec'. + preview whose plan digest binds exactly what a commit would apply, then + commits that exact plan through the privileged effect-session: it negotiates + an effect receipt, authorizes the verb through 'polkit', opens an + effect-bound intent, and records a durable, verified outcome. The privileged + effectors and the preview helper are shipped separately by 'pkgexec'; reads + of installed state live in the sibling 'pkgstate'. License: MIT + file LICENSE OS_type: unix SystemRequirements: pkgexec (provides the unprivileged 'runix-apt-preview' diff --git a/NAMESPACE b/NAMESPACE index e5225ce..e0b185c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,13 +1,22 @@ # tinyrox says don't edit this manually, but it can't stop you! +export(apt_configure) export(apt_configure_preview) +export(apt_dist_upgrade) export(apt_dist_upgrade_preview) +export(apt_hold) export(apt_hold_preview) +export(apt_install) export(apt_install_preview) +export(apt_purge) export(apt_purge_preview) +export(apt_remove) export(apt_remove_preview) +export(apt_unhold) export(apt_unhold_preview) +export(apt_update) export(apt_update_preview) +export(apt_upgrade) export(apt_upgrade_preview) S3method(print,pkgops_outcome) diff --git a/NEWS.md b/NEWS.md index c44a75d..ca63db4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,38 @@ +# pkgops 0.0.1.10 + +Commit lifecycle (slice 3b): the **exported per-verb `apt_()` commit API** +(§4.1 / §5). This is the increment that makes `pkgops` **mutation-capable** -- the +first release with a public code path that changes system state. + +* Nine exported entrypoints -- `apt_install`, `apt_remove`, `apt_purge`, + `apt_hold`, `apt_unhold`, `apt_update`, `apt_upgrade`, `apt_dist_upgrade`, + `apt_configure` -- each committing the `pkgops_preview` its + `apt__preview()` twin produced. A commit binds **only** that preview: its + `plan_hash` is what the privileged helper re-validates under the `dpkg` lock, so + a plan that drifted since the preview is refused there, never applied. +* Each `apt_()` refuses, **before anything is opened**, a preview for a + different verb (an `apt.remove` preview handed to `apt_install()` is a + `pkgops_bad_request` -- a verb/preview mismatch), a non-`ok` preview (a `no_op` + or a policy refusal is never committable), and any argument that is not a + `pkgops_preview`. It then drives the full `.commit_session` lifecycle + (capability, polkit, open, commit, verify, write-outcome, signal). +* `interactive` defaults to `interactive()`: an R console commits through the + `pkexec` prompt, a script or CI run commits in machine mode (a non-interactive + `pkcheck`, whose denial or approval challenge is a durably-audited refusal, never + a prompt). `lock_timeout` / `deadline_ms` / `socket_path` pass through. +* The `DESCRIPTION` no longer says mutation is out of scope. +* Rebased onto the merged Part A durable-record grammar (0.0.1.8) plus the + coarse-`outcome` conformance fix (0.0.1.9, below). A + left-open / effect-unknown condition that reaches the caller -- a mid-flight kill, + or a lost result -- now carries the session `correlation_id` (added, never + replacing its class or fields), so an open intent stays **reconcilable**; the cid + used to live only on an outcome the classifier discards. +* Still deferred to the **VM-gated increment (Part B)**: the real broker/polkit + disposable-VM proof of the whole public path; and, if wanted, the combined + plan-and-commit `apt__run()` convenience (definable purely in terms of the + two-call form). + + # pkgops 0.0.1.9 The durable audit record now carries the broker-required coarse `outcome` field. @@ -9,7 +44,6 @@ derives `outcome` from the closed status classification (`ok` for `ok`/`no_op`, result in `observed`. `.validate_record()` enforces the required pair locally so a future omission fails hermetically rather than only in the VM. - # pkgops 0.0.1.8 Durable audit record grammar (VM-gate increment, Part A). The committed outcome diff --git a/R/commit.R b/R/commit.R index e8afd71..4afa907 100644 --- a/R/commit.R +++ b/R/commit.R @@ -216,6 +216,25 @@ ## any spawn (no child-side fd-close primitive); a future refinement could close ## that FALSE. Leaving it open is safe (reconciliation resolves an unmatched open ## intent as not-applied) and never fabricates a false effect. +## Attach a broker correlation_id to a condition that reaches the caller on a +## left-open / effect-unknown path, so a killed or lost intent stays RECONCILABLE +## (the whole point of leaving an intent open is to resolve it later, which needs +## its cid). Preserves the condition's CLASS and every existing field. Replaces the +## condition's `correlation_id` unless it is ALREADY a well-formed broker cid: a +## missing, NA, empty, or malformed value is overwritten with the (well-formed) +## session cid, so an open intent is never left with an unreconcilable cid; a valid +## cid a lower layer set correctly is kept. A non-well-formed session cid is never +## stamped (that would replace one bad cid with another). +.ensure_cid <- function(cond, cid) { + if (!inherits(cond, "condition") || !.valid_broker_cid(cid)) { + return(cond) + } + if (!.valid_broker_cid(cond$correlation_id)) { + cond$correlation_id <- cid + } + cond +} + .commit_and_classify <- function(ops, session, preview, lock_timeout, deadline_ms) { commit <- tryCatch( @@ -228,9 +247,20 @@ plan_hash = preview$plan_hash, status = "effect_unknown", effect_issued = NA, condition = commit) - return(list(outcome = oc, condition = commit, leave_open = TRUE)) + ## the RAW runix commit condition carries no session cid -> attach it, so a + ## mid-flight kill (G-INT) leaves a reconcilable open intent. + return(list(outcome = oc, + condition = .ensure_cid(commit, session$correlation_id), + leave_open = TRUE)) } - .classify_commit(commit, preview) + ## every classified condition that reaches the caller carries a cid; fall back + ## to the session cid on a left-open / effect-unknown result whose delivered + ## frame lost its correlation_id (a lost result). + decided <- .classify_commit(commit, preview) + if (!is.null(decided$condition)) { + decided$condition <- .ensure_cid(decided$condition, session$correlation_id) + } + decided } ## Handle a machine-mode polkit refusal (contract 4.4). No effect intent is ever @@ -292,11 +322,45 @@ stop(cond) } +## The exported per-verb commit entrypoint's shared body (R/commit_api.R). It adds +## the two checks the verb-agnostic .commit_session cannot make -- the argument is +## a pkgops_preview, and its verb is the one THIS function commits (apt_install() +## must never commit an apt.remove plan) -- then delegates. Both run BEFORE any +## capability call or intent; .commit_session then enforces committability +## (advisory_verdict == "ok", a bound digest) and drives the lifecycle. The +## plan_hash the preview carries is the integrity authority: the privileged helper +## re-validates it under the dpkg lock, so a drifted plan is refused there, never +## applied -- pkgops does not (and cannot) re-derive it at the R layer. +.commit_verb <- function(expected_verb, preview, lock_timeout, deadline_ms, + interactive, socket_path) { + if (!inherits(preview, "pkgops_preview")) { + stop_pkgops("commit requires a pkgops_preview (from ", + sub("^apt\\.", "apt_", expected_verb), "_preview())", + class = "pkgops_bad_request") + } + if (!identical(preview$verb, expected_verb)) { + got <- if (.is_scalar_str(preview$verb)) { + shQuote(preview$verb) + } else { + "" + } + stop_pkgops("verb/preview mismatch: ", + sub("^apt\\.", "apt_", expected_verb), + "() cannot commit a preview for ", got, + class = "pkgops_bad_request", + data = list(expected_verb = expected_verb, + preview_verb = preview$verb)) + } + .commit_session(preview, socket_path = socket_path, + interactive = interactive, lock_timeout = lock_timeout, + deadline_ms = deadline_ms) +} + ## Drive the branched commit lifecycle for one committable preview and return the ## pkgops_outcome (success) or SIGNAL the mapped condition (failure), with the -## outcome ALWAYS written before the signal (4.8). Internal for now -- the public -## per-verb API that wraps this (with the preview {verb,resource,plan_hash} match -## check) lands once pkgstate verification completes the lifecycle. +## outcome ALWAYS written before the signal (4.8). Verb-agnostic: it reads the verb +## from the preview. The exported per-verb apt_() wrappers (R/commit_api.R) +## reach it through .commit_verb(), which adds the verb/preview match check. .commit_session <- function(preview, socket_path = .PKGOPS_BROKER_SOCKET, interactive = FALSE, lock_timeout = 0L, deadline_ms = 120000L) { diff --git a/R/commit_api.R b/R/commit_api.R new file mode 100644 index 0000000..e7b88f1 --- /dev/null +++ b/R/commit_api.R @@ -0,0 +1,133 @@ +#' Commit an authorized apt package-state change +#' +#' Commit the exact change an \code{apt__preview()} resolved. Each function +#' takes the \code{pkgops_preview} its preview twin produced and drives the +#' privileged commit lifecycle for it: it negotiates the effect-receipt +#' capability, authorizes the verb through polkit, opens an effect-bound intent, +#' commits through the runix effect-session, verifies the post-state against the +#' plan, and writes the durable outcome. Unlike the preview twin, this mutates the +#' system. +#' +#' A commit binds \strong{only} the preview it is handed. The \code{plan_hash} the +#' preview carries is what the privileged helper re-validates under the \code{dpkg} +#' lock, so a plan that has drifted since the preview is refused there, never +#' applied. Each \code{apt_()} refuses, before anything is opened, a preview +#' for a different verb (an \code{apt.remove} preview handed to +#' \code{apt_install()} is a \code{pkgops_bad_request}), a non-\code{ok} preview (a +#' \code{no_op} or a policy refusal is never committable), and anything that is not +#' a \code{pkgops_preview}. +#' +#' \strong{Authorization.} With \code{interactive = TRUE} the \code{pkexec} prompt +#' authenticates the change at the privileged spawn; with \code{interactive = +#' FALSE} (machine mode) a non-interactive \code{pkcheck} decides, and a denial or +#' an approval challenge becomes a durably-audited refusal, never a prompt. The +#' default follows \code{\link{interactive}()} -- an R console commits +#' interactively, a script or CI run commits in machine mode. +#' +#' On any refusal or failure the call signals a typed condition inheriting +#' \code{pkgops_error} and \code{runix_error} (for example \code{runix_unauthorized}, +#' \code{runix_held}, \code{runix_apt_locked}, \code{runix_operation_failed}, +#' \code{runix_dpkg_broken}). An effect whose outcome could not be determined +#' (\code{runix_helper_bad_result}) leaves the intent open for reconciliation and +#' is never reported as a clean failure. +#' +#' @param preview The \code{pkgops_preview} to commit, from the matching +#' \code{apt__preview()}. +#' @param lock_timeout Seconds the privileged helper waits for the \code{dpkg} +#' lock before refusing with \code{runix_apt_locked} (\code{0} = do not wait). +#' @param deadline_ms Overall commit deadline, in milliseconds. +#' @param interactive Authorize through the interactive \code{pkexec} prompt +#' (\code{TRUE}) or a non-interactive \code{pkcheck} (\code{FALSE}). Defaults to +#' \code{\link{interactive}()}. +#' @param socket_path The broker's \code{AF_UNIX} socket. +#' @return A \code{pkgops_outcome} recording the committed change: its +#' \code{effect_issued} and, for a verb with an observable post-state, its +#' \code{verified} verdict and \code{verify_detail}. Signals a typed condition +#' on any refusal or failure. +#' @examples +#' \dontrun{ +#' p <- apt_install_preview(c("nginx")) +#' out <- apt_install(p, lock_timeout = 300) +#' out$verified +#' } +#' @rdname apt_commit +#' @export +apt_install <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.install", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_remove <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.remove", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_purge <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.purge", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_hold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.hold", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_unhold <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.unhold", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_update <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.update", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_upgrade <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.upgrade", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_dist_upgrade <- function(preview, lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.dist_upgrade", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} + +#' @rdname apt_commit +#' @export +apt_configure <- function(preview, lock_timeout = 0L, deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET) { + .commit_verb("apt.configure", preview, lock_timeout, deadline_ms, + interactive, socket_path) +} diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 0e8f7a9..1482b21 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -186,6 +186,7 @@ r <- run_commit(ops_for(lg, cr("effect_unknown", effect_issued = NA))) expect_inherits(r, "runix_helper_bad_result") expect_false("write_outcome" %in% lg$seq) # intent left open expect_equal(lg$seq, c("capability", "open", "commit")) +expect_equal(r$correlation_id, CID) # left-open intent stays reconcilable ## ---- a RAISED commit is effect-unknown: left open, original re-signaled ----- lg <- newlog() @@ -196,6 +197,59 @@ r <- run_commit(ops_for(lg, boom, raise = TRUE)) expect_inherits(r, "runix_capability_unavailable") # the original, re-signaled expect_false("write_outcome" %in% lg$seq) # left open expect_equal(lg$seq, c("capability", "open", "commit")) +## the raw runix condition carried no cid; .ensure_cid attaches the SESSION cid so +## the killed/lost intent is reconcilable (G-INT), without replacing its class/fields +expect_equal(r$correlation_id, CID) +expect_inherits(r, "runix_capability_unavailable") # class preserved +expect_equal(conditionMessage(r), "no closefrom primitive") # message preserved + +## ---- a left-open result whose delivered frame LOST its cid falls back to the +## session cid (never leaving an unreconcilable open intent) ------------------- +lg <- newlog() +lost <- list(session_status = "effect_unknown", status = NULL, + effect_issued = NA, correlation_id = NULL, detail = NULL) +r <- run_commit(ops_for(lg, lost)) +expect_inherits(r, "runix_helper_bad_result") +expect_false("write_outcome" %in% lg$seq) +expect_equal(r$correlation_id, CID) # fell back to the session cid + +## ---- .ensure_cid: an EMPTY or MALFORMED existing cid is REPLACED (not only a +## missing/NA one), while a VALID broker cid is kept. This is the G-INT invariant: +## an open intent must never be left with an unreconcilable cid ------------------ +ec <- pkgops:::.ensure_cid +mkcond <- function(cid) structure(list(message = "m", call = NULL, + correlation_id = cid), + class = c("runix_error", "error", "condition")) +OTHER <- "20250202000000000000-fedcba9876543210" # a DIFFERENT valid broker cid +expect_equal(ec(mkcond(NULL), CID)$correlation_id, CID) # missing -> stamped +expect_equal(ec(mkcond(NA_character_), CID)$correlation_id, CID) # NA -> stamped +expect_equal(ec(mkcond(""), CID)$correlation_id, CID) # empty -> REPLACED +expect_equal(ec(mkcond("not-a-broker-cid"), CID)$correlation_id, CID) # malformed -> REPLACED +expect_equal(ec(mkcond(c(CID, CID)), CID)$correlation_id, CID) # non-scalar -> REPLACED +expect_equal(ec(mkcond(OTHER), CID)$correlation_id, OTHER) # valid cid KEPT +## a non-well-formed session cid is never stamped (no replacing bad with bad) +expect_equal(ec(mkcond(""), "bad")$correlation_id, "") +expect_null(ec(mkcond(NULL), "bad")$correlation_id) +## class + message preserved through the replacement +badc <- ec(mkcond(""), CID) +expect_inherits(badc, "runix_error") +expect_equal(conditionMessage(badc), "m") + +## ---- G-INT end to end: a raised commit whose condition carries an EMPTY or +## MALFORMED cid still leaves a reconcilable open intent (the bad cid is replaced +## with the session cid), class + message intact -------------------------------- +for (badcid in list("", "not-a-broker-cid", NA_character_)) { + lg <- newlog() + boom2 <- structure(list(message = "killed mid-commit", call = NULL, + correlation_id = badcid), + class = c("runix_capability_unavailable", "runix_error", + "error", "condition")) + r <- run_commit(ops_for(lg, boom2, raise = TRUE)) + expect_inherits(r, "runix_capability_unavailable") + expect_false("write_outcome" %in% lg$seq) # left open + expect_equal(r$correlation_id, CID) # bad cid replaced by session cid + expect_equal(conditionMessage(r), "killed mid-commit") +} ## ---- persist failure: write_outcome ran, effect open, broker_error --------- lg <- newlog() diff --git a/inst/tinytest/test_commit_api.R b/inst/tinytest/test_commit_api.R new file mode 100644 index 0000000..c2f33fd --- /dev/null +++ b/inst/tinytest/test_commit_api.R @@ -0,0 +1,165 @@ +# The exported per-verb commit API (R/commit_api.R): thin wrappers over +# .commit_session that add the verb/preview match check and pass the commit +# parameters through. Hermetic: the session ops, pkcheck, and (unused here, since +# the previews carry no records) the pkgstate reader are all behind seams. + +H <- strrep("b", 64L) +CID <- "20250101000000000000-0123456789abcdef" +set_ops <- pkgops:::set_session_ops +set_pkcheck <- pkgops:::set_pkcheck + +## A committable `ok` preview for a given verb. records = list() so step-6 +## verification short-circuits (verified NA) and never touches the reader. +mkprev <- function(verb, resource = "nginx", packages = "nginx", + records = list(), advisory_verdict = "ok") { + structure(list(schema_version = 1L, verb = verb, resource = resource, + plan_schema = 1L, plan_hash = H, autonomous = FALSE, + packages = packages, records = records, + advisory_verdict = advisory_verdict, + advisory_detail = NA_character_), class = "pkgops_preview") +} + +newlog <- function() { + e <- new.env(parent = emptyenv()) + e$seq <- character(0) + e +} + +## A recording fake ops set that commits cleanly (ok, effect TRUE). +ok_ops <- function(log) { + list(capability = function(socket_path, plan_schema, ...) { + log$seq <- c(log$seq, "capability") + invisible(TRUE) + }, refuse = function(socket_path, operation, resource, status, ...) { + log$seq <- c(log$seq, "refuse") + log$refuse <- list(operation = operation, status = status) + list(correlation_id = CID, audit_persisted = TRUE) + }, open = function(socket_path, operation, resource, plan_schema, plan_hash, + ...) { + log$seq <- c(log$seq, "open") + log$open <- list(operation = operation, resource = resource) + structure(list(handle = "fake", correlation_id = CID), + class = "runix_effect_session") + }, commit = function(session, packages, lock_timeout, deadline_ms, ...) { + log$seq <- c(log$seq, "commit") + log$commit <- list(packages = packages, lock_timeout = lock_timeout, + deadline_ms = deadline_ms) + structure(list(session_status = "ok", status = "ok", + effect_issued = TRUE, correlation_id = CID, + detail = NULL), class = "runix_commit_result") + }, write_outcome = function(session, record, ...) { + log$seq <- c(log$seq, "write_outcome") + list(status = "ok", detail = NULL) + }) +} + +## Run an exported commit fn under the fakes; return list(res, log). res is the +## outcome (success) or the raised condition. interactive is forced (default +## FALSE) so the run is deterministic regardless of the test session's mode. +run_api <- function(fn, preview, interactive = FALSE, pkcheck = function(a) 0L, + log = newlog(), ...) { + old_ops <- set_ops(ok_ops(log)) + old_pk <- set_pkcheck(pkcheck) + on.exit({ + set_ops(old_ops) + set_pkcheck(old_pk) + }) + res <- tryCatch(fn(preview, socket_path = "/fake.sock", + interactive = interactive, ...), + condition = function(c) c) + list(res = res, log = log) +} + +## The nine exported verbs and the request verb each commits. +verbs <- list(list(fn = pkgops::apt_install, verb = "apt.install"), + list(fn = pkgops::apt_remove, verb = "apt.remove"), + list(fn = pkgops::apt_purge, verb = "apt.purge"), + list(fn = pkgops::apt_hold, verb = "apt.hold"), + list(fn = pkgops::apt_unhold, verb = "apt.unhold"), + list(fn = pkgops::apt_update, verb = "apt.update"), + list(fn = pkgops::apt_upgrade, verb = "apt.upgrade"), + list(fn = pkgops::apt_dist_upgrade, verb = "apt.dist_upgrade"), + list(fn = pkgops::apt_configure, verb = "apt.configure")) + +## ---- each fn commits its OWN verb's preview, verb (not a path) reaches open --- +for (v in verbs) { + out <- run_api(v$fn, mkprev(v$verb)) + expect_inherits(out$res, "pkgops_outcome") + expect_equal(out$res$verb, v$verb) + expect_equal(out$log$open$operation, v$verb) # verb, never a path + expect_equal(out$log$seq, + c("capability", "open", "commit", "write_outcome")) +} + +## ---- each fn REFUSES a preview for a different verb, before anything opens ---- +for (i in seq_along(verbs)) { + v <- verbs[[i]] + other <- verbs[[if (i == 1L) 2L else 1L]]$verb # some other verb + out <- run_api(v$fn, mkprev(other)) + expect_inherits(out$res, "pkgops_bad_request") + expect_true(grepl("mismatch", conditionMessage(out$res))) + expect_equal(length(out$log$seq), 0L) # nothing opened or minted +} + +## ---- a non-preview argument is refused, nothing opened ----------------------- +out <- run_api(pkgops::apt_install, 42) +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) +out <- run_api(pkgops::apt_purge, list(verb = "apt.purge")) # bare list +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) + +## the not-a-preview message names the matching preview constructor (incl. the +## underscore verb, so the apt. -> apt_ derivation is right) +out <- run_api(pkgops::apt_dist_upgrade, 42) +expect_true(grepl("apt_dist_upgrade_preview", conditionMessage(out$res))) + +## ---- a non-ok preview is still refused through the wrapper ------------------- +out <- run_api(pkgops::apt_install, mkprev("apt.install", advisory_verdict = "no_op")) +expect_inherits(out$res, "pkgops_bad_request") +expect_equal(length(out$log$seq), 0L) +for (verdict in c("held", "protected_package", "package_not_owned")) { + out <- run_api(pkgops::apt_install, + mkprev("apt.install", advisory_verdict = verdict)) + expect_inherits(out$res, "pkgops_bad_request") + expect_equal(length(out$log$seq), 0L) +} + +## ---- commit parameters pass through to the session commit ------------------- +out <- run_api(pkgops::apt_install, mkprev("apt.install"), + lock_timeout = 300L, deadline_ms = 45000L) +expect_equal(out$log$commit$lock_timeout, 300L) +expect_equal(out$log$commit$deadline_ms, 45000L) +out <- run_api(pkgops::apt_update, mkprev("apt.update", resource = "@indexes", + packages = character(0))) +expect_inherits(out$res, "pkgops_outcome") +expect_equal(out$log$commit$packages, character(0)) # whole-system: no targets + +## ---- interactive = TRUE defers to the pkexec prompt: pkcheck is NOT run ------ +never_pk <- function(a) stop("pkcheck must not run when interactive") +out <- run_api(pkgops::apt_install, mkprev("apt.install"), interactive = TRUE, + pkcheck = never_pk) +expect_inherits(out$res, "pkgops_outcome") +expect_false("refuse" %in% out$log$seq) +expect_equal(out$log$seq, c("capability", "open", "commit", "write_outcome")) + +## ---- interactive = FALSE runs pkcheck: a denial is a durably-audited refusal -- +out <- run_api(pkgops::apt_install, mkprev("apt.install"), interactive = FALSE, + pkcheck = function(a) 1L) +expect_inherits(out$res, "runix_unauthorized") +expect_equal(out$log$seq, c("capability", "refuse")) # never opened the effect + +## ---- an autonomous verb (apt.update) commits for a member (rc 0) ------------ +out <- run_api(pkgops::apt_update, mkprev("apt.update", resource = "@indexes", + packages = character(0)), + pkcheck = function(a) if (grepl("update$", a)) 0L else 1L) +expect_inherits(out$res, "pkgops_outcome") +expect_equal(out$log$seq, c("capability", "open", "commit", "write_outcome")) + +## ---- the exported surface is exactly the nine commit verbs ------------------ +exp_commit <- c("apt_install", "apt_remove", "apt_purge", "apt_hold", + "apt_unhold", "apt_update", "apt_upgrade", "apt_dist_upgrade", + "apt_configure") +ns <- getNamespaceExports("pkgops") +expect_true(all(exp_commit %in% ns)) # all nine exported +expect_true(all(paste0(exp_commit, "_preview") %in% ns)) # and their preview twins diff --git a/man/apt_commit.Rd b/man/apt_commit.Rd new file mode 100644 index 0000000..d6ab218 --- /dev/null +++ b/man/apt_commit.Rd @@ -0,0 +1,147 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{apt_commit} +\alias{apt_install} +\alias{apt_remove} +\alias{apt_purge} +\alias{apt_hold} +\alias{apt_unhold} +\alias{apt_update} +\alias{apt_upgrade} +\alias{apt_dist_upgrade} +\alias{apt_configure} +\title{Commit an authorized apt package-state change} +\usage{ +apt_install( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_remove( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_purge( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_hold( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_unhold( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_update( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_upgrade( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_dist_upgrade( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) + +apt_configure( + preview, + lock_timeout = 0L, + deadline_ms = 120000L, + interactive = base::interactive(), + socket_path = .PKGOPS_BROKER_SOCKET +) +} +\arguments{ +\item{preview}{The \code{pkgops_preview} to commit, from the matching +\code{apt__preview()}.} + +\item{lock_timeout}{Seconds the privileged helper waits for the \code{dpkg} +lock before refusing with \code{runix_apt_locked} (\code{0} = do not wait).} + +\item{deadline_ms}{Overall commit deadline, in milliseconds.} + +\item{interactive}{Authorize through the interactive \code{pkexec} prompt +(\code{TRUE}) or a non-interactive \code{pkcheck} (\code{FALSE}). Defaults to +\code{\link{interactive}()}.} + +\item{socket_path}{The broker's \code{AF_UNIX} socket.} +} +\value{ +A \code{pkgops_outcome} recording the committed change: its + \code{effect_issued} and, for a verb with an observable post-state, its + \code{verified} verdict and \code{verify_detail}. Signals a typed condition + on any refusal or failure. +} +\description{ +Commit the exact change an \code{apt__preview()} resolved. Each function +takes the \code{pkgops_preview} its preview twin produced and drives the +privileged commit lifecycle for it: it negotiates the effect-receipt +capability, authorizes the verb through polkit, opens an effect-bound intent, +commits through the runix effect-session, verifies the post-state against the +plan, and writes the durable outcome. Unlike the preview twin, this mutates the +system. +} +\details{ +A commit binds \strong{only} the preview it is handed. The \code{plan_hash} the +preview carries is what the privileged helper re-validates under the \code{dpkg} +lock, so a plan that has drifted since the preview is refused there, never +applied. Each \code{apt_()} refuses, before anything is opened, a preview +for a different verb (an \code{apt.remove} preview handed to +\code{apt_install()} is a \code{pkgops_bad_request}), a non-\code{ok} preview (a +\code{no_op} or a policy refusal is never committable), and anything that is not +a \code{pkgops_preview}. + +\strong{Authorization.} With \code{interactive = TRUE} the \code{pkexec} prompt +authenticates the change at the privileged spawn; with \code{interactive = +FALSE} (machine mode) a non-interactive \code{pkcheck} decides, and a denial or +an approval challenge becomes a durably-audited refusal, never a prompt. The +default follows \code{\link{interactive}()} -- an R console commits +interactively, a script or CI run commits in machine mode. + +On any refusal or failure the call signals a typed condition inheriting +\code{pkgops_error} and \code{runix_error} (for example \code{runix_unauthorized}, +\code{runix_held}, \code{runix_apt_locked}, \code{runix_operation_failed}, +\code{runix_dpkg_broken}). An effect whose outcome could not be determined +(\code{runix_helper_bad_result}) leaves the intent open for reconciliation and +is never reported as a clean failure. + +} +\examples{ +\dontrun{ +p <- apt_install_preview(c("nginx")) +out <- apt_install(p, lock_timeout = 300) +out$verified +} +}