OXYGENHUB
# e5b97ac399e7 oxygenhub / crates / hub / src / tlog.rs
21.3 KiB · Rust · last changed Refs answers carry the transparency log (step A3) 18 days ago source blame history raw
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::repo::{PushReport, Repo};
25
use oxy_core::tlog::{self, Checkpoint, Entry, Hash, HashReader, Tile, TlogError, TILE_WIDTH};
26
use std::fs::{File, OpenOptions};
27
use std::io::Write;
28
use std::os::unix::fs::FileExt;
29
use std::path::{Path, PathBuf};
30
use std::sync::Mutex;
31
32
/// How often a new checkpoint is signed while entries arrive.
33
pub const CHECKPOINT_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
34
35
struct Files {
36
    entries: File,
37
    offsets: File,
38
    hashes: File,
39
    /// Entries in the log.
40
    size: u64,
41
    /// Bytes in `entries`.
42
    entries_len: u64,
43
}
44
45
/// The stored hashes in a file, by stored index.
46
struct FileHashes<'a>(&'a File);
47
48
impl HashReader for FileHashes<'_> {
49
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>, TlogError> {
50
        indexes
51
            .iter()
52
            .map(|&i| {
53
                let mut h = [0u8; 32];
54
                self.0.read_exact_at(&mut h, i * 32).map_err(|e| TlogError::Read(format!("stored hash {i}: {e}")))?;
55
                Ok(h)
56
            })
57
            .collect()
58
    }
59
}
60
61
/// The log on disk.
62
pub struct Log {
63
    dir: PathBuf,
64
    /// The log's name in its checkpoints: the hub's host and `/log`.
65
    pub origin: String,
66
    files: Mutex<Files>,
67
    /// The latest signed checkpoint: its size and its note.
68
    checkpoint: Mutex<Option<(u64, String)>>,
69
}
70
71
fn read_u64_at(f: &File, at: u64) -> std::io::Result<u64> {
72
    let mut b = [0u8; 8];
73
    f.read_exact_at(&mut b, at)?;
74
    Ok(u64::from_le_bytes(b))
75
}
76
77
/// The origin of a hub's log: its host (and port) and `/log`.
78
pub fn origin_of(base_url: &str) -> String {
79
    let host = base_url.split_once("://").map(|(_, h)| h).unwrap_or(base_url);
80
    format!("{}/log", host.trim_end_matches('/'))
81
}
82
83
impl Log {
84
    /// Open (or start) the log in `dir`, recovering from an interrupted
85
    /// append.
86
    pub fn open(dir: &Path, origin: &str) -> anyhow::Result<Log> {
87
        std::fs::create_dir_all(dir)?;
88
        let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name));
89
        let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?);
90
        let entries_on_disk = entries.metadata()?.len();
91
        // The whole entries: those whose offset was written and whose
92
        // bytes all were.
93
        let mut size = offsets.metadata()?.len() / 8;
94
        let mut entries_len = 0;
95
        while size > 0 {
96
            let off = read_u64_at(&offsets, (size - 1) * 8)?;
97
            let mut len = [0u8; 2];
98
            if off + 2 <= entries_on_disk && entries.read_exact_at(&mut len, off).is_ok() {
99
                let end = off + 2 + u16::from_be_bytes(len) as u64;
100
                if end <= entries_on_disk {
101
                    entries_len = end;
102
                    break;
103
                }
104
            }
105
            size -= 1;
106
        }
107
        offsets.set_len(size * 8)?;
108
        entries.set_len(entries_len)?;
109
        // The hashes of every leaf whose hashes were all written; the rest
110
        // are computed again from the entries.
111
        let have = hashes.metadata()?.len() / 32;
112
        let mut whole = size;
113
        while tlog::stored_hash_count(whole) > have {
114
            whole -= 1;
115
        }
116
        hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
117
        let mut files = Files { entries, offsets, hashes, size: whole, entries_len };
118
        for n in whole..size {
119
            let bytes = Self::entry_bytes(&files, n)?;
120
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&files.hashes))?;
121
            for h in add {
122
                files.hashes.write_all(&h)?;
123
            }
124
            files.size = n + 1;
125
        }
126
        files.size = size;
127
        files.hashes.sync_data()?;
128
        let checkpoint = std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| {
129
            let n = tlog::Note::parse(&note).ok()?;
130
            let c = Checkpoint::parse(&n.text).ok()?;
131
            (c.size <= size).then_some((c.size, note))
132
        });
133
        Ok(Log { dir: dir.to_path_buf(), origin: origin.to_string(), files: Mutex::new(files), checkpoint: Mutex::new(checkpoint) })
