OXYGENHUB

Coverage / crates/hub/src/tlog.rs 90.0%

609 of 677 lines run, 113 of 172 functions · run #494 on main · run missed

1
//! The hub's transparency log (oxygenhub#238, docs/transparency.md, Oxygen
2
//! spec §14.8): one entry per signed object the hub accepts, in the order
3
//! it accepts them, kept as append-only files and served as C2SP
4
//! tlog-tiles with a signed checkpoint, so anyone can check the hub shows
5
//! everyone the same history.
6
//!
7
//! Files in `<data>/tlog/`:
8
//! - `entries`: each entry as a big-endian u16 length and its bytes;
9
//! - `offsets`: each entry's offset in `entries`, a u64 (little-endian);
10
//! - `hashes`: every hash of the tree in stored order (Oxygen spec §14.8);
11
//! - `checkpoint`: the latest signed checkpoint note (replaced, not
12
//!   appended, as it is only a signature over the others).
13
//!
14
//! Nothing is rewritten: a crash mid-append leaves at most a tail that is
15
//! cut back to the last whole entry on open, and the hashes are rebuilt
16
//! from the entries. The database's `tlog_entries` points from objects to
17
//! entry numbers.
18
19
use crate::{db, Shared};
20
use axum::extract::{Path as UrlPath, State};
21
use axum::http::{header, StatusCode};
22
use axum::response::{IntoResponse, Response};
23
use oxy_core::hash::{KeyId, ObjectRef};
24
use oxy_core::identity::Identity;
25
use oxy_core::repo::{PushReport, Repo};
26
use oxy_core::tlog::{self, Checkpoint, Entry, Hash, HashReader, Tile, TlogError, TILE_WIDTH};
27
use std::fs::{File, OpenOptions};
28
use std::io::Write;
29
use std::os::unix::fs::FileExt;
30
use std::path::{Path, PathBuf};
31
use std::sync::Mutex;
32
33
/// How often a new checkpoint is signed while entries arrive.
34
pub const CHECKPOINT_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
35
36
struct Files {
37
    entries: File,
38
    offsets: File,
39
    hashes: File,
40
    /// Entries in the log.
41
    size: u64,
42
    /// Bytes in `entries`.
43
    entries_len: u64,
44
}
45
46
/// The stored hashes in a file, by stored index.
47
struct FileHashes<'a>(&'a File);
48
49
impl HashReader for FileHashes<'_> {
50880×
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>, TlogError> {
51880×
        indexes
52880×
            .iter()
531202×
            .map(|&i| {
541202×
                let mut h = [0u8; 32];
551202×
                self.0.read_exact_at(&mut h, i * 32).map_err(|e| TlogError::Read(format!("stored hash {i}: {e}")))?;
561202×
                Ok(h)
571202×
            })
58880×
            .collect()
59880×
    }
60
}
61
62
/// The log on disk.
63
pub struct Log {
64
    dir: PathBuf,
65
    /// The log's name in its checkpoints: the hub's host and `/log`.
66
    pub origin: String,
67
    files: Mutex<Files>,
68
    /// `append.lock`: held, exclusively, around each append, so several
69
    /// processes sharing the data directory append one at a time (#274).
70
    lock: File,
71
    /// The latest signed checkpoint: its size and its note.
72
    checkpoint: Mutex<Option<(u64, String)>>,
73
    /// Witnesses this log asks to cosign its checkpoints.
74
    witnesses: std::sync::RwLock<Vec<Peer>>,
75
    /// Logs this hub witnesses: their origins and keys.
76
    witness_for: std::sync::RwLock<Vec<(String, Identity)>>,
77
    /// Whether this process writes the log: the web role's (#272). A
78
    /// process without it only reads, catching up with the writers.
79
    writer: bool,
80
}
81
82
/// A witness of this hub's log.
83
#[derive(Clone, Debug)]
84
pub struct Peer {
85
    pub name: String,
86
    /// Its add-checkpoint address.
87
    pub url: String,
88
    pub identity: Identity,
89
}
90
91496×
fn read_u64_at(f: &File, at: u64) -> std::io::Result<u64> {
92496×
    let mut b = [0u8; 8];
93496×
    f.read_exact_at(&mut b, at)?;
94496×
    Ok(u64::from_le_bytes(b))
95496×
}
96
97
/// The origin of a hub's log: its host (and port) and `/log`.
98197×
pub fn origin_of(base_url: &str) -> String {
99197×
    let host = base_url.split_once("://").map(|(_, h)| h).unwrap_or(base_url);
100197×
    format!("{}/log", host.trim_end_matches('/'))
101197×
}
102
103
impl Log {
104
    /// Open (or start) the log in `dir`, recovering from an interrupted
105
    /// append.
106200×
    pub fn open(dir: &Path, origin: &str) -> anyhow::Result<Log> {
107200×
        Self::open_as(dir, origin, true)
108200×
    }
109
110
    /// Open the log to read only, as a process without the web role does
111
    /// (#272): it neither appends nor signs, and leaves the files as they
112
    /// are, so it never cuts short an append a writer is making. Several
113
    /// web processes may share the data directory and write (#274).
1143×
    pub fn open_reader(dir: &Path, origin: &str) -> anyhow::Result<Log> {
1153×
        Self::open_as(dir, origin, false)
1163×
    }
117
118203×
    fn open_as(dir: &Path, origin: &str, writer: bool) -> anyhow::Result<Log> {
119203×
        std::fs::create_dir_all(dir)?;
120609×
        let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name));
121203×
        let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?);
122203×
        let lock = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("append.lock"))?;
123203×
        let mut files = Files { entries, offsets, hashes, size: 0, entries_len: 0 };
124203×
        if writer {
125200×
            lock.lock()?;
126200×
            let done = Self::recover(&mut files, true);
127200×
            lock.unlock()?;
128200×
            done?;
129
        } else {
1303×
            Self::recover(&mut files, false)?;
131
        }
