Skip to main content

metamorphic_log/
tile.rs

1//! C2SP [`tlog-tiles`] substrate: tile coordinates, paths, and recompute.
2//!
3//! A tiled transparency log serves its Merkle tree not as dynamic proof
4//! endpoints but as a set of immutable, content-addressed **tiles** — each tile
5//! a sequence of consecutive RFC 6962 Merkle Tree Hashes at a given tree
6//! *level*. A client (or independent witness) fetches the tiles it needs and
7//! recomputes any root or proof itself. This module implements the read side:
8//! the tile coordinate system, the exact `tile/<L>/<N>[.p/<W>]` path encoding,
9//! the tile/entry-bundle byte layout, and the recompute relationship that lets
10//! a verifier reproduce a tile (and ultimately the tree root) from the tiles
11//! below it, using the same fixed RFC 6962 hashing as [`crate::merkle`].
12//!
13//! All Merkle operations are SHA-256 per RFC 6962 (the one ecosystem-fixed,
14//! witness-recomputable hashing spot — see [`crate::merkle`]); tiles are merely
15//! an alternative *encoding* of that same tree.
16//!
17//! ## Geometry
18//!
19//! A full tile is exactly **256 hashes** (8,192 bytes). The *n*-th tile at
20//! level *l* holds, for *i* in `0..256`, the hash
21//! `MTH(D[(n*256+i) * 256^l : (n*256+i+1) * 256^l])`. Its **start index** is
22//! `n * 256^(l+1)` and its **end index** is `(n+1) * 256^(l+1)`. The rightmost
23//! tiles of a growing tree are **partial** (1..=255 hashes); a partial tile at
24//! level *l* for a tree of size *s* has `floor(s / 256^l) mod 256` hashes and
25//! MUST NOT be hashed into the level above.
26//!
27//! [`tlog-tiles`]: https://c2sp.org/tlog-tiles
28
29use crate::error::{Error, Result};
30use crate::merkle::{HASH_LEN, Hash, merkle_tree_hash};
31
32/// The width (in hashes) of a full tile, and the tile fan-out per level.
33pub const TILE_WIDTH: u16 = 256;
34
35/// The maximum tile level, inclusive (`tile/<L>/...`, `L` in `0..=63`).
36pub const MAX_LEVEL: u8 = 63;
37
38/// A C2SP `tlog-tiles` tile coordinate.
39///
40/// Identifies a tile by its `level`, its `index` within that level, and its
41/// `width` in hashes. A width of [`TILE_WIDTH`] (256) is a *full* tile; a width
42/// in `1..=255` is a *partial* tile (the rightmost tile of a tree whose size is
43/// not a multiple of the tile span).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct Tile {
46    level: u8,
47    index: u64,
48    width: u16,
49}
50
51impl Tile {
52    /// Construct a tile coordinate, validating the level and width.
53    ///
54    /// # Errors
55    /// Returns [`Error::MalformedTile`] if `level > 63` or `width` is not in
56    /// `1..=256`.
57    pub fn new(level: u8, index: u64, width: u16) -> Result<Self> {
58        if level > MAX_LEVEL {
59            return Err(Error::MalformedTile(format!(
60                "level {level} exceeds maximum {MAX_LEVEL}"
61            )));
62        }
63        if width == 0 || width > TILE_WIDTH {
64            return Err(Error::MalformedTile(format!(
65                "tile width {width} not in 1..=256"
66            )));
67        }
68        Ok(Self {
69            level,
70            index,
71            width,
72        })
73    }
74
75    /// The tile level (`0` = leaf-hash tiles).
76    #[must_use]
77    pub fn level(&self) -> u8 {
78        self.level
79    }
80
81    /// The tile index within its level.
82    #[must_use]
83    pub fn index(&self) -> u64 {
84        self.index
85    }
86
87    /// The tile width in hashes (`256` for a full tile).
88    #[must_use]
89    pub fn width(&self) -> u16 {
90        self.width
91    }
92
93    /// Whether this is a partial tile (`width < 256`).
94    #[must_use]
95    pub fn is_partial(&self) -> bool {
96        self.width < TILE_WIDTH
97    }
98
99    /// The number of serialized bytes a tile of this width occupies
100    /// (`width * 32`).
101    #[must_use]
102    pub fn byte_len(&self) -> usize {
103        self.width as usize * HASH_LEN
104    }
105
106    /// The Merkle tile path `tile/<L>/<N>[.p/<W>]`.
107    ///
108    /// `<N>` is encoded as zero-padded 3-digit path elements, all but the last
109    /// prefixed with `x` (e.g. index `1234067` → `x001/x234/067`). The
110    /// `.p/<W>` suffix is present only for partial tiles.
111    #[must_use]
112    pub fn path(&self) -> String {
113        let mut p = format!("tile/{}/{}", self.level, encode_index(self.index));
114        if self.is_partial() {
115            p.push_str(&format!(".p/{}", self.width));
116        }
117        p
118    }
119
120    /// The entry-bundle path `tile/entries/<N>[.p/<W>]` for this tile's index
121    /// and width. Only meaningful for level-0 coordinates, but defined purely
122    /// in terms of `(index, width)`.
123    #[must_use]
124    pub fn entries_path(&self) -> String {
125        let mut p = format!("tile/entries/{}", encode_index(self.index));
126        if self.is_partial() {
127            p.push_str(&format!(".p/{}", self.width));
128        }
129        p
130    }
131
132    /// Parse a Merkle tile path `tile/<L>/<N>[.p/<W>]`.
133    ///
134    /// # Errors
135    /// Returns [`Error::MalformedTile`] if the path does not match the grammar,
136    /// including leading-zero levels/widths, malformed index path elements, or
137    /// an out-of-range level/width.
138    pub fn parse_path(path: &str) -> Result<Self> {
139        let rest = path
140            .strip_prefix("tile/")
141            .ok_or_else(|| Error::MalformedTile(format!("missing 'tile/' prefix: {path:?}")))?;
142
143        // Split the optional partial suffix `.p/<W>`.
144        let (coords, width_override) = match rest.split_once(".p/") {
145            Some((coords, w)) => {
146                let w = parse_decimal_u16(w)?;
147                if !(1..TILE_WIDTH).contains(&w) {
148                    return Err(Error::MalformedTile(format!(
149                        "partial tile width {w} not in 1..=255"
150                    )));
151                }
152                (coords, Some(w))
153            }
154            None => (rest, None),
155        };
156
157        let (level_str, index_str) = coords
158            .split_once('/')
159            .ok_or_else(|| Error::MalformedTile(format!("missing level/index: {path:?}")))?;
160
161        let level = parse_decimal_u8(level_str)?;
162        let index = decode_index(index_str)?;
163
164        let width = match width_override {
165            Some(w) => w,
166            None => TILE_WIDTH,
167        };
168        Self::new(level, index, width)
169    }
170
171    /// Interpret raw tile bytes as the tile's sequence of 32-byte hashes,
172    /// validating that the byte length matches the declared width.
173    ///
174    /// # Errors
175    /// Returns [`Error::MalformedTile`] if `bytes.len() != width * 32`.
176    pub fn hashes(&self, bytes: &[u8]) -> Result<Vec<Hash>> {
177        if bytes.len() != self.byte_len() {
178            return Err(Error::MalformedTile(format!(
179                "tile byte length {} does not match width {} ({} bytes)",
180                bytes.len(),
181                self.width,
182                self.byte_len()
183            )));
184        }
185        Ok(bytes
186            .chunks_exact(HASH_LEN)
187            .map(|c| {
188                let mut h = [0u8; HASH_LEN];
189                h.copy_from_slice(c);
190                h
191            })
192            .collect())
193    }
194}
195
196/// The partial-tile width for `level` in a tree of `size` leaves:
197/// `floor(size / 256^level) mod 256`. A return of `0` means there is no tile at
198/// this level for this size (it is exactly tile-aligned with nothing left
199/// over).
200#[must_use]
201pub fn partial_width(level: u8, size: u64) -> u16 {
202    // 256^level == 2^(8*level). For level >= 8 the span exceeds any u64 tree
203    // size, so floor(size / span) is 0 and there is no tile at this level.
204    // Short-circuit to avoid overflowing the 256^level computation.
205    if level >= 8 {
206        return 0;
207    }
208    let span = 256u128.pow(u32::from(level));
209    ((u128::from(size) / span) % 256) as u16
210}
211
212/// Enumerate every tile required to represent a tree of `size` leaves, ordered
213/// level-by-level (level 0 first) and by index within each level.
214///
215/// For each level, the full tiles come first (each width 256), followed by at
216/// most one trailing partial tile. Returns an empty vector for `size == 0`.
217///
218/// This is the set of tiles a witness fetches to recompute the tree: feeding
219/// the concatenated level-0 leaf hashes through [`recompute_root`] reproduces
220/// the checkpoint root.
221#[must_use]
222pub fn tiles_for_size(size: u64) -> Vec<Tile> {
223    let mut tiles = Vec::new();
224    if size == 0 {
225        return tiles;
226    }
227    for level in 0..=MAX_LEVEL {
228        let span = 256u128.pow(u32::from(level));
229        if u128::from(size) < span {
230            // No subtree of `256^level` leaves fits: this level (and every
231            // higher one) holds no hash. The previous level carried the root.
232            break;
233        }
234        let full = (u128::from(size) / (span * 256)) as u64;
235        for n in 0..full {
236            tiles.push(Tile {
237                level,
238                index: n,
239                width: TILE_WIDTH,
240            });
241        }
242        let partial = partial_width(level, size);
243        if partial > 0 {
244            tiles.push(Tile {
245                level,
246                index: full,
247                width: partial,
248            });
249        }
250    }
251    tiles
252}
253
254/// Recompute the RFC 6962 tree root from the in-order leaf hashes of a tree.
255///
256/// `leaf_hashes` is the concatenation, in index order, of every level-0 tile's
257/// hashes (i.e. the per-entry leaf hashes). The result is the Merkle Tree Hash
258/// that a checkpoint at this size commits to. This is independent recomputation
259/// (#316): the verifier derives the root itself rather than trusting a served
260/// value.
261#[must_use]
262pub fn recompute_root(leaf_hashes: &[Hash]) -> Hash {
263    merkle_tree_hash(leaf_hashes)
264}
265
266/// Recompute the single hash that the *parent* (level `l+1`) tile stores for a
267/// *full* level-`l` tile: the RFC 6962 Merkle Tree Hash over the tile's 256
268/// hashes.
269///
270/// # Errors
271/// Returns [`Error::MalformedTile`] if the tile is partial (partial tiles MUST
272/// NOT be hashed into the level above, per the spec).
273pub fn parent_hash(tile_hashes: &[Hash]) -> Result<Hash> {
274    if tile_hashes.len() != TILE_WIDTH as usize {
275        return Err(Error::MalformedTile(format!(
276            "parent_hash requires a full 256-hash tile, got {}",
277            tile_hashes.len()
278        )));
279    }
280    Ok(merkle_tree_hash(tile_hashes))
281}
282
283/// Encode a tile index into `x`-prefixed, zero-padded 3-digit path elements.
284fn encode_index(n: u64) -> String {
285    let mut parts = vec![format!("{:03}", n % 1000)];
286    let mut m = n / 1000;
287    while m > 0 {
288        parts.push(format!("x{:03}", m % 1000));
289        m /= 1000;
290    }
291    parts.reverse();
292    parts.join("/")
293}
294
295/// Decode an `x`-prefixed, 3-digit path-element index back into a `u64`.
296fn decode_index(s: &str) -> Result<u64> {
297    let parts: Vec<&str> = s.split('/').collect();
298    let malformed = || Error::MalformedTile(format!("malformed tile index path: {s:?}"));
299    let mut value: u64 = 0;
300    for (i, part) in parts.iter().enumerate() {
301        let is_last = i == parts.len() - 1;
302        let digits = if is_last {
303            part
304        } else {
305            part.strip_prefix('x').ok_or_else(malformed)?
306        };
307        if digits.len() != 3 || !digits.bytes().all(|b| b.is_ascii_digit()) {
308            return Err(malformed());
309        }
310        let group: u64 = digits.parse().map_err(|_| malformed())?;
311        value = value
312            .checked_mul(1000)
313            .and_then(|v| v.checked_add(group))
314            .ok_or_else(malformed)?;
315    }
316    Ok(value)
317}
318
319/// Parse a decimal `u8` with no leading zeroes (per the tile-path grammar).
320fn parse_decimal_u8(s: &str) -> Result<u8> {
321    check_no_leading_zero(s)?;
322    s.parse::<u8>()
323        .map_err(|_| Error::MalformedTile(format!("invalid decimal byte: {s:?}")))
324}
325
326/// Parse a decimal `u16` with no leading zeroes (per the tile-path grammar).
327fn parse_decimal_u16(s: &str) -> Result<u16> {
328    check_no_leading_zero(s)?;
329    s.parse::<u16>()
330        .map_err(|_| Error::MalformedTile(format!("invalid decimal: {s:?}")))
331}
332
333/// Reject empty strings and multi-digit values with a leading zero.
334fn check_no_leading_zero(s: &str) -> Result<()> {
335    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
336        return Err(Error::MalformedTile(format!(
337            "not a decimal integer: {s:?}"
338        )));
339    }
340    if s.len() > 1 && s.starts_with('0') {
341        return Err(Error::MalformedTile(format!(
342            "leading zero not allowed: {s:?}"
343        )));
344    }
345    Ok(())
346}
347
348#[cfg(all(test, not(target_arch = "wasm32")))]
349mod tests {
350    use super::*;
351    use crate::merkle::{MerkleTree, hash_leaf};
352    use proptest::prelude::*;
353
354    #[test]
355    fn index_encoding_spec_example() {
356        // The worked example from the tlog-tiles spec.
357        assert_eq!(encode_index(1_234_067), "x001/x234/067");
358        assert_eq!(encode_index(0), "000");
359        assert_eq!(encode_index(67), "067");
360        assert_eq!(encode_index(1234), "x001/234");
361    }
362
363    #[test]
364    fn path_round_trip_full_and_partial() {
365        let full = Tile::new(2, 1_234_067, TILE_WIDTH).unwrap();
366        assert_eq!(full.path(), "tile/2/x001/x234/067");
367        assert_eq!(Tile::parse_path(&full.path()).unwrap(), full);
368
369        let partial = Tile::new(1, 5, 17).unwrap();
370        assert_eq!(partial.path(), "tile/1/005.p/17");
371        assert_eq!(Tile::parse_path(&partial.path()).unwrap(), partial);
372    }
373
374    #[test]
375    fn parse_rejects_bad_paths() {
376        assert!(Tile::parse_path("tile/64/000").is_err()); // level too high
377        assert!(Tile::parse_path("tile/00/000").is_err()); // leading zero level
378        assert!(Tile::parse_path("tile/0/00").is_err()); // index group not 3 digits
379        assert!(Tile::parse_path("tile/0/000.p/256").is_err()); // partial width 256
380        assert!(Tile::parse_path("tile/0/000.p/0").is_err()); // partial width 0
381        assert!(Tile::parse_path("tile/0/1234/067").is_err()); // missing x prefix
382        assert!(Tile::parse_path("checkpoint").is_err());
383    }
384
385    #[test]
386    fn partial_width_does_not_overflow_for_high_levels() {
387        // Levels >= 8 have a span exceeding any u64 size: no tile, no panic.
388        assert_eq!(partial_width(8, u64::MAX), 0);
389        assert_eq!(partial_width(MAX_LEVEL, u64::MAX), 0);
390    }
391
392    #[test]
393    fn partial_width_spec_example() {
394        // tlog-tiles spec: a tree of size 70,000.
395        let size = 70_000;
396        assert_eq!(partial_width(0, size), 112); // 70000 mod 256
397        assert_eq!(partial_width(1, size), 17); // floor(70000/256)=273; 273 mod 256
398        assert_eq!(partial_width(2, size), 1); // floor(70000/65536)=1
399    }
400
401    #[test]
402    fn tiles_for_size_70000_matches_spec() {
403        // 273 full L0 tiles + 1 partial(112) L0 + 1 full L1 + 1 partial(17) L1
404        // + 1 partial(1) L2.
405        let tiles = tiles_for_size(70_000);
406        let l0: Vec<_> = tiles.iter().filter(|t| t.level == 0).collect();
407        let l1: Vec<_> = tiles.iter().filter(|t| t.level == 1).collect();
408        let l2: Vec<_> = tiles.iter().filter(|t| t.level == 2).collect();
409
410        assert_eq!(l0.iter().filter(|t| !t.is_partial()).count(), 273);
411        assert_eq!(l0.iter().filter(|t| t.is_partial()).count(), 1);
412        assert_eq!(l0.last().unwrap().width, 112);
413
414        assert_eq!(l1.iter().filter(|t| !t.is_partial()).count(), 1);
415        assert_eq!(l1.last().unwrap().width, 17);
416
417        assert_eq!(l2.len(), 1);
418        assert_eq!(l2[0].width, 1);
419    }
420
421    #[test]
422    fn tree_of_size_256_has_partial_level1_width_1() {
423        // Spec: a tree of size 256 is a full level-0 tile and a partial level-1
424        // tile of width 1.
425        let tiles = tiles_for_size(256);
426        assert_eq!(tiles.len(), 2);
427        assert_eq!((tiles[0].level, tiles[0].width), (0, 256));
428        assert_eq!((tiles[1].level, tiles[1].width), (1, 1));
429    }
430
431    #[test]
432    fn parent_hash_matches_oracle_subtree_root() {
433        // A full level-0 tile of 256 leaf hashes recomputes to the level-1 entry
434        // (the subtree root over those 256 leaves) per the oracle MerkleTree.
435        let mut tree = MerkleTree::new();
436        let leaves: Vec<Hash> = (0u32..256).map(|i| hash_leaf(&i.to_be_bytes())).collect();
437        for h in &leaves {
438            tree.push_leaf_hash(*h);
439        }
440        let oracle_root = tree.root();
441        assert_eq!(parent_hash(&leaves).unwrap(), oracle_root);
442        assert!(parent_hash(&leaves[..255]).is_err());
443    }
444
445    #[test]
446    fn recompute_root_matches_oracle() {
447        let mut tree = MerkleTree::new();
448        let mut leaf_hashes = Vec::new();
449        for i in 0u32..1000 {
450            let h = hash_leaf(&i.to_be_bytes());
451            leaf_hashes.push(h);
452            tree.push_leaf_hash(h);
453        }
454        assert_eq!(recompute_root(&leaf_hashes), tree.root());
455    }
456
457    #[test]
458    fn hashes_validates_length() {
459        let t = Tile::new(0, 0, 2).unwrap();
460        assert!(t.hashes(&[0u8; 64]).is_ok());
461        assert!(t.hashes(&[0u8; 63]).is_err());
462        assert!(t.hashes(&[0u8; 96]).is_err());
463    }
464
465    proptest! {
466        #[test]
467        fn index_round_trip(n in 0u64..100_000_000) {
468            prop_assert_eq!(decode_index(&encode_index(n)).unwrap(), n);
469        }
470
471        #[test]
472        fn tile_path_round_trip(
473            level in 0u8..=MAX_LEVEL,
474            index in 0u64..10_000_000,
475            width in 1u16..=TILE_WIDTH,
476        ) {
477            let t = Tile::new(level, index, width).unwrap();
478            prop_assert_eq!(Tile::parse_path(&t.path()).unwrap(), t);
479        }
480    }
481}