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())?;
366 if !bynk_emit::emitter::wrangler::wrangler_needs_kv_materialisation(&text)? {
367 return Ok(false);
368 }
369 let lock = read_lock(&project_root.join(LOCK_FILE))?;
370 let Some(id) = lock
371 .environments
372 .get(environment)
373 .and_then(|env| env.kv.get(worker))
374 .map(|namespace| namespace.id.as_str())
375 else {
376 return Err(format!(
377 "remote KV for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
378 ));
379 };
380 if materialise_kv_id(config, id) {
381 Ok(true)
382 } else {
383 Err("could not write generated configuration".into())
384 }
385}
386
387pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans) -> bool {
399 for kv in &orphans.kv {
400 eprintln!("bynk: will delete KV namespace for `{kv}`");
401 }
402 for queue in &orphans.queues {
403 eprintln!("bynk: will delete queue `{queue}`");
404 }
405 if yes {
406 return true;
407 }
408 if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
409 eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
410 return false;
411 }
412 let count = orphans.kv.len() + orphans.queues.len();
413 eprint!("Delete {count} resource(s)? [y/N] ");
414 let _ = io::stderr().flush();
415 let mut answer = String::new();
416 io::stdin().read_line(&mut answer).is_ok()
417 && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
418}
419
420pub(crate) fn prune_orphans(
432 provenance: &Provenance,
433 project_root: &Path,
434 lock: &mut DeployLock,
435 lock_path: &Path,
436 environment: &str,
437 orphans: &Orphans,
438) -> Result<(), DeployFailure> {
439 for worker in &orphans.kv {
440 let Some(id) = lock
441 .environments
442 .get(environment)
443 .and_then(|env| env.kv.get(worker))
444 .map(|ns| ns.id.clone())
445 else {
446 continue;
447 };
448 delete_kv_namespace(provenance, &id, project_root).map_err(|e| {
449 DeployFailure::driver(format!(
450 "could not delete the orphaned KV namespace for `{worker}`: {e}"
451 ))
452 })?;
453 if let Some(env) = lock.environments.get_mut(environment) {
454 env.kv.remove(worker);
455 }
456 write_lock(lock_path, lock).map_err(|e| {
457 DeployFailure::driver(format!(
458 "deleted the orphaned KV namespace for `{worker}` but could not record it in {}: {e}",
459 lock_path.display()
460 ))
461 })?;
462 }
463 for queue in &orphans.queues {
464 let physical = env_qualify(environment, queue);
465 delete_queue(provenance, &physical, project_root).map_err(|e| {
466 DeployFailure::driver(format!(
467 "could not delete the orphaned queue `{physical}`: {e}"
468 ))
469 })?;
470 if let Some(env) = lock.environments.get_mut(environment) {
471 env.queues.remove(queue);
472 }
473 write_lock(lock_path, lock).map_err(|e| {
474 DeployFailure::driver(format!(
475 "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
476 lock_path.display()
477 ))
478 })?;
479 }
480 Ok(())
481}
482
483#[cfg(test)]
484pub(crate) mod tests {
485 use super::*;
486
487 fn names(v: &[&str]) -> Vec<String> {
488 v.iter().map(|s| s.to_string()).collect()
489 }
490
491 pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
492 let mut lock = DeployLock::default();
493 for worker in workers {
494 lock.record_deployed("default", worker, Some(Default::default()));
495 }
496 lock
497 }
498
499 pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
501 lock.environments
502 .entry("default".into())
503 .or_default()
504 .kv
505 .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
506 lock
507 }
508
509 pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
511 lock.record_queue("default", queue);
512 lock
513 }
514
515 fn scratch_lock_path(label: &str) -> std::path::PathBuf {
516 let unique = std::time::SystemTime::now()
517 .duration_since(std::time::UNIX_EPOCH)
518 .unwrap()
519 .as_nanos();
520 std::env::temp_dir().join(format!(
521 "bynk-{label}-{}-{unique}.deploy.lock",
522 std::process::id()
523 ))
524 }
525
526 fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
527 let unique = std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .unwrap()
530 .as_nanos();
531 let dir =
532 std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
533 std::fs::create_dir_all(&dir).unwrap();
534 dir
535 }
536
537 fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
538 std::fs::read_dir(dir)
539 .unwrap()
540 .filter_map(|e| e.ok().map(|e| e.path()))
541 .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
542 .collect()
543 }
544
545 #[test]
546 fn lock_round_trip_is_environment_keyed() {
547 let lock = DeployLock {
548 version: 1,
549 environments: BTreeMap::from([(
550 "default".into(),
551 Environment {
552 kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
553 workers: BTreeMap::from([(
554 "api".into(),
555 WorkerRecord {
556 deployed: true,
557 contracts: Default::default(),
558 },
559 )]),
560 queues: BTreeSet::from(["intake".to_string()]),
561 },
562 )]),
563 };
564 assert_eq!(
565 toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
566 lock
567 );
568 }
569
570 #[test]
571 fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
572 let lock: DeployLock = toml::from_str(
577 r#"
578 version = 1
579 [environments.default.kv.api]
580 id = "abc"
581 "#,
582 )
583 .expect("a slice-0 ledger must still parse");
584 assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
585 assert!(!lock.is_deployed("default", "api"));
586 assert!(!lock.has_queue("default", "intake"));
587 }
588
589 #[test]
590 fn the_queue_set_serialises_as_names_under_the_environment() {
591 let mut lock = DeployLock {
595 version: 1,
596 ..Default::default()
597 };
598 lock.record_queue("default", "job-intake");
599 lock.record_queue("default", "job-retry");
600 let text = toml::to_string_pretty(&lock).unwrap();
601 assert!(
602 text.contains("[environments.default]") && text.contains("queues = ["),
603 "the queue set is environment-wide, not a per-worker table: {text}"
604 );
605 for queue in ["job-intake", "job-retry"] {
606 assert!(
607 text.contains(&format!("\"{queue}\"")),
608 "{queue} is recorded"
609 );
610 }
611 assert!(
612 !text.contains("id"),
613 "a queue is addressed by name — the ledger has no id to record: {text}"
614 );
615 assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
616 }
617
618 #[test]
619 fn an_empty_queue_set_is_not_written_at_all() {
620 let mut lock = DeployLock {
623 version: 1,
624 ..Default::default()
625 };
626 lock.record_deployed("default", "api", Some(Default::default()));
627 assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
628 }
629
630 #[test]
631 fn materialises_only_the_placeholder() {
632 let unique = std::time::SystemTime::now()
638 .duration_since(std::time::UNIX_EPOCH)
639 .unwrap()
640 .as_nanos();
641 let path =
642 std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
643 std::fs::write(
644 &path,
645 format!(
646 "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
647 ),
648 )
649 .unwrap();
650 assert!(materialise_kv_id(&path, "abc"));
651 let text = std::fs::read_to_string(&path).unwrap();
652 let parsed: toml::Table = text.parse().unwrap();
653 assert_eq!(
654 parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
655 Some("abc")
656 );
657 let _ = std::fs::remove_file(path);
658 }
659
660 #[test]
661 fn two_environments_do_not_cross_contaminate_the_ledger() {
662 let mut lock = DeployLock {
663 version: 1,
664 ..Default::default()
665 };
666 lock.record_deployed("staging", "api", None);
667 lock.record_queue("staging", "jobs");
668 lock.environments
669 .entry("staging".into())
670 .or_default()
671 .kv
672 .insert(
673 "api".into(),
674 KvNamespace {
675 id: "kv-staging".into(),
676 },
677 );
678
679 assert!(!lock.is_deployed("default", "api"));
682 assert!(!lock.has_queue("default", "jobs"));
683 assert_eq!(recorded_kv(&lock, "api", "default"), None);
684
685 assert!(lock.is_deployed("staging", "api"));
686 assert!(lock.has_queue("staging", "jobs"));
687 assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
688 }
689
690 #[test]
698 fn materialise_deploy_state_reads_the_named_environment_not_default() {
699 let unique = std::time::SystemTime::now()
700 .duration_since(std::time::UNIX_EPOCH)
701 .unwrap()
702 .as_nanos();
703 let dir = std::env::temp_dir().join(format!(
704 "bynk-materialise-state-{}-{unique}",
705 std::process::id()
706 ));
707 std::fs::create_dir_all(&dir).unwrap();
708 let project_root = dir.clone();
709 let config = dir.join("wrangler.toml");
710 std::fs::write(
711 &config,
712 format!(
713 "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
714 ),
715 )
716 .unwrap();
717
718 let mut lock = DeployLock {
721 version: 1,
722 ..Default::default()
723 };
724 lock.environments
725 .entry("staging".into())
726 .or_default()
727 .kv
728 .insert(
729 "api".into(),
730 KvNamespace {
731 id: "kv-staging".into(),
732 },
733 );
734 std::fs::write(
735 project_root.join(LOCK_FILE),
736 toml::to_string_pretty(&lock).unwrap(),
737 )
738 .unwrap();
739
740 let err = materialise_deploy_state(&project_root, "api", &config, "default")
743 .expect_err("nothing is recorded under \"default\" — this must not silently pass");
744 assert!(
745 err.contains("environment `default`"),
746 "the error should name which environment it looked under: {err}"
747 );
748
749 assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
752 let text = std::fs::read_to_string(&config).unwrap();
753 let parsed: toml::Table = text.parse().unwrap();
754 assert_eq!(
755 parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
756 Some("kv-staging")
757 );
758
759 let _ = std::fs::remove_dir_all(dir);
760 }
761
762 #[test]
768 fn materialise_deploy_state_is_a_no_op_without_a_kv_binding() {
769 let unique = std::time::SystemTime::now()
770 .duration_since(std::time::UNIX_EPOCH)
771 .unwrap()
772 .as_nanos();
773 let dir = std::env::temp_dir().join(format!(
774 "bynk-materialise-state-no-kv-{}-{unique}",
775 std::process::id()
776 ));
777 std::fs::create_dir_all(&dir).unwrap();
778 let config = dir.join("wrangler.toml");
779 std::fs::write(&config, "name = \"api\"\nmain = \"index.ts\"\n").unwrap();
780
781 assert_eq!(
782 materialise_deploy_state(&dir, "api", &config, "default"),
783 Ok(false)
784 );
785
786 let _ = std::fs::remove_dir_all(dir);
787 }
788
789 #[test]
792 fn a_removed_context_with_kv_is_two_orphans_not_one() {
793 let mut lock = DeployLock {
797 version: 1,
798 ..Default::default()
799 };
800 lock.environments
801 .entry("default".into())
802 .or_default()
803 .kv
804 .insert(
805 "gone".into(),
806 KvNamespace {
807 id: "kv-gone".into(),
808 },
809 );
810 lock.record_deployed("default", "gone", None);
811 let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
813
814 let orphans = find_orphans(&lock, "default", &resources);
815 assert_eq!(orphans.kv, names(&["gone"]));
816 assert_eq!(orphans.workers, names(&["gone"]));
817 assert!(orphans.queues.is_empty());
818 }
819
820 #[test]
821 fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
822 let mut lock = DeployLock {
826 version: 1,
827 ..Default::default()
828 };
829 lock.record_queue("default", "jobs");
830 let resources = BTreeMap::from([
831 ("orders".into(), Resources::default().consumes(&["jobs"])),
832 ("billing".into(), Resources::default().consumes(&["jobs"])),
833 ]);
834
835 let orphans = find_orphans(&lock, "default", &resources);
836 assert!(
837 orphans.queues.is_empty(),
838 "jobs is still consumed by two live contexts: {orphans:?}"
839 );
840 }
841
842 #[test]
843 fn a_queue_no_context_declares_anymore_is_orphaned() {
844 let mut lock = DeployLock {
845 version: 1,
846 ..Default::default()
847 };
848 lock.record_queue("default", "stale-jobs");
849 let resources = BTreeMap::from([("orders".into(), Resources::default())]);
850
851 let orphans = find_orphans(&lock, "default", &resources);
852 assert_eq!(orphans.queues, names(&["stale-jobs"]));
853 }
854
855 #[test]
856 fn find_orphans_is_scoped_to_the_named_environment() {
857 let mut lock = DeployLock {
860 version: 1,
861 ..Default::default()
862 };
863 lock.environments
864 .entry("staging".into())
865 .or_default()
866 .kv
867 .insert(
868 "gone".into(),
869 KvNamespace {
870 id: "kv-staging-gone".into(),
871 },
872 );
873 let resources = BTreeMap::new();
874
875 assert!(
876 find_orphans(&lock, "default", &resources).kv.is_empty(),
877 "default"
878 );
879 assert_eq!(
880 find_orphans(&lock, "staging", &resources).kv,
881 names(&["gone"])
882 );
883 }
884
885 #[test]
886 fn an_absent_environment_has_no_orphans() {
887 let lock = DeployLock::default();
888 let orphans = find_orphans(&lock, "default", &BTreeMap::new());
889 assert_eq!(orphans, Orphans::default());
890 }
891
892 #[test]
893 fn has_prunable_ignores_worker_only_orphans() {
894 let worker_only = Orphans {
898 workers: names(&["gone"]),
899 ..Default::default()
900 };
901 assert!(!worker_only.has_prunable());
902
903 let with_kv = Orphans {
904 kv: names(&["gone"]),
905 ..Default::default()
906 };
907 assert!(with_kv.has_prunable());
908
909 let with_queue = Orphans {
910 queues: names(&["gone"]),
911 ..Default::default()
912 };
913 assert!(with_queue.has_prunable());
914
915 assert!(!Orphans::default().has_prunable());
916 }
917
918 #[test]
919 fn a_written_ledger_reads_back_identically() {
920 let path = scratch_lock_path("roundtrip");
923 let mut lock = DeployLock {
924 version: 1,
925 ..Default::default()
926 };
927 lock.environments
928 .entry("default".into())
929 .or_default()
930 .kv
931 .insert(
932 "api".into(),
933 KvNamespace {
934 id: "kv-123".into(),
935 },
936 );
937 write_lock(&path, &lock).unwrap();
938 assert_eq!(read_lock(&path).unwrap(), lock);
939 let _ = std::fs::remove_file(path);
940 }
941
942 #[test]
943 fn an_empty_ledger_is_corruption_not_a_fresh_project() {
944 let path = scratch_lock_path("empty");
948 std::fs::write(&path, "").unwrap();
949 assert!(
950 read_lock(&path).is_err(),
951 "a zero-byte ledger must be rejected, not treated as no environments"
952 );
953 std::fs::write(&path, " \n\t\n").unwrap();
954 assert!(
955 read_lock(&path).is_err(),
956 "a whitespace-only ledger is just as corrupt"
957 );
958 let _ = std::fs::remove_file(&path);
959 assert!(
960 read_lock(&path).is_ok(),
961 "an absent ledger is a fresh project, not corruption"
962 );
963 }
964
965 #[test]
966 fn a_ledger_without_a_version_is_rejected() {
967 let path = scratch_lock_path("noversion");
970 std::fs::write(&path, "[environments]\n").unwrap();
971 assert!(read_lock(&path).is_err());
972 let _ = std::fs::remove_file(path);
973 }
974
975 #[test]
976 fn a_successful_write_leaves_no_temp_litter() {
977 let dir = scratch_lock_dir("nolitter");
980 let path = dir.join(LOCK_FILE);
981 let lock = DeployLock {
982 version: 1,
983 ..Default::default()
984 };
985 write_lock(&path, &lock).unwrap();
986 write_lock(&path, &lock).unwrap(); assert!(
988 temp_litter(&dir).is_empty(),
989 "no `.tmp` sibling may outlive the rename"
990 );
991 assert_eq!(read_lock(&path).unwrap(), lock);
992 let _ = std::fs::remove_dir_all(dir);
993 }
994
995 #[cfg(unix)]
996 #[test]
997 fn a_rewrite_preserves_the_ledger_permissions() {
998 use std::os::unix::fs::PermissionsExt;
999 let dir = scratch_lock_dir("perms");
1002 let path = dir.join(LOCK_FILE);
1003 let lock = DeployLock {
1004 version: 1,
1005 ..Default::default()
1006 };
1007 write_lock(&path, &lock).unwrap();
1008 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1009 write_lock(&path, &lock).unwrap();
1010 assert_eq!(
1011 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1012 0o600,
1013 "the rewrite must carry the ledger's own mode across the rename"
1014 );
1015 let _ = std::fs::remove_dir_all(dir);
1016 }
1017}