132203×
        let size = files.size;
133203×
        Ok(Log {
134203×
            dir: dir.to_path_buf(),
135203×
            origin: origin.to_string(),
136203×
            files: Mutex::new(files),
137203×
            lock,
138203×
            checkpoint: Mutex::new(Self::read_checkpoint(dir, size)),
139203×
            witnesses: Default::default(),
140203×
            witness_for: Default::default(),
141203×
            writer,
142203×
        })
143203×
    }
144
145
    /// Read the log's extent from its files, as another process may have
146
    /// appended since (#274). The whole entries count: those whose offset
147
    /// and bytes were written, and whose stored hashes all were. A writer,
148
    /// holding the append lock, also finishes what an append cut short left
149
    /// (it truncates partial bytes and computes missing hashes again); a
150
    /// reader changes nothing and takes the whole entries only.
151865×
    fn recover(files: &mut Files, writer: bool) -> anyhow::Result<()> {
152865×
        let entries_on_disk = files.entries.metadata()?.len();
153865×
        let mut size = files.offsets.metadata()?.len() / 8;
154865×
        let mut entries_len = 0;
155867×
        while size > 0 {
156413×
            let off = read_u64_at(&files.offsets, (size - 1) * 8)?;
157413×
            let mut len = [0u8; 2];
158413×
            if off + 2 <= entries_on_disk && files.entries.read_exact_at(&mut len, off).is_ok() {
159411×
                let end = off + 2 + u16::from_be_bytes(len) as u64;
160411×
                if end <= entries_on_disk {
161411×
                    entries_len = end;
162411×
                    break;
1630×
                }
1642×
            }
1652×
            size -= 1;
166
        }
167
        // The hashes of every leaf whose hashes were all written; the rest
168
        // are computed again from the entries.
169865×
        let have = files.hashes.metadata()?.len() / 32;
170865×
        let mut whole = size;
171866×
        while tlog::stored_hash_count(whole) > have {
1721×
            whole -= 1;
1731×
        }
174865×
        if !writer {
175250×
            files.size = whole;
176250×
            files.entries_len = entries_len;
177250×
            return Ok(());
178615×
        }
179615×
        files.offsets.set_len(size * 8)?;
180615×
        files.entries.set_len(entries_len)?;
181615×
        files.hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
182615×
        files.entries_len = entries_len;
183615×
        for n in whole..size {
1841×
            let bytes = Self::entry_bytes(files, n)?;
1851×
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&files.hashes))?;
1863×
            for h in add {
1873×
                files.hashes.write_all(&h)?;
188
            }
1891×
            files.size = n + 1;
190
        }
191615×
        files.size = size;
192615×
        files.hashes.sync_data()?;
193615×
        Ok(())
194865×
    }
195
196
    /// Catch up with what other processes appended (#274).
197247×
    fn catch_up(&self, f: &mut Files) {
198247×
        if let Err(e) = Self::recover(f, false) {
1990×
            tracing::warn!("tlog: reading the log's extent: {e:#}");
200247×
        }
201247×
    }
202
203
    /// The signed checkpoint kept in `dir`, if it is of at most `size` entries.
2041011×
    fn read_checkpoint(dir: &Path, size: u64) -> Option<(u64, String)> {
2051011×
        std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| {
206581×
            let n = tlog::Note::parse(&note).ok()?;
207581×
            let c = Checkpoint::parse(&n.text).ok()?;
208581×
            (c.size <= size).then_some((c.size, note))
209581×
        })
2101011×
    }
211
21283×
    fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> {
21383×
        let off = read_u64_at(&f.offsets, n * 8)?;
21483×
        let mut len = [0u8; 2];
21583×
        f.entries.read_exact_at(&mut len, off)?;
21683×
        let mut b = vec![0u8; u16::from_be_bytes(len) as usize];
21783×
        f.entries.read_exact_at(&mut b, off + 2)?;
21883×
        Ok(b)
21983×
    }
220
221
    /// Entry `n`'s bytes.
22231×
    pub fn entry(&self, n: u64) -> anyhow::Result<Vec<u8>> {
22331×
        let mut f = self.files.lock().unwrap();
22431×
        if n >= f.size {
2250×
            self.catch_up(&mut f);
22631×
        }
22731×
        anyhow::ensure!(n < f.size, "entry {n} is not in the log");
22831×
        Self::entry_bytes(&f, n)
22931×
    }
230
231
    /// Entries in the log.
232247×
    pub fn size(&self) -> u64 {
233247×
        let mut f = self.files.lock().unwrap();
234247×
        self.catch_up(&mut f);
235247×
        f.size
236247×
    }
237
238
    /// Append entries, durably; their numbers.
239417×
    pub fn append(&self, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
240417×
        if !self.writer {
2412×
            anyhow::bail!("this process does not write the log: it has no web role");
242415×
        }
243415×
        let mut f = self.files.lock().unwrap();
244
        // One append at a time across the processes sharing the directory
245
        // (#274), each from the log as the last left it.
246415×
        self.lock.lock()?;
247415×
        let appended = Self::recover(&mut f, true).and_then(|()| Self::append_locked(&mut f, entries));
248415×
        self.lock.unlock()?;
249415×
        appended
250417×
    }
251
252415×
    fn append_locked(f: &mut Files, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
253415×
        let mut out = Vec::with_capacity(entries.len());
254812×
        for e in entries {
255812×
            let bytes = e.encode();
256812×
            let n = f.size;
257812×
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&f.hashes))?;
258812×
            let off = f.entries_len;
259812×
            let mut rec = (bytes.len() as u16).to_be_bytes().to_vec();
260812×
            rec.extend_from_slice(&bytes);
261812×
            f.entries.write_all(&rec)?;
262812×
            f.entries_len += rec.len() as u64;
263812×
            f.offsets.write_all(&off.to_le_bytes())?;
