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