134
    }
135
136
    fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> {
137
        let off = read_u64_at(&f.offsets, n * 8)?;
138
        let mut len = [0u8; 2];
139
        f.entries.read_exact_at(&mut len, off)?;
140
        let mut b = vec![0u8; u16::from_be_bytes(len) as usize];
141
        f.entries.read_exact_at(&mut b, off + 2)?;
142
        Ok(b)
143
    }
144
145
    /// Entry `n`'s bytes.
146
    pub fn entry(&self, n: u64) -> anyhow::Result<Vec<u8>> {
147
        let f = self.files.lock().unwrap();
148
        anyhow::ensure!(n < f.size, "entry {n} is not in the log");
149
        Self::entry_bytes(&f, n)
150
    }
151
152
    /// Entries in the log.
153
    pub fn size(&self) -> u64 {
154
        self.files.lock().unwrap().size
155
    }
156
157
    /// Append entries, durably; their numbers.
158
    pub fn append(&self, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
159
        let mut f = self.files.lock().unwrap();
160
        let mut out = Vec::with_capacity(entries.len());
161
        for e in entries {
162
            let bytes = e.encode();
163
            let n = f.size;
164
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&f.hashes))?;
165
            let off = f.entries_len;
166
            let mut rec = (bytes.len() as u16).to_be_bytes().to_vec();
167
            rec.extend_from_slice(&bytes);
168
            f.entries.write_all(&rec)?;
169
            f.entries_len += rec.len() as u64;
170
            f.offsets.write_all(&off.to_le_bytes())?;
171
            let mut hs = Vec::with_capacity(add.len() * 32);
172
            for h in add {
173
                hs.extend_from_slice(&h);
174
            }
175
            f.hashes.write_all(&hs)?;
176
            f.size = n + 1;
177
            out.push(n);
178
        }
179
        f.entries.sync_data()?;
180
        f.offsets.sync_data()?;
181
        f.hashes.sync_data()?;
182
        Ok(out)
183
    }
184
185
    /// The root of the first `n` entries.
186
    pub fn tree_hash(&self, n: u64) -> anyhow::Result<Hash> {
187
        let f = self.files.lock().unwrap();
188
        Ok(tlog::tree_hash(n, &FileHashes(&f.hashes))?)
189
    }
190
191
    /// Entry `n`'s inclusion proof in the tree of `t` entries.
192
    pub fn prove_inclusion(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
193
        let f = self.files.lock().unwrap();
194
        Ok(tlog::prove_inclusion(t, n, &FileHashes(&f.hashes))?)
195
    }
196
197
    /// The consistency proof from `n` entries to `t`.
198
    pub fn prove_consistency(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
199
        let f = self.files.lock().unwrap();
200
        Ok(tlog::prove_consistency(t, n, &FileHashes(&f.hashes))?)
201
    }
202
203
    /// A tile's hashes, when the log holds all of them.
204
    pub fn tile(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
205
        let f = self.files.lock().unwrap();
206
        let at_level = f.size >> (tlog::TILE_HEIGHT * t.level);
207
        if at_level < (t.n << tlog::TILE_HEIGHT) + t.width as u64 {
208
            return Ok(None);
209
        }
210
        Ok(Some(t.read_data(&FileHashes(&f.hashes))?))
211
    }
212
213
    /// The entry bundle beside level-0 tile `t`, when the log holds it.
214
    pub fn bundle(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
215
        let f = self.files.lock().unwrap();
216
        let first = t.n << tlog::TILE_HEIGHT;
217
        if f.size < first + t.width as u64 {
218
            return Ok(None);
219
        }
220
        let entries: Vec<Vec<u8>> = (first..first + t.width as u64).map(|n| Self::entry_bytes(&f, n)).collect::<anyhow::Result<_>>()?;
221
        Ok(Some(tlog::encode_bundle(&entries)?))
222
    }
223
224
    /// The latest signed checkpoint note.
225
    pub fn checkpoint(&self) -> Option<String> {
226
        self.checkpoint.lock().unwrap().as_ref().map(|(_, n)| n.clone())
227
    }
228
229
    /// Sign a checkpoint of the log as it is, unless the last one already
230
    /// covers it. True when a new one was signed.
231
    pub fn refresh_checkpoint(&self, identity: &oxy_core::identity::Identity, sk: &oxy_core::identity::SecretKey) -> anyhow::Result<bool> {
232
        let size = self.size();
233
        if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) {
234
            return Ok(false);
235
        }
236
        let c = Checkpoint { origin: self.origin.clone(), size, root: self.tree_hash(size)?, extensions: vec![] };
