agent_frame/verify.rs
1//! **One verifier. Every surface calls it.**
2//!
3//! [`verify_unit`] is the only place in this line where the question *"is this
4//! unit what it says it is?"* is decided. The harness calls it. `newt frame
5//! verify` calls it. A second implementation of a verification decision is two
6//! answers waiting to disagree, and the one that disagrees quietly is the one
7//! that ships.
8//!
9//! # What re-derivation actually means
10//!
11//! `checkOf .elide = .rederive` is a claim with content only if something can
12//! be recomputed and compared. Here it is three comparisons, in the order a
13//! sceptic would make them:
14//!
15//! 1. **Is this the source it names?** Hash the supplied bytes; compare to
16//! [`Derivation::source`]. A verifier that trusts the caller's word about
17//! which bytes are the source has verified nothing.
18//! 2. **Does the address resolve?** The span must lie within those bytes.
19//! 3. **Is the elided material what it claims?** Hash the addressed range;
20//! compare to [`Derivation::elided`]. *This* is the re-derivation — the step
21//! the previous revision had no recorded claim to perform.
22//!
23//! All three must hold. Any failure names both sides, because "verification
24//! failed" that does not say what did not match sends the reader back to a
25//! debugger.
26//!
27//! # One link is the whole obligation
28//!
29//! [`verify_unit`] checks **one** derivation and stops. It does not resolve the
30//! source's own provenance, and it does not fail because the source is itself a
31//! derived thing — that is the source's business, not this unit's.
32//!
33//! That is complete verification, not a pragmatic cut, and the reason is
34//! `Unit.wf`. `depth <= 1` says nothing is more than one derivation away from
35//! source material; a frame with no parents is genesis, the defined bottom. So
36//! there is no deeper chain to regress into **by construction**. The forensic
37//! obligation and the depth bound are the same constraint seen from two
38//! directions — which is also why refusing `concise` and `generate` in v0 is
39//! not merely caution: it is what keeps regress impossible.
40//!
41//! **And it does not resolve the root.** `verify_unit` recomputes the three
42//! claims that the source bytes can settle — source, span, elided — and no
43//! others. A unit naming a root id nothing ever minted verifies exactly like
44//! one naming a real event, because the bytes in hand cannot tell them apart.
45//! [`Verified`] therefore carries no root field: reporting one would imply this
46//! function had established something about it. Root resolution belongs to
47//! whoever holds the store, and `newt frame` reports it as a separate,
48//! separately-failable fact.
49//!
50//! There is deliberately no walker here. [`Verified`] reports the source id
51//! that was checked, so a caller who wants the next link calls again with it.
52//! The recursion lives in the caller; nothing in this design forbids depth, it
53//! simply does not implement an unbounded walk over data we do not control on
54//! anybody's behalf.
55//!
56//! # Why not `NodeStore`
57//!
58//! `content-addressable`'s [`NodeStore`] is the right seam for units and
59//! packets, and its `get` / `get_unverified` split is the distinction this
60//! module honours. It cannot serve *source* bytes: it is keyed by [`ContentId`]
61//! (the dag-cbor profile) and a source is an opaque byte string keyed by
62//! [`RawContentId`] (the raw profile). The two are deliberately different types
63//! with no conversion between them. [`SourceResolver`] is that second profile's
64//! lookup, not a second copy of the first.
65//!
66//! [`NodeStore`]: https://docs.rs/content-addressable
67//! [`Derivation::source`]: crate::Derivation::source
68//! [`Derivation::elided`]: crate::Derivation::elided
69
70use content_addressable::RawContentId;
71
72use crate::derivation::Span;
73use crate::unit::Unit;
74
75/// Where source bytes come from.
76///
77/// Split verified/unverified in the same shape as `NodeStore`: an implementor
78/// supplies the raw fetch, and verification is the caller's — here,
79/// [`verify_unit`]'s — job. An implementation that "helpfully" verified inside
80/// `resolve` would make the mismatch case untestable.
81pub trait SourceResolver {
82 /// Fetch the bytes named by `id`, **without** checking that they hash to it.
83 ///
84 /// # Errors
85 ///
86 /// Implementation-defined; surfaced as [`VerifyError::SourceUnavailable`].
87 fn resolve_unverified(&self, id: &RawContentId) -> Result<Vec<u8>, String>;
88}
89
90/// Both sides of a hash comparison.
91///
92/// Boxed inside [`VerifyError`] rather than inlined: a [`RawContentId`] is ~96
93/// bytes, so two of them in a variant made every `Result` from [`verify_unit`]
94/// 200 bytes wide — on the success path too. `clippy::result_large_err` is
95/// right about that.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct Mismatch {
98 /// What the unit claims.
99 pub declared: RawContentId,
100 /// What recomputation actually produced.
101 pub actual: RawContentId,
102}
103
104impl std::fmt::Display for Mismatch {
105 /// Always names BOTH sides. "It did not match" that does not say what did
106 /// not match sends the reader to a debugger.
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "declared {}, recomputed {}", self.declared, self.actual)
109 }
110}
111
112/// Why a unit did not verify.
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum VerifyError {
115 /// The supplied bytes are not the source the unit names.
116 #[error(
117 "source mismatch ({at}) over {len} supplied bytes: this is not the material \
118 the unit was derived from"
119 )]
120 SourceMismatch {
121 /// Declared versus recomputed source id.
122 at: Box<Mismatch>,
123 /// How many bytes were supplied.
124 len: usize,
125 },
126 /// The span runs past the end of the source.
127 #[error(
128 "span [{start}, {end}) does not lie within a source of {source_len} bytes: the \
129 address does not resolve, so there is nothing to re-derive"
130 )]
131 SpanOutOfBounds {
132 /// Start of the offending span.
133 start: u64,
134 /// End of the offending span.
135 end: u64,
136 /// Length of the source.
137 source_len: usize,
138 },
139 /// The addressed bytes do not hash to what the unit claims. **The
140 /// re-derivation failed.**
141 #[error("re-derivation mismatch over source[{start}..{end}] ({at})")]
142 ElisionMismatch {
143 /// Declared versus recomputed elided-material id.
144 at: Box<Mismatch>,
145 /// Start of the span.
146 start: u64,
147 /// End of the span.
148 end: u64,
149 },
150 /// The source could not be fetched at all.
151 #[error("source {id} could not be resolved: {reason}")]
152 SourceUnavailable {
153 /// The source that could not be fetched.
154 id: Box<RawContentId>,
155 /// Why not.
156 reason: String,
157 },
158}
159
160/// What a successful verification establishes — the facts a report renders.
161///
162/// Returned rather than a bare `bool` so a caller (and `newt frame explain`)
163/// reports what was checked, not merely that something was.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub struct Verified {
166 /// The source, confirmed by hashing the supplied bytes.
167 pub source: RawContentId,
168 /// The range within it that resolved.
169 pub span: Span,
170 /// The re-derived material, confirmed equal to the unit's claim.
171 pub elided: RawContentId,
172 /// How many bytes the span covered.
173 pub elided_len: u64,
174 /// Total size of the source.
175 pub source_len: u64,
176}
177
178/// **The verification decision.** Recompute the unit's claims from the source
179/// bytes and compare.
180///
181/// # Errors
182///
183/// See [`VerifyError`]; each variant names both the declared and the actual
184/// value.
185pub fn verify_unit(unit: &Unit, source: &[u8]) -> Result<Verified, VerifyError> {
186 let d = unit.derivation();
187
188 let actual_source = RawContentId::from_content(source);
189 if actual_source != d.source {
190 return Err(VerifyError::SourceMismatch {
191 at: Box::new(Mismatch {
192 declared: d.source,
193 actual: actual_source,
194 }),
195 len: source.len(),
196 });
197 }
198
199 let addressed = d.span.slice(source).ok_or(VerifyError::SpanOutOfBounds {
200 start: d.span.start,
201 end: d.span.end,
202 source_len: source.len(),
203 })?;
204
205 let actual_elided = RawContentId::from_content(addressed);
206 if actual_elided != d.elided {
207 return Err(VerifyError::ElisionMismatch {
208 at: Box::new(Mismatch {
209 declared: d.elided,
210 actual: actual_elided,
211 }),
212 start: d.span.start,
213 end: d.span.end,
214 });
215 }
216
217 Ok(Verified {
218 source: actual_source,
219 span: d.span,
220 elided: actual_elided,
221 elided_len: d.span.len(),
222 source_len: source.len() as u64,
223 })
224}
225
226/// [`verify_unit`], fetching the source through a resolver.
227///
228/// A thin wrapper on purpose: the decision stays in one function, and this adds
229/// only the fetch. The CLI uses this; the harness may use either.
230///
231/// # Errors
232///
233/// [`VerifyError::SourceUnavailable`] if the fetch fails, else whatever
234/// [`verify_unit`] returns.
235pub fn verify_unit_with(
236 unit: &Unit,
237 resolver: &dyn SourceResolver,
238) -> Result<Verified, VerifyError> {
239 let id = unit.derivation().source;
240 let bytes =
241 resolver
242 .resolve_unverified(&id)
243 .map_err(|reason| VerifyError::SourceUnavailable {
244 id: Box::new(id),
245 reason,
246 })?;
247 verify_unit(unit, &bytes)
248}