264812×
            let mut hs = Vec::with_capacity(add.len() * 32);
2651549×
            for h in add {
2661549×
                hs.extend_from_slice(&h);
2671549×
            }
268812×
            f.hashes.write_all(&hs)?;
269812×
            f.size = n + 1;
270812×
            out.push(n);
271
        }
272415×
        f.entries.sync_data()?;
273415×
        f.offsets.sync_data()?;
274415×
        f.hashes.sync_data()?;
275415×
        Ok(out)
276415×
    }
277
278
    /// The root of the first `n` entries.
279223×
    pub fn tree_hash(&self, n: u64) -> anyhow::Result<Hash> {
280223×
        let f = self.files.lock().unwrap();
281223×
        Ok(tlog::tree_hash(n, &FileHashes(&f.hashes))?)
282223×
    }
283
284
    /// Entry `n`'s inclusion proof in the tree of `t` entries.
28530×
    pub fn prove_inclusion(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
28630×
        let f = self.files.lock().unwrap();
28730×
        Ok(tlog::prove_inclusion(t, n, &FileHashes(&f.hashes))?)
28830×
    }
289
290
    /// The consistency proof from `n` entries to `t`.
2912×
    pub fn prove_consistency(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
2922×
        let f = self.files.lock().unwrap();
2932×
        Ok(tlog::prove_consistency(t, n, &FileHashes(&f.hashes))?)
2942×
    }
295
296
    /// A tile's hashes, when the log holds all of them.
2977×
    pub fn tile(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
2987×
        let f = self.files.lock().unwrap();
2997×
        let at_level = f.size >> (tlog::TILE_HEIGHT * t.level);
3007×
        if at_level < (t.n << tlog::TILE_HEIGHT) + t.width as u64 {
3012×
            return Ok(None);
3025×
        }
3035×
        Ok(Some(t.read_data(&FileHashes(&f.hashes))?))
3047×
    }
305
306
    /// The entry bundle beside level-0 tile `t`, when the log holds it.
3073×
    pub fn bundle(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
3083×
        let f = self.files.lock().unwrap();
3093×
        let first = t.n << tlog::TILE_HEIGHT;
3103×
        if f.size < first + t.width as u64 {
3110×
            return Ok(None);
3123×
        }
31351×
        let entries: Vec<Vec<u8>> = (first..first + t.width as u64).map(|n| Self::entry_bytes(&f, n)).collect::<anyhow::Result<_>>()?;
3143×
        Ok(Some(tlog::encode_bundle(&entries)?))
3153×
    }
316
317
    /// Take `[tlog]` from the configuration: witnesses and witnessed logs.
318193×
    pub fn configure(&self, c: &crate::config::Tlog) -> anyhow::Result<()> {
319193×
        let key = |b64: &str, what: &str| -> anyhow::Result<Identity> {
3200×
            let bytes = oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| anyhow::anyhow!("[tlog] {what}: the key is not base64"))?;
3210×
            Identity::decode(&bytes).map_err(|e| anyhow::anyhow!("[tlog] {what}: {e}"))
3220×
        };
323193×
        let mut ws = Vec::new();
324193×
        for w in &c.witnesses {
3250×
            ws.push(Peer { name: w.name.clone(), url: w.url.clone(), identity: key(&w.key, &w.name)? });
326
        }
327193×
        let mut wf = Vec::new();
328193×
        for l in &c.witness_for {
3290×
            wf.push((l.origin.clone(), key(&l.key, &l.origin)?));
330
        }
331193×
        self.set_witnesses(ws);
332193×
        for (o, k) in wf {
3330×
            self.add_witness_for(&o, k);
3340×
        }
335193×
        Ok(())
336193×
    }
337
338194×
    pub fn set_witnesses(&self, peers: Vec<Peer>) {
339194×
        *self.witnesses.write().unwrap() = peers;
340194×
    }
341
342529×
    pub fn witnesses(&self) -> Vec<Peer> {
343529×
        self.witnesses.read().unwrap().clone()
344529×
    }
345
346
    /// Witness the log named `origin`, signed with `key`.
3471×
    pub fn add_witness_for(&self, origin: &str, key: Identity) {
3481×
        let mut w = self.witness_for.write().unwrap();
3491×
        w.retain(|(o, _)| o != origin);
3501×
        w.push((origin.to_string(), key));
3511×
    }
352
3535×
    fn witnessed_key(&self, origin: &str) -> Option<Identity> {
3545×
        self.witness_for.read().unwrap().iter().find(|(o, _)| o == origin).map(|(_, k)| k.clone())
3555×
    }
356
357
    /// This hub's name as a witness: its log's host.
3585×
    pub fn witness_name(&self) -> &str {
3595×
        self.origin.trim_end_matches("/log")
3605×
    }
361
362
    /// Replace the latest checkpoint's note with `note` (the same
363
    /// checkpoint, with cosignatures added), unless a newer one was signed.
3642×
    fn set_note(&self, size: u64, note: String) -> anyhow::Result<()> {
3652×
        let mut cur = self.checkpoint.lock().unwrap();
3662×
        if cur.as_ref().map(|(s, _)| *s) != Some(size) {
3670×
            return Ok(());
3682×
        }
3692×
        let tmp = self.dir.join("checkpoint.new");
3702×
        std::fs::write(&tmp, &note)?;
3712×
        File::open(&tmp)?.sync_all()?;
3722×
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
3732×
        *cur = Some((size, note));
3742×
        Ok(())
3752×
    }
376
377
    /// The latest signed checkpoint note: the newer of this process's and
378
    /// the one kept in the directory, which the process leading the
379
    /// checkpointer signs (#274).
