1use std::path::{Path, PathBuf};
36
37#[derive(Debug)]
45pub struct Refusal {
46 pub code: i32,
48 pub message: String,
50}
51
52impl Refusal {
53 #[must_use]
55 pub fn fail(message: impl Into<String>) -> Refusal {
56 Refusal {
57 code: 1,
58 message: message.into(),
59 }
60 }
61
62 #[must_use]
64 pub fn decide(message: impl Into<String>) -> Refusal {
65 Refusal {
66 code: 3,
67 message: message.into(),
68 }
69 }
70}
71
72#[derive(Debug)]
74pub struct Backup {
75 pub src: PathBuf,
77 pub policy: PathBuf,
80 pub repos: Vec<String>,
82 pub ops: usize,
85 pub bytes: u64,
87}
88
89#[derive(Debug)]
91pub struct Restored {
92 pub ops: usize,
94 pub repos: usize,
96 pub canary: String,
98 pub landed_in: String,
100}
101
102pub const REQUIRED: &[&str] = &["keys", "reviewers", "repos.list"];
104
105pub const OPTIONAL: &[&str] = &[
112 "protected-refs",
113 "newcomer-audit.jsonl",
114 "newcomer-adjudications.jsonl",
115 "review-adjudications.jsonl",
116 "acl",
117 "private-beta.manifest",
118];
119
120pub fn read(src: &Path, work: &Path, daemon: &Path) -> Result<Backup, Refusal> {
129 let ops_path = src.join("ops.jsonl");
130 let text = std::fs::read_to_string(&ops_path).map_err(|_| {
131 Refusal::fail(format!(
132 "no ops.jsonl in {}: that is not a backup",
133 src.display()
134 ))
135 })?;
136 let ops = text.lines().count();
137 if ops == 0 {
138 return Err(Refusal::fail("the backed-up log is empty"));
139 }
140 let bytes = text.len() as u64;
141
142 let verified = std::process::Command::new(daemon)
146 .args(["--verify-log", &ops_path.display().to_string()])
147 .output()
148 .is_ok_and(|out| out.status.success());
149 if !verified {
150 return Err(Refusal::fail(
151 "the backup failed format/sequence/parent/hash verification",
152 ));
153 }
154
155 let policy = if src.join("policy").is_dir() {
159 src.join("policy")
160 } else if src.join("policy.tar").is_file() {
161 let into = work.join("policy");
162 std::fs::create_dir_all(&into)
163 .map_err(|e| Refusal::fail(format!("create {}: {e}", into.display())))?;
164 let ok = std::process::Command::new("tar")
165 .arg("-xf")
166 .arg(src.join("policy.tar"))
167 .arg("-C")
168 .arg(&into)
169 .output()
170 .is_ok_and(|out| out.status.success());
171 if !ok {
172 return Err(Refusal::fail(format!(
173 "could not read {}",
174 src.join("policy.tar").display()
175 )));
176 }
177 into
178 } else {
179 return Err(Refusal::fail(format!(
180 "no policy in {} (neither policy/ nor policy.tar): a node restored \
181 without reviewers does not boot",
182 src.display()
183 )));
184 };
185
186 let missing: Vec<&str> = REQUIRED
187 .iter()
188 .copied()
189 .filter(|f| !policy.join(f).is_file())
190 .collect();
191 if !missing.is_empty() {
192 return Err(Refusal::fail(format!(
193 "policy files missing from the backup: {} (a node restored without \
194 reviewers does not boot)",
195 missing.join(" ")
196 )));
197 }
198
199 let mut leaked = Vec::new();
202 for dir in [src, policy.as_path()] {
203 if let Ok(entries) = std::fs::read_dir(dir) {
204 for entry in entries.flatten() {
205 let name = entry.file_name().to_string_lossy().to_string();
206 if crate::backup::is_secret(&name) {
207 leaked.push(name);
208 }
209 }
210 }
211 }
212 if !leaked.is_empty() {
213 leaked.sort();
214 leaked.dedup();
215 return Err(Refusal::fail(format!(
216 "SECRETS IN THE BACKUP: {} (a backup holding a token or key is a \
217 credential channel, and this restore will not spread it)",
218 leaked.join(" ")
219 )));
220 }
221
222 let listed = std::fs::read_to_string(policy.join("repos.list")).unwrap_or_default();
223 let repos: Vec<String> = listed
224 .lines()
225 .map(str::trim)
226 .filter(|line| !line.is_empty() && !line.starts_with('#'))
227 .map(str::to_string)
228 .collect();
229 if repos.is_empty() {
230 return Err(Refusal::fail(
231 "repos.list names no repositories: there is nothing to serve",
232 ));
233 }
234 for repo in &repos {
235 if bundle_for(src, repo).is_none() {
236 return Err(Refusal::fail(format!(
237 "no bundle for {repo}: the log's refs for it name commits nothing here holds"
238 )));
239 }
240 }
241
242 Ok(Backup {
243 src: src.to_path_buf(),
244 policy,
245 repos,
246 ops,
247 bytes,
248 })
249}
250
251#[must_use]
258pub fn bundle_for(src: &Path, repo: &str) -> Option<PathBuf> {
259 let full = src.join("repos").join(format!("{repo}.bundle"));
260 if full.is_file() {
261 return Some(full);
262 }
263 let base = repo.rsplit('/').next().unwrap_or(repo);
264 let base = base.strip_suffix(".git").unwrap_or(base);
265 let short = src.join("repos").join(format!("{base}.bundle"));
266 short.is_file().then_some(short)
267}
268
269pub fn resuming(root: &Path, backup: &Backup) -> Result<bool, Refusal> {
283 let placed = root.join(".choir/ops.jsonl");
284 let resume = if placed.exists() {
285 let same = std::fs::read(&placed).ok() == std::fs::read(backup.src.join("ops.jsonl")).ok();
286 if !same {
287 return Err(Refusal::fail(format!(
288 "{} already exists and is not this backup: restore into an empty \
289 root, or move the existing log aside first (it is never \
290 overwritten here)",
291 placed.display()
292 )));
293 }
294 true
295 } else {
296 false
297 };
298 if !resume {
299 for repo in &backup.repos {
300 if root.join(repo).exists() {
301 return Err(Refusal::fail(format!(
302 "{} already exists: restore into an empty root",
303 root.join(repo).display()
304 )));
305 }
306 }
307 }
308 Ok(resume)
309}
310
311pub fn place(root: &Path, backup: &Backup, resume: bool) -> Result<Vec<String>, Refusal> {
325 let choir = root.join(".choir");
326 std::fs::create_dir_all(choir.join("policy"))
327 .map_err(|e| Refusal::fail(format!("create {}: {e}", choir.display())))?;
328 std::fs::copy(backup.src.join("ops.jsonl"), choir.join("ops.jsonl"))
329 .map_err(|e| Refusal::fail(format!("place the log: {e}")))?;
330
331 if !resume && backup.src.join("node.fingerprint").is_file() {
337 std::fs::copy(
338 backup.src.join("node.fingerprint"),
339 choir.join("node.fingerprint"),
340 )
341 .map_err(|e| Refusal::fail(format!("place the fingerprint: {e}")))?;
342 }
343 if backup.src.join("refs.snapshot").is_file() {
344 std::fs::copy(
345 backup.src.join("refs.snapshot"),
346 choir.join("refs.snapshot"),
347 )
348 .map_err(|e| Refusal::fail(format!("place the attestation: {e}")))?;
349 }
350
351 let mut absent = Vec::new();
352 for name in REQUIRED.iter().chain(OPTIONAL) {
353 let from = backup.policy.join(name);
354 if from.is_file() {
355 std::fs::copy(&from, choir.join("policy").join(name))
356 .map_err(|e| Refusal::fail(format!("place {name}: {e}")))?;
357 } else if OPTIONAL.contains(name) {
358 absent.push((*name).to_string());
359 }
360 }
361 private(&choir)?;
362
363 for repo in &backup.repos {
364 let into = root.join(repo);
365 if into.exists() {
366 continue;
367 }
368 if let Some(parent) = into.parent() {
369 std::fs::create_dir_all(parent)
370 .map_err(|e| Refusal::fail(format!("create {}: {e}", parent.display())))?;
371 }
372 let Some(bundle) = bundle_for(&backup.src, repo) else {
373 return Err(Refusal::fail(format!("no bundle for {repo}")));
374 };
375 let ok = std::process::Command::new("git")
376 .args(["clone", "--bare", "--quiet"])
377 .arg(&bundle)
378 .arg(&into)
379 .output()
380 .is_ok_and(|out| out.status.success());
381 if !ok {
382 return Err(Refusal::fail(format!("could not unbundle {repo}")));
383 }
384 let _ = std::process::Command::new("git")
388 .arg("--git-dir")
389 .arg(&into)
390 .args(["remote", "remove", "origin"])
391 .output();
392 }
393 Ok(absent)
394}
395
396fn private(choir: &Path) -> Result<(), Refusal> {
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::PermissionsExt;
401 let set = |path: &Path, mode: u32| {
402 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
403 .map_err(|e| Refusal::fail(format!("chmod {}: {e}", path.display())))
404 };
405 set(choir, 0o700)?;
406 if let Ok(entries) = std::fs::read_dir(choir.join("policy")) {
407 for entry in entries.flatten() {
408 set(&entry.path(), 0o600)?;
409 }
410 }
411 }
412 #[cfg(not(unix))]
413 let _ = choir;
414 Ok(())
415}
416
417pub fn secrets(root: &Path, auth: &Path, ops: usize) -> Result<Option<String>, Refusal> {
429 let choir = root.join(".choir");
430 if !choir.join("node.key").is_file() {
431 if choir.join("node.fingerprint").is_file() {
432 return Err(Refusal::decide(format!(
433 "the node's signing key is not here, and {} pins the identity that \
434 wrote this log.\n Files are in place; the daemon will refuse to \
435 start until you choose:\n (a) put the original 32-byte key at \
436 {} (chmod 600) and re-run this, or\n (b) accept that the log \
437 changes author at this point:\n rm {}\n and \
438 re-run. Every op after the seam is signed by a different actor.\n \
439 Option (b) is not reversible and not invisible: see \
440 docs/runbook-restore.md.",
441 choir.join("node.fingerprint").display(),
442 choir.join("node.key").display(),
443 choir.join("node.fingerprint").display(),
444 )));
445 }
446 let warning = format!(
452 "NO SIGNING KEY AND NO PIN — the daemon will mint a fresh key on the \
453 start below.\n Every op from seq {ops} on is signed by a different \
454 actor than seq 0..{}.\n Anyone holding the old fingerprint should be \
455 told (docs/runbook-restore.md).",
456 ops.saturating_sub(1)
457 );
458 if !auth.is_file() {
459 return Err(no_credential(auth));
460 }
461 return Ok(Some(warning));
462 }
463 if !auth.is_file() {
464 return Err(no_credential(auth));
465 }
466 Ok(None)
467}
468
469fn no_credential(auth: &Path) -> Refusal {
470 Refusal::decide(format!(
471 "no auth file at {}. Backups carry no credentials, so mint one now:\n \
472 printf '<operator>:%s\\n' \"$(openssl rand -hex 32)\" > {} && chmod 600 {}\n \
473 Replace <operator> with a username the restored ACL grants ownership of a \
474 restored repository, then re-run. Reuse of the old token is not possible \
475 and not wanted: it was last seen on a host you are restoring away from.",
476 auth.display(),
477 auth.display(),
478 auth.display(),
479 ))
480}
481
482struct Rehearsal {
488 child: std::process::Child,
489 port: u16,
490}
491
492impl Drop for Rehearsal {
493 fn drop(&mut self) {
494 let _ = self.child.kill();
495 let _ = self.child.wait();
496 }
497}
498
499fn boot(
506 root: &Path,
507 auth: &Path,
508 daemon: &Path,
509 work: &Path,
510 backup: &Backup,
511) -> Result<Rehearsal, Refusal> {
512 let policy = root.join(".choir/policy");
513 let mut args: Vec<String> = vec![
514 root.display().to_string(),
515 "0".to_string(),
516 "--bind".to_string(),
517 "127.0.0.1".to_string(),
518 "--auth-file".to_string(),
519 auth.display().to_string(),
520 "--keys-file".to_string(),
521 policy.join("keys").display().to_string(),
522 "--reviewers-file".to_string(),
523 policy.join("reviewers").display().to_string(),
524 "--require-assignment".to_string(),
525 "--require-scope".to_string(),
526 "--read-only-browser".to_string(),
527 "--journal".to_string(),
528 root.join(".choir/journal.jsonl").display().to_string(),
529 "--request-log".to_string(),
530 root.join(".choir/requests.jsonl").display().to_string(),
531 "--request-log-max-bytes".to_string(),
532 "33554432".to_string(),
533 "--rate-limit-api".to_string(),
534 "120".to_string(),
535 "--rate-limit-git".to_string(),
536 "60".to_string(),
537 "--quota-push-bytes".to_string(),
538 "536870912".to_string(),
539 "--quota-workspaces".to_string(),
540 "8".to_string(),
541 "--api-body-limit".to_string(),
542 "1048576".to_string(),
543 "--batch-limit".to_string(),
544 "256".to_string(),
545 "--ready-min-free-bytes".to_string(),
546 "1073741824".to_string(),
547 ];
548 for (name, flag, also) in [
556 (
557 "protected-refs",
558 "--protected-refs",
559 Some("--require-review"),
560 ),
561 ("newcomer-audit.jsonl", "--newcomer-audit", None),
562 (
563 "newcomer-adjudications.jsonl",
564 "--newcomer-adjudications",
565 None,
566 ),
567 ("review-adjudications.jsonl", "--review-adjudications", None),
568 ("acl", "--acl-file", None),
569 ] {
570 let path = policy.join(name);
571 if !path.is_file() {
572 continue;
573 }
574 args.push(flag.to_string());
575 args.push(path.display().to_string());
576 if let Some(also) = also {
577 args.push(also.to_string());
578 }
579 }
580 for repo in &backup.repos {
581 args.push("--create".to_string());
582 args.push(repo.clone());
583 }
584
585 let err_path = work.join("node.err");
586 let err = std::fs::File::create(&err_path)
587 .map_err(|e| Refusal::fail(format!("create {}: {e}", err_path.display())))?;
588 let out = std::fs::File::create(work.join("node.out"))
589 .map_err(|e| Refusal::fail(format!("create node.out: {e}")))?;
590 let mut child = std::process::Command::new(daemon)
591 .args(&args)
592 .stdout(out)
593 .stderr(err)
594 .spawn()
595 .map_err(|e| Refusal::fail(format!("could not start {}: {e}", daemon.display())))?;
596
597 let mut port = None;
598 for _ in 0..600 {
599 let text = std::fs::read_to_string(&err_path).unwrap_or_default();
600 if let Some(found) = serving_port(&text) {
601 port = Some(found);
602 break;
603 }
604 if matches!(child.try_wait(), Ok(Some(_))) {
606 break;
607 }
608 std::thread::sleep(std::time::Duration::from_millis(100));
609 }
610 let stderr = std::fs::read_to_string(&err_path).unwrap_or_default();
611 let Some(port) = port else {
612 let _ = child.kill();
613 return Err(Refusal::fail(format!(
614 "the restored node did not start. Its output:\n{stderr}"
615 )));
616 };
617 let rehearsal = Rehearsal { child, port };
618
619 let retracted: Vec<&str> = stderr
623 .lines()
624 .filter(|l| l.starts_with("choir: retracted"))
625 .collect();
626 if !retracted.is_empty() {
627 return Err(Refusal::fail(format!(
628 "the restored node RETRACTED refs on start:\n{}\n The log named \
629 commits the repos do not hold. The restored log now has compensating \
630 ops in it and is no longer the backup. Start again from the backup \
631 into a clean root.",
632 retracted.join("\n")
633 )));
634 }
635 Ok(rehearsal)
636}
637
638#[must_use]
643pub fn serving_port(stderr: &str) -> Option<u16> {
644 stderr.lines().find_map(|line| {
645 let rest = line.strip_prefix("choir-node serving ")?;
646 let at = rest.rfind("http://")?;
647 let host = rest[at + "http://".len()..]
648 .split(|c: char| c == '/' || c.is_whitespace())
649 .next()?;
650 host.rsplit_once(':')?.1.parse().ok()
651 })
652}
653
654fn get(api: &str, credential: &str, path: &str) -> Option<serde_json::Value> {
656 let out = std::process::Command::new("curl")
657 .args(["-sS", "-u", credential])
658 .arg(format!("{api}{path}"))
659 .output()
660 .ok()?;
661 serde_json::from_slice(&out.stdout).ok()
662}
663
664pub fn attested_refs(
676 snapshot: &serde_json::Value,
677) -> Result<std::collections::BTreeMap<String, String>, String> {
678 let refs = snapshot
679 .get("refs")
680 .and_then(serde_json::Value::as_object)
681 .ok_or("no `refs` in the attestation")?;
682 let mut out = std::collections::BTreeMap::new();
683 for (name, value) in refs {
684 let hash: choir_hash::ContentHash = serde_json::from_value(value.clone())
685 .map_err(|e| format!("{name} is not a content hash: {e}"))?;
686 out.insert(name.clone(), hash.to_hex());
687 }
688 Ok(out)
689}
690
691#[must_use]
693pub fn served_refs(view: &serde_json::Value) -> std::collections::BTreeMap<String, String> {
694 view.get("refs")
695 .and_then(serde_json::Value::as_object)
696 .map(|refs| {
697 refs.iter()
698 .filter_map(|(name, value)| {
699 value.as_str().map(|hex| (name.clone(), hex.to_string()))
700 })
701 .collect()
702 })
703 .unwrap_or_default()
704}
705
706#[must_use]
708pub fn ref_mismatches(
709 attested: &std::collections::BTreeMap<String, String>,
710 served: &std::collections::BTreeMap<String, String>,
711) -> Vec<String> {
712 let mut names: Vec<&String> = attested.keys().chain(served.keys()).collect();
713 names.sort();
714 names.dedup();
715 names
716 .into_iter()
717 .filter(|name| attested.get(*name) != served.get(*name))
718 .map(|name| {
719 format!(
720 " {name}: attested {}, serving {}",
721 attested.get(name).map_or("nothing", String::as_str),
722 served.get(name).map_or("nothing", String::as_str)
723 )
724 })
725 .collect()
726}
727
728pub fn run(
738 src: &Path,
739 root: &Path,
740 daemon: &Path,
741 auth: &Path,
742 say: &mut dyn FnMut(&str),
743) -> Result<Restored, Refusal> {
744 let work = scratch()?;
745 let backup = read(src, &work, daemon)?;
746 let resume = resuming(root, &backup)?;
747 if resume {
748 say(&format!(
749 "resuming — {} is this backup, placed and not appended to",
750 root.join(".choir/ops.jsonl").display()
751 ));
752 }
753 let absent = place(root, &backup, resume)?;
754 for name in &absent {
755 say(&format!(
756 "{name} is not in this backup — the restored node starts without it"
757 ));
758 }
759 if let Some(warning) = secrets(root, auth, backup.ops)? {
760 say(&warning);
761 }
762
763 let rehearsal = boot(root, auth, daemon, &work, &backup)?;
764 let api = format!("http://127.0.0.1:{}", rehearsal.port);
765 let credential = std::fs::read_to_string(auth)
766 .map_err(|e| Refusal::fail(format!("read {}: {e}", auth.display())))?
767 .lines()
768 .next()
769 .unwrap_or_default()
770 .to_string();
771
772 let view = get(&api, &credential, "/api/view")
777 .ok_or_else(|| Refusal::fail("the restored node served no view"))?;
778 let head_before = view["log"]["head"].as_str().unwrap_or_default().to_string();
779 if head_before.is_empty() {
780 return Err(Refusal::fail(
781 "the restored node serves no log head; it did not replay the log it was given",
782 ));
783 }
784
785 let snapshot_path = root.join(".choir/refs.snapshot");
788 if snapshot_path.is_file() {
789 let snapshot: serde_json::Value = std::fs::read_to_string(&snapshot_path)
790 .ok()
791 .and_then(|t| serde_json::from_str(&t).ok())
792 .ok_or_else(|| Refusal::fail("refs.snapshot is not readable JSON"))?;
793 let attested = attested_refs(&snapshot).map_err(Refusal::fail)?;
794 let served = served_refs(&view);
795 let differences = ref_mismatches(&attested, &served);
796 if !differences.is_empty() {
797 return Err(Refusal::fail(format!(
798 "the restored view does not match the backup's ref attestation:\n{}",
799 differences.join("\n")
800 )));
801 }
802 say(&format!(
803 "view matches the attestation at seq {} ({} refs)",
804 snapshot["at_seq"],
805 attested.len()
806 ));
807 }
808
809 let landed_in = canary(&backup, &work, &credential, rehearsal.port)?;
810 let canary_ref = landed_in.1;
811 let landed_in = landed_in.0;
812
813 let placed = root.join(".choir/ops.jsonl");
818 let after = std::fs::read_to_string(&placed).unwrap_or_default();
819 let lines: Vec<&str> = after.lines().collect();
820 if lines.len() <= backup.ops {
821 return Err(Refusal::fail(
822 "the canary push returned success but the log did not grow. The repo \
823 is being served without its pre-receive hook, so pushes bypass the \
824 sequencer.",
825 ));
826 }
827 let entry: serde_json::Value = serde_json::from_str(lines[backup.ops])
828 .map_err(|e| Refusal::fail(format!("the appended entry is not JSON: {e}")))?;
829 let parent: choir_hash::ContentHash = serde_json::from_value(entry["parent"].clone())
830 .map_err(|e| Refusal::fail(format!("the appended entry has no parent hash: {e}")))?;
831 if parent.to_hex() != head_before {
832 return Err(Refusal::fail(format!(
833 "the first appended entry chains onto {}, but the node served head \
834 {head_before}: the replay and the log do not agree",
835 parent.to_hex()
836 )));
837 }
838
839 let restored_bytes = std::fs::read(&placed).unwrap_or_default();
843 let backup_bytes = std::fs::read(backup.src.join("ops.jsonl")).unwrap_or_default();
844 if restored_bytes.len() < backup_bytes.len()
845 || restored_bytes[..backup_bytes.len()] != backup_bytes[..]
846 {
847 return Err(Refusal::fail(
848 "the restored log is not a byte-exact continuation of the backup: \
849 something rewrote history",
850 ));
851 }
852
853 drop(rehearsal);
854 std::fs::remove_dir_all(&work).ok();
855 Ok(Restored {
856 ops: backup.ops,
857 repos: backup.repos.len(),
858 canary: canary_ref,
859 landed_in,
860 })
861}
862
863fn canary(
871 backup: &Backup,
872 work: &Path,
873 credential: &str,
874 port: u16,
875) -> Result<(String, String), Refusal> {
876 let stamp = std::time::SystemTime::now()
877 .duration_since(std::time::UNIX_EPOCH)
878 .map(|d| d.as_secs())
879 .unwrap_or_default();
880 let canary = format!("refs/heads/restore-canary-{stamp}");
881 for repo in &backup.repos {
882 let url = format!("http://{credential}@127.0.0.1:{port}/{repo}");
883 let clone = work.join("canary");
884 std::fs::remove_dir_all(&clone).ok();
885 let cloned = std::process::Command::new("git")
886 .args(["clone", "--quiet", &url])
887 .arg(&clone)
888 .output()
889 .is_ok_and(|out| out.status.success());
890 if !cloned {
891 continue;
892 }
893 let tip = git_line(&clone, &["rev-parse", "--verify", "--quiet", "HEAD"]).or_else(|| {
900 git_line(
901 &clone,
902 &[
903 "for-each-ref",
904 "--count=1",
905 "--format=%(objectname)",
906 "refs/remotes/origin/",
907 ],
908 )
909 });
910 let Some(tip) = tip else { continue };
913 let pushed = std::process::Command::new("git")
914 .arg("-C")
915 .arg(&clone)
916 .args(["push", "--quiet", &url, &format!("{tip}:{canary}")])
917 .output()
918 .is_ok_and(|out| out.status.success());
919 if !pushed {
920 return Err(Refusal::fail(format!(
921 "the canary push to {repo} was refused. The restored node serves, \
922 but it does not accept writes."
923 )));
924 }
925 return Ok((repo.clone(), canary));
926 }
927 Err(Refusal::fail(
928 "no restored repo has a commit to push, so nothing proved the node accepts \
929 writes. This is not a pass.",
930 ))
931}
932
933fn git_line(dir: &Path, args: &[&str]) -> Option<String> {
935 let out = std::process::Command::new("git")
936 .arg("-C")
937 .arg(dir)
938 .args(args)
939 .output()
940 .ok()?;
941 if !out.status.success() {
942 return None;
943 }
944 let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
945 (!line.is_empty()).then_some(line)
946}
947
948fn scratch() -> Result<PathBuf, Refusal> {
950 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
951 let at = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
952 let dir = std::env::temp_dir().join(format!("choir-restore-{}-{at}", std::process::id()));
953 std::fs::remove_dir_all(&dir).ok();
954 std::fs::create_dir_all(&dir)
955 .map_err(|e| Refusal::fail(format!("create {}: {e}", dir.display())))?;
956 Ok(dir)
957}