1use super::*;
2
3#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
4pub(crate) struct DeployLock {
5 version: u32,
9 #[serde(default)]
10 pub(crate) environments: BTreeMap<String, Environment>,
11}
12
13fn lock_version() -> u32 {
14 1
15}
16
17#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
18pub(crate) struct Environment {
19 #[serde(default)]
20 pub(crate) kv: BTreeMap<String, KvNamespace>,
21 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28 workers: BTreeMap<String, WorkerRecord>,
29 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
41 queues: BTreeSet<String>,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
45pub(crate) struct KvNamespace {
46 pub(crate) id: String,
47}
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
52struct WorkerRecord {
53 deployed: bool,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
73 contracts: Option<BTreeMap<String, String>>,
74}
75
76impl DeployLock {
77 pub(crate) fn is_deployed(&self, environment: &str, worker: &str) -> bool {
78 self.environments
79 .get(environment)
80 .and_then(|env| env.workers.get(worker))
81 .is_some_and(|record| record.deployed)
82 }
83
84 pub(crate) fn record_deployed(
85 &mut self,
86 environment: &str,
87 worker: &str,
88 contracts: Option<BTreeMap<String, String>>,
89 ) {
90 self.environments
91 .entry(environment.to_string())
92 .or_default()
93 .workers
94 .insert(
95 worker.to_string(),
96 WorkerRecord {
97 deployed: true,
98 contracts,
99 },
100 );
101 }
102
103 pub(crate) fn live_contracts(
109 &self,
110 environment: &str,
111 worker: &str,
112 ) -> Option<&BTreeMap<String, String>> {
113 self.environments
114 .get(environment)
115 .and_then(|env| env.workers.get(worker))
116 .and_then(|record| record.contracts.as_ref())
117 }
118
119 pub(crate) fn has_queue(&self, environment: &str, queue: &str) -> bool {
120 self.environments
121 .get(environment)
122 .is_some_and(|env| env.queues.contains(queue))
123 }
124
125 pub(crate) fn record_queue(&mut self, environment: &str, queue: &str) -> bool {
128 self.environments
129 .entry(environment.to_string())
130 .or_default()
131 .queues
132 .insert(queue.to_string())
133 }
134}
135
136#[derive(Debug, Default, PartialEq, Eq, Serialize)]
149pub(crate) struct Orphans {
150 pub(crate) kv: Vec<String>,
151 pub(crate) workers: Vec<String>,
152 pub(crate) queues: Vec<String>,
153}
154
155impl Orphans {
156 pub(crate) fn has_prunable(&self) -> bool {
163 !self.kv.is_empty() || !self.queues.is_empty()
164 }
165}
166
167pub(crate) fn find_orphans(
181 lock: &DeployLock,
182 environment: &str,
183 resources: &BTreeMap<String, Resources>,
184) -> Orphans {
185 let Some(env) = lock.environments.get(environment) else {
186 return Orphans::default();
187 };
188 let live_queues: BTreeSet<&str> = resources
189 .values()
190 .flat_map(|r| r.queues.iter().map(String::as_str))
191 .collect();
192 Orphans {
193 kv: env
194 .kv
195 .keys()
196 .filter(|worker| !resources.contains_key(worker.as_str()))
197 .cloned()
198 .collect(),
199 workers: env
200 .workers
201 .keys()
202 .filter(|worker| !resources.contains_key(worker.as_str()))
203 .cloned()
204 .collect(),
205 queues: env
206 .queues
207 .iter()
208 .filter(|queue| !live_queues.contains(queue.as_str()))
209 .cloned()
210 .collect(),
211 }
212}
213
214pub(crate) fn recorded_kv<'a>(
215 lock: &'a DeployLock,
216 worker: &str,
217 environment: &str,
218) -> Option<&'a str> {
219 lock.environments
220 .get(environment)
221 .and_then(|env| env.kv.get(worker))
222 .map(|kv| kv.id.as_str())
223}
224
225pub(crate) fn should_trust_recorded_kv(
237 recorded: Option<&str>,
238 live_kv_ids: Option<&BTreeSet<String>>,
239) -> bool {
240 match (recorded, live_kv_ids) {
241 (Some(id), Some(live)) => live.contains(id),
242 (Some(_), None) => true,
243 (None, _) => false,
244 }
245}
246
247pub(crate) fn read_lock(path: &Path) -> Result<DeployLock, String> {
248 if !path.exists() {
249 return Ok(DeployLock {
250 version: lock_version(),
251 ..Default::default()
252 });
253 }
254 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
255 if text.trim().is_empty() {
260 return Err(format!(
261 "deploy ledger `{}` is empty or truncated (corrupt); refusing to \
262 treat it as a fresh project — restore it from version control",
263 path.display()
264 ));
265 }
266 let lock: DeployLock = toml::from_str(&text).map_err(|e| {
271 format!(
272 "deploy ledger `{}` is corrupt ({e}) — restore it from version control",
273 path.display()
274 )
275 })?;
276 if lock.version != lock_version() {
277 return Err(format!("unsupported deploy lock version {}", lock.version));
278 }
279 Ok(lock)
280}
281
282static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
284
285pub(crate) fn write_lock(path: &Path, lock: &DeployLock) -> Result<(), String> {
286 let body = toml::to_string_pretty(lock).map_err(|e| e.to_string())?;
287 let dir = path
295 .parent()
296 .filter(|p| !p.as_os_str().is_empty())
297 .unwrap_or_else(|| Path::new("."));
298 let file_name = path
299 .file_name()
300 .and_then(|n| n.to_str())
301 .unwrap_or(LOCK_FILE);
302
303 let (tmp, mut file) = loop {
307 let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
308 let candidate = dir.join(format!(".{file_name}.{}.{n}.tmp", std::process::id()));
309 match std::fs::OpenOptions::new()
310 .write(true)
311 .create_new(true)
312 .open(&candidate)
313 {
314 Ok(f) => break (candidate, f),
315 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
316 Err(e) => return Err(e.to_string()),
317 }
318 };
319
320 let write_then_sync = file
323 .write_all(body.as_bytes())
324 .and_then(|()| file.sync_all());
325 if let Err(e) = write_then_sync {
326 let _ = std::fs::remove_file(&tmp);
327 return Err(e.to_string());
328 }
329 drop(file);
330
331 if let Ok(meta) = std::fs::metadata(path) {
333 let _ = std::fs::set_permissions(&tmp, meta.permissions());
334 }
335
336 if let Err(e) = std::fs::rename(&tmp, path) {
337 let _ = std::fs::remove_file(&tmp);
338 return Err(e.to_string());
339 }
340 if let Ok(dir_file) = std::fs::File::open(dir) {
343 let _ = dir_file.sync_all();
344 }
345 Ok(())
346}
347
348pub fn materialise_deploy_state(
359 project_root: &Path,
360 worker: &str,
361 config: &Path,
362 environment: &str,
363) -> Result<bool, String> {
364 let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
365 if !text.contains(KV_NAMESPACE_ID_PLACEHOLDER) {
366 return Ok(false);
367 }
368 let lock = read_lock(&project_root.join(LOCK_FILE))?;
369 let Some(id) = lock
370 .environments
371 .get(environment)
372 .and_then(|env| env.kv.get(worker))
373 .map(|namespace| namespace.id.as_str())
374 else {
375 return Err(format!(
376 "remote KV for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
377 ));
378 };
379 if materialise_kv_id(config, id) {
380 Ok(true)
381 } else {
382 Err("could not write generated configuration".into())
383 }
384}
385
386pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans) -> bool {
398 for kv in &orphans.kv {
399 eprintln!("bynk: will delete KV namespace for `{kv}`");
400 }
401 for queue in &orphans.queues {
402 eprintln!("bynk: will delete queue `{queue}`");
403 }
404 if yes {
405 return true;
406 }
407 if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
408 eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
409 return false;
410 }
411 let count = orphans.kv.len() + orphans.queues.len();
412 eprint!("Delete {count} resource(s)? [y/N] ");
413 let _ = io::stderr().flush();
414 let mut answer = String::new();
415 io::stdin().read_line(&mut answer).is_ok()
416 && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
417}
418
419pub(crate) fn prune_orphans(
431 provenance: &Provenance,
432 project_root: &Path,
433 lock: &mut DeployLock,
434 lock_path: &Path,
435 environment: &str,
436 orphans: &Orphans,
437) -> Result<(), DeployFailure> {
438 for worker in &orphans.kv {
439 let Some(id) = lock
440 .environments
441 .get(environment)
442 .and_then(|env| env.kv.get(worker))
443 .map(|ns| ns.id.clone())
444 else {
445 continue;
446 };
447 delete_kv_namespace(provenance, &id, project_root).map_err(|e| {
448 DeployFailure::driver(format!(
449 "could not delete the orphaned KV namespace for `{worker}`: {e}"
450 ))
451 })?;
452 if let Some(env) = lock.environments.get_mut(environment) {
453 env.kv.remove(worker);
454 }
455 write_lock(lock_path, lock).map_err(|e| {
456 DeployFailure::driver(format!(
457 "deleted the orphaned KV namespace for `{worker}` but could not record it in {}: {e}",
458 lock_path.display()
459 ))
460 })?;
461 }
462 for queue in &orphans.queues {
463 let physical = env_qualify(environment, queue);
464 delete_queue(provenance, &physical, project_root).map_err(|e| {
465 DeployFailure::driver(format!(
466 "could not delete the orphaned queue `{physical}`: {e}"
467 ))
468 })?;
469 if let Some(env) = lock.environments.get_mut(environment) {
470 env.queues.remove(queue);
471 }
472 write_lock(lock_path, lock).map_err(|e| {
473 DeployFailure::driver(format!(
474 "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
475 lock_path.display()
476 ))
477 })?;
478 }
479 Ok(())
480}
481
482#[cfg(test)]
483pub(crate) mod tests {
484 use super::*;
485
486 fn names(v: &[&str]) -> Vec<String> {
487 v.iter().map(|s| s.to_string()).collect()
488 }
489
490 pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
491 let mut lock = DeployLock::default();
492 for worker in workers {
493 lock.record_deployed("default", worker, Some(Default::default()));
494 }
495 lock
496 }
497
498 pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
500 lock.environments
501 .entry("default".into())
502 .or_default()
503 .kv
504 .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
505 lock
506 }
507
508 pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
510 lock.record_queue("default", queue);
511 lock
512 }
513
514 fn scratch_lock_path(label: &str) -> std::path::PathBuf {
515 let unique = std::time::SystemTime::now()
516 .duration_since(std::time::UNIX_EPOCH)
517 .unwrap()
518 .as_nanos();
519 std::env::temp_dir().join(format!(
520 "bynk-{label}-{}-{unique}.deploy.lock",
521 std::process::id()
522 ))
523 }
524
525 fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
526 let unique = std::time::SystemTime::now()
527 .duration_since(std::time::UNIX_EPOCH)
528 .unwrap()
529 .as_nanos();
530 let dir =
531 std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
532 std::fs::create_dir_all(&dir).unwrap();
533 dir
534 }
535
536 fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
537 std::fs::read_dir(dir)
538 .unwrap()
539 .filter_map(|e| e.ok().map(|e| e.path()))
540 .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
541 .collect()
542 }
543
544 #[test]
545 fn lock_round_trip_is_environment_keyed() {
546 let lock = DeployLock {
547 version: 1,
548 environments: BTreeMap::from([(
549 "default".into(),
550 Environment {
551 kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
552 workers: BTreeMap::from([(
553 "api".into(),
554 WorkerRecord {
555 deployed: true,
556 contracts: Default::default(),
557 },
558 )]),
559 queues: BTreeSet::from(["intake".to_string()]),
560 },
561 )]),
562 };
563 assert_eq!(
564 toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
565 lock
566 );
567 }
568
569 #[test]
570 fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
571 let lock: DeployLock = toml::from_str(
576 r#"
577 version = 1
578 [environments.default.kv.api]
579 id = "abc"
580 "#,
581 )
582 .expect("a slice-0 ledger must still parse");
583 assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
584 assert!(!lock.is_deployed("default", "api"));
585 assert!(!lock.has_queue("default", "intake"));
586 }
587
588 #[test]
589 fn the_queue_set_serialises_as_names_under_the_environment() {
590 let mut lock = DeployLock {
594 version: 1,
595 ..Default::default()
596 };
597 lock.record_queue("default", "job-intake");
598 lock.record_queue("default", "job-retry");
599 let text = toml::to_string_pretty(&lock).unwrap();
600 assert!(
601 text.contains("[environments.default]") && text.contains("queues = ["),
602 "the queue set is environment-wide, not a per-worker table: {text}"
603 );
604 for queue in ["job-intake", "job-retry"] {
605 assert!(
606 text.contains(&format!("\"{queue}\"")),
607 "{queue} is recorded"
608 );
609 }
610 assert!(
611 !text.contains("id"),
612 "a queue is addressed by name — the ledger has no id to record: {text}"
613 );
614 assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
615 }
616
617 #[test]
618 fn an_empty_queue_set_is_not_written_at_all() {
619 let mut lock = DeployLock {
622 version: 1,
623 ..Default::default()
624 };
625 lock.record_deployed("default", "api", Some(Default::default()));
626 assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
627 }
628
629 #[test]
630 fn materialises_only_the_placeholder() {
631 let unique = std::time::SystemTime::now()
632 .duration_since(std::time::UNIX_EPOCH)
633 .unwrap()
634 .as_nanos();
635 let path =
636 std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
637 std::fs::write(&path, format!("id = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"")).unwrap();
638 assert!(materialise_kv_id(&path, "abc"));
639 assert_eq!(std::fs::read_to_string(&path).unwrap(), "id = \"abc\"");
640 let _ = std::fs::remove_file(path);
641 }
642
643 #[test]
644 fn two_environments_do_not_cross_contaminate_the_ledger() {
645 let mut lock = DeployLock {
646 version: 1,
647 ..Default::default()
648 };
649 lock.record_deployed("staging", "api", None);
650 lock.record_queue("staging", "jobs");
651 lock.environments
652 .entry("staging".into())
653 .or_default()
654 .kv
655 .insert(
656 "api".into(),
657 KvNamespace {
658 id: "kv-staging".into(),
659 },
660 );
661
662 assert!(!lock.is_deployed("default", "api"));
665 assert!(!lock.has_queue("default", "jobs"));
666 assert_eq!(recorded_kv(&lock, "api", "default"), None);
667
668 assert!(lock.is_deployed("staging", "api"));
669 assert!(lock.has_queue("staging", "jobs"));
670 assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
671 }
672
673 #[test]
681 fn materialise_deploy_state_reads_the_named_environment_not_default() {
682 let unique = std::time::SystemTime::now()
683 .duration_since(std::time::UNIX_EPOCH)
684 .unwrap()
685 .as_nanos();
686 let dir = std::env::temp_dir().join(format!(
687 "bynk-materialise-state-{}-{unique}",
688 std::process::id()
689 ));
690 std::fs::create_dir_all(&dir).unwrap();
691 let project_root = dir.clone();
692 let config = dir.join("wrangler.toml");
693 std::fs::write(&config, format!("id = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"")).unwrap();
694
695 let mut lock = DeployLock {
698 version: 1,
699 ..Default::default()
700 };
701 lock.environments
702 .entry("staging".into())
703 .or_default()
704 .kv
705 .insert(
706 "api".into(),
707 KvNamespace {
708 id: "kv-staging".into(),
709 },
710 );
711 std::fs::write(
712 project_root.join(LOCK_FILE),
713 toml::to_string_pretty(&lock).unwrap(),
714 )
715 .unwrap();
716
717 let err = materialise_deploy_state(&project_root, "api", &config, "default")
720 .expect_err("nothing is recorded under \"default\" — this must not silently pass");
721 assert!(
722 err.contains("environment `default`"),
723 "the error should name which environment it looked under: {err}"
724 );
725
726 assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
729 assert_eq!(
730 std::fs::read_to_string(&config).unwrap(),
731 "id = \"kv-staging\""
732 );
733
734 let _ = std::fs::remove_dir_all(dir);
735 }
736
737 #[test]
740 fn a_removed_context_with_kv_is_two_orphans_not_one() {
741 let mut lock = DeployLock {
745 version: 1,
746 ..Default::default()
747 };
748 lock.environments
749 .entry("default".into())
750 .or_default()
751 .kv
752 .insert(
753 "gone".into(),
754 KvNamespace {
755 id: "kv-gone".into(),
756 },
757 );
758 lock.record_deployed("default", "gone", None);
759 let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
761
762 let orphans = find_orphans(&lock, "default", &resources);
763 assert_eq!(orphans.kv, names(&["gone"]));
764 assert_eq!(orphans.workers, names(&["gone"]));
765 assert!(orphans.queues.is_empty());
766 }
767
768 #[test]
769 fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
770 let mut lock = DeployLock {
774 version: 1,
775 ..Default::default()
776 };
777 lock.record_queue("default", "jobs");
778 let resources = BTreeMap::from([
779 ("orders".into(), Resources::default().consumes(&["jobs"])),
780 ("billing".into(), Resources::default().consumes(&["jobs"])),
781 ]);
782
783 let orphans = find_orphans(&lock, "default", &resources);
784 assert!(
785 orphans.queues.is_empty(),
786 "jobs is still consumed by two live contexts: {orphans:?}"
787 );
788 }
789
790 #[test]
791 fn a_queue_no_context_declares_anymore_is_orphaned() {
792 let mut lock = DeployLock {
793 version: 1,
794 ..Default::default()
795 };
796 lock.record_queue("default", "stale-jobs");
797 let resources = BTreeMap::from([("orders".into(), Resources::default())]);
798
799 let orphans = find_orphans(&lock, "default", &resources);
800 assert_eq!(orphans.queues, names(&["stale-jobs"]));
801 }
802
803 #[test]
804 fn find_orphans_is_scoped_to_the_named_environment() {
805 let mut lock = DeployLock {
808 version: 1,
809 ..Default::default()
810 };
811 lock.environments
812 .entry("staging".into())
813 .or_default()
814 .kv
815 .insert(
816 "gone".into(),
817 KvNamespace {
818 id: "kv-staging-gone".into(),
819 },
820 );
821 let resources = BTreeMap::new();
822
823 assert!(
824 find_orphans(&lock, "default", &resources).kv.is_empty(),
825 "default"
826 );
827 assert_eq!(
828 find_orphans(&lock, "staging", &resources).kv,
829 names(&["gone"])
830 );
831 }
832
833 #[test]
834 fn an_absent_environment_has_no_orphans() {
835 let lock = DeployLock::default();
836 let orphans = find_orphans(&lock, "default", &BTreeMap::new());
837 assert_eq!(orphans, Orphans::default());
838 }
839
840 #[test]
841 fn has_prunable_ignores_worker_only_orphans() {
842 let worker_only = Orphans {
846 workers: names(&["gone"]),
847 ..Default::default()
848 };
849 assert!(!worker_only.has_prunable());
850
851 let with_kv = Orphans {
852 kv: names(&["gone"]),
853 ..Default::default()
854 };
855 assert!(with_kv.has_prunable());
856
857 let with_queue = Orphans {
858 queues: names(&["gone"]),
859 ..Default::default()
860 };
861 assert!(with_queue.has_prunable());
862
863 assert!(!Orphans::default().has_prunable());
864 }
865
866 #[test]
867 fn a_written_ledger_reads_back_identically() {
868 let path = scratch_lock_path("roundtrip");
871 let mut lock = DeployLock {
872 version: 1,
873 ..Default::default()
874 };
875 lock.environments
876 .entry("default".into())
877 .or_default()
878 .kv
879 .insert(
880 "api".into(),
881 KvNamespace {
882 id: "kv-123".into(),
883 },
884 );
885 write_lock(&path, &lock).unwrap();
886 assert_eq!(read_lock(&path).unwrap(), lock);
887 let _ = std::fs::remove_file(path);
888 }
889
890 #[test]
891 fn an_empty_ledger_is_corruption_not_a_fresh_project() {
892 let path = scratch_lock_path("empty");
896 std::fs::write(&path, "").unwrap();
897 assert!(
898 read_lock(&path).is_err(),
899 "a zero-byte ledger must be rejected, not treated as no environments"
900 );
901 std::fs::write(&path, " \n\t\n").unwrap();
902 assert!(
903 read_lock(&path).is_err(),
904 "a whitespace-only ledger is just as corrupt"
905 );
906 let _ = std::fs::remove_file(&path);
907 assert!(
908 read_lock(&path).is_ok(),
909 "an absent ledger is a fresh project, not corruption"
910 );
911 }
912
913 #[test]
914 fn a_ledger_without_a_version_is_rejected() {
915 let path = scratch_lock_path("noversion");
918 std::fs::write(&path, "[environments]\n").unwrap();
919 assert!(read_lock(&path).is_err());
920 let _ = std::fs::remove_file(path);
921 }
922
923 #[test]
924 fn a_successful_write_leaves_no_temp_litter() {
925 let dir = scratch_lock_dir("nolitter");
928 let path = dir.join(LOCK_FILE);
929 let lock = DeployLock {
930 version: 1,
931 ..Default::default()
932 };
933 write_lock(&path, &lock).unwrap();
934 write_lock(&path, &lock).unwrap(); assert!(
936 temp_litter(&dir).is_empty(),
937 "no `.tmp` sibling may outlive the rename"
938 );
939 assert_eq!(read_lock(&path).unwrap(), lock);
940 let _ = std::fs::remove_dir_all(dir);
941 }
942
943 #[cfg(unix)]
944 #[test]
945 fn a_rewrite_preserves_the_ledger_permissions() {
946 use std::os::unix::fs::PermissionsExt;
947 let dir = scratch_lock_dir("perms");
950 let path = dir.join(LOCK_FILE);
951 let lock = DeployLock {
952 version: 1,
953 ..Default::default()
954 };
955 write_lock(&path, &lock).unwrap();
956 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
957 write_lock(&path, &lock).unwrap();
958 assert_eq!(
959 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
960 0o600,
961 "the rewrite must carry the ledger's own mode across the rename"
962 );
963 let _ = std::fs::remove_dir_all(dir);
964 }
965}