380808×
    pub fn checkpoint(&self) -> Option<String> {
381808×
        let mut held = self.checkpoint.lock().unwrap();
382808×
        if let Some(kept) = Self::read_checkpoint(&self.dir, u64::MAX) {
383580×
            if held.as_ref().is_none_or(|(s, _)| kept.0 > *s) {
3841×
                *held = Some(kept);
385579×
            }
386228×
        }
387808×
        held.as_ref().map(|(_, n)| n.clone())
388808×
    }
389
390
    /// Sign a checkpoint of the log as it is, unless the last one already
391
    /// covers it. True when a new one was signed.
392235×
    pub fn refresh_checkpoint(&self, identity: &oxy_core::identity::Identity, sk: &oxy_core::identity::SecretKey) -> anyhow::Result<bool> {
393235×
        if !self.writer {
3940×
            return Ok(false);
395235×
        }
396235×
        let size = self.size();
397
        // From the newest kept, whichever process signed it.
398235×
        let _ = self.checkpoint();
399235×
        if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) {
40022×
            return Ok(false);
401213×
        }
402213×
        let c = Checkpoint { origin: self.origin.clone(), size, root: self.tree_hash(size)?, extensions: vec![] };
403213×
        let note = c.sign(identity, sk)?.encode();
404213×
        let tmp = self.dir.join("checkpoint.new");
405213×
        std::fs::write(&tmp, &note)?;
406206×
        File::open(&tmp)?.sync_all()?;
407206×
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
408206×
        *self.checkpoint.lock().unwrap() = Some((size, note));
409206×
        Ok(true)
410235×
    }
411
}
412
413
/// An object to log.
414
#[derive(Clone, Debug)]
415
pub struct Pending {
416
    pub kind: &'static str,
417
    /// The repository's id (zero for none) and its row on this hub.
418
    pub repo: [u8; 16],
419
    pub repo_row: Option<i64>,
420
    pub object: ObjectRef,
421
    pub signers: Vec<KeyId>,
422
}
423
424
/// Log what is not logged yet. A failure is reported, never fatal: the
425
/// object stays accepted (enforcing logging before acceptance comes with
426
/// inclusion proofs in push responses).
427111×
pub async fn log(app: &Shared, items: Vec<Pending>) -> Vec<u64> {
428111×
    let mut fresh = Vec::new();
429226×
    for p in items {
430226×
        let object = p.object.to_hex();
431226×
        match db::tlog::entry_of(&app.db, &object, p.kind).await {
43217×
            Ok(Some(_)) => continue,
433209×
            Ok(None) => {}
4340×
            Err(e) => {
4350×
                eprintln!("tlog: looking up {} {}: {e}", p.kind, &object[..12]);
4360×
                continue;
437
            }
438
        }
439209×
        if !fresh.iter().any(|q: &Pending| q.kind == p.kind && q.object == p.object) {
440207×
            fresh.push(p);
441207×
        }
442
    }
443111×
    if fresh.is_empty() {
4440×
        return vec![];
445111×
    }
446111×
    let now = db::now_ms();
447111×
    let entries: Vec<Entry> = fresh
448111×
        .iter()
449207×
        .map(|p| Entry { kind: p.kind.to_string(), repo: p.repo, object: p.object, signers: p.signers.clone(), received: now as u64 })
450111×
        .collect();
451111×
    let tl = app.tlog.clone();
452111×
    let numbers = match tokio::task::spawn_blocking(move || tl.append(&entries)).await {
453111×
        Ok(Ok(n)) => n,
4540×
        Ok(Err(e)) => {
4550×
            eprintln!("tlog: appending {} entries: {e}", fresh.len());
4560×
            return vec![];
457
        }
4580×
        Err(e) => {
4590×
            eprintln!("tlog: appending: {e}");
4600×
            return vec![];
461
        }
462
    };
463207×
    for (p, n) in fresh.iter().zip(&numbers) {
464207×
        if let Err(e) = db::tlog::record(&app.db, *n as i64, &p.object.to_hex(), p.kind, p.repo_row, now).await {
4650×
            eprintln!("tlog: indexing entry {n}: {e}");
466207×
        }
467
    }
468111×
    numbers
469111×
}
470
471198×
fn sig_keys(sigs: &[oxy_core::identity::Signature]) -> Vec<KeyId> {
472198×
    let mut keys: Vec<KeyId> = sigs.iter().map(|s| s.key).collect();
473198×
    keys.sort();
474198×
    keys.dedup();
475198×
    keys
476198×
}
477
478
/// The certificates of short-lived keys among `signers`, to log beside
479
/// what they signed.
480182×
fn certificates(repo: &Repo, rid: [u8; 16], row: Option<i64>, signers: &[KeyId], out: &mut Vec<Pending>) {
481186×
    for k in signers {
482186×
        if let Ok(Some(c)) = repo.store().certificate_of(k) {
48312×
            let issuer = repo.store().get_certificate(&c).map(|sc| vec![sc.cert.issuer]).unwrap_or_default();
48412×
            out.push(Pending { kind: "certificate", repo: rid, repo_row: row, object: c, signers: issuer });
485174×
        }
486
    }
487182×
}
488
489
/// What a push brought that the log keeps: the attestations of the
490
/// references it moved (and the hub's acceptance of them), tags, seals,
491
/// the repository's policy, and the certificates of short-lived signers.
492246×
pub fn push_entries(repo: &Repo, row: Option<i64>, report: &PushReport) -> Vec<Pending> {
493246×
    let Ok(rid) = repo.id() else { return vec![] };
494246×
    let mut out = Vec::new();
495246×
    let add = |out: &mut Vec<Pending>, kind: &'static str, object: ObjectRef, signers: Vec<KeyId>| {
496182×
        certificates(repo, rid, row, &signers, out);
497182×
        out.push(Pending { kind, repo: rid, repo_row: row, object, signers });
498182×
    };
