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