Skip to content

Add the runtime trust-generation accept path with the epoch floor (#50) - #52

Merged
sehkone merged 3 commits into
mainfrom
sehkone/issue-50
Aug 9, 2026
Merged

Add the runtime trust-generation accept path with the epoch floor (#50)#52
sehkone merged 3 commits into
mainfrom
sehkone/issue-50

Conversation

@sehkone

@sehkone sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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 &Path and 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:

  • The common reader (read_active_generation) factors what active_trust_set already did — read and parse active/trust-set.json, read active/epoch, refuse a disagreement — and now yields the verified document, the epoch and the assembled TrustSet from one pass. It calls no read_link and keeps today's error surface verbatim; active_trust_set is a thin wrapper that drops the extra two, so the seed and install-time paths see no change. It still succeeds on a tree whose active is a real directory or a symlink to a non-canonical name.
  • The runtime-only probe (active_generation_index) runs the engine's own pair — read_link on active, target through generation::parse_generation — with four outcomes: the canonical index; Ok(None) for a NotFound link and nothing else; ActiveNotCanonical { target } for a target that does not parse; and Io naming active for any other link failure, including the EINVAL a real directory produces. Only the accept path, the state query and each chain step call it. accept_generation maps the absent link to NoActiveGeneration; read_generation_state maps it to Ok(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 joining GENERATION_PACKAGE_FILE, fail-closed with no NotFound exemption), then the delivered-epoch carrier check, then verify_package, then install_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 into StaleTrustSet. The unchanged result is the same AdmittedGeneration every other outcome returns, carrying changed: false, the probe's index, and the active generation's epoch and document — never anything parsed out of the delivered bytes.

New ReleaseTrustError variants, added plainly with no #[non_exhaustive] and no shim: ActiveNotCanonical { target: String } and DeliveredEpochDisagreement { document: u64, manifest: String }. New public types ActiveGeneration, ChainReplay and ChainReplayError, all fields pub, with the derives stated in the issue; ChainReplayError is a struct error carrying completed, last and the ReleaseTrustError as its #[source], so no recursive variant lands on the module's enum.

completed counts input steps, successful no-ops included, so packages[..completed] all succeeded and packages[completed] is the step that raised source. No numeric contiguity test anywhere — a gap shows up as UnknownKeyId from the verifier, out-of-order as StaleTrustSet. 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.rs the change is two lines of prose: a ### Added entry 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=StdExternalCrate
  • cargo clippy --all-targets -- -D warnings
  • cargo clippy --all-targets --features test-support -- -D warnings
  • cargo test
  • cargo test --features test-support
  • A generation one epoch above the active one is accepted and activated; equal and lower epochs return StaleTrustSet.
  • Redelivering the active generation's exact bytes returns unchanged and leaves the tree byte-identical, asserted against a snapshot before and after.
  • That unchanged result carries changed: false, the index active names, and the active generation's own epoch and document, asserted field by field against what was seeded.
  • A non-canonical symlink at active is refused with ActiveNotCanonical, and a real directory at active with Io naming active — each for both a redelivery and a genuinely newer generation, with the tree untouched.
  • active_trust_set still succeeds on both of those trees, proving the factoring did not tighten the public reader.
  • The state query returns those same two refusals on those two trees rather than Ok(None).
  • A dangling active naming a canonical gen-<n> is NoActiveGeneration from accept, Ok(None) from the state query, and None from read_active_epoch.
  • The existing active_trust_set tests pass unchanged across the factoring.
  • A different document at the active epoch is StaleTrustSet rather than unchanged.
  • An accept onto a tree with no active is NoActiveGeneration; the state query returns Ok(None) on it.
  • An unreadable active container is Io naming <root>/active/generation.pkg rather than a fall-through to verification.
  • Disagreeing document epoch and manifest artifact version is refused before verification with DeliveredEpochDisagreement.
  • A manifest below the active generation's floor is UnsupportedManifestFormat, pinning the self-lockout as intended.
  • A three-step chain replays in order; non-contiguous epochs replay fully; a step signed by a key the active set does not carry stops there with the earlier steps activated.
  • An out-of-order chain is refused at the step that goes backwards; a redelivery two steps back is StaleTrustSet, not a no-op.
  • A duplicate delivered immediately after the step that activated it is a no-op, allocating no generation directory of its own.
  • A chain whose first step is the active generation's own bytes and whose second is newer reports completed: 2, last.activation.changed == true, and an active index exactly one past the seeded generation.
  • A chain that fails after such a redelivery reports completed: 1 with last carrying the changed: false record.
  • A chain of nothing but the active generation's bytes reports completed == packages.len() with the tree byte-identical.
  • Every refusal before install_generation leaves the active generation, the recorded epoch and the generation directories exactly as they were.
  • The state query reports index and epoch on a well-formed tree, Ok(None) on an empty one, and Err for a malformed epoch record and for EpochDisagreement.
  • The state query writes nothing over every tree shape, asserted by snapshot.
  • A lower epoch is refused through both accept_generation and a one-step accept_generation_chain.
  • The #[cfg(test)] thread-local recorder on replace_generation is empty after every drive of the three entry points, successful drives included, and a direct replace_generation call does land in it.

sehkone added 3 commits August 9, 2026 17:03
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
@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1]\n\nNo blocking concerns found. The implementation follows the issue’s required ordering: it probes the canonical active link before the following-stat reader, returns the byte-identical no-op before delivered-byte verification, compares the two delivered epoch carriers before invoking the shared verifier, and reaches only install_generation after a clean verification. The chain replay correctly delegates each step to the single-accept path and preserves progress on failure.\n\nThe tests meaningfully cover the security-relevant edge cases, including non-canonical and dangling active entries, equal-epoch redelivery, stale/out-of-order deliveries, partial replay, and the explicit assertion that runtime paths never call replace_generation. PR hygiene also looks complete: Closes #50 and a checked test plan are present.

@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: APPROVED]

