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}