Skip to main content

storage/
snapshot.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The chain-hash snapshot: what a fold held after one line of the
7//! Ledger, with that line's seq and chain hash (`crates/storage/spec/Snapshot.lean` ยง8-26).
8//!
9//! A projection, never history: a start that finds no snapshot, a
10//! damaged one, or one whose line hash does not match the line on disk
11//! folds the whole ledger, as it did before snapshots existed. That
12//! resuming is sound at all is `crates/storage/spec/Snapshot.lean`'s
13//! `resumeIsWhole`.
14
15use std::path::Path;
16
17use kernel::ledger::chain_hash;
18use kernel::{AxError, B3Hash, Seq};
19
20use crate::error::StorageError;
21use crate::jsonl::LineCheck;
22use crate::real_fs::RealFs;
23use crate::vfs::Vfs;
24
25mod start;
26
27pub use start::{SnapshotStart, WholeFold, start_from_snapshot, tail_after};
28
29const MAGIC: &[u8; 8] = b"SPRSNAP1";
30const FILE: &str = "chain.snap";
31/// Written whole and synced here first, then renamed over [`FILE`], so a
32/// write torn by a crash never leaves half a snapshot under that name.
33const STAGED: &str = "chain.snap.staged";
34
35/// What a fold held after the line at `seq`, and the chain hash that
36/// names that line.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ChainSnapshot {
39    fold_version: u32,
40    seq: Seq,
41    line_hash: B3Hash,
42    views: Vec<u8>,
43}
44
45/// Whether the line a positioned read found at the snapshot's seq is
46/// the line the snapshot was cut at.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SnapshotFit {
49    Fits,
50    /// Another line sits at that seq: the ledger is not the one the
51    /// snapshot was cut from, so its state must not be resumed.
52    Stale,
53}
54
55/// What the snapshot directory holds.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StoredSnapshot {
58    Absent,
59    /// Bytes that are not a whole snapshot, with the reason; a caller
60    /// discards them and folds the whole ledger.
61    Damaged(String),
62    Present(ChainSnapshot),
63}
64
65impl ChainSnapshot {
66    /// Cut a snapshot after `line`, the raw line (without its `\n`) at `seq`.
67    pub fn cut(fold_version: u32, seq: Seq, line: &[u8], views: Vec<u8>) -> ChainSnapshot {
68        ChainSnapshot {
69            fold_version,
70            seq,
71            line_hash: chain_hash(line),
72            views,
73        }
74    }
75
76    pub fn fold_version(&self) -> u32 {
77        self.fold_version
78    }
79
80    pub fn seq(&self) -> Seq {
81        self.seq
82    }
83
84    pub fn views(&self) -> &[u8] {
85        &self.views
86    }
87
88    /// Whether `other` was cut at the same line of the same ledger: the
89    /// same seq and the same chain hash, whatever each fold holds.
90    pub fn same_line(&self, other: &ChainSnapshot) -> bool {
91        self.seq == other.seq && self.line_hash == other.line_hash
92    }
93
94    /// Judge the raw line a positioned read found at [`Self::seq`].
95    pub fn fit(&self, line_at_seq: &[u8]) -> SnapshotFit {
96        if chain_hash(line_at_seq) == self.line_hash {
97            SnapshotFit::Fits
98        } else {
99            SnapshotFit::Stale
100        }
101    }
102
103    /// The chain state after the snapshot's line: the next line must
104    /// carry `prev = line_hash` and the seq after `seq`.
105    ///
106    /// # Errors
107    /// What `Seq::next` says when `seq` is the last seq there is.
108    pub fn resume(&self) -> Result<LineCheck, AxError> {
109        Ok(LineCheck::after(self.line_hash, self.seq.next()?))
110    }
111
112    /// `MAGIC | fold_version u32 LE | seq u64 LE | line_hash | blake3(views) | views`.
113    fn encode(&self) -> Vec<u8> {
114        let digest = B3Hash::digest(&self.views);
115        [
116            MAGIC.as_slice(),
117            &self.fold_version.to_le_bytes(),
118            &self.seq.value().to_le_bytes(),
119            self.line_hash.as_bytes(),
120            digest.as_bytes(),
121            &self.views,
122        ]
123        .concat()
124    }
125
126    fn decode(bytes: &[u8]) -> Result<ChainSnapshot, String> {
127        let short = || "the file ends inside its header".to_owned();
128        let (magic, rest) = bytes.split_first_chunk::<8>().ok_or_else(short)?;
129        if magic != MAGIC {
130            return Err("the file does not start with the snapshot magic".to_owned());
131        }
132        let (fold_version, rest) = rest.split_first_chunk::<4>().ok_or_else(short)?;
133        let (seq, rest) = rest.split_first_chunk::<8>().ok_or_else(short)?;
134        let (line_hash, rest) = rest.split_first_chunk::<32>().ok_or_else(short)?;
135        let (digest, views) = rest.split_first_chunk::<32>().ok_or_else(short)?;
136        if B3Hash::digest(views).as_bytes() != digest {
137            return Err("the views do not hash to the digest written beside them".to_owned());
138        }
139        Ok(ChainSnapshot {
140            fold_version: u32::from_le_bytes(*fold_version),
141            seq: Seq::new(u64::from_le_bytes(*seq)),
142            line_hash: B3Hash::from_bytes(*line_hash),
143            views: views.to_vec(),
144        })
145    }
146}
147
148/// Write `snapshot` into `dir` (created when absent), replacing the one
149/// there.
150///
151/// # Errors
152/// `StorageError::Snapshot` naming the step that failed.
153pub fn write_snapshot(dir: &Path, snapshot: &ChainSnapshot) -> Result<(), StorageError> {
154    let mut vfs = RealFs::new();
155    let (file, staged) = (dir.join(FILE), dir.join(STAGED));
156    vfs.create_dir_all(dir)
157        .map_err(refused("create snapshot dir", dir))?;
158    if vfs.exists(&staged) {
159        vfs.remove_file(&staged)
160            .map_err(refused("remove staged snapshot", &staged))?;
161    }
162    vfs.append(&staged, &snapshot.encode())
163        .map_err(refused("write staged snapshot", &staged))?;
164    vfs.sync_data(&staged)
165        .map_err(refused("sync staged snapshot", &staged))?;
166    vfs.rename(&staged, &file)
167        .map_err(refused("rename staged snapshot", &file))?;
168    vfs.sync_dir(dir).map_err(refused("sync snapshot dir", dir))
169}
170
171/// Read the snapshot `dir` holds.
172///
173/// # Errors
174/// `StorageError::Snapshot` when the file exists and cannot be read; bytes that
175/// are not a snapshot are `StoredSnapshot::Damaged`, not an error.
176pub fn read_snapshot(dir: &Path) -> Result<StoredSnapshot, StorageError> {
177    let file = dir.join(FILE);
178    match RealFs::new().read(&file) {
179        Ok(bytes) => Ok(ChainSnapshot::decode(&bytes)
180            .map_or_else(StoredSnapshot::Damaged, StoredSnapshot::Present)),
181        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoredSnapshot::Absent),
182        Err(e) => Err(refused("read snapshot", &file)(e)),
183    }
184}
185
186/// The constructor of a snapshot's I/O failure, as `io_err` is the
187/// ledger's.
188fn refused(op: &'static str, path: &Path) -> impl FnOnce(std::io::Error) -> StorageError {
189    let path = path.to_path_buf();
190    move |source| StorageError::Snapshot { op, path, source }
191}
192
193#[cfg(test)]
194#[allow(
195    clippy::unwrap_used,
196    clippy::expect_used,
197    clippy::panic,
198    clippy::indexing_slicing,
199    clippy::arithmetic_side_effects,
200    reason = "test code"
201)]
202mod tests;