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