499254×
    for u in &report.updated {
500277×
        if !report.results.iter().any(|r| r.ok && r.name == u.name) {
5010×
            continue;
502254×
        }
503
        // A tag's withdrawal (#301), and when the hub accepted it.
504254×
        if let (Some(tag), true) = (u.name.strip_prefix("tags/"), u.new_state.is_zero()) {
5052×
            if let Ok(Some((r, sa))) = repo.tag_withdrawal(tag) {
5062×
                add(&mut out, "untag", r, sig_keys(&sa.sigs));
5072×
                if let Ok(Some(acc)) = repo.acceptance_of(&r) {
5080×
                    add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
5092×
                }
5100×
            }
5112×
            continue;
512252×
        }
513252×
        if u.new_state.is_zero() {
5142×
            continue;
515250×
        }
516250×
        if let Some(tag) = u.name.strip_prefix("tags/") {
51713×
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
51812×
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
519
                // A short-lived key's tag, and when the hub accepted it
520
                // (oxygen#129).
52112×
                if let Ok(Some(acc)) = repo.acceptance_of(&r) {
5221×
                    add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
52311×
                }
5241×
            }
525237×
        } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) {
52686×
            add(&mut out, "ref", r, sig_keys(&sa.sigs));
52786×
            if let Ok(Some(acc)) = repo.acceptance_of(&r) {
5286×
                add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
52980×
            }
530151×
        }
531
    }
532246×
    let mut seals = std::collections::BTreeSet::new();
533246×
    for h in &report.imported {
534236×
        for s in repo.store().seals_for(h).unwrap_or_default() {
53558×
            seals.insert(s);
53658×
        }
537
    }
538246×
    for s in seals {
53958×
        let signers = sig_keys(&repo.store().signatures(&s).unwrap_or_default());
54058×
        add(&mut out, "seal", s, signers);
54158×
    }
542246×
    if let Ok(Some((r, sp))) = repo.current_policy() {
54317×
        add(&mut out, "policy", r, sig_keys(&sp.sigs));
544229×
    }
545246×
    out
546246×
}
547
548
/// Add the log to a refs answer (Oxygen spec §14.8): the latest
549
/// checkpoint, and for each bookmark's and tag's attestation its entry and
550
/// inclusion proof against it. An attestation the hub serves but never
551
/// logged (one pushed before the log began) is logged now, and proved on a
552
/// later fetch.
553345×
pub fn add_log(app: &Shared, row: Option<i64>, refs: &mut oxy_core::protocol::RefsResponse) -> Vec<Pending> {
554345×
    let mut backfill = Vec::new();
555345×
    let Some(note) = app.tlog.checkpoint() else { return backfill };
556311×
    let Some(size) = tlog::Note::parse(&note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.size) else {
5570×
        return backfill;
558
    };
559311×
    refs.log = Some(note);
560
    // The witnesses a client may pin, for integrated times (#240).
561311×
    refs.witnesses = app.tlog.witnesses().iter().map(|p| (p.name.clone(), p.identity.encode())).collect();
562348×
    for r in refs.refs.iter_mut() {
563348×
        let Some(a) = &r.attestation else { continue };
564139×
        if let Some((logged, integration)) = place(app, size, row, "ref", a, &mut backfill) {
56519×
            r.logged = Some(logged);
56619×
            r.integration = integration;
567120×
        }
568
    }
569
    // Tags too (oxygen#129): a short-lived key's tag is checked at its
570
    // integrated time on every later fetch.
571311×
    for t in refs.tags.iter_mut() {
57233×
        let Some(a) = &t.attestation else { continue };
57332×
        if let Some((logged, integration)) = place(app, size, row, "tag", a, &mut backfill) {
5748×
            t.logged = Some(logged);
5758×
            t.integration = integration;
57624×
        }
577
    }
578311×
    backfill
579345×
}
580
581
/// Attestation `a`'s entry (of `kind`) in a log of `size` entries, its
582
/// inclusion proof, and the first cosigned checkpoints that hold it; none
583
/// when it is not in that checkpoint yet, and queued in `backfill` when it
584
/// was never logged.
585171×
fn place(
586171×
    app: &Shared,
587171×
    size: u64,
588171×
    row: Option<i64>,
589171×
    kind: &'static str,
590171×
    a: &oxy_core::protocol::SignedAttest,
591171×
    backfill: &mut Vec<Pending>,
592171×
) -> Option<(oxy_core::protocol::Logged, Vec<oxy_core::protocol::Integration>)> {
593171×
    let object = a.attest.hash();
594171×
    let idx =
595171×
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), kind)));
596155×
    match idx {
597155×
        Ok(Some(i)) if (i as u64) < size => {
59827×
            let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { return None };
599
            // When witnesses first saw it (#240): the first cosigned
600
            // checkpoints that hold it, each with the entry's proof.
60127×
            let notes =
60227×
                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2)))
60327×
                    .unwrap_or_default();
60427×
            let mut integration = Vec::new();
60527×
            for (at, text) in notes {
6062×
                if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) {
6072×
                    integration.push(oxy_core::protocol::Integration { note: text, proof });
6082×
                }
609
            }
61027×
            Some((oxy_core::protocol::Logged { index: i as u64, entry, proof }, integration))
611
        }
612128×
        Ok(Some(_)) => None,
613
        Ok(None) => {
61416×
            backfill.push(Pending { kind, repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) });
61516×
            None
616
        }
6170×
        Err(e) => {
6180×
            eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]);
6190×
            None
620
        }
621
    }
