1use crate::style::Style;
32use std::path::PathBuf;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum Status {
43 Pass,
45 Warn,
47 Fail,
49}
50
51impl Status {
52 pub fn worst(checks: &[Check]) -> Status {
58 checks
59 .iter()
60 .map(|c| c.status)
61 .max()
62 .unwrap_or(Status::Pass)
63 }
64
65 pub fn label(self) -> &'static str {
67 match self {
68 Status::Pass => "ok ",
69 Status::Warn => "warn",
70 Status::Fail => "FAIL",
71 }
72 }
73
74 fn paint(self, style: Style) -> String {
75 match self {
76 Status::Pass => style.green(self.label()),
77 Status::Warn => style.cyan(self.label()),
78 Status::Fail => style.red(self.label()),
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct Check {
86 pub name: String,
88 pub status: Status,
90 pub detail: String,
92 pub fix: Option<String>,
98}
99
100impl Check {
101 pub fn pass(name: &str, detail: impl Into<String>) -> Check {
103 Check {
104 name: name.into(),
105 status: Status::Pass,
106 detail: detail.into(),
107 fix: None,
108 }
109 }
110
111 pub fn warn(name: &str, detail: impl Into<String>) -> Check {
113 Check {
114 name: name.into(),
115 status: Status::Warn,
116 detail: detail.into(),
117 fix: None,
118 }
119 }
120
121 pub fn fail(name: &str, detail: impl Into<String>) -> Check {
123 Check {
124 name: name.into(),
125 status: Status::Fail,
126 detail: detail.into(),
127 fix: None,
128 }
129 }
130
131 pub fn with_fix(mut self, fix: impl Into<String>) -> Check {
133 self.fix = Some(fix.into());
134 self
135 }
136}
137
138pub fn exit_code(checks: &[Check]) -> i32 {
145 match Status::worst(checks) {
146 Status::Fail => 1,
147 _ => 0,
148 }
149}
150
151pub fn on_path(tool: &str) -> Option<PathBuf> {
159 let path = std::env::var_os("PATH")?;
160 std::env::split_paths(&path)
161 .map(|dir| dir.join(tool))
162 .find(|candidate| is_executable(candidate))
163}
164
165#[cfg(unix)]
166fn is_executable(path: &std::path::Path) -> bool {
167 use std::os::unix::fs::PermissionsExt;
168 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
169}
170
171#[cfg(not(unix))]
172fn is_executable(path: &std::path::Path) -> bool {
173 path.is_file()
174}
175
176fn first_line(tool: &std::path::Path, args: &[&str]) -> Option<String> {
182 let out = std::process::Command::new(tool).args(args).output().ok()?;
183 let text = if out.stdout.is_empty() {
184 out.stderr
185 } else {
186 out.stdout
187 };
188 String::from_utf8_lossy(&text)
189 .lines()
190 .next()
191 .map(str::to_string)
192}
193
194struct Tool {
196 name: &'static str,
197 version: &'static [&'static str],
201 without: &'static str,
203 fix: &'static str,
204}
205
206const TOOLS: &[Tool] = &[
211 Tool {
212 name: "git",
213 version: &["--version"],
214 without: "",
215 fix: "install git (xcode-select --install, or your package manager)",
216 },
217 Tool {
218 name: "curl",
219 version: &["--version"],
220 without: "",
221 fix: "install curl",
222 },
223 Tool {
224 name: "openssl",
225 version: &["version"],
226 without: "",
227 fix: "brew install openssl@3 (macOS) or apt install openssl libssl-dev (Debian)",
228 },
229 Tool {
230 name: "ssh-keygen",
231 version: &[],
232 without: "",
233 fix: "install openssh-client",
234 },
235 Tool {
236 name: "mergiraf",
237 version: &["--version"],
238 without: "structured merge; line merge and first-class conflicts still work",
239 fix: "brew install mergiraf",
240 },
241 Tool {
242 name: "mdbook",
243 version: &["--version"],
244 without: "`choir docs` cannot build the book",
245 fix: "cargo install mdbook --locked",
246 },
247];
248
249fn tool_checks() -> Vec<Check> {
251 TOOLS
252 .iter()
253 .map(|tool| match on_path(tool.name) {
254 Some(path) => {
255 let detail = match tool.version.is_empty() {
256 true => path.display().to_string(),
257 false => first_line(&path, tool.version)
258 .unwrap_or_else(|| path.display().to_string()),
259 };
260 Check::pass(tool.name, detail)
261 }
262 None if tool.without.is_empty() => {
263 Check::fail(tool.name, "not on PATH").with_fix(tool.fix)
264 }
265 None => Check::warn(
266 tool.name,
267 format!("not on PATH — without it, {}", tool.without),
268 )
269 .with_fix(tool.fix),
270 })
271 .collect()
272}
273
274fn auth_check(path: Option<&str>) -> Check {
283 let Some(path) = path else {
284 return Check::warn("auth file", "none given, and no default found").with_fix(
285 "choir join '<link>' to be issued one, or name yours: choir --auth-file <path> …",
286 );
287 };
288 let Ok(meta) = std::fs::metadata(path) else {
289 return Check::fail("auth file", format!("{path} cannot be read"))
290 .with_fix("choir join '<link>'");
291 };
292 #[cfg(unix)]
293 {
294 use std::os::unix::fs::PermissionsExt;
295 let mode = meta.permissions().mode() & 0o777;
296 if mode & 0o077 != 0 {
297 return Check::fail("auth file", format!("{path} is mode {mode:04o}"))
298 .with_fix(format!("chmod 600 {path}"));
299 }
300 Check::pass("auth file", format!("{path} ({mode:04o})"))
301 }
302 #[cfg(not(unix))]
303 {
304 let _ = meta;
305 Check::pass("auth file", path.to_string())
306 }
307}
308
309fn node_check(api: Option<&str>, auth: Option<&str>, curl: bool) -> Check {
325 let Some(api) = api else {
326 return Check::warn("node", "no node configured").with_fix(
327 "choir join '<link>' writes one to ~/.choir/config; or write `node = <url>` \
328 to .choir/config here, or pass the URL",
329 );
330 };
331 if !curl {
332 return Check::fail("node", format!("{api}: cannot check without curl"));
333 }
334 let client = match crate::mcp::HttpClient::new(api, auth.map(std::path::Path::new), None) {
335 Ok(client) => client,
336 Err(error) => return Check::fail("node", format!("{api}: {error}")),
337 };
338 match client.get("/healthz") {
339 Ok((200, _)) => Check::pass("node", format!("{api} is healthy")),
340 Ok((503, body)) => Check::fail("node", format!("{api} reports unhealthy: {body}"))
344 .with_fix("check the node's log; a failed durable append exits it 75"),
345 Ok((401, _)) if auth.is_none() => {
346 Check::warn("node", format!("{api} is up; needs a credential"))
347 .with_fix("choir --auth-file <path> doctor")
348 }
349 Ok((401, _)) => Check::fail("node", format!("{api} refused the credential"))
350 .with_fix("check --auth-file names a token this node knows"),
351 Ok((code, _)) => Check::warn("node", format!("{api} answered {code} on /healthz")),
352 Err(error) => Check::fail("node", format!("{api}: {error}")),
353 }
354}
355
356pub fn run(api: Option<&str>, auth: Option<&str>) -> Vec<Check> {
364 let mut checks = vec![self_check()];
365 checks.extend(tool_checks());
366 let curl = checks
367 .iter()
368 .any(|c| c.name == "curl" && c.status == Status::Pass);
369 checks.push(daemon_check());
370 checks.push(auth_check(auth));
371 checks.push(node_check(api, auth, curl));
372 checks
373}
374
375pub const FAR_BEHIND: u64 = 8;
383
384const FORK_CHECK: &str = "fork check";
386
387#[must_use]
394pub fn fork_check(
395 seed: &str,
396 statement: &choir_node::replica::SignedStatement,
397 home_node: &str,
398 current: &str,
399 chain: &choir_node::replica::SnapshotChain,
400) -> Check {
401 use choir_node::replica::Relation;
402 if let Err(why) = statement.verify() {
403 return Check::fail(
404 FORK_CHECK,
405 format!("{seed}: its statement does not verify: {why}"),
406 );
407 }
408 let said = &statement.statement;
409 if said.home_node_id != home_node {
410 return Check::fail(
411 FORK_CHECK,
412 format!(
413 "{seed}: witnesses the home {}, and this node is {home_node}",
414 said.home_node_id
415 ),
416 )
417 .with_fix("name in `seeds =` only seeds of the node `node =` names");
418 }
419 match chain.relate(&said.snapshot, current) {
420 Relation::Same => Check::pass(
421 FORK_CHECK,
422 format!(
423 "{seed}: agrees; it witnessed the home's current attestation (seq {})",
424 said.at_seq
425 ),
426 ),
427 Relation::Behind(steps) if steps > FAR_BEHIND => Check::warn(
428 FORK_CHECK,
429 format!(
430 "{seed}: agrees, {steps} attestations behind the home's current one \
431 (it last witnessed seq {})",
432 said.at_seq
433 ),
434 )
435 .with_fix("read `replica` in the seed's GET /api/view: it says where replication stopped"),
436 Relation::Behind(steps) => Check::pass(
437 FORK_CHECK,
438 format!(
439 "{seed}: agrees; its statement is {steps} attestation(s) behind the home's \
440 current one"
441 ),
442 ),
443 Relation::Ahead(steps) => Check::warn(
444 FORK_CHECK,
445 format!(
446 "{seed}: agrees, but it has witnessed {steps} attestation(s) past the one the \
447 home is showing you, so the home may be serving you an older view"
448 ),
449 ),
450 Relation::Fork => Check::fail(
451 FORK_CHECK,
452 format!(
453 "{seed}: fork: the seed witnessed {} at seq {}, which is not on the \
454 attestation chain the home shows you (current {current}); the home has \
455 shown two histories",
456 said.snapshot, said.at_seq
457 ),
458 )
459 .with_fix(
460 "stop acting on this home's answers; an export from the home and one from the \
461 seed (choir-node --export) show which history each holds",
462 ),
463 }
464}
465
466fn get(
468 url: &str,
469 auth: Option<&str>,
470 user: Option<&str>,
471 path: &str,
472) -> Result<(u16, serde_json::Value), String> {
473 let client = crate::mcp::HttpClient::new(url, auth.map(std::path::Path::new), user)?;
474 let (status, body) = client.get(path)?;
475 let value = serde_json::from_str(&body)
476 .map_err(|_| format!("{url}{path} answered {status} with a body that is not JSON"))?;
477 Ok((status, value))
478}
479
480fn copy_rows(seed: &str, witness: &serde_json::Value) -> Vec<Check> {
486 let mut rows = Vec::new();
487 if witness["gap"] == true {
488 rows.push(
489 Check::warn(
490 "seed gap",
491 format!("{seed}: its copy has a hole, so it is a copy and not a witness"),
492 )
493 .with_fix("re-seed it from an export of the home (choir-node --import)"),
494 );
495 }
496 if let (Some(seq), Some(reason)) = (
497 witness["halted"]["seq"].as_u64(),
498 witness["halted"]["reason"].as_str(),
499 ) {
500 rows.push(
501 Check::warn(
502 "seed halted",
503 format!("{seed}: stopped replicating at seq {seq}: {reason}"),
504 )
505 .with_fix("read that page from the home yourself; a seed never skips an entry"),
506 );
507 }
508 rows
509}
510
511fn seed_checks(
518 home: &str,
519 seed: &str,
520 auth_for: &dyn Fn(&str) -> Option<String>,
521 user: Option<&str>,
522) -> Vec<Check> {
523 let warn = |detail: String| vec![Check::warn(FORK_CHECK, format!("{seed}: {detail}"))];
524 let seed_auth = auth_for(seed);
525 let witness = match get(seed, seed_auth.as_deref(), user, "/api/witness") {
526 Ok((200, witness)) => witness,
527 Ok((status, body)) => {
528 return warn(format!(
529 "answered {status} on /api/witness, so its statement cannot be checked: {}",
530 body["error"].as_str().unwrap_or("")
531 ))
532 }
533 Err(why) => return warn(format!("cannot be read: {why}")),
534 };
535 let mut rows = copy_rows(seed, &witness);
536 let Some(statement) = choir_node::replica::SignedStatement::from_json(&witness["latest"])
537 else {
538 rows.extend(warn(
539 "has signed no statement yet: it has folded no attestation".to_string(),
540 ));
541 return rows;
542 };
543 let home_auth = auth_for(home);
544 let view = match get(home, home_auth.as_deref(), user, "/api/view") {
545 Ok((200, view)) => view,
546 Ok((status, _)) => {
547 rows.extend(warn(format!("the home answered {status} on /api/view")));
548 return rows;
549 }
550 Err(why) => {
551 rows.extend(warn(format!("the home cannot be read: {why}")));
552 return rows;
553 }
554 };
555 let home_node = view["log"]["node"].as_str().unwrap_or_default().to_string();
556 let (Some(current), Some(current_at)) = (
557 view["snapshot"]["id"].as_str().map(str::to_string),
558 view["snapshot"]["at_seq"].as_u64(),
559 ) else {
560 rows.extend(warn(
561 "the home has made no attestation to compare".to_string(),
562 ));
563 return rows;
564 };
565 let mut chain = choir_node::replica::SnapshotChain::default();
568 let mut from = statement.statement.at_seq.min(current_at);
569 loop {
570 match get(
571 home,
572 home_auth.as_deref(),
573 user,
574 &format!("/api/log?from={from}"),
575 ) {
576 Ok((200, page)) => {
577 let entries = page["entries"].as_array().cloned().unwrap_or_default();
578 let Some(last) = entries.last().and_then(|e| e["seq"].as_u64()) else {
579 break;
580 };
581 chain.read_page(&entries);
582 from = last + 1;
583 }
584 Ok((status, _)) => {
585 rows.extend(warn(format!(
586 "the home answered {status} on /api/log; walking its attestation chain \
587 needs the node-wide read grant (`@node auditor`)"
588 )));
589 return rows;
590 }
591 Err(why) => {
592 rows.extend(warn(format!("the home's log cannot be read: {why}")));
593 return rows;
594 }
595 }
596 }
597 rows.push(fork_check(seed, &statement, &home_node, ¤t, &chain));
598 rows
599}
600
601pub fn seeds(
607 home: Option<&str>,
608 seeds: &[String],
609 auth_for: &dyn Fn(&str) -> Option<String>,
610 user: Option<&str>,
611) -> Vec<Check> {
612 let Some(home) = home else {
613 return vec![Check::warn(
614 FORK_CHECK,
615 "seeds are configured and no node is, so there is no home to compare them with",
616 )
617 .with_fix("write `node = <url>` beside `seeds =` in .choir/config")];
618 };
619 seeds
620 .iter()
621 .flat_map(|seed| seed_checks(home, seed, auth_for, user))
622 .collect()
623}
624
625fn self_check() -> Check {
647 let Ok(exe) = std::env::current_exe() else {
648 return Check::warn(
651 "choir",
652 format!(
653 "{} {} (this process cannot name its own path)",
654 env!("CARGO_PKG_VERSION"),
655 choir_node::build_line()
656 ),
657 );
658 };
659 let where_ = if crate::supervise::in_build_directory(&exe) {
660 "a build directory"
661 } else if in_cargo_home(&exe) {
662 "an install directory"
663 } else {
664 "not an install or build directory"
665 };
666 Check::pass(
667 "choir",
668 format!(
669 "{} {}, {} ({where_})",
670 env!("CARGO_PKG_VERSION"),
671 choir_node::build_line(),
672 exe.display()
673 ),
674 )
675}
676
677fn in_cargo_home(exe: &std::path::Path) -> bool {
683 let bin = match std::env::var_os("CARGO_HOME") {
684 Some(home) => PathBuf::from(home).join("bin"),
685 None => match std::env::var_os("HOME") {
686 Some(home) => PathBuf::from(home).join(".cargo").join("bin"),
687 None => return false,
688 },
689 };
690 exe.parent() == Some(bin.as_path())
691}
692
693#[must_use]
708pub fn host(state: &std::path::Path, auth: Option<&str>) -> Vec<Check> {
709 let layout = crate::serve::Layout::new(state, port_of_state(state));
710 let tls = layout.tls();
711 let mut checks = vec![Check::pass(
712 "bind",
713 match tls {
714 Some(_) => format!("{}:{} (reachable from outside)", layout.bind(), layout.port),
715 None => format!("127.0.0.1:{} (loopback only)", layout.port),
716 },
717 )];
718
719 checks.push(match &tls {
720 Some((cert, _)) => Check::pass("tls", format!("on, {}", cert.display())),
721 None => Check::pass(
732 "tls",
733 "off — not needed on loopback; `choir host --domain <name>` publishes it",
734 ),
735 });
736
737 checks.push(match &tls {
738 None => Check::pass("certificate", "none needed for a loopback node"),
739 Some((cert, _)) => match crate::tls::expiry(cert) {
740 Err(error) => Check::fail("certificate", error)
741 .with_fix("sudo choir node tls <domain> --user $(id -un)"),
742 Ok(when) => {
743 if !checkend(cert, 0) {
749 Check::fail("certificate", format!("EXPIRED — was valid until {when}"))
750 .with_fix("sudo certbot renew --force-renewal && choir node restart")
751 } else if !checkend(cert, 21 * 24 * 3600) {
752 Check::warn("certificate", format!("expires within 21 days — {when}"))
753 .with_fix("sudo certbot renew --dry-run (checks the renewal path)")
754 } else {
755 Check::pass("certificate", format!("valid until {when}"))
756 }
757 }
758 },
759 });
760
761 let user = crate::host::username();
762 checks.push(match crate::host::linger(&user) {
763 None => Check::pass("linger", "not a systemd --user machine"),
764 Some(true) => Check::pass("linger", format!("on for {user}")),
765 Some(false) => Check::fail(
766 "linger",
767 format!("off — the node dies when {user} logs out"),
768 )
769 .with_fix(format!("sudo loginctl enable-linger {user}")),
770 });
771
772 checks.push(unit_check());
773
774 checks.push(match layout.public() {
775 None => Check::pass("public url", "none; this node is not published"),
776 Some(url) => {
777 match crate::mcp::HttpClient::new(&url, auth.map(std::path::Path::new), None) {
778 Err(error) => Check::fail("public url", format!("{url}: {error}")),
779 Ok(client) => match client.get("/healthz") {
780 Ok((200 | 401, _)) => Check::pass("public url", format!("{url} answers")),
781 Ok((code, _)) => Check::warn("public url", format!("{url} answered {code}")),
782 Err(error) => Check::fail("public url", format!("{url}: {error}")).with_fix(
783 "check DNS points here, and that the port is open in the firewall",
784 ),
785 },
786 }
787 }
788 });
789
790 checks
791}
792
793fn checkend(cert: &std::path::Path, seconds: u64) -> bool {
795 std::process::Command::new("openssl")
796 .args(["x509", "-noout", "-checkend", &seconds.to_string(), "-in"])
797 .arg(cert)
798 .output()
799 .is_ok_and(|out| out.status.success())
800}
801
802fn unit_check() -> Check {
804 let Some(supervisor) = crate::supervise::Supervisor::detect() else {
805 return Check::warn(
806 "unit",
807 format!("no service manager on {}", std::env::consts::OS),
808 )
809 .with_fix("run it in the foreground: choir node serve");
810 };
811 let (program, args) = match supervisor {
812 crate::supervise::Supervisor::Launchd => (
813 "launchctl",
814 vec![
815 "print".to_string(),
816 format!("gui/{}/{}", crate::tls::uid(), crate::supervise::LABEL),
817 ],
818 ),
819 crate::supervise::Supervisor::Systemd => (
820 "systemctl",
821 vec![
822 "--user".to_string(),
823 "is-active".to_string(),
824 "choir-node.service".to_string(),
825 ],
826 ),
827 };
828 match std::process::Command::new(program).args(&args).output() {
829 Ok(out) if out.status.success() => Check::pass("unit", "loaded and running"),
830 Ok(_) => Check::warn("unit", "not loaded — this node is not supervised")
837 .with_fix("choir node install, to survive a logout and a reboot"),
838 Err(error) => Check::warn("unit", format!("could not ask {program}: {error}")),
839 }
840}
841
842fn port_of_state(state: &std::path::Path) -> u16 {
847 let layout = crate::serve::Layout::new(state, 8417);
848 layout
849 .public()
850 .as_deref()
851 .and_then(crate::serve::port_of)
852 .unwrap_or(8417)
853}
854
855fn daemon_check() -> Check {
864 match crate::serve::find_daemon() {
865 Ok(path) => {
866 let where_ = path.display().to_string();
867 if crate::supervise::in_build_directory(&path) {
868 Check::warn("choir-node", format!("{where_} (a build directory)")).with_fix(
873 "fine for a checkout; `choir node install` refuses it, so install \
874 the pair before supervising one",
875 )
876 } else {
877 Check::pass("choir-node", where_)
878 }
879 }
880 Err(_) => Check::warn("choir-node", "not beside `choir` or on PATH")
881 .with_fix("only needed to run a node yourself: cargo build --release -p choir-node"),
882 }
883}
884
885#[must_use]
893pub fn heading(role: crate::join::Role, style: Style) -> String {
894 format!("\n {}\n\n", style.bold(role.line()))
895}
896
897pub fn report(checks: &[Check], style: Style) -> String {
905 let width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
906 let mut out = String::new();
907 for check in checks {
908 let name = format!("{:width$}", check.name);
909 out.push_str(&format!(
910 " {} {} {}\n",
911 check.status.paint(style),
912 style.dim(&name),
913 check.detail
914 ));
915 }
916 let fixes: Vec<&Check> = checks.iter().filter(|c| c.fix.is_some()).collect();
917 if !fixes.is_empty() {
918 out.push('\n');
919 for check in fixes {
920 let fix = check.fix.as_deref().unwrap_or_default();
921 out.push_str(&format!(
922 " {} {}\n",
923 style.dim(&format!("{}:", check.name)),
924 fix
925 ));
926 }
927 }
928 out.push('\n');
929 out.push_str(&match Status::worst(checks) {
930 Status::Fail => format!(" {}\n", style.red("something required is missing")),
931 Status::Warn => format!(" {}\n", style.cyan("usable; some things are degraded")),
932 Status::Pass => format!(" {}\n", style.green("everything checks out")),
933 });
934 out
935}
936
937#[cfg(test)]
938mod tests {
939 #[test]
942 fn a_statement_off_the_homes_chain_is_a_fork() {
943 use choir_node::replica::{SignedStatement, SnapshotChain, WitnessStatement};
944 let key = choir_identity::ActorKey::generate();
945 let statement = |snapshot: &str| {
946 SignedStatement::sign(
947 &key,
948 WitnessStatement {
949 format_version: 1,
950 witness: key.actor_id().to_hex(),
951 home_node_id: "1e-home".into(),
952 snapshot: snapshot.into(),
953 at_seq: 2,
954 seen_at_seq: 2,
955 },
956 )
957 };
958 let mut chain = SnapshotChain::default();
959 chain.insert("s1".into(), None);
960 chain.insert("s2".into(), Some("s1".into()));
961
962 let same = super::fork_check("seed", &statement("s2"), "1e-home", "s2", &chain);
963 assert_eq!(same.status, super::Status::Pass, "{}", same.detail);
964 let behind = super::fork_check("seed", &statement("s1"), "1e-home", "s2", &chain);
965 assert_eq!(behind.status, super::Status::Pass, "{}", behind.detail);
966 let forged = super::fork_check("seed", &statement("elsewhere"), "1e-home", "s2", &chain);
967 assert_eq!(forged.status, super::Status::Fail);
968 assert!(forged.detail.contains("fork"), "{}", forged.detail);
969 let other = super::fork_check("seed", &statement("s2"), "1e-other", "s2", &chain);
970 assert_eq!(other.status, super::Status::Fail);
971
972 let mut far = SnapshotChain::default();
973 far.insert("a0".into(), None);
974 for i in 1..=super::FAR_BEHIND + 1 {
975 far.insert(format!("a{i}"), Some(format!("a{}", i - 1)));
976 }
977 let current = format!("a{}", super::FAR_BEHIND + 1);
978 let stale = super::fork_check("seed", &statement("a0"), "1e-home", ¤t, &far);
979 assert_eq!(stale.status, super::Status::Warn);
980 assert!(
981 stale.detail.contains("attestations behind"),
982 "{}",
983 stale.detail
984 );
985 }
986
987 #[test]
989 fn a_halted_seed_is_named_with_the_seq_it_stopped_at() {
990 let clean = serde_json::json!({"gap": false, "halted": null});
991 assert!(super::copy_rows("seed", &clean).is_empty());
992 let halted = serde_json::json!({
993 "gap": false,
994 "halted": {"seq": 3, "reason": "entry 3 does not hash to what the page claimed"},
995 });
996 let rows = super::copy_rows("seed", &halted);
997 assert_eq!(rows.len(), 1);
998 assert_eq!(rows[0].status, super::Status::Warn);
999 assert_eq!(rows[0].name, "seed halted");
1000 assert!(rows[0].detail.contains("seq 3"), "{}", rows[0].detail);
1001 assert!(
1002 rows[0].detail.contains("does not hash"),
1003 "{}",
1004 rows[0].detail
1005 );
1006 }
1007}