237
        let note = c.sign(identity, sk)?.encode();
238
        let tmp = self.dir.join("checkpoint.new");
239
        std::fs::write(&tmp, &note)?;
240
        File::open(&tmp)?.sync_all()?;
241
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
242
        *self.checkpoint.lock().unwrap() = Some((size, note));
243
        Ok(true)
244
    }
245
}
246
247
/// An object to log.
248
#[derive(Clone, Debug)]
249
pub struct Pending {
250
    pub kind: &'static str,
251
    /// The repository's id (zero for none) and its row on this hub.
252
    pub repo: [u8; 16],
253
    pub repo_row: Option<i64>,
254
    pub object: ObjectRef,
255
    pub signers: Vec<KeyId>,
256
}
257
258
/// Log what is not logged yet. A failure is reported, never fatal: the
259
/// object stays accepted (enforcing logging before acceptance comes with
260
/// inclusion proofs in push responses).
261
pub async fn log(app: &Shared, items: Vec<Pending>) -> Vec<u64> {
262
    let mut fresh = Vec::new();
263
    for p in items {
264
        let object = p.object.to_hex();
265
        match db::tlog::entry_of(&app.db, &object, p.kind).await {
266
            Ok(Some(_)) => continue,
267
            Ok(None) => {}
268
            Err(e) => {
269
                eprintln!("tlog: looking up {} {}: {e}", p.kind, &object[..12]);
270
                continue;
271
            }
272
        }
273
        if !fresh.iter().any(|q: &Pending| q.kind == p.kind && q.object == p.object) {
274
            fresh.push(p);
275
        }
276
    }
277
    if fresh.is_empty() {
278
        return vec![];
279
    }
280
    let now = db::now_ms();
281
    let entries: Vec<Entry> = fresh
282
        .iter()
283
        .map(|p| Entry { kind: p.kind.to_string(), repo: p.repo, object: p.object, signers: p.signers.clone(), received: now as u64 })
284
        .collect();
285
    let tl = app.tlog.clone();
286
    let numbers = match tokio::task::spawn_blocking(move || tl.append(&entries)).await {
287
        Ok(Ok(n)) => n,
288
        Ok(Err(e)) => {
289
            eprintln!("tlog: appending {} entries: {e}", fresh.len());
290
            return vec![];
291
        }
292
        Err(e) => {
293
            eprintln!("tlog: appending: {e}");
294
            return vec![];
295
        }
296
    };
297
    for (p, n) in fresh.iter().zip(&numbers) {
298
        if let Err(e) = db::tlog::record(&app.db, *n as i64, &p.object.to_hex(), p.kind, p.repo_row, now).await {
299
            eprintln!("tlog: indexing entry {n}: {e}");
300
        }
301
    }
302
    numbers
303
}
304
305
fn sig_keys(sigs: &[oxy_core::identity::Signature]) -> Vec<KeyId> {
306
    let mut keys: Vec<KeyId> = sigs.iter().map(|s| s.key).collect();
307
    keys.sort();
308
    keys.dedup();
309
    keys
310
}
311
312
/// The certificates of short-lived keys among `signers`, to log beside
313
/// what they signed.
314
fn certificates(repo: &Repo, rid: [u8; 16], row: Option<i64>, signers: &[KeyId], out: &mut Vec<Pending>) {
315
    for k in signers {
316
        if let Ok(Some(c)) = repo.store().certificate_of(k) {
317
            let issuer = repo.store().get_certificate(&c).map(|sc| vec![sc.cert.issuer]).unwrap_or_default();
318
            out.push(Pending { kind: "certificate", repo: rid, repo_row: row, object: c, signers: issuer });
319
        }
320
    }
321
}
322
323
/// What a push brought that the log keeps: the attestations of the
324
/// references it moved (and the hub's acceptance of them), tags, seals,
325
/// the repository's policy, and the certificates of short-lived signers.
326
pub fn push_entries(repo: &Repo, row: Option<i64>, report: &PushReport) -> Vec<Pending> {
327
    let Ok(rid) = repo.id() else { return vec![] };
328
    let mut out = Vec::new();
329
    let add = |out: &mut Vec<Pending>, kind: &'static str, object: ObjectRef, signers: Vec<KeyId>| {
330
        certificates(repo, rid, row, &signers, out);
331
        out.push(Pending { kind, repo: rid, repo_row: row, object, signers });
332
    };
