1use 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
32pub const FORMAT_LEGACY: u32 = 0;
35pub const FORMAT_CURRENT: u32 = 27;
60pub const FORMAT_SYMBOLS_ENGINE: u32 = 2;
63pub const FORMAT_HUMAN_KEYS: u32 = 3;
66pub const FORMAT_DAEMON_KEYS: u32 = 4;
68pub const FORMAT_SCRATCH: u32 = 5;
70pub const FORMAT_REJECT: u32 = 6;
72pub const FORMAT_LEASE_PINS: u32 = 7;
76pub const FORMAT_LEASE_LIVENESS: u32 = 8;
80pub const FORMAT_SESSION_CONTINUE: u32 = 9;
82pub const FORMAT_LEASE_QUEUE: u32 = 10;
85pub const FORMAT_UNPROPOSED: u32 = 11;
87pub const FORMAT_LEASE_INTENT: u32 = 12;
90pub const FORMAT_SYMBOL_KEYS: u32 = 13;
93pub const FORMAT_RENAMES: u32 = 14;
96pub const FORMAT_UNPARSED: u32 = 15;
99pub const FORMAT_UNREAD: u32 = 16;
102pub const FORMAT_BATCH: u32 = 17;
104pub const FORMAT_AFFECTED: u32 = 18;
107pub const FORMAT_ISOLATE: u32 = 19;
109pub const FORMAT_SESSION_HARNESS: u32 = 20;
113pub const FORMAT_SHARED_LEASES: u32 = 21;
115pub const FORMAT_INSERT_NAMES: u32 = 22;
119pub const FORMAT_GIVE_BACK: u32 = 23;
124pub const FORMAT_UNHOOKED: u32 = 24;
127pub const FORMAT_NAMED_CHECKS: u32 = 25;
130pub const FORMAT_REPLACES: u32 = 26;
136pub const FORMAT_RECOVERY_DAEMON: u32 = 27;
141
142fn is_legacy(format: &u32) -> bool {
143 *format == FORMAT_LEGACY
144}
145
146pub 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 #[serde(default, skip_serializing_if = "is_legacy")]
161 pub format: u32,
162 pub seq: u64,
163 pub prev: String,
164 pub time_ms: i64,
166 pub actor: Actor,
167 pub op: Op,
168 pub evidence: Vec<Attestation>,
169 pub facts: Option<Facts>,
170 #[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 pub fn uses_symbols_engine(&self) -> bool {
186 matches!(
187 self.op,
188 Op::ChangePropose { .. } | Op::ChangeIntegrate { .. } | Op::ChangeIntegrateBatch { .. }
189 )
190 }
191
192 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 pub fn engine(&self) -> Option<u32> {
255 self.uses_symbols_engine()
256 .then(|| self.symbols_engine.unwrap_or(0))
257 }
258
259 pub fn switches_daemon_key(&self) -> Option<&str> {
262 self.op
263 .daemon_key_switch()
264 .filter(|_| self.decision.is_allow())
265 }
266
267 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#[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 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 pub fn read_all(&self) -> Result<Vec<SignedOp>> {
373 self.read_from(0)
374 }
375
376 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
401pub 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
421pub 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 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(¤t).starts_with(&format!("{{\"format\":{FORMAT_CURRENT},\"seq\":0,")),
612 "{}",
613 enc(¤t)
614 );
615 assert_ne!(legacy.digest(), current.digest(), "format is in the digest");
616
617 let mut edited = SignedOp::sign(current, &key);
619 edited.entry.format = FORMAT_LEGACY;
620 assert!(edited.verify(&key.public_hex()).is_err());
621
622 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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}