1use std::collections::BTreeMap;
61use std::sync::{Arc, Mutex, MutexGuard};
62
63use blue_lang_waku::{Capability, Provided};
64use serde_json::{json, Value as Json};
65
66use crate::Pid;
67
68pub const FORMAT: &str = "blue-receipt";
70pub const VERSION: u64 = 2;
72pub const VERSION_UNROOTED: u64 = 1;
74pub const VERSION_STEPPED: u64 = 3;
75pub const READS: [u64; 3] = [VERSION_UNROOTED, VERSION, VERSION_STEPPED];
77pub const ROOT_KEYS: [&str; 2] = ["input_root", "output_root"];
79pub const STEPS_KEY: &str = "steps";
80
81pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
85
86#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct Roots {
90 pub input: String,
91 pub output: String,
92}
93
94fn is_root(text: &str) -> bool {
95 text.strip_prefix("b3:").is_some_and(|hex| {
96 hex.len() == 64
97 && hex
98 .bytes()
99 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
100 })
101}
102
103pub fn check(receipt: &Json) -> Result<(), String> {
113 let version = receipt.get("version").and_then(Json::as_u64);
114 let (rooted, stepped) = match version {
115 Some(VERSION_UNROOTED) => (false, false),
116 Some(VERSION) => (true, false),
117 Some(VERSION_STEPPED) => (ROOT_KEYS.iter().any(|k| receipt.get(*k).is_some()), true),
118 _ => {
119 return Err(format!(
120 "{FORMAT} version {}; this blue reads versions {} to {VERSION_STEPPED}",
121 receipt.get("version").unwrap_or(&Json::Null),
122 READS[0]
123 ))
124 }
125 };
126 let v = version.unwrap_or_default();
127 for key in ROOT_KEYS {
128 match (rooted, receipt.get(key)) {
129 (false, None) => {}
130 (false, Some(_)) => {
131 return Err(format!(
132 "a version {v} receipt carries `{key}`, which only a server's receipt of version {VERSION} or {VERSION_STEPPED} has"
133 ))
134 }
135 (true, None) => return Err(format!("a version {v} receipt without `{key}`")),
136 (true, Some(root)) if !root.as_str().is_some_and(is_root) => {
137 return Err(format!("`{key}` is {root}, not b3: and 64 hex digits"))
138 }
139 (true, Some(_)) => {}
140 }
141 }
142 match (stepped, receipt.get(STEPS_KEY)) {
143 (false, Some(_)) => Err(format!(
144 "a version {v} receipt carries `{STEPS_KEY}`, which only version {VERSION_STEPPED} has"
145 )),
146 (true, None) => Err(format!("a version {v} receipt without `{STEPS_KEY}`")),
147 (true, Some(n)) if n.as_u64().is_none() => {
148 Err(format!("`{STEPS_KEY}` is {n}, not a count"))
149 }
150 _ => Ok(()),
151 }
152}
153
154fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
155 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
156}
157
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Frame {
161 pub granted: Vec<Capability>,
163 pub provided: Vec<Provided>,
166 pub withheld: Vec<Capability>,
168}
169
170fn labels(caps: &[Capability]) -> Json {
171 Json::Array(caps.iter().map(|c| json!(c.label())).collect())
172}
173
174fn capability(label: &str) -> Option<Capability> {
175 Capability::host_effects()
176 .into_iter()
177 .find(|c| c.label() == label)
178}
179
180fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
181 j.and_then(Json::as_array)
182 .ok_or_else(|| format!("a hold without `{what}`"))?
183 .iter()
184 .map(|l| {
185 l.as_str()
186 .and_then(capability)
187 .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
188 })
189 .collect()
190}
191
192impl Frame {
193 #[must_use]
195 pub fn block(&self, host_io: u64) -> Json {
196 json!({
197 "granted": labels(&self.granted),
198 "withheld": labels(&self.withheld),
199 "host_io": host_io,
200 })
201 }
202
203 #[must_use]
206 pub fn provided_json(&self) -> Json {
207 Json::Array(
208 self.provided
209 .iter()
210 .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
211 .collect(),
212 )
213 }
214
215 #[must_use]
217 pub fn to_json(&self) -> Json {
218 json!({
219 "granted": labels(&self.granted),
220 "provided": self.provided_json(),
221 "withheld": labels(&self.withheld),
222 })
223 }
224
225 pub fn from_json(j: &Json) -> Result<Self, String> {
232 let provided = j
233 .get("provided")
234 .and_then(Json::as_array)
235 .ok_or("a hold without `provided`")?
236 .iter()
237 .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
238 .collect::<Result<_, _>>()?;
239 Ok(Self {
240 granted: capabilities(j.get("granted"), "granted")?,
241 provided,
242 withheld: capabilities(j.get("withheld"), "withheld")?,
243 })
244 }
245
246 #[must_use]
248 pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
249 self.withheld
250 .iter()
251 .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
252 .collect()
253 }
254}
255
256#[derive(Debug, Default)]
257struct Ledger {
258 current: Pid,
259 total: u64,
260 by_pid: BTreeMap<Pid, u64>,
261}
262
263#[derive(Clone, Debug, Default)]
265pub struct HostIo(Arc<Mutex<Ledger>>);
266
267impl HostIo {
268 #[must_use]
269 pub fn new() -> Self {
270 Self::default()
271 }
272
273 pub fn tally(&self) {
275 let mut l = locked(&self.0);
276 l.total += 1;
277 let pid = crate::scheduling::lane().unwrap_or(l.current);
278 *l.by_pid.entry(pid).or_default() += 1;
279 }
280
281 #[must_use]
283 pub fn total(&self) -> u64 {
284 locked(&self.0).total
285 }
286
287 #[must_use]
289 pub fn of(&self, pid: Pid) -> u64 {
290 locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
291 }
292
293 pub fn enter(&self, pid: Pid) {
295 locked(&self.0).current = pid;
296 }
297
298 pub fn take(&self, pid: Pid) -> u64 {
300 locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
301 }
302}
303
304type Deferred<T> = Box<dyn FnOnce() -> T + Send>;
305
306enum Table<T> {
307 Unknown,
308 Deferred(Deferred<T>),
309 Known(T),
310}
311
312struct Lazy<T>(Arc<Mutex<Table<T>>>);
315
316impl<T> Clone for Lazy<T> {
317 fn clone(&self) -> Self {
318 Self(Arc::clone(&self.0))
319 }
320}
321
322impl<T> Default for Lazy<T> {
323 fn default() -> Self {
324 Self(Arc::new(Mutex::new(Table::Unknown)))
325 }
326}
327
328impl<T> Lazy<T> {
329 fn defer(&self, compute: impl FnOnce() -> T + Send + 'static) {
330 *locked(&self.0) = Table::Deferred(Box::new(compute));
331 }
332
333 fn read<R>(&self, f: impl FnOnce(&T) -> Option<R>) -> Option<R> {
334 let mut state = locked(&self.0);
335 if let Table::Deferred(_) = &*state {
336 if let Table::Deferred(compute) = std::mem::replace(&mut *state, Table::Unknown) {
337 *state = Table::Known(compute());
338 }
339 }
340 match &*state {
341 Table::Known(table) => f(table),
342 Table::Unknown | Table::Deferred(_) => None,
343 }
344 }
345
346 fn state(&self) -> &'static str {
347 match &*locked(&self.0) {
348 Table::Unknown => "unknown",
349 Table::Deferred(_) => "deferred",
350 Table::Known(_) => "known",
351 }
352 }
353}
354
355#[derive(Clone, Default)]
357pub struct Identities(Lazy<BTreeMap<String, String>>);
358
359impl std::fmt::Debug for Identities {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 write!(f, "Identities({})", self.0.state())
362 }
363}
364
365impl Identities {
366 pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
368 self.0.defer(hash);
369 }
370
371 #[must_use]
373 pub fn of(&self, key: &str) -> Option<String> {
374 self.0.read(|table| table.get(key).cloned())
375 }
376}
377
378#[derive(Clone, Debug)]
381pub struct Source {
382 pub file: String,
383 pub text: Arc<str>,
384}
385
386#[derive(Clone, Debug, PartialEq, Eq)]
388pub struct Place {
389 pub file: String,
390 pub line: usize,
391 pub column: usize,
392}
393
394impl std::fmt::Display for Place {
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 write!(f, "{}:{}:{}", self.file, self.line, self.column)
397 }
398}
399
400#[derive(Clone, Default)]
403pub struct Places(Lazy<BTreeMap<String, Source>>);
404
405impl std::fmt::Debug for Places {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 write!(f, "Places({})", self.0.state())
408 }
409}
410
411impl Places {
412 pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
414 self.0.defer(read);
415 }
416
417 #[must_use]
422 pub fn locate(&self, key: &str, span: tatara_lisp::Span) -> Option<Place> {
423 if span.is_synthetic() {
424 return None;
425 }
426 self.0.read(|table| {
427 let source = table.get(key)?;
428 (span.end <= source.text.len()).then(|| {
429 let (line, column) = tatara_lisp::Span::line_col(&source.text, span.start);
430 Place {
431 file: source.file.clone(),
432 line,
433 column,
434 }
435 })
436 })
437 }
438}
439
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
442pub enum Subject {
443 Actor { pid: Pid, incarnation: usize },
445 Run,
447}
448
449#[derive(Clone, Debug, PartialEq, Eq)]
451pub struct Receipt {
452 pub subject: Subject,
453 pub definition: Option<String>,
455 pub def_hash: Option<String>,
457 pub frame: Frame,
458 pub host_io: u64,
460 pub exit_kind: String,
461 pub exit_text: String,
462 pub roots: Option<Roots>,
465 pub steps: Option<u64>,
466}
467
468impl Receipt {
469 #[must_use]
472 pub fn to_json(&self) -> Json {
473 let (subject, pid, incarnation) = match self.subject {
474 Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
475 Subject::Run => ("run", crate::ROOT.0, 0),
476 };
477 let mut j = json!({
478 "format": FORMAT,
479 "version": VERSION_UNROOTED,
480 "subject": subject,
481 "pid": pid,
482 "incarnation": incarnation,
483 "definition": self.definition,
484 "def_hash": self.def_hash,
485 "exit": { "kind": self.exit_kind, "text": self.exit_text },
486 "frame": self.frame.block(self.host_io),
487 "provided": self.frame.provided_json(),
488 });
489 if let Some(roots) = &self.roots {
490 j["version"] = json!(VERSION);
491 j["input_root"] = json!(roots.input);
492 j["output_root"] = json!(roots.output);
493 }
494 if let Some(steps) = self.steps {
495 j["version"] = json!(VERSION_STEPPED);
496 j[STEPS_KEY] = json!(steps);
497 }
498 j
499 }
500
501 #[must_use]
503 pub fn bytes(&self) -> Vec<u8> {
504 canonical(&self.to_json()).into_bytes()
505 }
506}
507
508pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
510
511#[must_use]
513pub fn canonical(j: &Json) -> String {
514 let mut out = String::new();
515 write_canonical(j, &mut out);
516 out
517}
518
519fn write_canonical(j: &Json, out: &mut String) {
520 match j {
521 Json::Object(m) => {
522 let mut keys: Vec<&String> = m.keys().collect();
523 keys.sort();
524 out.push('{');
525 for (i, k) in keys.into_iter().enumerate() {
526 if i > 0 {
527 out.push(',');
528 }
529 out.push_str(&Json::String(k.clone()).to_string());
530 out.push(':');
531 write_canonical(&m[k], out);
532 }
533 out.push('}');
534 }
535 Json::Array(items) => {
536 out.push('[');
537 for (i, v) in items.iter().enumerate() {
538 if i > 0 {
539 out.push(',');
540 }
541 write_canonical(v, out);
542 }
543 out.push(']');
544 }
545 other => out.push_str(&other.to_string()),
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn frame() -> Frame {
554 Frame {
555 granted: vec![Capability::FileSystem],
556 provided: vec![
557 blue_lang_waku::Provision::DeterministicStepped {
558 at_ms: 5,
559 step_ms: 1,
560 }
561 .into(),
562 Provided {
563 provision: blue_lang_waku::Provision::NullSink,
564 scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
565 },
566 ],
567 withheld: vec![
568 Capability::Process,
569 Capability::Environment,
570 Capability::Network,
571 Capability::Entropy,
572 ],
573 }
574 }
575
576 #[test]
577 fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
578 let block = frame().block(3);
579 let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
580 let mut sorted = keys.clone();
581 sorted.sort();
582 assert_eq!(sorted, ["granted", "host_io", "withheld"]);
583 assert_eq!(
584 canonical(&block),
585 r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
586 );
587 }
588
589 #[test]
590 fn a_hold_survives_the_trace_format() {
591 let f = frame();
592 assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
593 let mut bad = f.to_json();
594 bad["withheld"] = json!(["disk"]);
595 assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
596 assert!(Frame::from_json(&json!({})).is_err());
597 }
598
599 #[test]
600 fn withheld_names_are_every_name_of_every_withheld_capability() {
601 let names = frame().withheld_names();
602 assert!(names.contains(&("getenv", Capability::Environment)));
603 assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
604 assert_eq!(
605 names.len(),
606 frame()
607 .withheld
608 .iter()
609 .map(|c| c.names().len())
610 .sum::<usize>()
611 );
612 assert!(Frame::default().withheld_names().is_empty());
613 }
614
615 #[test]
616 fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
617 let io = HostIo::new();
618 io.tally();
619 io.enter(Pid(3));
620 io.tally();
621 io.tally();
622 io.enter(crate::ROOT);
623 io.tally();
624 assert_eq!(io.total(), 4);
625 assert_eq!(io.of(crate::ROOT), 2);
626 assert_eq!(io.of(Pid(3)), 2);
627 assert_eq!(io.take(Pid(3)), 2);
628 assert_eq!(io.of(Pid(3)), 0);
629 assert_eq!(io.total(), 4, "the run total keeps every call");
630 assert_eq!(HostIo::new().total(), 0);
631 }
632
633 #[test]
634 fn identities_are_hashed_once_and_only_when_asked() {
635 let ids = Identities::default();
636 assert_eq!(ids.of("%root/f"), None);
637 let calls = Arc::new(Mutex::new(0));
638 let c = Arc::clone(&calls);
639 ids.defer(move || {
640 *locked(&c) += 1;
641 BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
642 });
643 assert_eq!(*locked(&calls), 0);
644 assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
645 assert_eq!(ids.clone().of("%root/g"), None);
646 assert_eq!(*locked(&calls), 1);
647 }
648
649 #[test]
650 fn a_place_is_read_once_and_only_inside_its_own_file() {
651 let places = Places::default();
652 let at = tatara_lisp::Span::new(10, 14);
653 assert_eq!(places.locate("%root/f", at), None);
654 let calls = Arc::new(Mutex::new(0));
655 let c = Arc::clone(&calls);
656 places.defer(move || {
657 *locked(&c) += 1;
658 BTreeMap::from([(
659 "%root/f".to_string(),
660 Source {
661 file: "w.b".into(),
662 text: Arc::from("def f()\n g(1)\nend\n"),
663 },
664 )])
665 });
666 assert_eq!(*locked(&calls), 0);
667 let place = places.locate("%root/f", at).expect("in its file");
668 assert_eq!(place.to_string(), "w.b:2:3");
669 assert_eq!(
670 places.locate("%root/f", tatara_lisp::Span::new(10, 99)),
671 None
672 );
673 assert_eq!(
674 places.locate("%root/f", tatara_lisp::Span::synthetic()),
675 None
676 );
677 assert_eq!(places.clone().locate("%root/g", at), None);
678 assert_eq!(*locked(&calls), 1);
679 }
680
681 #[test]
682 fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
683 let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
684 assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
685 let r = Receipt {
686 subject: Subject::Actor {
687 pid: Pid(2),
688 incarnation: 1,
689 },
690 definition: Some("%root/w".into()),
691 def_hash: Some("b3:ab".into()),
692 frame: frame(),
693 host_io: 7,
694 exit_kind: "normal".into(),
695 exit_text: "normal".into(),
696 roots: None,
697 steps: None,
698 };
699 let text = String::from_utf8(r.bytes()).expect("utf8");
700 assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
701 let back: Json = serde_json::from_str(&text).expect("json");
702 assert_eq!(canonical(&back), text);
703 assert_eq!(back["frame"]["host_io"], 7);
704 assert_eq!(back["subject"], "actor");
705 assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
706 assert!(
707 back["provided"][0].get("for").is_none(),
708 "a whole-program provider"
709 );
710 assert_eq!(
711 back["provided"][1],
712 json!({"provider": "null-sink", "for": ["kazu"]})
713 );
714 }
715
716 const V1: &str = r#"{"def_hash":"b3:ab","definition":"%root/w","exit":{"kind":"normal","text":"normal"},"format":"blue-receipt","frame":{"granted":["filesystem"],"host_io":7,"withheld":["process","environment","network","entropy"]},"incarnation":1,"pid":2,"provided":[{"at_ms":5,"provider":"deterministic-stepped","step_ms":1},{"for":["kazu"],"provider":"null-sink"}],"subject":"actor","version":1}"#;
717
718 fn actor(roots: Option<Roots>) -> Receipt {
719 Receipt {
720 subject: Subject::Actor {
721 pid: Pid(2),
722 incarnation: 1,
723 },
724 definition: Some("%root/w".into()),
725 def_hash: Some("b3:ab".into()),
726 frame: frame(),
727 host_io: 7,
728 exit_kind: "normal".into(),
729 exit_text: "normal".into(),
730 roots,
731 steps: None,
732 }
733 }
734
735 fn root(c: char) -> String {
736 format!("b3:{}", c.to_string().repeat(64))
737 }
738
739 #[test]
740 fn a_receipt_without_roots_is_the_version_1_receipt_byte_for_byte() {
741 let r = actor(None);
742 assert_eq!(String::from_utf8(r.bytes()).expect("utf8"), V1);
743 assert_eq!(check(&r.to_json()), Ok(()));
744 }
745
746 #[test]
747 fn a_receipt_with_roots_is_version_2_and_without_them_is_version_1_again() {
748 let r = actor(Some(Roots {
749 input: root('a'),
750 output: root('b'),
751 }));
752 let j = r.to_json();
753 assert_eq!(j["version"], VERSION);
754 assert_eq!(j["input_root"], root('a'));
755 assert_eq!(j["output_root"], root('b'));
756 assert_eq!(check(&j), Ok(()));
757 let mut stripped = j.clone();
758 for key in ROOT_KEYS {
759 stripped.as_object_mut().expect("object").remove(key);
760 }
761 stripped["version"] = json!(VERSION_UNROOTED);
762 assert_eq!(canonical(&stripped), V1);
763 }
764
765 #[test]
766 fn check_reads_versions_1_and_2_and_refuses_a_root_out_of_place() {
767 let v1 = actor(None).to_json();
768 let v2 = actor(Some(Roots {
769 input: root('0'),
770 output: root('f'),
771 }))
772 .to_json();
773 let edited = |base: &Json, key: &str, value: Json| {
774 let mut j = base.clone();
775 j[key] = value;
776 j
777 };
778 let refused = |j: &Json, why: &str| {
779 let e = check(j).expect_err(why);
780 assert!(e.contains(why), "{e}");
781 };
782 refused(
783 &edited(&v1, "input_root", json!(root('a'))),
784 "carries `input_root`",
785 );
786 refused(&edited(&v1, "version", json!(4)), "reads versions 1 to 3");
787 refused(
788 &edited(&v2, "output_root", json!("b3:00")),
789 "not b3: and 64 hex",
790 );
791 refused(
792 &edited(&v2, "input_root", json!(root('A'))),
793 "not b3: and 64 hex",
794 );
795 let mut half = v2.clone();
796 half.as_object_mut().expect("object").remove("output_root");
797 refused(&half, "without `output_root`");
798 let mut unversioned = v1.clone();
799 unversioned
800 .as_object_mut()
801 .expect("object")
802 .remove("version");
803 refused(&unversioned, "reads versions 1 to 3");
804 }
805
806 const V2: &str = r#"{"def_hash":"b3:ab","definition":"%root/w","exit":{"kind":"normal","text":"normal"},"format":"blue-receipt","frame":{"granted":["filesystem"],"host_io":7,"withheld":["process","environment","network","entropy"]},"incarnation":1,"input_root":"b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_root":"b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","pid":2,"provided":[{"at_ms":5,"provider":"deterministic-stepped","step_ms":1},{"for":["kazu"],"provider":"null-sink"}],"subject":"actor","version":2}"#;
807
808 fn rooted() -> Option<Roots> {
809 Some(Roots {
810 input: root('a'),
811 output: root('b'),
812 })
813 }
814
815 fn counted(roots: Option<Roots>, steps: u64) -> Receipt {
816 Receipt {
817 steps: Some(steps),
818 ..actor(roots)
819 }
820 }
821
822 fn uncounted(mut j: Json, version: u64) -> String {
823 j.as_object_mut().expect("object").remove(STEPS_KEY);
824 j["version"] = json!(version);
825 canonical(&j)
826 }
827
828 #[test]
829 fn a_receipt_that_counts_no_steps_keeps_its_version_1_or_2_bytes() {
830 assert_eq!(String::from_utf8(actor(None).bytes()).expect("utf8"), V1);
831 assert_eq!(
832 String::from_utf8(actor(rooted()).bytes()).expect("utf8"),
833 V2
834 );
835 for text in [V1, V2] {
836 let j: Json = serde_json::from_str(text).expect("json");
837 assert_eq!(check(&j), Ok(()), "{text}");
838 }
839 }
840
841 #[test]
842 fn a_receipt_with_steps_is_version_3_and_without_them_its_old_bytes() {
843 let run = counted(None, 42).to_json();
844 assert_eq!(run["version"], VERSION_STEPPED);
845 assert_eq!(run[STEPS_KEY], 42);
846 assert!(run.get("input_root").is_none());
847 assert_eq!(check(&run), Ok(()));
848 assert_eq!(uncounted(run, VERSION_UNROOTED), V1);
849 let server = counted(rooted(), 0).to_json();
850 assert_eq!(server["version"], VERSION_STEPPED);
851 assert_eq!(server[STEPS_KEY], 0);
852 assert_eq!(server["input_root"], root('a'));
853 assert_eq!(check(&server), Ok(()));
854 assert_eq!(uncounted(server, VERSION), V2);
855 }
856
857 #[test]
858 fn check_reads_version_3_and_refuses_a_count_out_of_place() {
859 let refused = |j: &Json, why: &str| {
860 let e = check(j).expect_err(why);
861 assert!(e.contains(why), "{why}: {e}");
862 };
863 let with = |base: Json, key: &str, value: Json| {
864 let mut j = base;
865 j[key] = value;
866 j
867 };
868 let without = |base: Json, key: &str| {
869 let mut j = base;
870 j.as_object_mut().expect("object").remove(key);
871 j
872 };
873 refused(
874 &with(actor(None).to_json(), STEPS_KEY, json!(3)),
875 "carries `steps`",
876 );
877 refused(
878 &with(actor(rooted()).to_json(), STEPS_KEY, json!(3)),
879 "carries `steps`",
880 );
881 refused(
882 &without(counted(None, 1).to_json(), STEPS_KEY),
883 "without `steps`",
884 );
885 refused(
886 &with(counted(None, 1).to_json(), STEPS_KEY, json!(-1)),
887 "not a count",
888 );
889 refused(
890 &with(counted(None, 1).to_json(), STEPS_KEY, json!("1")),
891 "not a count",
892 );
893 refused(
894 &with(counted(None, 1).to_json(), STEPS_KEY, json!(1.5)),
895 "not a count",
896 );
897 refused(
898 &without(counted(rooted(), 1).to_json(), "input_root"),
899 "without `input_root`",
900 );
901 refused(
902 &with(
903 counted(rooted(), 1).to_json(),
904 "output_root",
905 json!("b3:00"),
906 ),
907 "not b3: and 64 hex",
908 );
909 }
910}