333
    for u in &report.updated {
334
        if u.new_state.is_zero() || !report.results.iter().any(|r| r.ok && r.name == u.name) {
335
            continue;
336
        }
337
        if let Some(tag) = u.name.strip_prefix("tags/") {
338
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
339
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
340
            }
341
        } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) {
342
            add(&mut out, "ref", r, sig_keys(&sa.sigs));
343
            if let Ok(Some(acc)) = repo.acceptance_of(&r) {
344
                add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
345
            }
346
        }
347
    }
348
    let mut seals = std::collections::BTreeSet::new();
349
    for h in &report.imported {
350
        for s in repo.store().seals_for(h).unwrap_or_default() {
351
            seals.insert(s);
352
        }
353
    }
354
    for s in seals {
355
        let signers = sig_keys(&repo.store().signatures(&s).unwrap_or_default());
356
        add(&mut out, "seal", s, signers);
357
    }
358
    if let Ok(Some((r, sp))) = repo.current_policy() {
359
        add(&mut out, "policy", r, sig_keys(&sp.sigs));
360
    }
361
    out
362
}
363
364
/// Add the log to a refs answer (Oxygen spec §14.8): the latest
365
/// checkpoint, and for each bookmark's attestation its entry and inclusion
366
/// proof against it. An attestation the hub serves but never logged (one
367
/// pushed before the log began) is logged now, and proved on a later fetch.
368
pub fn add_log(app: &Shared, row: Option<i64>, refs: &mut oxy_core::protocol::RefsResponse) -> Vec<Pending> {
369
    let mut backfill = Vec::new();
370
    let Some(note) = app.tlog.checkpoint() else { return backfill };
371
    let Some(size) = tlog::Note::parse(&note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.size) else {
372
        return backfill;
373
    };
374
    refs.log = Some(note);
375
    for r in refs.refs.iter_mut() {
376
        let Some(a) = &r.attestation else { continue };
377
        let object = a.attest.hash();
378
        let idx = tokio::task::block_in_place(|| {
379
            tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), "ref"))
380
        });
381
        match idx {
382
            Ok(Some(i)) if (i as u64) < size => {
383
                let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { continue };
384
                r.logged = Some(oxy_core::protocol::Logged { index: i as u64, entry, proof });
385
            }
386
            Ok(Some(_)) => {}
387
            Ok(None) => backfill.push(Pending { kind: "ref", repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) }),
388
            Err(e) => eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]),
389
        }
390
    }
391
    backfill
392
}
393
394
/// Sign a new checkpoint every few seconds while entries arrive.
395
pub async fn checkpointer(app: Shared) {
396
    let mut tick = tokio::time::interval(CHECKPOINT_EVERY);
397
    loop {
398
        tick.tick().await;
399
        let (identity, keys) = (app.hub_key.identity.clone(), app.config.keys_dir());
400
        let tl = app.tlog.clone();
401
        let done = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
402
            let (_, sk) = crate::keys::load_secret(&keys)?;
403
            tl.refresh_checkpoint(&identity, &sk)
404
        })
405
        .await;
406
        if let Ok(Err(e)) = done {
407
            eprintln!("tlog: signing a checkpoint: {e}");
408
        }
409
    }
410
}
411
412
fn plain(status: StatusCode, why: &str) -> Response {
413
    (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{why}\n")).into_response()
414
}
415
416
/// `GET /log/checkpoint`: the latest signed checkpoint (C2SP
417
/// tlog-checkpoint, a signed note).
418
pub async fn checkpoint(State(app): State<Shared>) -> Response {
419
    match app.tlog.checkpoint() {
420
        Some(note) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], note).into_response(),
421
        None => plain(StatusCode::SERVICE_UNAVAILABLE, "no checkpoint yet: the log signs one within seconds"),
422
    }
423
}
424
425
/// `GET /log/tile/{L}/{N}[.p/{W}]` and `/log/tile/entries/{N}[.p/{W}]`:
426
/// a tile of hashes or an entry bundle (C2SP tlog-tiles). Full tiles never
427
/// change and are cached for good.
428
pub async fn tile(State(app): State<Shared>, UrlPath(path): UrlPath<String>) -> Response {
429
    let (entries, rest) = match path.strip_prefix("entries/") {
430
        Some(r) => (true, format!("0/{r}")),
431
        None => (false, path.clone()),
432
    };
433
    let Some(t) = Tile::parse(&rest) else {
434
        return plain(StatusCode::NOT_FOUND, "no such tile");
435
    };
436
    let tl = app.tlog.clone();
437
    let got = tokio::task::spawn_blocking(move || if entries { tl.bundle(&t) } else { tl.tile(&t) }).await;
438
    match got {
439
        Ok(Ok(Some(data))) => {
440
            let cache = if t.width as u64 == TILE_WIDTH { "public, max-age=31536000, immutable" } else { "public, max-age=5" };
441
            ([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, cache)], data).into_response()
442
        }
443
        Ok(Ok(None)) => plain(StatusCode::NOT_FOUND, "the log does not hold that tile yet"),
444
        Ok(Err(e)) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
445
        Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
446
    }