@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Add the runtime trust-generation accept path

Body

Keys rotate, keys get revoked and builds get withdrawn long after the
one-shot installer is gone, so a new release-trust generation has to be
deliverable over the ordinary package channel and judged against the
generation currently active before it is activated. The install-time
doors cannot serve that: the seed refuses a tree that already carries a
generation, and replace applies no epoch floor in either direction.

The floor lives here rather than in the installer because the caller it
is built for is a root daemon that links this crate directly and can
construct no installer type, and because there must be exactly one
implementation of it.

Two notions of "the active generation" already existed in this module
and they disagree on real trees, so both are kept, separately. The
common reader is the following stat, factored out of `active_trust_set`
so the document and the epoch survive the call rather than being
dropped; it calls no `read_link` and keeps its error surface verbatim,
because the seed and install-time paths read through it too. The probe
is the engine's own pair — `read_link` plus `parse_generation` — spelled
that way so the runtime paths and the engine cannot drift about which
trees they own. Merging them would force one value to mean "refuse" to
one caller and "ignore me" to another.

The probe runs first and the reader second, which matters on a dangling
`active`: the link parses, the stat then sees nothing, and the tree is
reported empty rather than as the generation the link names.

Byte-identical redelivery is detected by this path's own comparison
against the active container, ahead of the verifier, because such a
delivery sits at an equal epoch and the strictly-greater test would
refuse it as stale first. The unchanged result is the same
`AdmittedGeneration` every other outcome returns, built from what the
reader already holds rather than from a parse of unverified bytes.

Chain replay keeps whatever progress it made, counting input steps so
the count indexes the caller's own slice and names the step to resume
from. A successful no-op is an accepted step like any other, since a
control plane sending generations N through M to a host it believes is
on N produces one routinely.

Closes #50

@sehkone
sehkone merged commit 9a9091e into main Aug 9, 2026
4 checks passed
@sehkone
sehkone deleted the sehkone/issue-50 branch August 9, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the runtime trust-generation accept path with the epoch floor

1 participant