622171×
}
623
624
/// Ask each witness to cosign the latest checkpoint, and serve their
625
/// cosignatures with it. A witness that answers 409 is told again from the
626
/// size it says it has; one that refuses is logged.
627217×
pub async fn collect(app: &Shared) {
628217×
    let peers = app.tlog.witnesses();
629217×
    let Some(text) = app.tlog.checkpoint() else { return };
630217×
    if peers.is_empty() {
631215×
        return;
6322×
    }
6332×
    let Ok(mut note) = tlog::Note::parse(&text) else { return };
6342×
    let Ok(cp) = Checkpoint::parse(&note.text) else { return };
635
    // What a witness checks: the note as the log signed it.
6364×
    let own = tlog::Note { text: note.text.clone(), sigs: note.sigs.iter().filter(|s| s.name == cp.origin).cloned().collect() };
6372×
    let mut changed = false;
6382×
    for p in peers {
6392×
        if note.cosigned_by(&p.name, &p.identity).is_ok() {
6400×
            continue;
6412×
        }
6422×
        let mut old = db::tlog::cosigned_size(&app.db, &p.name).await.unwrap_or(0) as u64;
6432×
        for _ in 0..2 {
6442×
            let proof = if old == 0 || old >= cp.size { Vec::new() } else { app.tlog.prove_consistency(cp.size, old).unwrap_or_default() };
6452×
            let body = tlog::AddCheckpoint { old_size: old, proof, note: own.encode() }.encode();
6462×
            let client = match crate::net::client(&p.url, std::slice::from_ref(&p.url), std::time::Duration::from_secs(20)).await {
6472×
                Ok(c) => c,
6480×
                Err(e) => {
6490×
                    eprintln!("tlog: witness {}: {e}", p.name);
6500×
                    break;
651
                }
652
            };
6532×
            let res = match client.post(&p.url).header(header::CONTENT_TYPE, "text/plain").body(body).send().await {
6542×
                Ok(r) => r,
6550×
                Err(e) => {
6560×
                    eprintln!("tlog: witness {}: {e}", p.name);
6570×
                    break;
658
                }
659
            };
6602×
            let status = res.status();
6612×
            let answer = res.text().await.unwrap_or_default();
6622×
            if status == StatusCode::CONFLICT {
6630×
                match answer.trim().parse() {
6640×
                    Ok(n) if n != old => {
6650×
                        old = n;
6660×
                        continue;
667
                    }
668
                    _ => {
6690×
                        eprintln!("tlog: witness {}: a conflict without a size: {answer}", p.name);
6700×
                        break;
671
                    }
672
                }
6732×
            }
6742×
            if !status.is_success() {
6750×
                eprintln!("tlog: witness {} refused the checkpoint of {} entries ({status}): {}", p.name, cp.size, answer.trim());
6760×
                break;
6772×
            }
6782×
            let lines = tlog::Note::parse(&format!("{}\n{answer}", note.text)).map(|n| n.sigs).unwrap_or_default();
6794×
            let theirs: Vec<_> = lines.into_iter().filter(|s| s.name == p.name).collect();
6802×
            let check = tlog::Note { text: note.text.clone(), sigs: theirs.clone() };
6812×
            match check.cosigned_by(&p.name, &p.identity) {
682
                Ok(_) => {
6832×
                    note.sigs.extend(theirs);
6842×
                    db::tlog::set_cosigned(&app.db, &p.name, cp.size as i64).await.ok();
6852×
                    changed = true;
686
                }
6870×
                Err(e) => eprintln!("tlog: witness {} answered a cosignature that does not verify: {e}", p.name),
688
            }
6892×
            break;
690
        }
691
    }
6922×
    if changed {
693
        // Kept by size too, for integrated times (#240).
6942×
        db::tlog::keep_cosigned_note(&app.db, cp.size as i64, &note.encode()).await.ok();
6952×
        if let Err(e) = app.tlog.set_note(cp.size, note.encode()) {
6960×
            eprintln!("tlog: keeping the cosigned checkpoint: {e}");
6972×
        }
6980×
    }
699217×
}
700
701
/// `POST /log/witness/add-checkpoint` (C2SP tlog-witness): cosign a
702
/// checkpoint of a log this hub witnesses, if it extends the last one
703
/// cosigned.
7045×
pub async fn add_checkpoint(State(app): State<Shared>, body: String) -> Response {
7055×
    let Ok(req) = tlog::AddCheckpoint::parse(&body) else {
7060×
        return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request");
707
    };
7085×
    let Some(origin) = tlog::Note::parse(&req.note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.origin) else {
7090×
        return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse");
710
    };
7115×
    let Some(key) = app.tlog.witnessed_key(&origin) else {
7121×
        return plain(StatusCode::NOT_FOUND, &format!("this hub does not witness {origin}"));
713
    };
7144×
    let latest = match db::tlog::witnessed(&app.db, &origin).await {
7154×
        Ok(l) => l.and_then(|(size, root)| Some((size as u64, ObjectRef::from_hex(&root)?.0))),
7160×
        Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
717
    };
7184×
    let sk = match crate::keys::load_secret(&app.config.keys_dir()) {
7194×
        Ok((_, sk)) => sk,
7200×
        Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
721
    };
7224×
    let me = tlog::WitnessKey { name: app.tlog.witness_name(), identity: &app.hub_key.identity, sk: &sk };
7234×
    let now = (db::now_ms() / 1000) as u64;
7244×
    let conflict = |n: u64| ([(header::CONTENT_TYPE, "text/x.tlog.size")], format!("{n}\n"));
7254×
    match tlog::witness(&req, &origin, &key, latest, &me, now) {
7262×
        Ok(tlog::Witnessed::Cosigned { checkpoint, lines }) => {
7272×
            let root = ObjectRef(checkpoint.root).to_hex();
7282×
            match db::tlog::witness_advance(&app.db, &origin, req.old_size as i64, checkpoint.size as i64, &root).await {
729
                Ok(true) => {
7302×
                    let body: String = tlog::Note { text: String::new(), sigs: lines }.encode().trim_start_matches('\n').to_string();
7312×
                    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response()
732
                }
733
                Ok(false) => {
7340×
                    let now = db::tlog::witnessed(&app.db, &origin).await.ok().flatten().map(|(s, _)| s as u64).unwrap_or(0);
7350×
                    (StatusCode::CONFLICT, conflict(now)).into_response()
736
                }
7370×
                Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
738
            }
739
        }
7401×
        Ok(tlog::Witnessed::Conflict(n)) => (StatusCode::CONFLICT, conflict(n)).into_response(),
7410×
        Err(tlog::WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e),
7421×
        Err(tlog::WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e),
7430×
        Err(tlog::WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e),
744
    }
7455×
}
746
747
/// Sign a new checkpoint every few seconds while entries arrive.
748192×
pub async fn checkpointer(app: Shared) {
749192×
    let mut tick = tokio::time::interval(CHECKPOINT_EVERY);
750
    loop {
751408×
        tick.tick().await;
752223×
        let (identity, keys) = (app.hub_key.identity.clone(), app.config.keys_dir());
753223×
        let tl = app.tlog.clone();
754223×
        let done = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
755223×
            let (_, sk) = crate::keys::load_secret(&keys)?;
756223×
            tl.refresh_checkpoint(&identity, &sk)
757223×
        })
