Add the runtime trust-generation accept path with the epoch floor (#50) - #52
Conversation
Keys rotate and builds are withdrawn long after the one-shot installer is gone, so a new release-trust generation has to be deliverable at runtime over the same package channel every other package rides. The host side of that is an accept which refuses anything not strictly newer than the active generation, and it lands here because the caller it is built for is a root daemon that links this crate directly and because there must be exactly one implementation of the floor. Two notions of "the active generation" now exist side by side, and they differ on purpose. The reader behind `active_trust_set` asks only "is anything resolvable at `active`", through a stat that follows the link, and the seed and install-time paths rely on that; the runtime paths additionally ask the generation engine's own question, a `read_link` whose target must parse as a canonical `gen-<n>`. They disagree on exactly the trees whose `active` this crate did not write, so they are separate functions rather than one value serving both. The byte-identity check is this path's own and runs before the verifier. A control plane with bounded retry redelivers the current generation routinely, and for those bytes the delivered epoch equals the active one, so leaning on the generation engine's downstream no-op would turn an exact redelivery into a stale-trust-set error. Chain replay is an ordered sequence of ordinary accepts, with no numeric contiguity test: epochs are allocated by hand, and a gap shows up as the next generation being signed by a key the host does not carry. Progress is counted in input steps and is never unwound, so a lagging host keeps whatever ground it gained. Closes #50
The rule that a duplicate falls through to the floor once a later generation is active was pinned, but its other half was not: a duplicate delivered immediately after the step that activated it is a no-op, because by then it is the active container. The redelivery tests all short-circuited against bytes the tree was seeded with, so nothing covered a chain short-circuiting against bytes the same call installed. Part of #50
The three entry points are new public API a dependent inherits when it bumps its pinned rev, so the changelog is where it learns of them. The byte-identical no-op and the strictly-newer rule are named because both change what a caller has to handle, not merely what it may call. Part of #50
|
[Reviewer Round 1]\n\nNo blocking concerns found. The implementation follows the issue’s required ordering: it probes the canonical |
|
[Review Verdict Round 1: APPROVED] |
Suggested squash commitTitle Body |
Summary
Adds the runtime trust-generation accept path to
src/release_trust.rs, so a new release-trust generation can be delivered over the ordinary package channel long after the one-shot installer is gone, and judged against the current active generation before it is activated.Three public entry points, each taking the already-resolved tree root as
&Pathand constructing no product type:read_generation_state(root) -> Result<Option<ActiveGeneration>, ReleaseTrustError>— the question a caller asks before it pushes.accept_generation(root, package) -> Result<AdmittedGeneration, ReleaseTrustError>— one delivered generation.accept_generation_chain(root, packages) -> Result<ChainReplay, ChainReplayError>— the ordered replay that catches a lagging host up,&[&[u8]]so a caller replaying out of one contiguous buffer copies nothing.Plus the two internal readers they need, deliberately kept apart:
read_active_generation) factors whatactive_trust_setalready did — read and parseactive/trust-set.json, readactive/epoch, refuse a disagreement — and now yields the verified document, the epoch and the assembledTrustSetfrom one pass. It calls noread_linkand keeps today's error surface verbatim;active_trust_setis a thin wrapper that drops the extra two, so the seed and install-time paths see no change. It still succeeds on a tree whoseactiveis a real directory or a symlink to a non-canonical name.active_generation_index) runs the engine's own pair —read_linkonactive, target throughgeneration::parse_generation— with four outcomes: the canonical index;Ok(None)for aNotFoundlink and nothing else;ActiveNotCanonical { target }for a target that does not parse; andIonamingactivefor any other link failure, including theEINVALa real directory produces. Only the accept path, the state query and each chain step call it.accept_generationmaps the absent link toNoActiveGeneration;read_generation_statemaps it toOk(None); both propagate the two refusals unchanged.Accept runs in the fixed order: probe, then the common reader, then this path's own byte comparison against
<root>/active/generation.pkg(through a root-based helper joiningGENERATION_PACKAGE_FILE, fail-closed with noNotFoundexemption), then the delivered-epoch carrier check, thenverify_package, theninstall_generation. The byte comparison has to run before the verifier: on an exact redelivery the delivered epoch equals the active one, so leaning on the engine's downstream no-op would turn redelivery intoStaleTrustSet. The unchanged result is the sameAdmittedGenerationevery other outcome returns, carryingchanged: false, the probe's index, and the active generation's epoch and document — never anything parsed out of the delivered bytes.New
ReleaseTrustErrorvariants, added plainly with no#[non_exhaustive]and no shim:ActiveNotCanonical { target: String }andDeliveredEpochDisagreement { document: u64, manifest: String }. New public typesActiveGeneration,ChainReplayandChainReplayError, all fieldspub, with the derives stated in the issue;ChainReplayErroris a struct error carryingcompleted,lastand theReleaseTrustErroras its#[source], so no recursive variant lands on the module's enum.completedcounts input steps, successful no-ops included, sopackages[..completed]all succeeded andpackages[completed]is the step that raisedsource. No numeric contiguity test anywhere — a gap shows up asUnknownKeyIdfrom the verifier, out-of-order asStaleTrustSet. A partial replay is never unwound.Nothing calls these entry points the day they merge — the root daemon has not linked this crate in code yet — so no pin bump is made anywhere.
Outside
src/release_trust.rsthe change is two lines of prose: a### Addedentry under## [Unreleased]announcing the three entry points, and a README sentence placing the runtime accept path beside the two install-time doors the release-trust bullet already describes.Closes #50
Test plan
cargo fmt -- --check --config group_imports=StdExternalCratecargo clippy --all-targets -- -D warningscargo clippy --all-targets --features test-support -- -D warningscargo testcargo test --features test-supportStaleTrustSet.changed: false, the indexactivenames, and the active generation's own epoch and document, asserted field by field against what was seeded.activeis refused withActiveNotCanonical, and a real directory atactivewithIonamingactive— each for both a redelivery and a genuinely newer generation, with the tree untouched.active_trust_setstill succeeds on both of those trees, proving the factoring did not tighten the public reader.Ok(None).activenaming a canonicalgen-<n>isNoActiveGenerationfrom accept,Ok(None)from the state query, andNonefromread_active_epoch.active_trust_settests pass unchanged across the factoring.StaleTrustSetrather than unchanged.activeisNoActiveGeneration; the state query returnsOk(None)on it.Ionaming<root>/active/generation.pkgrather than a fall-through to verification.epochand manifest artifactversionis refused before verification withDeliveredEpochDisagreement.UnsupportedManifestFormat, pinning the self-lockout as intended.StaleTrustSet, not a no-op.completed: 2,last.activation.changed == true, and an active index exactly one past the seeded generation.completed: 1withlastcarrying thechanged: falserecord.completed == packages.len()with the tree byte-identical.install_generationleaves the active generation, the recorded epoch and the generation directories exactly as they were.Ok(None)on an empty one, andErrfor a malformedepochrecord and forEpochDisagreement.accept_generationand a one-stepaccept_generation_chain.#[cfg(test)]thread-local recorder onreplace_generationis empty after every drive of the three entry points, successful drives included, and a directreplace_generationcall does land in it.