Skip to main content

rfc9162_log/
lib.rs

1//! An authenticated, indexed, append-only log over [`rfc9162`] and
2//! [`rfc9162_tiles`]: the contract, the store that keeps it, and a sidecar
3//! over a project tree.
4//!
5//! `rfc9162` is the tree; `rfc9162-tiles` stores it with key sidecars that
6//! the checkpoint does not sign, finds a key by filtered scan, and detects a
7//! torn tail without proving the recovery. This crate adds, as a contract
8//! ([`AuthenticatedLog`]) and a store ([`Store`]) behind it:
9//!
10//! - every index structure is itself logged, so the checkpoint that signs
11//!   the entries signs the index ([`RunManifest::manifest_index`]);
12//! - a lookup answers with the entry and with the run that located it, each
13//!   carrying an inclusion path ([`Located`]);
14//! - keys are sorted into runs with a range, so a prefix or range query is
15//!   a seek through [`RunManifest::key_lo`] and [`RunManifest::key_hi`];
16//! - compaction writes a new run and logs it; the runs it replaces stay in
17//!   the log and stay provable ([`Compaction`]);
18//! - recovery replays the tail past the last signed checkpoint and proves the
19//!   result consistent with it ([`Recovery`]).
20//!
21//! [`Sidecar`] sits beside a project tree the way `.git` does: every change
22//! to a path is an entry whose bytes are a content-addressed blob in the
23//! same store, a snapshot is a signed checkpoint, and over a
24//! [`rfc9162_tiles::PackStore`] the whole history is one file.
25//!
26//! ```
27//! use std::sync::Arc;
28//!
29//! use rfc9162_log::{AuthenticatedLog, Store};
30//! use rfc9162_tiles::MemoryStore;
31//!
32//! let mut log = Store::open(MemoryStore::new(), Arc::new(|_| Vec::new()))?;
33//! let index = log.append(b"alpha")?;
34//! log.seal()?;
35//! let found = log.find(&rfc9162_tiles::index::identity(b"alpha"))?;
36//! assert_eq!(found[0].entry.index, index);
37//! let bytes = log.index()?.reader().get(index)?.unwrap();
38//! assert!(rfc9162::verify_inclusion(
39//!     &rfc9162::leaf_hash(&bytes),
40//!     index as usize,
41//!     found[0].entry.tree_size as usize,
42//!     &found[0].entry.path,
43//!     &rfc9162::hex(&log.root()),
44//! ));
45//! # Ok::<(), rfc9162_log::Error>(())
46//! ```
47
48#![forbid(unsafe_code)]
49
50pub mod sidecar;
51pub mod store;
52
53use std::ops::RangeInclusive;
54
55pub use sidecar::{Change, Found, Op, Sidecar, Synced};
56pub use store::{Keys, Store};
57
58/// A sealed, sorted run of lookup keys over a contiguous range of entries,
59/// recorded in the log as an entry of its own so the checkpoint signs it.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct RunManifest {
62    /// The entries the run indexes, by log index.
63    pub entries: RangeInclusive<u64>,
64    /// The smallest key in the run.
65    pub key_lo: Vec<u8>,
66    /// The largest key in the run.
67    pub key_hi: Vec<u8>,
68    /// `SHA-256` of the run's key file (sorted `(key, index)` pairs and the
69    /// filter), the bytes a reader checks before trusting a seek.
70    pub run_hash: [u8; 32],
71    /// The log index of the entry that records this manifest, once logged.
72    /// A run with no manifest index is sealed on disk and not yet in the
73    /// tree; a checkpoint does not cover it.
74    pub manifest_index: Option<u64>,
75}
76
77/// An inclusion path for one entry at one tree size, as [`rfc9162`] checks
78/// it: the sibling hashes from the leaf to the root.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Inclusion {
81    /// The entry's log index.
82    pub index: u64,
83    /// The tree size the path proves against.
84    pub tree_size: u64,
85    /// Sibling hashes, leaf to root.
86    pub path: Vec<[u8; 32]>,
87}
88
89/// What a lookup returns: the entry the key names and the run that located
90/// it, both with inclusion paths against the same tree size, so the reader
91/// proves the answer and the index that gave it with one root.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Located {
94    /// The entry's inclusion path.
95    pub entry: Inclusion,
96    /// The inclusion path of the run manifest the seek went through. `None`
97    /// when the entry sits in the unsealed tail, found by scan.
98    pub run: Option<Inclusion>,
99}
100
101/// One compaction: the runs it replaced and the run it wrote, each a logged
102/// manifest. The replaced runs remain in the log; a reader at an older
103/// checkpoint still proves through them.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Compaction {
106    /// Manifest indices of the runs merged.
107    pub replaced: Vec<u64>,
108    /// Manifest index of the run written.
109    pub written: u64,
110}
111
112/// What recovery proved after a crash: the checkpoint it started from, the
113/// entries replayed beyond it, and that the recovered tree is consistent
114/// with the checkpoint by [`rfc9162::verify_consistency`].
115///
116/// The checkpoint recovery starts from is the largest size a trusted key
117/// signed, read from a record the store's writer cannot reach: a store that
118/// can rewrite its own tree state or hand back an older signed checkpoint
119/// would otherwise shorten the log and keep every proof valid.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Recovery {
122    /// Tree size the last signed checkpoint covered.
123    pub checkpoint_size: u64,
124    /// Entries found beyond it and replayed into the tree.
125    pub replayed: u64,
126    /// Entries found beyond it that could not be replayed (torn, or past a
127    /// gap) and were quarantined.
128    pub quarantined: u64,
129    /// The consistency path from the checkpoint's tree to the recovered one.
130    pub consistency: Vec<[u8; 32]>,
131}
132
133/// The operations the log offers. Each returns the crate's [`Error`] on a
134/// store that cannot answer; a reader that distrusts the store checks the
135/// paths these types carry against a checkpoint it trusts.
136pub trait AuthenticatedLog {
137    /// Append one entry; returns its log index.
138    fn append(&mut self, entry: &[u8]) -> Result<u64, Error>;
139
140    /// Every entry carrying `key`, each with the run that located it. An
141    /// empty answer is proven, not assumed: the logged runs' entry ranges
142    /// cover every index below the checkpoint size except the scanned
143    /// tail, or the answer is [`Error::Tree`], since a withheld run file
144    /// would otherwise hide an entry without doctoring any bytes.
145    fn find(&self, key: &[u8]) -> Result<Vec<Located>, Error>;
146
147    /// Every entry whose keys fall in `range`, in key order, with their runs.
148    fn range(&self, range: RangeInclusive<&[u8]>) -> Result<Vec<Located>, Error>;
149
150    /// Seal the unsealed tail into a run and log its manifest.
151    fn seal(&mut self) -> Result<RunManifest, Error>;
152
153    /// Merge runs into one and log the result.
154    fn compact(&mut self) -> Result<Compaction, Error>;
155
156    /// Replay past the last checkpoint and prove the result consistent.
157    fn recover(&mut self) -> Result<Recovery, Error>;
158}
159
160/// What can go wrong between a caller and the store.
161#[derive(Debug)]
162pub enum Error {
163    /// The store answered with bytes that do not hash to what the log
164    /// committed to.
165    Tampered {
166        /// What was being checked.
167        what: &'static str,
168    },
169    /// The store could not be read or written.
170    Store(std::io::Error),
171    /// The tree or a proof is malformed.
172    Tree(&'static str),
173    /// The tile store refused in its own terms (a lock held, an entry too
174    /// long, a signature), with its message.
175    Tiles(String),
176}
177
178impl std::fmt::Display for Error {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            Self::Tampered { what } => write!(f, "{what} does not match what the log committed to"),
182            Self::Store(e) => write!(f, "store: {e}"),
183            Self::Tree(what) => write!(f, "tree: {what}"),
184            Self::Tiles(what) => write!(f, "tiles: {what}"),
185        }
186    }
187}
188
189impl std::error::Error for Error {}
190
191impl From<std::io::Error> for Error {
192    fn from(e: std::io::Error) -> Self {
193        Self::Store(e)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn a_run_outside_the_tree_has_no_manifest_index() {
203        let run = RunManifest {
204            entries: 0..=255,
205            key_lo: b"module=A".to_vec(),
206            key_hi: b"pkg=Z".to_vec(),
207            run_hash: rfc9162::leaf_hash(b"run bytes"),
208            manifest_index: None,
209        };
210        assert!(run.manifest_index.is_none());
211        assert_eq!(run.entries.clone().count(), 256);
212    }
213
214    #[test]
215    fn errors_name_what_failed() {
216        let e = Error::Tampered { what: "run file" };
217        assert_eq!(
218            e.to_string(),
219            "run file does not match what the log committed to"
220        );
221        let e: Error = std::io::Error::other("disk full").into();
222        assert!(e.to_string().starts_with("store: "));
223    }
224}