758223×
        .await;
759216×
        match done {
7601×
            Ok(Err(e)) => eprintln!("tlog: signing a checkpoint: {e}"),
761
            // A new checkpoint, or one some witness has not cosigned yet.
762215×
            _ => collect(&app).await,
763
        }
764
    }
765
}
766
7673×
fn plain(status: StatusCode, why: &str) -> Response {
7683×
    (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{why}\n")).into_response()
7693×
}
770
771
/// `GET /log/checkpoint`: the latest signed checkpoint (C2SP
772
/// tlog-checkpoint, a signed note).
7733×
pub async fn checkpoint(State(app): State<Shared>) -> Response {
7743×
    match app.tlog.checkpoint() {
7753×
        Some(note) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], note).into_response(),
7760×
        None => plain(StatusCode::SERVICE_UNAVAILABLE, "no checkpoint yet: the log signs one within seconds"),
777
    }
7783×
}
779
780
/// `GET /log/witnesses` (#240): this log's witnesses, one `NAME BASE64`
781
/// line each (the identity object), which `oxy tlog verify` pins.
7821×
pub async fn witnesses(State(app): State<Shared>) -> Response {
7831×
    let body: String =
7841×
        app.tlog.witnesses().iter().map(|p| format!("{} {}\n", p.name, oxy_core::dsse::base64(&p.identity.encode()))).collect();
7851×
    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], body).into_response()
7861×
}
787
788
/// `GET /log/tile/{L}/{N}[.p/{W}]` and `/log/tile/entries/{N}[.p/{W}]`:
789
/// a tile of hashes or an entry bundle (C2SP tlog-tiles). Full tiles never
790
/// change and are cached for good.
7916×
pub async fn tile(State(app): State<Shared>, UrlPath(path): UrlPath<String>) -> Response {
7926×
    let (entries, rest) = match path.strip_prefix("entries/") {
7932×
        Some(r) => (true, format!("0/{r}")),
7944×
        None => (false, path.clone()),
795
    };
7966×
    let Some(t) = Tile::parse(&rest) else {
7970×
        return plain(StatusCode::NOT_FOUND, "no such tile");
798
    };
7996×
    let tl = app.tlog.clone();
8006×
    let got = tokio::task::spawn_blocking(move || if entries { tl.bundle(&t) } else { tl.tile(&t) }).await;
8016×
    match got {
8025×
        Ok(Ok(Some(data))) => {
8035×
            let cache = if t.width as u64 == TILE_WIDTH { "public, max-age=31536000, immutable" } else { "public, max-age=5" };
8045×
            ([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, cache)], data).into_response()
805
        }
8061×
        Ok(Ok(None)) => plain(StatusCode::NOT_FOUND, "the log does not hold that tile yet"),
8070×
        Ok(Err(e)) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
8080×
        Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
809
    }