447
}
448
449
#[cfg(test)]
450
mod tests {
451
    use super::*;
452
453
    fn entry(i: u64) -> Entry {
454
        Entry { kind: "ref".into(), repo: [1; 16], object: ObjectRef([i as u8; 32]), signers: vec![], received: i }
455
    }
456
457
    #[test]
458
    fn the_log_survives_a_torn_append_and_keeps_its_root() {
459
        let dir = tempfile::tempdir().unwrap();
460
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
461
        let first: Vec<Entry> = (0..300).map(entry).collect();
462
        assert_eq!(log.append(&first).unwrap(), (0..300).collect::<Vec<_>>());
463
        let root = log.tree_hash(300).unwrap();
464
        // Leaves alone, as RFC 6962 roots them.
465
        let mut stored: Vec<Hash> = Vec::new();
466
        for (n, e) in first.iter().enumerate() {
467
            stored.extend(tlog::stored_hashes_for_leaf(n as u64, e.leaf_hash(), &stored).unwrap());
468
        }
469
        assert_eq!(root, tlog::tree_hash(300, &stored).unwrap());
470
        drop(log);
471
        // A crash after the entry and part of the hashes were written.
472
        let torn = dir.path().join("hashes");
473
        let len = std::fs::metadata(&torn).unwrap().len();
474
        OpenOptions::new().write(true).open(&torn).unwrap().set_len(len - 40).unwrap();
475
        let mut f = OpenOptions::new().append(true).open(dir.path().join("entries")).unwrap();
476
        f.write_all(&[0, 200, 1, 2]).unwrap(); // a partial entry
477
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
478
        assert_eq!(log.size(), 300);
479
        assert_eq!(log.tree_hash(300).unwrap(), root);
480
        log.append(&[entry(300)]).unwrap();
481
        assert_eq!(log.size(), 301);
482
        let p = log.prove_inclusion(301, 7).unwrap();
483
        tlog::check_inclusion(&p, 301, &log.tree_hash(301).unwrap(), 7, &first[7].leaf_hash()).unwrap();
484
        let c = log.prove_consistency(301, 300).unwrap();
485
        tlog::check_consistency(&c, 301, &log.tree_hash(301).unwrap(), 300, &root).unwrap();
486
        // Tiles and bundles: the full level-0 tile and the partial one.
487
        let full = log.tile(&Tile { level: 0, n: 0, width: 256 }).unwrap().unwrap();
488
        assert_eq!(full.len(), 256 * 32);
489
        assert!(log.tile(&Tile { level: 0, n: 1, width: 45 }).unwrap().is_some());
490
        assert!(log.tile(&Tile { level: 0, n: 1, width: 46 }).unwrap().is_none(), "not yet");
491
        let bundle = log.bundle(&Tile { level: 0, n: 1, width: 45 }).unwrap().unwrap();
492
        assert_eq!(tlog::decode_bundle(&bundle).unwrap()[0], first[256].encode());
493
    }
494
495
    #[test]
496
    fn checkpoints_are_signed_when_the_log_grows() {
497
        let dir = tempfile::tempdir().unwrap();
498
        let sk = oxy_core::identity::SecretKey::generate();
499
        let hub = sk.identity("hub", "[email protected]", 1);
500
        let log = Log::open(dir.path(), &origin_of("https://hub.example:7343/")).unwrap();
501
        assert_eq!(log.origin, "hub.example:7343/log");
502
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap(), "the empty log's");
503
        assert!(!log.refresh_checkpoint(&hub, &sk).unwrap(), "nothing new");
504
        log.append(&[entry(1), entry(2)]).unwrap();
505
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap());
506
        let note = tlog::Note::parse(&log.checkpoint().unwrap()).unwrap();
507
        let c = Checkpoint::verify(&note, "hub.example:7343/log", &hub).unwrap();
508
        assert_eq!((c.size, c.root), (2, log.tree_hash(2).unwrap()));
509
        // Kept across a restart.
510
        drop(log);
511
        let log = Log::open(dir.path(), "hub.example:7343/log").unwrap();
512
        assert_eq!(log.checkpoint().map(|n| n.contains("\n2\n")), Some(true));
513
    }
514
}