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 = 25;
58pub const FORMAT_SYMBOLS_ENGINE: u32 = 2;
61pub const FORMAT_HUMAN_KEYS: u32 = 3;
64pub const FORMAT_DAEMON_KEYS: u32 = 4;
66pub const FORMAT_SCRATCH: u32 = 5;
68pub const FORMAT_REJECT: u32 = 6;
70pub const FORMAT_LEASE_PINS: u32 = 7;
74pub const FORMAT_LEASE_LIVENESS: u32 = 8;
78pub const FORMAT_SESSION_CONTINUE: u32 = 9;
80pub const FORMAT_LEASE_QUEUE: u32 = 10;
83pub const FORMAT_UNPROPOSED: u32 = 11;
85pub const FORMAT_LEASE_INTENT: u32 = 12;
88pub const FORMAT_SYMBOL_KEYS: u32 = 13;
91pub const FORMAT_RENAMES: u32 = 14;
94pub const FORMAT_UNPARSED: u32 = 15;
97pub const FORMAT_UNREAD: u32 = 16;
100pub const FORMAT_BATCH: u32 = 17;
102pub const FORMAT_AFFECTED: u32 = 18;
105pub const FORMAT_ISOLATE: u32 = 19;
107pub const FORMAT_SESSION_HARNESS: u32 = 20;
111pub const FORMAT_SHARED_LEASES: u32 = 21;
113pub const FORMAT_INSERT_NAMES: u32 = 22;
117pub const FORMAT_GIVE_BACK: u32 = 23;
122pub const FORMAT_UNHOOKED: u32 = 24;
125pub const FORMAT_NAMED_CHECKS: u32 = 25;
128
129fn is_legacy(format: &u32) -> bool {
130 *format == FORMAT_LEGACY
131}
132
133pub 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 #[serde(default, skip_serializing_if = "is_legacy")]
148 pub format: u32,
149 pub seq: u64,
150 pub prev: String,
151 pub time_ms: i64,
153 pub actor: Actor,
154 pub op: Op,
155 pub evidence: Vec<Attestation>,
156 pub facts: Option<Facts>,
157 #[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 pub fn uses_symbols_engine(&self) -> bool {
173 matches!(
174 self.op,
175 Op::ChangePropose { .. } | Op::ChangeIntegrate { .. } | Op::ChangeIntegrateBatch { .. }
176 )
177 }
178
179 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 pub fn engine(&self) -> Option<u32> {
239 self.uses_symbols_engine()
240 .then(|| self.symbols_engine.unwrap_or(0))
241 }
242
243 pub fn switches_daemon_key(&self) -> Option<&str> {
246 self.op
247 .daemon_key_switch()
248 .filter(|_| self.decision.is_allow())
249 }
250
251 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#[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 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 pub fn read_all(&self) -> Result<Vec<SignedOp>> {
357 self.read_from(0)
358 }
359
360 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
385pub 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
405pub 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 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(¤t).starts_with(&format!("{{\"format\":{FORMAT_CURRENT},\"seq\":0,")),
596 "{}",
597 enc(¤t)
598 );
599 assert_ne!(legacy.digest(), current.digest(), "format is in the digest");
600
601 let mut edited = SignedOp::sign(current, &key);
603 edited.entry.format = FORMAT_LEGACY;
604 assert!(edited.verify(&key.public_hex()).is_err());
605
606 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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}