Skip to main content

lonca_core/
oplog.rs

1//! The operation log: an append-only, hash-chained, signed list of every
2//! operation, stored as a chain of Git commits on `refs/lonca/oplog` so that
3//! it travels with `git push`.
4//!
5//! The chain does not rely on Git's SHA-1: every entry carries the SHA-256 of
6//! the previous entry, and the daemon signs the SHA-256 of each entry.
7//!
8//! Every entry carries a format version (`docs/decisions.md`, "Log format
9//! versions"). Entries are never rewritten, so every version ever written
10//! stays readable; a version this build does not know is refused.
11
12use serde::{Deserialize, Serialize};
13
14#[cfg(feature = "store")]
15use crate::canonical::canonical_bytes;
16use crate::canonical::digest_of;
17use crate::error::{Error, Result};
18use crate::keys::{KeyPair, verify_hex};
19#[cfg(feature = "store")]
20use crate::objects::{FileEntry, FileMap, FileMode, GitId};
21use crate::ops::{
22    Actor, Attestation, Decision, Facts, HumanSignature, LeaseItem, NegotiationMsg, ObsKind, Op,
23};
24#[cfg(feature = "store")]
25use crate::store::{CommitSpec, Store};
26
27pub const OPLOG_REF: &str = "refs/lonca/oplog";
28pub const GENESIS_PREV: &str = "0000000000000000000000000000000000000000000000000000000000000000";
29#[cfg(feature = "store")]
30const OP_FILE: &str = "op.json";
31
32/// Entries written before the format field existed. The field is left out of
33/// their encoding, so their bytes, hashes and signatures are unchanged.
34pub const FORMAT_LEGACY: u32 = 0;
35/// The format this build writes. Version 1 is version 0 with the field
36/// written out; version 2 adds `symbols_engine`; version 3 adds the human
37/// key operations; version 4 adds the daemon key operations; version 5
38/// adds `scope.scratch` on `session.start` and `cap.attenuate`; version 6
39/// adds `reject`; version 7 changes what `change.propose` means for
40/// leases (they are pinned to the change) and prunes dead lease sets;
41/// version 8 gives heartbeats and denied acquires a meaning for leases;
42/// version 9 lets a `session.start` continue an earlier session; version 10
43/// adds `lease.interest`, `to` on a yield and `leases` on `cap.attenuate`;
44/// version 11 adds the `unproposed` observation; version 12 makes a lease
45/// say why and lets a waiting interest go first; version 13 lets a name
46/// stand for every symbol of that name; version 14 adds `renames` to facts
47/// and follows them in leases; version 15 adds `unparsed_files` to facts;
48/// version 16 adds `unread_files`; version 17 adds `change.integrate_batch`;
49/// version 18 adds `affected` to facts and reports who holds it; version 19
50/// adds `session.isolate`; version 20 adds `harness` to `session.start`, and
51/// a level rests on it; version 21 adds the lease kind `shared`; version 22
52/// adds reserved `names` to an `insert` and makes a new file need a lease;
53/// version 23 gives a delegated lease back to the parent when the child
54/// expires or stops beating, as when it ends; version 24 adds the
55/// `unhooked` observation, which taints a session; version 25 adds named
56/// `checks` to `approve`; version 26 makes an agent's proposal withdraw the
57/// pending changes of its workspace; version 27 lets a recovery key sign a
58/// `daemon.recover` (`recover`); version 28 accepts human keys on FIDO
59/// security keys (`sk-p256`, `sk-ed25519`).
60pub const FORMAT_CURRENT: u32 = 29;
61/// The first format whose `change.propose` and `change.integrate` entries
62/// record the symbol engine version.
63pub const FORMAT_SYMBOLS_ENGINE: u32 = 2;
64/// The first format with `human.rotate`, `human.revoke`,
65/// `human.recovery_key` and `human.recover`.
66pub const FORMAT_HUMAN_KEYS: u32 = 3;
67/// The first format with `daemon.rotate` and `daemon.recover`.
68pub const FORMAT_DAEMON_KEYS: u32 = 4;
69/// The first format whose session scopes may name scratch roots.
70pub const FORMAT_SCRATCH: u32 = 5;
71/// The first format with `reject`.
72pub const FORMAT_REJECT: u32 = 6;
73/// The first format whose entries pin leases to the changes they propose
74/// and prune dead lease sets. No new field: the meaning of existing entries
75/// changes, so a replay must apply it to these entries and no older ones.
76pub const FORMAT_LEASE_PINS: u32 = 7;
77/// The first format in which a heartbeat keeps a session's leases alive
78/// (and its absence ends them), a denied acquire registers interest and
79/// `leases.max_hold_minutes` limits a renewal. No new field, again.
80pub const FORMAT_LEASE_LIVENESS: u32 = 8;
81/// The first format in which a `session.start` may continue a session.
82pub const FORMAT_SESSION_CONTINUE: u32 = 9;
83/// The first format with `lease.interest`, a yield to a session and lease
84/// sets carried by `cap.attenuate`.
85pub const FORMAT_LEASE_QUEUE: u32 = 10;
86/// The first format with the `unproposed` observation.
87pub const FORMAT_UNPROPOSED: u32 = 11;
88/// The first format in which a lease or an interest needs an intent and a
89/// waiting interest stops new leases that conflict with it. No new field.
90pub const FORMAT_LEASE_INTENT: u32 = 12;
91/// The first format in which `path#Name` covers `path#Name(key)`, in leases
92/// and in scopes. No new field.
93pub const FORMAT_SYMBOL_KEYS: u32 = 13;
94/// The first format with `renames` in facts. Older entries have none, so
95/// nothing is gated on it.
96pub const FORMAT_RENAMES: u32 = 14;
97/// The first format with `unparsed_files` in facts. Older entries have none,
98/// so nothing is gated on it.
99pub const FORMAT_UNPARSED: u32 = 15;
100/// The first format with `unread_files` in facts. Older entries have none,
101/// so nothing is gated on it.
102pub const FORMAT_UNREAD: u32 = 16;
103/// The first format with `change.integrate_batch`.
104pub const FORMAT_BATCH: u32 = 17;
105/// The first format with `affected` in facts, and whose proposals report the
106/// lease sets it reaches. Older entries report none and replay as they were.
107pub const FORMAT_AFFECTED: u32 = 18;
108/// The first format with `session.isolate`.
109pub const FORMAT_ISOLATE: u32 = 19;
110/// The first format with `harness` on `session.start`. From here a
111/// `session.bind` is held to it, and can not raise a level already bound.
112/// Older entries replay as they were.
113pub const FORMAT_SESSION_HARNESS: u32 = 20;
114/// The first format with the lease kind `shared`.
115pub const FORMAT_SHARED_LEASES: u32 = 21;
116/// The first format with reserved `names` on an `insert` item. From here a
117/// change that adds a file needs an exclusive lease on its `path#<file>`.
118/// Older entries replay as they were.
119pub const FORMAT_INSERT_NAMES: u32 = 22;
120/// The first format in which a lease set delegated to a child session goes
121/// back to the parent that gave it when the child expires or stops beating,
122/// and not only when it ends or is revoked. No new field: older entries
123/// replay as they were.
124pub const FORMAT_GIVE_BACK: u32 = 23;
125/// The first format with the `unhooked` observation: a call to `lonca mcp`
126/// whose before-tool event never came. From here it taints the session.
127pub const FORMAT_UNHOOKED: u32 = 24;
128/// The first format with named `checks` on `approve`: the checks of
129/// `verification.checks` a change owes. Evidence of them is `named`.
130pub const FORMAT_NAMED_CHECKS: u32 = 25;
131/// The first format in which an agent's `change.propose` that is not denied
132/// withdraws the pending changes of its workspace: those of its session and
133/// of the sessions it continues that wait for a human. The decision names
134/// each with `replaces:<change>`. No new field: older entries replay as they
135/// were.
136pub const FORMAT_REPLACES: u32 = 26;
137/// The first format with `recover` on `daemon.recover`: signed with a
138/// human's recovery key, it also replaces that human's lost key, so a
139/// lost Mac (daemon key and Secure Enclave key both gone) is recovered
140/// with the recovery key alone.
141pub const FORMAT_RECOVERY_DAEMON: u32 = 27;
142/// The first format that accepts a human key on a FIDO security key
143/// (`sk-p256`, `sk-ed25519`), whose signatures prove presence. No new
144/// field: before it, such a key is `invalid_key`, as it always was.
145pub const FORMAT_SECURITY_KEYS: u32 = 28;
146/// The first format with keyed directories (#261): an `insert` lease on a
147/// `dir#<dir>` reserves keys, a proposal's added files there need theirs, a
148/// proposal pins the key leases of the directories it adds files to, and
149/// facts may name `taken_keys`; a keyed directory's path takes no other
150/// symbol than its `<dir>`. Before it, a `<dir>` lease was one on any
151/// container. Older entries replay as they were.
152pub const FORMAT_KEYED: u32 = 29;
153
154fn is_legacy(format: &u32) -> bool {
155    *format == FORMAT_LEGACY
156}
157
158/// Refuses a format this build can not read.
159pub fn check_format(seq: u64, format: u32) -> Result<()> {
160    if format > FORMAT_CURRENT {
161        return Err(Error::Unsupported(format!(
162            "op {seq}: log format {format} is newer than this build of lonca knows \
163             (up to {FORMAT_CURRENT}); upgrade lonca"
164        )));
165    }
166    Ok(())
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct OpEntry {
171    /// Entry format version, covered by the digest and the signature.
172    #[serde(default, skip_serializing_if = "is_legacy")]
173    pub format: u32,
174    pub seq: u64,
175    pub prev: String,
176    /// Assigned by the daemon; never decreases along the chain.
177    pub time_ms: i64,
178    pub actor: Actor,
179    pub op: Op,
180    pub evidence: Vec<Attestation>,
181    pub facts: Option<Facts>,
182    /// Version of the symbol engine that computed this entry's facts or
183    /// merge. Present exactly on `change.propose` and `change.integrate`
184    /// entries from format 2 on; absent means version 0.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub symbols_engine: Option<u32>,
187    pub decision: Decision,
188    pub human: Option<HumanSignature>,
189}
190
191impl OpEntry {
192    pub fn digest(&self) -> String {
193        digest_of(self)
194    }
195
196    /// Whether this entry's facts or merge come from the symbol engine.
197    pub fn uses_symbols_engine(&self) -> bool {
198        matches!(
199            self.op,
200            Op::ChangePropose { .. } | Op::ChangeIntegrate { .. } | Op::ChangeIntegrateBatch { .. }
201        )
202    }
203
204    /// The first format that can contain this entry's operation type.
205    pub fn min_format(&self) -> u32 {
206        match self.op {
207            Op::HumanRotate { .. }
208            | Op::HumanRevoke { .. }
209            | Op::HumanRecoveryKey { .. }
210            | Op::HumanRecover { .. } => FORMAT_HUMAN_KEYS,
211            Op::DaemonRecover {
212                recover: Some(_), ..
213            } => FORMAT_RECOVERY_DAEMON,
214            Op::DaemonRotate { .. } | Op::DaemonRecover { .. } => FORMAT_DAEMON_KEYS,
215            Op::Reject { .. } => FORMAT_REJECT,
216            Op::Approve { ref checks, .. } if !checks.is_empty() => FORMAT_NAMED_CHECKS,
217            Op::ChangeIntegrateBatch { .. } => FORMAT_BATCH,
218            Op::SessionIsolate { .. } => FORMAT_ISOLATE,
219            Op::LeaseAcquire { ref items, .. } | Op::LeaseInterest { ref items, .. }
220                if items.iter().any(|i| !i.names.is_empty()) =>
221            {
222                FORMAT_INSERT_NAMES
223            }
224            Op::LeaseAcquire { ref items, .. } | Op::LeaseInterest { ref items, .. }
225                if items.iter().any(LeaseItem::is_shared) =>
226            {
227                FORMAT_SHARED_LEASES
228            }
229            Op::Observe { ref events, .. }
230                if events
231                    .iter()
232                    .any(|e| matches!(e.kind, ObsKind::Unhooked { .. })) =>
233            {
234                FORMAT_UNHOOKED
235            }
236            Op::Observe { ref events, .. }
237                if events
238                    .iter()
239                    .any(|e| matches!(e.kind, ObsKind::Unproposed { .. })) =>
240            {
241                FORMAT_UNPROPOSED
242            }
243            Op::LeaseInterest { .. }
244            | Op::Negotiate {
245                message: NegotiationMsg::Yield { to: Some(_), .. },
246                ..
247            } => FORMAT_LEASE_QUEUE,
248            Op::CapAttenuate { ref leases, .. } if !leases.is_empty() => FORMAT_LEASE_QUEUE,
249            Op::SessionStart {
250                harness: Some(_), ..
251            } => FORMAT_SESSION_HARNESS,
252            Op::SessionStart {
253                continues: Some(_), ..
254            } => FORMAT_SESSION_CONTINUE,
255            Op::SessionStart { ref scope, .. } | Op::CapAttenuate { ref scope, .. }
256                if !scope.scratch.is_empty() =>
257            {
258                FORMAT_SCRATCH
259            }
260            _ => FORMAT_LEGACY,
261        }
262    }
263
264    /// The symbol engine version the auditor must recompute with, for
265    /// entries that use the engine.
266    pub fn engine(&self) -> Option<u32> {
267        self.uses_symbols_engine()
268            .then(|| self.symbols_engine.unwrap_or(0))
269    }
270
271    /// The daemon key in force after this entry, if the entry switched it:
272    /// an allowed `daemon.rotate` or `daemon.recover`.
273    pub fn switches_daemon_key(&self) -> Option<&str> {
274        self.op
275            .daemon_key_switch()
276            .filter(|_| self.decision.is_allow())
277    }
278
279    /// The daemon key that must have signed this entry, given the key in
280    /// force before it. An allowed `daemon.recover` is signed by the key it
281    /// installs, because the old key is gone; every other entry, a rotation
282    /// included, by the key in force.
283    pub fn signer<'a>(&'a self, in_force: &'a str) -> &'a str {
284        match (&self.op, self.switches_daemon_key()) {
285            (Op::DaemonRecover { .. }, Some(new)) => new,
286            _ => in_force,
287        }
288    }
289}
290
291#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292pub struct SignedOp {
293    pub entry: OpEntry,
294    pub hash: String,
295    pub sig: String,
296}
297
298impl SignedOp {
299    pub fn sign(entry: OpEntry, key: &KeyPair) -> Self {
300        let hash = entry.digest();
301        let sig = key.sign_hex(hash.as_bytes());
302        Self { entry, hash, sig }
303    }
304
305    pub fn verify(&self, daemon_key: &str) -> Result<()> {
306        if self.entry.digest() != self.hash {
307            return Err(Error::Integrity(format!(
308                "op {}: hash does not match content",
309                self.entry.seq
310            )));
311        }
312        if !verify_hex(daemon_key, self.hash.as_bytes(), &self.sig) {
313            return Err(Error::Integrity(format!(
314                "op {}: bad daemon signature",
315                self.entry.seq
316            )));
317        }
318        Ok(())
319    }
320}
321
322/// The log in a store.
323#[cfg(feature = "store")]
324pub struct OpLog<'a> {
325    store: &'a Store,
326}
327
328#[cfg(feature = "store")]
329impl<'a> OpLog<'a> {
330    pub fn new(store: &'a Store) -> Self {
331        Self { store }
332    }
333
334    pub fn head(&self) -> Result<Option<GitId>> {
335        self.store.ref_get(OPLOG_REF)
336    }
337
338    /// Appends one signed entry. `expected_head` must be the current head
339    /// commit (compare-and-swap), so two writers can never interleave.
340    pub fn append(&self, op: &SignedOp, expected_head: Option<&GitId>) -> Result<GitId> {
341        if op.entry.format != FORMAT_CURRENT {
342            return Err(Error::Invalid(format!(
343                "op {}: refusing to append log format {} (this build writes {FORMAT_CURRENT})",
344                op.entry.seq, op.entry.format
345            )));
346        }
347        let blob = self.store.write_blob(&canonical_bytes(op))?;
348        let mut files = FileMap::new();
349        files.insert(
350            OP_FILE.into(),
351            FileEntry {
352                mode: FileMode::Regular,
353                blob,
354            },
355        );
356        let tree = self.store.write_tree(&files)?;
357        let commit = self.store.write_commit(&CommitSpec {
358            tree,
359            parents: expected_head.into_iter().cloned().collect(),
360            author_name: "lonca".into(),
361            author_email: "daemon@lonca.invalid".into(),
362            time_ms: op.entry.time_ms,
363            message: format!(
364                "lonca op {} {} {:?}\n\nactor: {}\n",
365                op.entry.seq,
366                op.entry.op.kind().as_str(),
367                op.entry.decision.outcome,
368                op.entry.actor
369            ),
370            headers: vec![],
371        })?;
372        self.store.ref_cas(OPLOG_REF, &commit, expected_head)?;
373        Ok(commit)
374    }
375
376    pub fn read_at(&self, commit: &GitId) -> Result<SignedOp> {
377        let bytes = self.store.read_file(commit, OP_FILE)?.ok_or_else(|| {
378            Error::Integrity(format!("oplog commit {} has no {OP_FILE}", commit.short()))
379        })?;
380        decode(&bytes)
381    }
382
383    /// All entries, oldest first.
384    pub fn read_all(&self) -> Result<Vec<SignedOp>> {
385        self.read_from(0)
386    }
387
388    /// The entries from sequence `seq` on, oldest first. The log is walked
389    /// from its head, so this costs what it returns, however long the log.
390    pub fn read_from(&self, seq: u64) -> Result<Vec<SignedOp>> {
391        let mut out = Vec::new();
392        let mut cur = self.head()?;
393        while let Some(c) = cur {
394            let op = self.read_at(&c)?;
395            if op.entry.seq < seq {
396                break;
397            }
398            out.push(op);
399            let info = self.store.read_commit(&c)?;
400            if info.parents.len() > 1 {
401                return Err(Error::Integrity(format!(
402                    "oplog commit {} is a merge",
403                    c.short()
404                )));
405            }
406            cur = info.parents.into_iter().next();
407        }
408        out.reverse();
409        Ok(out)
410    }
411}
412
413/// Decodes a stored entry. The format version is read first, so an entry
414/// from a newer lonca is refused as such rather than as a decoding or hash
415/// error.
416pub fn decode(bytes: &[u8]) -> Result<SignedOp> {
417    #[derive(Deserialize)]
418    struct Probe {
419        entry: ProbeEntry,
420    }
421    #[derive(Deserialize)]
422    struct ProbeEntry {
423        #[serde(default)]
424        format: u32,
425        #[serde(default)]
426        seq: u64,
427    }
428    let probe: Probe = serde_json::from_slice(bytes)?;
429    check_format(probe.entry.seq, probe.entry.format)?;
430    Ok(serde_json::from_slice(bytes)?)
431}
432
433/// Checks sequence numbers, the hash chain, signatures, time order and
434/// format versions (known, and never decreasing along the chain).
435/// The daemon key is taken from the genesis `repo.init` entry and follows
436/// every allowed `daemon.rotate` and `daemon.recover`.
437///
438/// This checks who signed each entry, not whether a key switch was
439/// authorised: that needs the human keys in force, so the daemon
440/// (`lonca_policy::chain::replay_checked`) and the auditor decide every
441/// switch again.
442pub fn verify_chain(ops: &[SignedOp]) -> Result<String> {
443    let Some(first) = ops.first() else {
444        return Err(Error::Integrity("empty operation log".into()));
445    };
446    let mut daemon_key = match &first.entry.op {
447        Op::RepoInit { daemon_key, .. } => daemon_key.clone(),
448        _ => return Err(Error::Integrity("first operation is not repo.init".into())),
449    };
450    let mut prev = GENESIS_PREV.to_string();
451    let mut last_time = i64::MIN;
452    let mut last_format = FORMAT_LEGACY;
453    for (i, op) in ops.iter().enumerate() {
454        check_format(op.entry.seq, op.entry.format)?;
455        if op.entry.format < last_format {
456            return Err(Error::Integrity(format!(
457                "op {i}: log format goes back from {last_format} to {}",
458                op.entry.format
459            )));
460        }
461        let wants_engine =
462            op.entry.format >= FORMAT_SYMBOLS_ENGINE && op.entry.uses_symbols_engine();
463        if op.entry.symbols_engine.is_some() != wants_engine {
464            return Err(Error::Integrity(format!(
465                "op {i}: symbols_engine must be {} in a format {} {} entry",
466                if wants_engine { "present" } else { "absent" },
467                op.entry.format,
468                op.entry.op.kind().as_str()
469            )));
470        }
471        if op.entry.format < op.entry.min_format() {
472            return Err(Error::Integrity(format!(
473                "op {i}: {} needs log format {} or later, found {}",
474                op.entry.op.kind().as_str(),
475                op.entry.min_format(),
476                op.entry.format
477            )));
478        }
479        if op.entry.seq != i as u64 {
480            return Err(Error::Integrity(format!(
481                "expected seq {i}, found {}",
482                op.entry.seq
483            )));
484        }
485        if op.entry.prev != prev {
486            return Err(Error::Integrity(format!("op {i}: broken hash chain")));
487        }
488        if op.entry.time_ms < last_time {
489            return Err(Error::Integrity(format!("op {i}: time goes backwards")));
490        }
491        op.verify(op.entry.signer(&daemon_key))?;
492        if let Some(new) = op.entry.switches_daemon_key() {
493            daemon_key = new.to_string();
494        }
495        prev = op.hash.clone();
496        last_time = op.entry.time_ms;
497        last_format = op.entry.format;
498    }
499    Ok(prev)
500}
501
502#[cfg(test)]
503#[cfg(feature = "store")]
504mod tests {
505    use super::*;
506    use crate::ChangeId;
507    use crate::ops::{Continuation, Decision, HumanKey, LeaseKind, Scope};
508
509    fn genesis(key: &KeyPair, store: &Store) -> OpEntry {
510        let tree = store.empty_tree().unwrap();
511        OpEntry {
512            format: FORMAT_CURRENT,
513            seq: 0,
514            prev: GENESIS_PREV.into(),
515            time_ms: 1000,
516            actor: Actor::Human {
517                name: "ayse".into(),
518            },
519            op: Op::RepoInit {
520                repo: "demo".into(),
521                daemon_key: key.public_hex(),
522                human: "ayse".into(),
523                human_key: HumanKey {
524                    alg: "ed25519".into(),
525                    public: "00".into(),
526                },
527                policy: tree.clone(),
528                trunk: tree,
529            },
530            evidence: vec![],
531            facts: None,
532            symbols_engine: None,
533            decision: Decision::allow(),
534            human: None,
535        }
536    }
537
538    fn next(prev: &SignedOp, time_ms: i64) -> OpEntry {
539        OpEntry {
540            format: FORMAT_CURRENT,
541            seq: prev.entry.seq + 1,
542            prev: prev.hash.clone(),
543            time_ms,
544            actor: Actor::Daemon,
545            op: Op::Freeze {
546                reason: "test".into(),
547            },
548            evidence: vec![],
549            facts: None,
550            symbols_engine: None,
551            decision: Decision::allow(),
552            human: None,
553        }
554    }
555
556    #[test]
557    fn append_read_verify() {
558        let dir = tempfile::tempdir().unwrap();
559        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
560        let key = KeyPair::generate();
561        let log = OpLog::new(&store);
562
563        let g = SignedOp::sign(genesis(&key, &store), &key);
564        let h0 = log.append(&g, None).unwrap();
565        let o1 = SignedOp::sign(next(&g, 2000), &key);
566        log.append(&o1, Some(&h0)).unwrap();
567
568        let all = log.read_all().unwrap();
569        assert_eq!(all, vec![g.clone(), o1.clone()]);
570        assert_eq!(verify_chain(&all).unwrap(), o1.hash);
571
572        // A writer with a stale head cannot append.
573        let o2 = SignedOp::sign(next(&o1, 3000), &key);
574        assert!(log.append(&o2, Some(&h0)).is_err());
575    }
576
577    #[test]
578    fn tampering_is_detected() {
579        let dir = tempfile::tempdir().unwrap();
580        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
581        let key = KeyPair::generate();
582        let g = SignedOp::sign(genesis(&key, &store), &key);
583        let o1 = SignedOp::sign(next(&g, 2000), &key);
584
585        let mut edited = o1.clone();
586        edited.entry.decision = Decision::deny("rewritten");
587        assert!(
588            verify_chain(&[g.clone(), edited]).is_err(),
589            "content change"
590        );
591
592        let mut resigned = o1.clone();
593        resigned.entry.time_ms = 5000;
594        let forged = SignedOp::sign(resigned.entry, &KeyPair::generate());
595        assert!(verify_chain(&[g.clone(), forged]).is_err(), "other key");
596
597        let backwards = SignedOp::sign(next(&g, 500), &key);
598        assert!(verify_chain(&[g.clone(), backwards]).is_err(), "time");
599
600        let skipped = SignedOp::sign(
601            OpEntry {
602                seq: 2,
603                ..next(&g, 2000)
604            },
605            &key,
606        );
607        assert!(verify_chain(&[g, skipped]).is_err(), "seq gap");
608    }
609
610    #[test]
611    fn format_is_encoded_and_signed_except_for_legacy_entries() {
612        let dir = tempfile::tempdir().unwrap();
613        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
614        let key = KeyPair::generate();
615        let current = genesis(&key, &store);
616        let legacy = OpEntry {
617            format: FORMAT_LEGACY,
618            ..current.clone()
619        };
620        let enc = |e: &OpEntry| String::from_utf8(canonical_bytes(e)).unwrap();
621        assert!(enc(&legacy).starts_with("{\"seq\":0,"), "{}", enc(&legacy));
622        assert!(
623            enc(&current).starts_with(&format!("{{\"format\":{FORMAT_CURRENT},\"seq\":0,")),
624            "{}",
625            enc(&current)
626        );
627        assert_ne!(legacy.digest(), current.digest(), "format is in the digest");
628
629        // Changing the format of a signed entry breaks its signature.
630        let mut edited = SignedOp::sign(current, &key);
631        edited.entry.format = FORMAT_LEGACY;
632        assert!(edited.verify(&key.public_hex()).is_err());
633
634        // A legacy entry round-trips to the same bytes.
635        let signed = SignedOp::sign(legacy, &key);
636        let bytes = canonical_bytes(&signed);
637        let back = decode(&bytes).unwrap();
638        assert_eq!(back, signed);
639        assert_eq!(canonical_bytes(&back), bytes);
640    }
641
642    #[test]
643    fn unknown_formats_are_refused_before_decoding() {
644        let dir = tempfile::tempdir().unwrap();
645        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
646        let key = KeyPair::generate();
647        let g = SignedOp::sign(genesis(&key, &store), &key);
648        let mut v: serde_json::Value = serde_json::from_slice(&canonical_bytes(&g)).unwrap();
649        v["entry"]["format"] = (FORMAT_CURRENT + 1).into();
650        // A newer lonca may also have changed the shape of other fields.
651        v["entry"]["op"] = serde_json::json!({"type": "from.the.future"});
652        let bytes = serde_json::to_vec(&v).unwrap();
653        match decode(&bytes) {
654            Err(Error::Unsupported(m)) => assert!(
655                m.contains(&format!("op 0: log format {}", FORMAT_CURRENT + 1)),
656                "{m}"
657            ),
658            other => panic!("expected Unsupported, got {other:?}"),
659        }
660
661        let future = SignedOp::sign(
662            OpEntry {
663                format: FORMAT_CURRENT + 1,
664                ..genesis(&key, &store)
665            },
666            &key,
667        );
668        assert!(matches!(
669            verify_chain(std::slice::from_ref(&future)),
670            Err(Error::Unsupported(_))
671        ));
672        assert!(matches!(
673            OpLog::new(&store).append(&future, None),
674            Err(Error::Invalid(_))
675        ));
676    }
677
678    #[test]
679    fn formats_may_rise_along_the_chain_but_never_fall() {
680        let dir = tempfile::tempdir().unwrap();
681        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
682        let key = KeyPair::generate();
683        let with = |e: OpEntry, format| OpEntry { format, ..e };
684        let g = SignedOp::sign(with(genesis(&key, &store), FORMAT_LEGACY), &key);
685        let o1 = SignedOp::sign(with(next(&g, 2000), FORMAT_LEGACY), &key);
686        let o2 = SignedOp::sign(with(next(&o1, 3000), FORMAT_CURRENT), &key);
687        assert_eq!(
688            verify_chain(&[g.clone(), o1.clone(), o2.clone()]).unwrap(),
689            o2.hash
690        );
691
692        let back = SignedOp::sign(with(next(&o2, 4000), FORMAT_LEGACY), &key);
693        let err = verify_chain(&[g, o1, o2, back]).unwrap_err();
694        assert!(
695            err.to_string().contains("op 3: log format goes back"),
696            "{err}"
697        );
698    }
699
700    #[test]
701    fn the_symbols_engine_is_recorded_exactly_on_proposals_and_integrations() {
702        let dir = tempfile::tempdir().unwrap();
703        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
704        let key = KeyPair::generate();
705        let g = SignedOp::sign(genesis(&key, &store), &key);
706        let tree = store.empty_tree().unwrap();
707        let integrate = |prev: &SignedOp, format, symbols_engine| OpEntry {
708            format,
709            symbols_engine,
710            op: Op::ChangeIntegrate {
711                change: crate::ChangeId::random(),
712                trunk_before: tree.clone(),
713                merged: None,
714                conflicts: vec![],
715            },
716            ..next(prev, 2000)
717        };
718        let ok = |e: OpEntry| verify_chain(&[g.clone(), SignedOp::sign(e, &key)]);
719
720        let recorded = integrate(&g, FORMAT_CURRENT, Some(7));
721        assert_eq!(recorded.engine(), Some(7));
722        assert!(ok(recorded).is_ok());
723        assert!(ok(integrate(&g, FORMAT_CURRENT, None)).is_err(), "missing");
724        let other = OpEntry {
725            symbols_engine: Some(0),
726            ..next(&g, 2000)
727        };
728        assert_eq!(other.engine(), None);
729        assert!(ok(other).is_err(), "on an op that uses no engine");
730
731        // Before format 2 the field did not exist; absent means engine 0.
732        let old = SignedOp::sign(with_format(genesis(&key, &store), 1), &key);
733        let v1 = |e| verify_chain(&[old.clone(), SignedOp::sign(e, &key)]);
734        let legacy = integrate(&old, 1, None);
735        assert_eq!(legacy.engine(), Some(0));
736        assert!(v1(legacy).is_ok());
737        assert!(v1(integrate(&old, 1, Some(0))).is_err());
738    }
739
740    #[test]
741    fn human_key_operations_need_format_3() {
742        let dir = tempfile::tempdir().unwrap();
743        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
744        let key = KeyPair::generate();
745        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
746        let k = || HumanKey {
747            alg: "ed25519".into(),
748            public: "01".into(),
749        };
750        let ops = [
751            Op::HumanRotate {
752                human: "ayse".into(),
753                key: k(),
754            },
755            Op::HumanRevoke {
756                human: "ayse".into(),
757                reason: "lost".into(),
758            },
759            Op::HumanRecoveryKey {
760                human: "ayse".into(),
761                key: k(),
762            },
763            Op::HumanRecover {
764                human: "ayse".into(),
765                key: k(),
766            },
767        ];
768        for op in ops {
769            let at = |format| OpEntry {
770                format,
771                op: op.clone(),
772                ..next(&g, 2000)
773            };
774            assert_eq!(at(FORMAT_HUMAN_KEYS).min_format(), FORMAT_HUMAN_KEYS);
775            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
776            assert!(chain(FORMAT_HUMAN_KEYS).is_ok(), "{op:?}");
777            let err = chain(FORMAT_HUMAN_KEYS - 1).unwrap_err();
778            assert!(
779                err.to_string()
780                    .contains(&format!("{} needs log format 3", op.kind().as_str())),
781                "{err}"
782            );
783        }
784        assert_eq!(next(&g, 2000).min_format(), FORMAT_LEGACY);
785    }
786
787    #[test]
788    fn continuing_a_session_needs_format_9() {
789        let dir = tempfile::tempdir().unwrap();
790        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
791        let key = KeyPair::generate();
792        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
793        let start = |continues| Op::SessionStart {
794            session: "s".into(),
795            agent: "bot".into(),
796            model: None,
797            task: String::new(),
798            scope: Scope {
799                paths: vec!["**".into()],
800                symbols: None,
801                ops: vec![],
802                max_files: None,
803                max_lines: None,
804                scratch: vec![],
805            },
806            expires_ms: 9000,
807            continues,
808            harness: None,
809        };
810        let plain = OpEntry {
811            format: FORMAT_LEGACY,
812            op: start(None),
813            ..next(&g, 2000)
814        };
815        assert_eq!(plain.min_format(), FORMAT_LEGACY);
816        let at = |format| OpEntry {
817            format,
818            op: start(Some(Continuation {
819                session: "old".into(),
820                widen: false,
821            })),
822            ..next(&g, 2000)
823        };
824        assert_eq!(at(9).min_format(), FORMAT_SESSION_CONTINUE);
825        let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
826        assert!(chain(9).is_ok());
827        let err = chain(8).unwrap_err();
828        assert!(err.to_string().contains("needs log format 9"), "{err}");
829    }
830
831    /// A harness needs its format whatever else the start carries.
832    #[test]
833    fn a_named_harness_needs_format_20() {
834        let dir = tempfile::tempdir().unwrap();
835        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
836        let key = KeyPair::generate();
837        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
838        let start = |continues: Option<Continuation>, scratch: Vec<String>| Op::SessionStart {
839            session: "s".into(),
840            agent: "bot".into(),
841            model: None,
842            task: String::new(),
843            scope: Scope {
844                paths: vec!["**".into()],
845                symbols: None,
846                ops: vec![],
847                max_files: None,
848                max_lines: None,
849                scratch,
850            },
851            expires_ms: 9000,
852            continues,
853            harness: Some("codex".into()),
854        };
855        let continued = Continuation {
856            session: "old".into(),
857            widen: false,
858        };
859        for op in [
860            start(None, vec![]),
861            start(Some(continued), vec![]),
862            start(None, vec!["/tmp/x".into()]),
863        ] {
864            let at = |format| OpEntry {
865                format,
866                op: op.clone(),
867                ..next(&g, 2000)
868            };
869            assert_eq!(at(20).min_format(), FORMAT_SESSION_HARNESS, "{op:?}");
870            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
871            assert!(chain(20).is_ok());
872            let err = chain(19).unwrap_err();
873            assert!(err.to_string().contains("needs log format 20"), "{err}");
874        }
875    }
876
877    /// A `shared` item needs its format, in an acquire or an interest, among
878    /// other items or alone; the other kinds keep theirs.
879    #[test]
880    fn a_shared_lease_needs_format_21() {
881        let dir = tempfile::tempdir().unwrap();
882        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
883        let key = KeyPair::generate();
884        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
885        let items = |kinds: &[LeaseKind]| -> Vec<LeaseItem> {
886            let item = |&kind| LeaseItem {
887                symbol: "a.rs#f".into(),
888                kind,
889                names: Vec::new(),
890            };
891            kinds.iter().map(item).collect()
892        };
893        let acquire = |items| Op::LeaseAcquire {
894            set: "l".into(),
895            items,
896            intent: "x".into(),
897            ttl_ms: 1000,
898        };
899        let interest = |items| Op::LeaseInterest {
900            items,
901            intent: "x".into(),
902        };
903        let at = |op: &Op, format| OpEntry {
904            format,
905            op: op.clone(),
906            ..next(&g, 2000)
907        };
908        let shared = [LeaseKind::Edit, LeaseKind::Shared];
909        for op in [
910            acquire(items(&shared)),
911            acquire(items(&[LeaseKind::Shared])),
912            interest(items(&shared)),
913        ] {
914            assert_eq!(at(&op, 21).min_format(), FORMAT_SHARED_LEASES, "{op:?}");
915            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(&op, format), &key)]);
916            assert!(chain(21).is_ok(), "{op:?}");
917            let err = chain(20).unwrap_err();
918            assert!(err.to_string().contains("needs log format 21"), "{err}");
919        }
920        let others = [LeaseKind::Edit, LeaseKind::Insert, LeaseKind::Rename];
921        assert_eq!(at(&acquire(items(&others)), 2).min_format(), FORMAT_LEGACY);
922        assert_eq!(
923            at(&interest(items(&others)), 10).min_format(),
924            FORMAT_LEASE_QUEUE
925        );
926    }
927
928    /// Reserved names need their format, in an acquire or an interest, and
929    /// come before `shared` in what an entry needs.
930    #[test]
931    fn reserved_names_need_format_22() {
932        let dir = tempfile::tempdir().unwrap();
933        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
934        let key = KeyPair::generate();
935        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
936        let named = LeaseItem {
937            names: vec!["throttle".into()],
938            ..LeaseItem::new("a.rs#T", LeaseKind::Insert)
939        };
940        let shared = LeaseItem::new("a.rs#f", LeaseKind::Shared);
941        let acquire = |items| Op::LeaseAcquire {
942            set: "l".into(),
943            items,
944            intent: "x".into(),
945            ttl_ms: 1000,
946        };
947        let interest = |items| Op::LeaseInterest {
948            items,
949            intent: "x".into(),
950        };
951        let at = |op: &Op, format| OpEntry {
952            format,
953            op: op.clone(),
954            ..next(&g, 2000)
955        };
956        for op in [
957            acquire(vec![named.clone()]),
958            acquire(vec![shared.clone(), named.clone()]),
959            interest(vec![named.clone()]),
960        ] {
961            assert_eq!(at(&op, 22).min_format(), FORMAT_INSERT_NAMES, "{op:?}");
962            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(&op, format), &key)]);
963            assert!(chain(22).is_ok(), "{op:?}");
964            let err = chain(21).unwrap_err();
965            assert!(err.to_string().contains("needs log format 22"), "{err}");
966        }
967        let unnamed = acquire(vec![LeaseItem::new("a.rs#T", LeaseKind::Insert)]);
968        assert_eq!(at(&unnamed, 2).min_format(), FORMAT_LEGACY);
969        // No names, no field: the bytes of an older item.
970        let bytes = serde_json::to_string(&LeaseItem::new("a.rs#T", LeaseKind::Insert)).unwrap();
971        assert_eq!(bytes, r#"{"symbol":"a.rs#T","kind":"insert"}"#);
972    }
973
974    #[test]
975    fn the_lease_queue_needs_format_10() {
976        let dir = tempfile::tempdir().unwrap();
977        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
978        let key = KeyPair::generate();
979        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
980        let scope = Scope {
981            paths: vec!["**".into()],
982            symbols: None,
983            ops: vec![],
984            max_files: None,
985            max_lines: None,
986            scratch: vec![],
987        };
988        let attenuate = |leases| Op::CapAttenuate {
989            parent: "p".into(),
990            child: "c".into(),
991            agent: "bot".into(),
992            scope: scope.clone(),
993            expires_ms: 9000,
994            leases,
995        };
996        let negotiate = |to| Op::Negotiate {
997            thread: "t".into(),
998            to_session: "s".into(),
999            message: NegotiationMsg::Yield {
1000                set: "l".into(),
1001                to,
1002            },
1003        };
1004        let at = |op: &Op, format| OpEntry {
1005            format,
1006            op: op.clone(),
1007            ..next(&g, 2000)
1008        };
1009        for op in [
1010            Op::LeaseInterest {
1011                items: vec![],
1012                intent: String::new(),
1013            },
1014            negotiate(Some("s".into())),
1015            attenuate(vec!["l".into()]),
1016        ] {
1017            assert_eq!(at(&op, 10).min_format(), FORMAT_LEASE_QUEUE, "{op:?}");
1018            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(&op, format), &key)]);
1019            assert!(chain(10).is_ok(), "{op:?}");
1020            let err = chain(9).unwrap_err();
1021            assert!(err.to_string().contains("needs log format 10"), "{err}");
1022        }
1023        for op in [negotiate(None), attenuate(vec![])] {
1024            assert_eq!(at(&op, 2).min_format(), FORMAT_LEGACY, "{op:?}");
1025        }
1026    }
1027
1028    #[test]
1029    fn isolating_a_session_needs_its_format() {
1030        let dir = tempfile::tempdir().unwrap();
1031        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1032        let key = KeyPair::generate();
1033        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
1034        let op = Op::SessionIsolate {
1035            session: "s".into(),
1036            sandbox: "seatbelt".into(),
1037            profile: "ab".repeat(32),
1038        };
1039        let at = |format| OpEntry {
1040            format,
1041            op: op.clone(),
1042            ..next(&g, 2000)
1043        };
1044        assert_eq!(at(FORMAT_ISOLATE).min_format(), FORMAT_ISOLATE);
1045        let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
1046        assert!(chain(FORMAT_ISOLATE).is_ok());
1047        let err = chain(FORMAT_ISOLATE - 1).unwrap_err();
1048        let needs = format!("needs log format {FORMAT_ISOLATE}");
1049        assert!(err.to_string().contains(&needs), "{err}");
1050    }
1051
1052    #[test]
1053    fn a_batch_needs_its_format() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1056        let key = KeyPair::generate();
1057        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
1058        let tip = GitId::of_blob(b"tip");
1059        let op = Op::ChangeIntegrateBatch {
1060            trunk_before: tip.clone(),
1061            merged: vec![(ChangeId::random(), tip)],
1062        };
1063        let at = |format| OpEntry {
1064            format,
1065            op: op.clone(),
1066            symbols_engine: Some(1),
1067            ..next(&g, 2000)
1068        };
1069        assert_eq!(at(FORMAT_BATCH).min_format(), FORMAT_BATCH);
1070        let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
1071        assert!(chain(FORMAT_BATCH).is_ok());
1072        let err = chain(FORMAT_BATCH - 1).unwrap_err();
1073        let needs = format!("needs log format {FORMAT_BATCH}");
1074        assert!(err.to_string().contains(&needs), "{err}");
1075    }
1076
1077    #[test]
1078    fn the_unproposed_observation_needs_format_11() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1081        let key = KeyPair::generate();
1082        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
1083        let observe = |kind| Op::Observe {
1084            session: "s".into(),
1085            events: vec![crate::ops::Observation {
1086                phase: crate::ops::Phase::Post,
1087                kind,
1088                external: false,
1089            }],
1090        };
1091        let at = |op: &Op, format| OpEntry {
1092            format,
1093            op: op.clone(),
1094            ..next(&g, 2000)
1095        };
1096        let report = observe(ObsKind::Unproposed {
1097            paths: vec![],
1098            total: 0,
1099        });
1100        assert_eq!(at(&report, 11).min_format(), FORMAT_UNPROPOSED);
1101        let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(&report, format), &key)]);
1102        assert!(chain(11).is_ok());
1103        let err = chain(10).unwrap_err();
1104        assert!(err.to_string().contains("needs log format 11"), "{err}");
1105        assert_eq!(
1106            at(&observe(ObsKind::Heartbeat), 2).min_format(),
1107            FORMAT_LEGACY
1108        );
1109    }
1110
1111    #[test]
1112    fn the_unhooked_observation_needs_format_24() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1115        let key = KeyPair::generate();
1116        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
1117        let observe = |kinds: Vec<ObsKind>| Op::Observe {
1118            session: "s".into(),
1119            events: kinds
1120                .into_iter()
1121                .map(|kind| crate::ops::Observation {
1122                    phase: crate::ops::Phase::Pre,
1123                    kind,
1124                    external: false,
1125                })
1126                .collect(),
1127        };
1128        let at = |op: &Op, format| OpEntry {
1129            format,
1130            op: op.clone(),
1131            ..next(&g, 2000)
1132        };
1133        let unhooked = || ObsKind::Unhooked {
1134            tool: "propose".into(),
1135        };
1136        let mark = observe(vec![unhooked()]);
1137        assert_eq!(at(&mark, 24).min_format(), FORMAT_UNHOOKED);
1138        // With an older kind beside it, the newer one decides.
1139        let unproposed = ObsKind::Unproposed {
1140            paths: vec![],
1141            total: 0,
1142        };
1143        let both = observe(vec![unproposed, unhooked()]);
1144        assert_eq!(at(&both, 24).min_format(), FORMAT_UNHOOKED);
1145        let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(&mark, format), &key)]);
1146        assert!(chain(24).is_ok());
1147        let err = chain(23).unwrap_err();
1148        assert!(err.to_string().contains("needs log format 24"), "{err}");
1149    }
1150
1151    #[test]
1152    fn named_checks_on_an_approval_need_format_25() {
1153        let dir = tempfile::tempdir().unwrap();
1154        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1155        let key = KeyPair::generate();
1156        let g = SignedOp::sign(with_format(genesis(&key, &store), 2), &key);
1157        let approve = |checks: &[&str]| Op::Approve {
1158            target_seq: 1,
1159            note: String::new(),
1160            checks: checks.iter().map(|c| c.to_string()).collect(),
1161        };
1162        let at = |op: Op, format| OpEntry {
1163            format,
1164            op,
1165            ..next(&g, 2000)
1166        };
1167        assert_eq!(at(approve(&["api"]), 25).min_format(), FORMAT_NAMED_CHECKS);
1168        // Without checks an approval is what it always was.
1169        assert_eq!(at(approve(&[]), 2).min_format(), FORMAT_LEGACY);
1170        let chain = |format| {
1171            let e = SignedOp::sign(at(approve(&["api"]), format), &key);
1172            verify_chain(&[g.clone(), e])
1173        };
1174        assert!(chain(25).is_ok());
1175        let err = chain(24).unwrap_err();
1176        assert!(err.to_string().contains("needs log format 25"), "{err}");
1177        // Old approvals keep their bytes: no `checks` key at all.
1178        let text = serde_json::to_string(&approve(&[])).unwrap();
1179        assert!(!text.contains("checks"), "{text}");
1180    }
1181
1182    #[test]
1183    fn a_recovery_key_on_a_daemon_recovery_needs_format_27() {
1184        let dir = tempfile::tempdir().unwrap();
1185        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1186        let key = KeyPair::generate();
1187        let g = SignedOp::sign(with_format(genesis(&key, &store), 4), &key);
1188        let recover = |with: bool| Op::DaemonRecover {
1189            key: "01".into(),
1190            recover: with.then(|| crate::ops::Recovery {
1191                human: "ayse".into(),
1192                key: crate::ops::HumanKey {
1193                    alg: "ed25519".into(),
1194                    public: "02".into(),
1195                },
1196            }),
1197        };
1198        // Denied, so the key in force signs it.
1199        let at = |op: Op, format| OpEntry {
1200            format,
1201            op,
1202            decision: Decision::deny("x"),
1203            ..next(&g, 2000)
1204        };
1205        assert_eq!(at(recover(true), 27).min_format(), FORMAT_RECOVERY_DAEMON);
1206        assert_eq!(at(recover(false), 4).min_format(), FORMAT_DAEMON_KEYS);
1207        let chain =
1208            |format| verify_chain(&[g.clone(), SignedOp::sign(at(recover(true), format), &key)]);
1209        assert!(chain(27).is_ok());
1210        let err = chain(26).unwrap_err();
1211        assert!(err.to_string().contains("needs log format 27"), "{err}");
1212        // Old recoveries keep their bytes: no `recover` key at all.
1213        let text = serde_json::to_string(&recover(false)).unwrap();
1214        assert!(!text.contains("\"recover\""), "{text}");
1215        assert!(recover(true).signed_by_recovery_key());
1216        assert!(!recover(false).signed_by_recovery_key());
1217    }
1218
1219    #[test]
1220    fn daemon_key_operations_need_format_4() {
1221        let dir = tempfile::tempdir().unwrap();
1222        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1223        let key = KeyPair::generate();
1224        let g = SignedOp::sign(with_format(genesis(&key, &store), 3), &key);
1225        // Denied, so the key in force signs them and nothing switches.
1226        for op in [
1227            Op::DaemonRotate { key: "01".into() },
1228            Op::DaemonRecover {
1229                key: "01".into(),
1230                recover: None,
1231            },
1232        ] {
1233            let at = |format| OpEntry {
1234                format,
1235                op: op.clone(),
1236                decision: Decision::deny("test"),
1237                ..next(&g, 2000)
1238            };
1239            assert_eq!(at(FORMAT_DAEMON_KEYS).min_format(), FORMAT_DAEMON_KEYS);
1240            let chain = |format| verify_chain(&[g.clone(), SignedOp::sign(at(format), &key)]);
1241            assert!(chain(FORMAT_DAEMON_KEYS).is_ok(), "{op:?}");
1242            let err = chain(FORMAT_DAEMON_KEYS - 1).unwrap_err();
1243            assert!(
1244                err.to_string()
1245                    .contains(&format!("{} needs log format 4", op.kind().as_str())),
1246                "{err}"
1247            );
1248        }
1249    }
1250
1251    /// An entry of `op` after `prev`, signed by `by`.
1252    fn switch(prev: &SignedOp, op: Op, decision: Decision, by: &KeyPair) -> SignedOp {
1253        SignedOp::sign(
1254            OpEntry {
1255                op,
1256                decision,
1257                ..next(prev, prev.entry.time_ms + 1)
1258            },
1259            by,
1260        )
1261    }
1262
1263    fn signed_by_error(ops: &[SignedOp]) -> String {
1264        verify_chain(ops).unwrap_err().to_string()
1265    }
1266
1267    #[test]
1268    fn the_daemon_key_follows_rotations() {
1269        let dir = tempfile::tempdir().unwrap();
1270        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1271        let (old, new) = (KeyPair::generate(), KeyPair::generate());
1272        let g = SignedOp::sign(genesis(&old, &store), &old);
1273        let rotate = Op::DaemonRotate {
1274            key: new.public_hex(),
1275        };
1276        // The rotation itself is signed by the old key, what follows by the
1277        // new one.
1278        let r = switch(&g, rotate.clone(), Decision::allow(), &old);
1279        assert_eq!(
1280            r.entry.switches_daemon_key(),
1281            Some(new.public_hex().as_str())
1282        );
1283        let after = SignedOp::sign(next(&r, 3000), &new);
1284        assert_eq!(
1285            verify_chain(&[g.clone(), r.clone(), after.clone()]).unwrap(),
1286            after.hash
1287        );
1288        let stale = SignedOp::sign(next(&r, 3000), &old);
1289        assert!(
1290            signed_by_error(&[g.clone(), r.clone(), stale]).contains("op 2: bad daemon signature")
1291        );
1292        let later = SignedOp::sign(next(&after, 4000), &old);
1293        assert!(
1294            signed_by_error(&[g.clone(), r, after, later]).contains("op 3: bad daemon signature")
1295        );
1296        let by_new = switch(&g, rotate.clone(), Decision::allow(), &new);
1297        assert!(signed_by_error(&[g.clone(), by_new]).contains("op 1: bad daemon signature"));
1298
1299        // A denied rotation switches nothing.
1300        let denied = switch(&g, rotate, Decision::deny("x"), &old);
1301        assert_eq!(denied.entry.switches_daemon_key(), None);
1302        let after = SignedOp::sign(next(&denied, 3000), &old);
1303        assert!(verify_chain(&[g.clone(), denied.clone(), after]).is_ok());
1304        let after = SignedOp::sign(next(&denied, 3000), &new);
1305        assert!(signed_by_error(&[g, denied, after]).contains("op 2: bad daemon signature"));
1306    }
1307
1308    #[test]
1309    fn a_recovery_is_signed_by_the_key_it_installs() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let store = Store::init_bare(&dir.path().join("r.git")).unwrap();
1312        let (old, new) = (KeyPair::generate(), KeyPair::generate());
1313        let g = SignedOp::sign(genesis(&old, &store), &old);
1314        let recover = Op::DaemonRecover {
1315            key: new.public_hex(),
1316            recover: None,
1317        };
1318        let r = switch(&g, recover.clone(), Decision::allow(), &new);
1319        assert_eq!(r.entry.signer(&old.public_hex()), new.public_hex());
1320        let after = SignedOp::sign(next(&r, 3000), &new);
1321        assert!(verify_chain(&[g.clone(), r.clone(), after]).is_ok());
1322        let stale = SignedOp::sign(next(&r, 3000), &old);
1323        assert!(signed_by_error(&[g.clone(), r, stale]).contains("op 2: bad daemon signature"));
1324
1325        let by_old = switch(&g, recover.clone(), Decision::allow(), &old);
1326        assert!(signed_by_error(&[g.clone(), by_old]).contains("op 1: bad daemon signature"));
1327        // A denied recovery is an ordinary entry of the key in force.
1328        let denied = switch(&g, recover, Decision::deny("x"), &old);
1329        assert_eq!(denied.entry.signer(&old.public_hex()), old.public_hex());
1330        assert!(verify_chain(&[g, denied]).is_ok());
1331    }
1332
1333    fn with_format(e: OpEntry, format: u32) -> OpEntry {
1334        OpEntry { format, ..e }
1335    }
1336}