1use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::volume::VolumeConfig;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug)]
14pub struct VolumeStore {
15 path: PathBuf,
17 volumes_dir: PathBuf,
19}
20
21#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
23struct VolumesFile {
24 volumes: HashMap<String, VolumeConfig>,
25}
26
27const ANONYMOUS_LABEL: &str = "anonymous";
28const ANONYMOUS_KIND_LABEL: &str = "a3s.box.volume.kind";
29const ANONYMOUS_KIND: &str = "anonymous-v1";
30const ANONYMOUS_OWNER_LABEL: &str = "a3s.box.volume.owner";
31
32impl VolumeStore {
33 pub fn new(path: impl Into<PathBuf>, volumes_dir: impl Into<PathBuf>) -> Self {
35 Self {
36 path: path.into(),
37 volumes_dir: volumes_dir.into(),
38 }
39 }
40
41 pub fn default_path() -> Result<Self> {
43 let home = a3s_box_core::dirs_home();
44 Ok(Self::new(home.join("volumes.json"), home.join("volumes")))
45 }
46
47 pub fn load(&self) -> Result<HashMap<String, VolumeConfig>> {
49 if !self.path.exists() {
50 return Ok(HashMap::new());
51 }
52
53 let data = std::fs::read_to_string(&self.path).map_err(|e| {
54 BoxError::ConfigError(format!(
55 "failed to read volumes file {}: {}",
56 self.path.display(),
57 e
58 ))
59 })?;
60
61 let file: VolumesFile = match serde_json::from_str(&data) {
65 Ok(f) => f,
66 Err(e) => {
67 let preserved = crate::store_io::quarantine_label(&self.path);
68 tracing::warn!(
69 "volumes file {} is corrupt ({e}); preserved a copy at {preserved} \
70 and started from an empty volume set",
71 self.path.display(),
72 );
73 return Ok(HashMap::new());
74 }
75 };
76
77 Ok(file.volumes)
78 }
79
80 pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
82 if let Some(parent) = self.path.parent() {
83 std::fs::create_dir_all(parent).map_err(|e| {
84 BoxError::ConfigError(format!(
85 "failed to create directory {}: {}",
86 parent.display(),
87 e
88 ))
89 })?;
90 }
91
92 let file = VolumesFile {
93 volumes: volumes.clone(),
94 };
95
96 let json = serde_json::to_string_pretty(&file).map_err(|e| {
97 BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
98 })?;
99
100 let tmp_path = self.path.with_extension("json.tmp");
101 std::fs::write(&tmp_path, &json).map_err(|e| {
102 BoxError::ConfigError(format!(
103 "failed to write tmp file {}: {}",
104 tmp_path.display(),
105 e
106 ))
107 })?;
108
109 std::fs::rename(&tmp_path, &self.path).map_err(|e| {
110 BoxError::ConfigError(format!(
111 "failed to rename {} → {}: {}",
112 tmp_path.display(),
113 self.path.display(),
114 e
115 ))
116 })?;
117
118 Ok(())
119 }
120
121 fn with_write_lock<F, R>(&self, f: F) -> Result<R>
133 where
134 F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
135 {
136 let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
137 BoxError::ConfigError(format!(
138 "failed to lock volumes file {}: {e}",
139 self.path.display()
140 ))
141 })?;
142 let mut volumes = self.load()?;
143 let r = f(&mut volumes)?;
144 self.save(&volumes)?;
145 Ok(r)
146 }
147
148 pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
150 let volumes = self.load()?;
151 Ok(volumes.get(name).cloned())
152 }
153
154 pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
160 self.with_write_lock(|volumes| {
161 if volumes.contains_key(&config.name) {
162 return Err(BoxError::ConfigError(format!(
163 "volume '{}' already exists",
164 config.name
165 )));
166 }
167 self.materialize(config, volumes)
168 })
169 }
170
171 pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
175 self.with_write_lock(|volumes| {
176 if let Some(existing) = volumes.get(&config.name) {
177 return Ok(existing.clone());
178 }
179 self.materialize(config, volumes)
180 })
181 }
182
183 pub(crate) fn claim_anonymous(&self, name: &str, owner: &str) -> Result<(VolumeConfig, bool)> {
189 validate_anonymous_identity(name, owner)?;
190
191 let expected_mount_point = self.volumes_dir.join(name).to_string_lossy().into_owned();
192 self.with_write_lock(|volumes| {
193 if let Some(existing) = volumes.get_mut(name) {
194 validate_anonymous_config(existing, name, owner, &expected_mount_point)?;
195 ensure_managed_volume_directory(Path::new(&expected_mount_point))?;
196 existing.attach(owner);
197 existing
198 .labels
199 .insert(ANONYMOUS_KIND_LABEL.to_string(), ANONYMOUS_KIND.to_string());
200 existing
201 .labels
202 .insert(ANONYMOUS_OWNER_LABEL.to_string(), owner.to_string());
203 return Ok((existing.clone(), false));
204 }
205
206 let mut config = VolumeConfig::new(name, "");
207 config
208 .labels
209 .insert(ANONYMOUS_LABEL.to_string(), "true".to_string());
210 config
211 .labels
212 .insert(ANONYMOUS_KIND_LABEL.to_string(), ANONYMOUS_KIND.to_string());
213 config
214 .labels
215 .insert(ANONYMOUS_OWNER_LABEL.to_string(), owner.to_string());
216 config.attach(owner);
217 self.materialize(config, volumes)
218 .map(|created| (created, true))
219 })
220 }
221
222 pub fn remove_anonymous(&self, name: &str, owner: &str) -> Result<bool> {
229 validate_anonymous_identity(name, owner)?;
230 let expected_mount_point = self.volumes_dir.join(name).to_string_lossy().into_owned();
231 self.with_write_lock(|volumes| {
232 let existed = if let Some(existing) = volumes.get(name) {
233 validate_anonymous_config(existing, name, owner, &expected_mount_point)?;
234 true
235 } else {
236 false
237 };
238
239 remove_managed_volume_path(Path::new(&expected_mount_point))?;
240 if existed {
241 volumes.remove(name);
242 }
243 Ok(existed)
244 })
245 }
246
247 fn materialize(
250 &self,
251 mut config: VolumeConfig,
252 volumes: &mut HashMap<String, VolumeConfig>,
253 ) -> Result<VolumeConfig> {
254 let vol_dir = self.volumes_dir.join(&config.name);
255 ensure_managed_volume_directory(&vol_dir)?;
256 config.mount_point = vol_dir.to_string_lossy().into_owned();
257 volumes.insert(config.name.clone(), config.clone());
258 Ok(config)
259 }
260
261 pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
263 self.with_write_lock(|volumes| {
264 let config = volumes
265 .get(name)
266 .cloned()
267 .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
268
269 if config.is_in_use() && !force {
270 return Err(BoxError::ConfigError(format!(
271 "volume '{}' is in use by {} box(es); use --force to remove",
272 name,
273 config.in_use_by.len()
274 )));
275 }
276
277 let vol_dir = self.volumes_dir.join(name);
282 remove_managed_volume_path(&vol_dir).map_err(|error| {
283 BoxError::ConfigError(format!(
284 "failed to remove volume '{}' data directory {}: {error}",
285 name,
286 vol_dir.display()
287 ))
288 })?;
289
290 volumes.remove(name);
291 Ok(config)
292 })
293 }
294
295 pub fn list(&self) -> Result<Vec<VolumeConfig>> {
297 let volumes = self.load()?;
298 Ok(volumes.into_values().collect())
299 }
300
301 pub fn update(&self, config: &VolumeConfig) -> Result<()> {
307 self.with_write_lock(|volumes| {
308 if !volumes.contains_key(&config.name) {
309 return Err(BoxError::ConfigError(format!(
310 "volume '{}' not found",
311 config.name
312 )));
313 }
314 volumes.insert(config.name.clone(), config.clone());
315 Ok(())
316 })
317 }
318
319 pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
326 where
327 F: FnOnce(&mut VolumeConfig),
328 {
329 self.with_write_lock(|volumes| match volumes.get_mut(name) {
330 Some(config) => {
331 f(config);
332 Ok(true)
333 }
334 None => Ok(false),
335 })
336 }
337
338 pub fn prune(&self) -> Result<Vec<String>> {
340 let volumes = self.load()?;
341 let mut pruned = Vec::new();
342
343 for (name, config) in &volumes {
344 if !config.is_in_use() {
345 pruned.push(name.clone());
346 }
347 }
348
349 for name in &pruned {
350 self.remove(name, false).ok();
351 }
352
353 Ok(pruned)
354 }
355
356 pub fn volume_dir(&self, name: &str) -> PathBuf {
358 self.volumes_dir.join(name)
359 }
360
361 pub fn path(&self) -> &Path {
363 &self.path
364 }
365}
366
367fn valid_anonymous_volume_name(name: &str) -> bool {
368 name.starts_with("anon_")
369 && name.len() <= 128
370 && name
371 .bytes()
372 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
373}
374
375fn validate_anonymous_identity(name: &str, owner: &str) -> Result<()> {
376 if !valid_anonymous_volume_name(name) {
377 return Err(BoxError::ConfigError(format!(
378 "invalid anonymous volume name {name:?}"
379 )));
380 }
381 if owner.is_empty() || owner.contains('\0') {
382 return Err(BoxError::ConfigError(
383 "anonymous volume owner must be a non-empty execution identity".to_string(),
384 ));
385 }
386 Ok(())
387}
388
389fn validate_anonymous_config(
390 config: &VolumeConfig,
391 name: &str,
392 owner: &str,
393 expected_mount_point: &str,
394) -> Result<()> {
395 if config.labels.get(ANONYMOUS_LABEL).map(String::as_str) != Some("true") {
396 return Err(BoxError::ConfigError(format!(
397 "volume {name:?} is not an anonymous volume"
398 )));
399 }
400 if config.driver != "local" {
401 return Err(BoxError::ConfigError(format!(
402 "anonymous volume {name:?} does not use the local driver"
403 )));
404 }
405 if config.mount_point != expected_mount_point {
406 return Err(BoxError::ConfigError(format!(
407 "anonymous volume {name:?} does not use its canonical managed directory"
408 )));
409 }
410 let exact_owner = config.in_use_by.len() == 1
411 && config
412 .in_use_by
413 .first()
414 .is_some_and(|current| current == owner);
415 if config.in_use_by.iter().any(|current| current != owner) {
416 return Err(BoxError::ConfigError(format!(
417 "anonymous volume {name:?} is owned by another execution"
418 )));
419 }
420 match config.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str) {
421 Some(ANONYMOUS_KIND)
422 if exact_owner
423 && config
424 .labels
425 .get(ANONYMOUS_OWNER_LABEL)
426 .is_some_and(|current| current == owner) =>
427 {
428 Ok(())
429 }
430 None if exact_owner => Ok(()),
431 _ => Err(BoxError::ConfigError(format!(
432 "anonymous volume {name:?} has no compatible ownership contract"
433 ))),
434 }
435}
436
437fn ensure_managed_volume_directory(path: &Path) -> Result<()> {
438 match std::fs::symlink_metadata(path) {
439 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
440 return Err(BoxError::ConfigError(format!(
441 "managed volume path {} is not a directory",
442 path.display()
443 )))
444 }
445 Ok(_) => return Ok(()),
446 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
447 Err(error) => {
448 return Err(BoxError::ConfigError(format!(
449 "failed to inspect volume directory {}: {error}",
450 path.display()
451 )))
452 }
453 }
454 std::fs::create_dir_all(path).map_err(|error| {
455 BoxError::ConfigError(format!(
456 "failed to create volume directory {}: {error}",
457 path.display()
458 ))
459 })?;
460 let metadata = std::fs::symlink_metadata(path).map_err(|error| {
461 BoxError::ConfigError(format!(
462 "failed to verify volume directory {}: {error}",
463 path.display()
464 ))
465 })?;
466 if metadata.file_type().is_symlink() || !metadata.is_dir() {
467 return Err(BoxError::ConfigError(format!(
468 "managed volume path {} is not a directory",
469 path.display()
470 )));
471 }
472 Ok(())
473}
474
475fn remove_managed_volume_path(path: &Path) -> Result<()> {
476 let metadata = match std::fs::symlink_metadata(path) {
477 Ok(metadata) => metadata,
478 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
479 Err(error) => return Err(BoxError::IoError(error)),
480 };
481 if metadata.is_dir() && !metadata.file_type().is_symlink() {
482 std::fs::remove_dir_all(path).map_err(BoxError::IoError)
483 } else {
484 std::fs::remove_file(path).map_err(BoxError::IoError)
485 }
486}
487
488impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
489 fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
490 self.get(name)
491 }
492
493 fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
494 self.create(config)
495 }
496
497 fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
498 self.remove(name, force)
499 }
500
501 fn list(&self) -> Result<Vec<VolumeConfig>> {
502 self.list()
503 }
504
505 fn update(&self, config: &VolumeConfig) -> Result<()> {
506 self.update(config)
507 }
508
509 fn prune(&self) -> Result<Vec<String>> {
510 self.prune()
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn temp_store() -> (tempfile::TempDir, VolumeStore) {
519 let dir = tempfile::tempdir().unwrap();
520 let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
521 (dir, store)
522 }
523
524 #[test]
525 fn test_load_empty() {
526 let (_dir, store) = temp_store();
527 let volumes = store.load().unwrap();
528 assert!(volumes.is_empty());
529 }
530
531 #[test]
532 fn test_create_and_load() {
533 let (_dir, store) = temp_store();
534 let vol = VolumeConfig::new("mydata", "");
535 store.create(vol).unwrap();
536
537 let volumes = store.load().unwrap();
538 assert_eq!(volumes.len(), 1);
539 assert!(volumes.contains_key("mydata"));
540 }
541
542 #[test]
543 fn test_create_sets_mount_point() {
544 let (_dir, store) = temp_store();
545 let vol = VolumeConfig::new("mydata", "");
546 let created = store.create(vol).unwrap();
547
548 assert!(created.mount_point.contains("mydata"));
549 assert!(PathBuf::from(&created.mount_point).exists());
550 }
551
552 #[test]
553 fn test_create_duplicate() {
554 let (_dir, store) = temp_store();
555 let v1 = VolumeConfig::new("mydata", "");
556 let v2 = VolumeConfig::new("mydata", "");
557
558 store.create(v1).unwrap();
559 assert!(store.create(v2).is_err());
560 }
561
562 #[test]
563 fn test_get_existing() {
564 let (_dir, store) = temp_store();
565 store.create(VolumeConfig::new("mydata", "")).unwrap();
566
567 let found = store.get("mydata").unwrap();
568 assert!(found.is_some());
569 assert_eq!(found.unwrap().name, "mydata");
570 }
571
572 #[test]
573 fn test_get_nonexistent() {
574 let (_dir, store) = temp_store();
575 let found = store.get("nope").unwrap();
576 assert!(found.is_none());
577 }
578
579 #[test]
580 fn test_remove() {
581 let (_dir, store) = temp_store();
582 store.create(VolumeConfig::new("mydata", "")).unwrap();
583
584 let removed = store.remove("mydata", false).unwrap();
585 assert_eq!(removed.name, "mydata");
586
587 let volumes = store.load().unwrap();
588 assert!(volumes.is_empty());
589 }
590
591 #[test]
592 fn test_remove_nonexistent() {
593 let (_dir, store) = temp_store();
594 assert!(store.remove("nope", false).is_err());
595 }
596
597 #[test]
598 fn test_remove_in_use_fails() {
599 let (_dir, store) = temp_store();
600 let mut vol = VolumeConfig::new("mydata", "");
601 vol.attach("box-1");
602 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
604 let mut updated = created;
605 updated.attach("box-1");
606 store.update(&updated).unwrap();
607
608 assert!(store.remove("mydata", false).is_err());
609 }
610
611 #[test]
612 fn test_remove_in_use_force() {
613 let (_dir, store) = temp_store();
614 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
615 let mut updated = created;
616 updated.attach("box-1");
617 store.update(&updated).unwrap();
618
619 let removed = store.remove("mydata", true).unwrap();
620 assert_eq!(removed.name, "mydata");
621 }
622
623 #[test]
624 fn test_list() {
625 let (_dir, store) = temp_store();
626 store.create(VolumeConfig::new("vol1", "")).unwrap();
627 store.create(VolumeConfig::new("vol2", "")).unwrap();
628
629 let list = store.list().unwrap();
630 assert_eq!(list.len(), 2);
631 }
632
633 #[test]
634 fn test_update() {
635 let (_dir, store) = temp_store();
636 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
637
638 let mut updated = created;
639 updated.attach("box-1");
640 store.update(&updated).unwrap();
641
642 let loaded = store.get("mydata").unwrap().unwrap();
643 assert_eq!(loaded.in_use_by, vec!["box-1"]);
644 }
645
646 #[test]
647 fn test_update_nonexistent() {
648 let (_dir, store) = temp_store();
649 let vol = VolumeConfig::new("nope", "/tmp");
650 assert!(store.update(&vol).is_err());
651 }
652
653 #[test]
654 fn test_prune() {
655 let (_dir, store) = temp_store();
656 store.create(VolumeConfig::new("unused1", "")).unwrap();
657 store.create(VolumeConfig::new("unused2", "")).unwrap();
658
659 let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
660 let mut updated = created;
661 updated.attach("box-1");
662 store.update(&updated).unwrap();
663
664 let pruned = store.prune().unwrap();
665 assert_eq!(pruned.len(), 2);
666 assert!(pruned.contains(&"unused1".to_string()));
667 assert!(pruned.contains(&"unused2".to_string()));
668
669 let remaining = store.list().unwrap();
671 assert_eq!(remaining.len(), 1);
672 assert_eq!(remaining[0].name, "in_use");
673 }
674
675 #[test]
676 fn test_atomic_write() {
677 let (_dir, store) = temp_store();
678 store.create(VolumeConfig::new("mydata", "")).unwrap();
679
680 let data = std::fs::read_to_string(store.path()).unwrap();
681 let _: serde_json::Value = serde_json::from_str(&data).unwrap();
682
683 let tmp = store.path().with_extension("json.tmp");
684 assert!(!tmp.exists());
685 }
686
687 #[test]
688 fn test_creates_parent_directory() {
689 let dir = tempfile::tempdir().unwrap();
690 let store = VolumeStore::new(
691 dir.path().join("subdir").join("volumes.json"),
692 dir.path().join("subdir").join("volumes"),
693 );
694
695 store.create(VolumeConfig::new("mydata", "")).unwrap();
696 assert!(store.path().exists());
697 }
698
699 #[test]
700 fn test_remove_cleans_up_directory() {
701 let (_dir, store) = temp_store();
702 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
703 let vol_dir = PathBuf::from(&created.mount_point);
704 assert!(vol_dir.exists());
705
706 store.remove("mydata", false).unwrap();
707 assert!(!vol_dir.exists());
708 }
709
710 #[test]
711 fn test_get_or_create_is_idempotent() {
712 let (_dir, store) = temp_store();
713 let first = store
714 .get_or_create(VolumeConfig::new("shared", ""))
715 .unwrap();
716 let second = store
717 .get_or_create(VolumeConfig::new("shared", ""))
718 .unwrap();
719 assert_eq!(first.mount_point, second.mount_point);
720 assert_eq!(store.list().unwrap().len(), 1);
721 }
722
723 #[test]
724 fn test_get_or_create_preserves_existing_config() {
725 let (_dir, store) = temp_store();
726 let created = store
727 .get_or_create(VolumeConfig::with_size_limit("shared", "", 4096))
728 .unwrap();
729 let mut updated = created;
730 updated.attach("box-1");
731 store.update(&updated).unwrap();
732
733 let reused = store
734 .get_or_create(VolumeConfig::with_size_limit("shared", "/ignored", 8192))
735 .unwrap();
736
737 assert_eq!(reused.size_limit, 4096);
738 assert_eq!(reused.in_use_by, vec!["box-1"]);
739 assert!(PathBuf::from(&reused.mount_point).exists());
740 }
741
742 #[test]
743 fn claim_anonymous_is_idempotent_for_the_exact_owner() {
744 let (_dir, store) = temp_store();
745
746 let (first, first_created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
747 let (second, second_created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
748
749 assert!(first_created);
750 assert!(!second_created);
751 assert_eq!(first.mount_point, second.mount_point);
752 assert_eq!(second.in_use_by, vec!["box-owner"]);
753 assert_eq!(
754 second.labels.get(ANONYMOUS_LABEL).map(String::as_str),
755 Some("true")
756 );
757 assert_eq!(
758 second.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str),
759 Some(ANONYMOUS_KIND)
760 );
761 assert_eq!(
762 second.labels.get(ANONYMOUS_OWNER_LABEL).map(String::as_str),
763 Some("box-owner")
764 );
765 }
766
767 #[test]
768 fn claim_anonymous_upgrades_an_exact_owner_legacy_volume() {
769 let (_dir, store) = temp_store();
770 let mut legacy = VolumeConfig::new("anon_legacy", "");
771 legacy
772 .labels
773 .insert(ANONYMOUS_LABEL.to_string(), "true".to_string());
774 legacy.attach("box-owner");
775 store.create(legacy).unwrap();
776
777 let (claimed, created) = store.claim_anonymous("anon_legacy", "box-owner").unwrap();
778
779 assert!(!created);
780 assert_eq!(
781 claimed.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str),
782 Some(ANONYMOUS_KIND)
783 );
784 assert_eq!(
785 claimed
786 .labels
787 .get(ANONYMOUS_OWNER_LABEL)
788 .map(String::as_str),
789 Some("box-owner")
790 );
791 }
792
793 #[test]
794 fn claim_anonymous_rejects_named_volume_collision_without_mutation() {
795 let (_dir, store) = temp_store();
796 let named = store
797 .create(VolumeConfig::new("anon_collision", ""))
798 .unwrap();
799
800 let error = store
801 .claim_anonymous("anon_collision", "box-owner")
802 .expect_err("a named volume must never become anonymously owned");
803
804 assert!(error.to_string().contains("not an anonymous volume"));
805 assert_eq!(
806 store.get("anon_collision").unwrap().unwrap().in_use_by,
807 named.in_use_by
808 );
809 }
810
811 #[test]
812 fn claim_anonymous_rejects_a_different_owner_without_mutation() {
813 let (_dir, store) = temp_store();
814 store.claim_anonymous("anon_owned", "box-one").unwrap();
815
816 let error = store
817 .claim_anonymous("anon_owned", "box-two")
818 .expect_err("anonymous volumes have exactly one Box owner");
819
820 assert!(error.to_string().contains("owned by another execution"));
821 assert_eq!(
822 store.get("anon_owned").unwrap().unwrap().in_use_by,
823 vec!["box-one"]
824 );
825 }
826
827 #[test]
828 fn claim_anonymous_rejects_unsafe_identity_before_creating_a_directory() {
829 let (dir, store) = temp_store();
830
831 let error = store
832 .claim_anonymous("../escaped", "box-owner")
833 .expect_err("managed identities may not escape the volume root");
834
835 assert!(error.to_string().contains("invalid anonymous volume name"));
836 assert!(!dir.path().join("escaped").exists());
837 assert!(store.load().unwrap().is_empty());
838 }
839
840 #[test]
841 fn concurrent_anonymous_claims_publish_one_exact_owner() {
842 use std::sync::Arc;
843 use std::thread;
844
845 let dir = tempfile::tempdir().unwrap();
846 let store = Arc::new(VolumeStore::new(
847 dir.path().join("volumes.json"),
848 dir.path().join("volumes"),
849 ));
850 let handles = (0..16)
851 .map(|_| {
852 let store = Arc::clone(&store);
853 thread::spawn(move || {
854 store
855 .claim_anonymous("anon_concurrent", "box-owner")
856 .unwrap()
857 .1
858 })
859 })
860 .collect::<Vec<_>>();
861
862 let created = handles
863 .into_iter()
864 .map(|handle| usize::from(handle.join().unwrap()))
865 .sum::<usize>();
866 let claimed = store.get("anon_concurrent").unwrap().unwrap();
867
868 assert_eq!(created, 1);
869 assert_eq!(claimed.in_use_by, vec!["box-owner"]);
870 assert_eq!(store.list().unwrap().len(), 1);
871 }
872
873 #[test]
874 fn remove_anonymous_requires_and_removes_the_exact_owner() {
875 let (_dir, store) = temp_store();
876 let (claimed, _) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
877
878 let removed = store.remove_anonymous("anon_owned", "box-owner").unwrap();
879
880 assert!(removed);
881 assert!(store.get("anon_owned").unwrap().is_none());
882 assert!(!PathBuf::from(claimed.mount_point).exists());
883 }
884
885 #[test]
886 fn remove_anonymous_rejects_named_and_different_owner_collisions() {
887 let (_dir, store) = temp_store();
888 let named = store.create(VolumeConfig::new("anon_named", "")).unwrap();
889 store.claim_anonymous("anon_owned", "box-one").unwrap();
890
891 let named_error = store
892 .remove_anonymous("anon_named", "box-one")
893 .expect_err("named volume metadata must fail closed");
894 let owner_error = store
895 .remove_anonymous("anon_owned", "box-two")
896 .expect_err("another owner must fail closed");
897
898 assert!(named_error.to_string().contains("not an anonymous volume"));
899 assert!(owner_error
900 .to_string()
901 .contains("owned by another execution"));
902 assert!(PathBuf::from(named.mount_point).exists());
903 assert!(store.get("anon_owned").unwrap().is_some());
904 }
905
906 #[test]
907 fn remove_anonymous_cleans_an_unpublished_deterministic_directory() {
908 let (_dir, store) = temp_store();
909 let orphan = store.volume_dir("anon_unpublished");
910 std::fs::create_dir_all(&orphan).unwrap();
911 std::fs::write(orphan.join("partial"), b"partial").unwrap();
912
913 let removed = store
914 .remove_anonymous("anon_unpublished", "box-owner")
915 .unwrap();
916
917 assert!(!removed);
918 assert!(!orphan.exists());
919 assert!(store.get("anon_unpublished").unwrap().is_none());
920 }
921
922 #[test]
923 fn reclaim_anonymous_recreates_a_missing_owned_directory() {
924 let (_dir, store) = temp_store();
925 let (claimed, _) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
926 std::fs::remove_dir_all(&claimed.mount_point).unwrap();
927
928 let (reclaimed, created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
929
930 assert!(!created);
931 assert!(PathBuf::from(reclaimed.mount_point).is_dir());
932 }
933
934 #[test]
935 fn test_modify_missing_returns_false() {
936 let (_dir, store) = temp_store();
937 assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
938 }
939
940 #[test]
941 fn test_modify_existing_returns_true_and_persists() {
942 let (_dir, store) = temp_store();
943 store.create(VolumeConfig::new("shared", "")).unwrap();
944
945 let modified = store.modify("shared", |c| c.attach("box-1")).unwrap();
946
947 assert!(modified);
948 assert_eq!(
949 store.get("shared").unwrap().unwrap().in_use_by,
950 vec!["box-1"]
951 );
952 }
953
954 #[test]
955 fn test_volume_dir_joins_name_under_base_directory() {
956 let (dir, store) = temp_store();
957 assert_eq!(
958 store.volume_dir("mydata"),
959 dir.path().join("volumes").join("mydata")
960 );
961 }
962
963 #[test]
964 fn test_volume_store_backend_trait_dispatch() {
965 let (_dir, store) = temp_store();
966 let backend: &dyn a3s_box_core::traits::VolumeStoreBackend = &store;
967
968 let created = backend.create(VolumeConfig::new("shared", "")).unwrap();
969 assert_eq!(created.name, "shared");
970 assert!(backend.get("shared").unwrap().is_some());
971
972 let mut updated = created;
973 updated.attach("box-1");
974 backend.update(&updated).unwrap();
975 assert_eq!(backend.list().unwrap().len(), 1);
976
977 let err = backend.remove("shared", false).unwrap_err().to_string();
978 assert!(err.contains("is in use"));
979
980 let removed = backend.remove("shared", true).unwrap();
981 assert_eq!(removed.name, "shared");
982 assert!(backend.list().unwrap().is_empty());
983 }
984
985 #[test]
989 fn concurrent_attaches_accumulate_without_lost_update() {
990 use std::sync::Arc;
991 use std::thread;
992
993 let dir = tempfile::tempdir().unwrap();
994 let store = Arc::new(VolumeStore::new(
995 dir.path().join("volumes.json"),
996 dir.path().join("volumes"),
997 ));
998 store.create(VolumeConfig::new("shared", "")).unwrap();
999
1000 let n = 16;
1001 let handles: Vec<_> = (0..n)
1002 .map(|i| {
1003 let store = Arc::clone(&store);
1004 thread::spawn(move || {
1005 store
1006 .modify("shared", |c| c.attach(&format!("box-{i}")))
1007 .unwrap();
1008 })
1009 })
1010 .collect();
1011 for h in handles {
1012 h.join().unwrap();
1013 }
1014
1015 let cfg = store.get("shared").unwrap().unwrap();
1016 assert_eq!(
1017 cfg.in_use_by.len(),
1018 n,
1019 "every concurrent attach must persist (no lost update): {:?}",
1020 cfg.in_use_by
1021 );
1022 for i in 0..n {
1023 assert!(cfg.in_use_by.contains(&format!("box-{i}")));
1024 }
1025 }
1026
1027 #[test]
1028 fn concurrent_creates_persist_every_volume() {
1029 use std::sync::Arc;
1030 use std::thread;
1031
1032 let dir = tempfile::tempdir().unwrap();
1033 let store = Arc::new(VolumeStore::new(
1034 dir.path().join("volumes.json"),
1035 dir.path().join("volumes"),
1036 ));
1037
1038 let n = 16;
1039 let handles: Vec<_> = (0..n)
1040 .map(|i| {
1041 let store = Arc::clone(&store);
1042 thread::spawn(move || {
1043 store
1044 .create(VolumeConfig::new(&format!("vol-{i}"), ""))
1045 .unwrap();
1046 })
1047 })
1048 .collect();
1049 for h in handles {
1050 h.join().unwrap();
1051 }
1052
1053 assert_eq!(
1054 store.list().unwrap().len(),
1055 n,
1056 "every concurrent create must persist (no lost update)"
1057 );
1058 }
1059
1060 #[test]
1061 fn corrupt_volumes_file_is_quarantined_not_fatal() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let path = dir.path().join("volumes.json");
1064 std::fs::write(&path, "{ not valid json").unwrap();
1065 let store = VolumeStore::new(path.clone(), dir.path().join("volumes"));
1066
1067 assert!(store.load().unwrap().is_empty());
1069 let quarantined = std::fs::read_dir(dir.path())
1070 .unwrap()
1071 .filter_map(|e| e.ok())
1072 .any(|e| {
1073 e.file_name()
1074 .to_string_lossy()
1075 .contains("volumes.json.corrupt-")
1076 });
1077 assert!(quarantined, "corrupt volumes.json must be quarantined");
1078 }
1079}