8106×
}
811
812
#[cfg(test)]
813
mod tests {
814
    use super::*;
815
816
    #[test]
8171×
    fn a_reader_leaves_the_files_alone_and_cannot_append() {
8181×
        let dir = tempfile::tempdir().unwrap();
8191×
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
8201×
        log.append(&[entry(0), entry(1)]).unwrap();
821
        // A torn append: an offset whose entry never arrived.
8221×
        std::fs::OpenOptions::new().append(true).open(dir.path().join("offsets")).unwrap().write_all(&9999u64.to_le_bytes()).unwrap();
8231×
        let before = std::fs::metadata(dir.path().join("offsets")).unwrap().len();
8241×
        let reader = Log::open_reader(dir.path(), "hub.example/log").unwrap();
8251×
        assert_eq!(reader.size(), 2);
8261×
        assert_eq!(std::fs::metadata(dir.path().join("offsets")).unwrap().len(), before, "the writer's files are untouched");
8271×
        assert!(reader.append(&[entry(2)]).is_err());
8281×
        assert_eq!(reader.tree_hash(2).unwrap(), log.tree_hash(2).unwrap());
8291×
    }
830
831606×
    fn entry(i: u64) -> Entry {
832606×
        Entry { kind: "ref".into(), repo: [1; 16], object: ObjectRef([i as u8; 32]), signers: vec![], received: i }
833606×
    }
834
835
    #[test]
8361×
    fn the_log_survives_a_torn_append_and_keeps_its_root() {
8371×
        let dir = tempfile::tempdir().unwrap();
8381×
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
8391×
        let first: Vec<Entry> = (0..300).map(entry).collect();
8401×
        assert_eq!(log.append(&first).unwrap(), (0..300).collect::<Vec<_>>());
8411×
        let root = log.tree_hash(300).unwrap();
842
        // Leaves alone, as RFC 6962 roots them.
8431×
        let mut stored: Vec<Hash> = Vec::new();
844300×
        for (n, e) in first.iter().enumerate() {
845300×
            stored.extend(tlog::stored_hashes_for_leaf(n as u64, e.leaf_hash(), &stored).unwrap());
846300×
        }
8471×
        assert_eq!(root, tlog::tree_hash(300, &stored).unwrap());
8481×
        drop(log);
849
        // A crash after the entry and part of the hashes were written.
8501×
        let torn = dir.path().join("hashes");
8511×
        let len = std::fs::metadata(&torn).unwrap().len();
8521×
        OpenOptions::new().write(true).open(&torn).unwrap().set_len(len - 40).unwrap();
8531×
        let mut f = OpenOptions::new().append(true).open(dir.path().join("entries")).unwrap();
8541×
        f.write_all(&[0, 200, 1, 2]).unwrap(); // a partial entry
8551×
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
8561×
        assert_eq!(log.size(), 300);
8571×
        assert_eq!(log.tree_hash(300).unwrap(), root);
8581×
        log.append(&[entry(300)]).unwrap();
8591×
        assert_eq!(log.size(), 301);
8601×
        let p = log.prove_inclusion(301, 7).unwrap();
8611×
        tlog::check_inclusion(&p, 301, &log.tree_hash(301).unwrap(), 7, &first[7].leaf_hash()).unwrap();
8621×
        let c = log.prove_consistency(301, 300).unwrap();
8631×
        tlog::check_consistency(&c, 301, &log.tree_hash(301).unwrap(), 300, &root).unwrap();
864
        // Tiles and bundles: the full level-0 tile and the partial one.
8651×
        let full = log.tile(&Tile { level: 0, n: 0, width: 256 }).unwrap().unwrap();
8661×
        assert_eq!(full.len(), 256 * 32);
8671×
        assert!(log.tile(&Tile { level: 0, n: 1, width: 45 }).unwrap().is_some());
8681×
        assert!(log.tile(&Tile { level: 0, n: 1, width: 46 }).unwrap().is_none(), "not yet");
8691×
        let bundle = log.bundle(&Tile { level: 0, n: 1, width: 45 }).unwrap().unwrap();
8701×
        assert_eq!(tlog::decode_bundle(&bundle).unwrap()[0], first[256].encode());
8711×
    }
872
873
    /// oxygenhub#274: writers sharing the directory (each its own handles,
874
    /// as separate processes have) append one at a time, each from the log
875
    /// as the others left it; readers and checkpoints follow.
876
    #[test]
8771×
    fn writers_sharing_the_directory_append_one_at_a_time() {
8781×
        let dir = tempfile::tempdir().unwrap();
8791×
        let a = std::sync::Arc::new(Log::open(dir.path(), "hub.example/log").unwrap());
8801×
        let b = std::sync::Arc::new(Log::open(dir.path(), "hub.example/log").unwrap());
8811×
        let reader = Log::open_reader(dir.path(), "hub.example/log").unwrap();
8822×
        let spawn = |log: std::sync::Arc<Log>, from: u64| {
8832×
            std::thread::spawn(move || {
8842×
                let mut got = Vec::new();
885300×
                for i in 0..150 {
886300×
                    got.extend(log.append(&[entry(from + i)]).unwrap());
887300×
                }
8882×
                got
8892×
            })
8902×
        };
8911×
        let (ta, tb) = (spawn(a.clone(), 0), spawn(b.clone(), 1000));
8921×
        let mut numbers: Vec<u64> = ta.join().unwrap().into_iter().chain(tb.join().unwrap()).collect();
8931×
        numbers.sort();
8941×
        assert_eq!(numbers, (0..300).collect::<Vec<_>>(), "every entry its own place");
8951×
        assert_eq!((a.size(), b.size(), reader.size()), (300, 300, 300));
8961×
        let root = a.tree_hash(300).unwrap();
8971×
        assert_eq!(b.tree_hash(300).unwrap(), root);
8981×
        let fresh = Log::open(dir.path(), "hub.example/log").unwrap();
8991×
        assert_eq!(fresh.tree_hash(300).unwrap(), root, "the files are one log");
9001×
        assert_eq!(b.entry(299).unwrap(), fresh.entry(299).unwrap());
9011×
        assert_eq!(reader.entry(299).unwrap(), fresh.entry(299).unwrap());
902
        // A checkpoint one signs, the other serves.
9031×
        let sk = oxy_core::identity::SecretKey::generate();
9041×
        let hub = sk.identity("hub", "[email protected]", 1);
9051×
        assert!(a.refresh_checkpoint(&hub, &sk).unwrap());
9061×
        assert_eq!(b.checkpoint(), a.checkpoint());
9071×
        assert!(!b.refresh_checkpoint(&hub, &sk).unwrap(), "already covered by the other's");
9081×
    }
909
910
    #[test]
9111×
    fn checkpoints_are_signed_when_the_log_grows() {
9121×
        let dir = tempfile::tempdir().unwrap();
9131×
        let sk = oxy_core::identity::SecretKey::generate();
9141×
        let hub = sk.identity("hub", "[email protected]", 1);
9151×
        let log = Log::open(dir.path(), &origin_of("https://hub.example:7343/")).unwrap();
9161×
        assert_eq!(log.origin, "hub.example:7343/log");
9171×
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap(), "the empty log's");
9181×
        assert!(!log.refresh_checkpoint(&hub, &sk).unwrap(), "nothing new");
9191×
        log.append(&[entry(1), entry(2)]).unwrap();
9201×
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap());
9211×
        let note = tlog::Note::parse(&log.checkpoint().unwrap()).unwrap();
9221×
        let c = Checkpoint::verify(&note, "hub.example:7343/log", &hub).unwrap();
9231×
        assert_eq!((c.size, c.root), (2, log.tree_hash(2).unwrap()));
924
        // Kept across a restart.
9251×
        drop(log);
9261×
        let log = Log::open(dir.path(), "hub.example:7343/log").unwrap();
9271×
        assert_eq!(log.checkpoint().map(|n| n.contains("\n2\n")), Some(true));
9281×
    }
929
}