dig_store/size.rs
1//! The store SIZE and the SIZE PROOF (SPEC §4) — the download-gating core of this crate.
2//!
3//! A store anchors its `.dig` SIZE on chain so a client can decide, BEFORE downloading, whether the
4//! artifact is worth fetching. The anchored size is a coarse **power-of-2 bucket**, not an exact byte
5//! count — the smallest representation that still conveys magnitude (NC-8, minimal on-chain
6//! encoding): a single exponent `k ∈ 0..=10` mapping to `2^k MB`, 1 MB..1 GB.
7//!
8//! [`SizeBucket`] is re-exported VERBATIM from [`dig_merkle::SizeBucket`] — the ONE canonical owner
9//! of the ladder AND the byte→bucket mapping ([`SizeBucket::for_byte_len`]) — so the encoding lives
10//! in a single place and the on-chain (`dig-merkle` metadata `"sz"`) and client-side (this proof)
11//! views can never drift.
12//!
13//! The SIZE PROOF is the client-side check: given the bucket the store anchored on chain (NC-9) and
14//! the real byte length of a downloaded `.dig`, [`SizeProof::verify`] decides ACCEPT or DISCARD. A
15//! `.dig` whose real size does not fall in the anchored bucket is rejected — a dig-node MUST NOT
16//! store or serve it (SPEC §4, the discard rule).
17
18pub use dig_merkle::SizeBucket;
19
20use crate::error::{DigStoreError, DigStoreResult};
21
22/// The largest bucket exponent (`k = 10`, i.e. 1 GB). A byte length above the ceiling has no bucket;
23/// the discard diagnostics report `MAX_EXPONENT + 1` in that case to stay well-formed.
24const MAX_EXPONENT: u8 = 10;
25
26/// The verdict of a [`SizeProof::verify`] check: whether a downloaded `.dig` may be kept.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SizeVerdict {
29 /// The `.dig`'s real size falls in the store's on-chain-anchored bucket — keep it.
30 Accept,
31 /// The `.dig`'s real size does NOT match the anchored bucket — a dig-node MUST discard it.
32 Discard,
33}
34
35/// The client-side SIZE PROOF check (SPEC §4).
36///
37/// A `.dig`'s real size is trusted ONLY when it matches the size the store anchored on chain. The
38/// anchored bucket is obtained via an on-chain read (NC-9, `dig-merkle`); the real byte length is
39/// measured from the downloaded artifact.
40pub struct SizeProof;
41
42impl SizeProof {
43 /// Decides whether a downloaded `.dig` of `actual_bytes` bytes may be kept, given the
44 /// `anchored` bucket the store recorded on chain.
45 ///
46 /// The `.dig` is ACCEPTED iff its real byte length falls into the SAME bucket the store anchored
47 /// (`SizeBucket::for_byte_len(actual_bytes) == anchored`). Any other size — larger OR smaller —
48 /// yields [`SizeVerdict::Discard`]: a wrong-size artifact is never what the store committed to,
49 /// so it must not be cached or served.
50 ///
51 /// A byte length above the 1 GiB ceiling has no bucket and therefore can never match; it is
52 /// discarded rather than erroring, since a caller checking untrusted downloaded bytes wants a
53 /// verdict, not a failure.
54 pub fn verify(anchored: SizeBucket, actual_bytes: u64) -> SizeVerdict {
55 match SizeBucket::for_byte_len(actual_bytes) {
56 Ok(actual) if actual == anchored => SizeVerdict::Accept,
57 _ => SizeVerdict::Discard,
58 }
59 }
60
61 /// Like [`verify`](Self::verify) but returns an error carrying the mismatch detail instead of a
62 /// verdict — convenient when a caller wants `?`-propagation on the discard path.
63 ///
64 /// # Errors
65 ///
66 /// Returns [`DigStoreError::SizeProofMismatch`] when the `.dig` would be discarded.
67 pub fn require(anchored: SizeBucket, actual_bytes: u64) -> DigStoreResult<()> {
68 match Self::verify(anchored, actual_bytes) {
69 SizeVerdict::Accept => Ok(()),
70 SizeVerdict::Discard => {
71 // Report the observed bucket where one exists; a byte length over the ceiling has no
72 // bucket, so report `MAX_EXPONENT + 1` to keep the message well-formed.
73 let actual_k = SizeBucket::for_byte_len(actual_bytes)
74 .map(|bucket| bucket.exponent())
75 .unwrap_or(MAX_EXPONENT + 1);
76 Err(DigStoreError::SizeProofMismatch {
77 anchored_k: anchored.exponent(),
78 actual_k,
79 actual_bytes,
80 })
81 }
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 const MIB: u64 = 1 << 20;
91 const GIB: u64 = 1 << 30;
92
93 /// A `.dig` whose real size lands in the anchored bucket is accepted.
94 #[test]
95 fn size_proof_accepts_matching_bucket() {
96 let anchored = SizeBucket::from_exponent(7).unwrap(); // 128 MB
97 // Any byte length in (64 MiB, 128 MiB] maps to bucket 7.
98 assert_eq!(SizeProof::verify(anchored, 100 * MIB), SizeVerdict::Accept);
99 assert_eq!(SizeProof::verify(anchored, 128 * MIB), SizeVerdict::Accept);
100 }
101
102 /// A `.dig` that is too LARGE for the anchored bucket is discarded.
103 #[test]
104 fn size_proof_discards_oversize() {
105 let anchored = SizeBucket::from_exponent(7).unwrap(); // 128 MB
106 assert_eq!(
107 SizeProof::verify(anchored, 128 * MIB + 1),
108 SizeVerdict::Discard
109 );
110 }
111
112 /// A `.dig` that is too SMALL for the anchored bucket is also discarded — a store commits to an
113 /// exact bucket, not a maximum.
114 #[test]
115 fn size_proof_discards_undersize() {
116 let anchored = SizeBucket::from_exponent(7).unwrap(); // 128 MB, i.e. (64 MiB, 128 MiB]
117 assert_eq!(SizeProof::verify(anchored, 64 * MIB), SizeVerdict::Discard);
118 }
119
120 /// A `.dig` larger than the whole ladder can never match and is discarded, not errored.
121 #[test]
122 fn size_proof_discards_over_ceiling() {
123 let anchored = SizeBucket::from_exponent(10).unwrap();
124 assert_eq!(SizeProof::verify(anchored, GIB + 1), SizeVerdict::Discard);
125 }
126
127 /// `require` surfaces the discard as a structured error carrying the anchored + observed buckets.
128 #[test]
129 fn require_reports_mismatch_detail() {
130 let anchored = SizeBucket::from_exponent(7).unwrap();
131 let err = SizeProof::require(anchored, 64 * MIB).unwrap_err();
132 match err {
133 DigStoreError::SizeProofMismatch {
134 anchored_k,
135 actual_k,
136 actual_bytes,
137 } => {
138 assert_eq!(anchored_k, 7);
139 assert_eq!(actual_k, 6); // 64 MiB fits bucket 6
140 assert_eq!(actual_bytes, 64 * MIB);
141 }
142 other => panic!("expected SizeProofMismatch, got {other:?}"),
143 }
144 }
145
146 /// The over-ceiling discard path reports `MAX_EXPONENT + 1` as the observed bucket (no real
147 /// bucket exists) rather than erroring on the lookup.
148 #[test]
149 fn require_over_ceiling_reports_synthetic_bucket() {
150 let anchored = SizeBucket::from_exponent(10).unwrap();
151 let err = SizeProof::require(anchored, GIB + 1).unwrap_err();
152 match err {
153 DigStoreError::SizeProofMismatch { actual_k, .. } => assert_eq!(actual_k, 11),
154 other => panic!("expected SizeProofMismatch, got {other:?}"),
155 }
156 }
157
158 /// The accept path of `require` is a plain `Ok`.
159 #[test]
160 fn require_accepts_matching() {
161 let anchored = SizeBucket::from_exponent(7).unwrap();
162 assert!(SizeProof::require(anchored, 100 * MIB).is_ok());
163 }
164
165 /// The re-exported ladder is the canonical `dig_merkle::SizeBucket`: `k = 10` is a full 1 GB and
166 /// `byte_len()` is `2^(k+20)`, so the on-chain and client-side size views cannot drift.
167 #[test]
168 fn re_exported_ladder_is_canonical() {
169 assert_eq!(SizeBucket::from_exponent(10).unwrap().megabytes(), 1024);
170 assert_eq!(SizeBucket::from_exponent(10).unwrap().byte_len(), GIB);
171 assert_eq!(SizeBucket::for_byte_len(MIB).unwrap().exponent(), 0);
172 assert_eq!(SizeBucket::for_byte_len(MIB + 1).unwrap().exponent(), 1);
173 }
174}