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 let config = self.with_write_lock(|volumes| {
264 let config = volumes
265 .remove(name)
266 .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
267
268 if config.is_in_use() && !force {
269 volumes.insert(name.to_string(), config.clone());
271 return Err(BoxError::ConfigError(format!(
272 "volume '{}' is in use by {} box(es); use --force to remove",
273 name,
274 config.in_use_by.len()
275 )));
276 }
277 Ok(config)
278 })?;
279
280 let vol_dir = self.volumes_dir.join(name);
283 if vol_dir.exists() {
284 std::fs::remove_dir_all(&vol_dir).ok();
285 }
286
287 Ok(config)
288 }
289
290 pub fn list(&self) -> Result<Vec<VolumeConfig>> {
292 let volumes = self.load()?;
293 Ok(volumes.into_values().collect())
294 }
295
296 pub fn update(&self, config: &VolumeConfig) -> Result<()> {
302 self.with_write_lock(|volumes| {
303 if !volumes.contains_key(&config.name) {
304 return Err(BoxError::ConfigError(format!(
305 "volume '{}' not found",
306 config.name
307 )));
308 }
309 volumes.insert(config.name.clone(), config.clone());
310 Ok(())
311 })
312 }
313
314 pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
321 where
322 F: FnOnce(&mut VolumeConfig),
323 {
324 self.with_write_lock(|volumes| match volumes.get_mut(name) {
325 Some(config) => {
326 f(config);
327 Ok(true)
328 }
329 None => Ok(false),
330 })
331 }
332
333 pub fn prune(&self) -> Result<Vec<String>> {
335 let volumes = self.load()?;
336 let mut pruned = Vec::new();
337
338 for (name, config) in &volumes {
339 if !config.is_in_use() {
340 pruned.push(name.clone());
341 }
342 }
343
344 for name in &pruned {
345 self.remove(name, false).ok();
346 }
347
348 Ok(pruned)
349 }
350
351 pub fn volume_dir(&self, name: &str) -> PathBuf {
353 self.volumes_dir.join(name)
354 }
355
356 pub fn path(&self) -> &Path {
358 &self.path
359 }
360}
361
362fn valid_anonymous_volume_name(name: &str) -> bool {
363 name.starts_with("anon_")
364 && name.len() <= 128
365 && name
366 .bytes()
367 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
368}
369
370fn validate_anonymous_identity(name: &str, owner: &str) -> Result<()> {
371 if !valid_anonymous_volume_name(name) {
372 return Err(BoxError::ConfigError(format!(
373 "invalid anonymous volume name {name:?}"
374 )));
375 }
376 if owner.is_empty() || owner.contains('\0') {
377 return Err(BoxError::ConfigError(
378 "anonymous volume owner must be a non-empty execution identity".to_string(),
379 ));
380 }
381 Ok(())
382}
383
384fn validate_anonymous_config(
385 config: &VolumeConfig,
386 name: &str,
387 owner: &str,
388 expected_mount_point: &str,
389) -> Result<()> {
390 if config.labels.get(ANONYMOUS_LABEL).map(String::as_str) != Some("true") {
391 return Err(BoxError::ConfigError(format!(
392 "volume {name:?} is not an anonymous volume"
393 )));
394 }
395 if config.driver != "local" {
396 return Err(BoxError::ConfigError(format!(
397 "anonymous volume {name:?} does not use the local driver"
398 )));
399 }
400 if config.mount_point != expected_mount_point {
401 return Err(BoxError::ConfigError(format!(
402 "anonymous volume {name:?} does not use its canonical managed directory"
403 )));
404 }
405 let exact_owner = config.in_use_by.len() == 1
406 && config
407 .in_use_by
408 .first()
409 .is_some_and(|current| current == owner);
410 if config.in_use_by.iter().any(|current| current != owner) {
411 return Err(BoxError::ConfigError(format!(
412 "anonymous volume {name:?} is owned by another execution"
413 )));
414 }
415 match config.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str) {
416 Some(ANONYMOUS_KIND)
417 if exact_owner
418 && config
419 .labels
420 .get(ANONYMOUS_OWNER_LABEL)
421 .is_some_and(|current| current == owner) =>
422 {
423 Ok(())
424 }
425 None if exact_owner => Ok(()),
426 _ => Err(BoxError::ConfigError(format!(
427 "anonymous volume {name:?} has no compatible ownership contract"
428 ))),
429 }
430}
431
432fn ensure_managed_volume_directory(path: &Path) -> Result<()> {
433 match std::fs::symlink_metadata(path) {
434 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
435 return Err(BoxError::ConfigError(format!(
436 "managed volume path {} is not a directory",
437 path.display()
438 )))
439 }
440 Ok(_) => return Ok(()),
441 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
442 Err(error) => {
443 return Err(BoxError::ConfigError(format!(
444 "failed to inspect volume directory {}: {error}",
445 path.display()
446 )))
447 }
448 }
449 std::fs::create_dir_all(path).map_err(|error| {
450 BoxError::ConfigError(format!(
451 "failed to create volume directory {}: {error}",
452 path.display()
453 ))
454 })?;
455 let metadata = std::fs::symlink_metadata(path).map_err(|error| {
456 BoxError::ConfigError(format!(
457 "failed to verify volume directory {}: {error}",
458 path.display()
459 ))
460 })?;
461 if metadata.file_type().is_symlink() || !metadata.is_dir() {
462 return Err(BoxError::ConfigError(format!(
463 "managed volume path {} is not a directory",
464 path.display()
465 )));
466 }
467 Ok(())
468}
469
470fn remove_managed_volume_path(path: &Path) -> Result<()> {
471 let metadata = match std::fs::symlink_metadata(path) {
472 Ok(metadata) => metadata,
473 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
474 Err(error) => return Err(BoxError::IoError(error)),
475 };
476 if metadata.is_dir() && !metadata.file_type().is_symlink() {
477 std::fs::remove_dir_all(path).map_err(BoxError::IoError)
478 } else {
479 std::fs::remove_file(path).map_err(BoxError::IoError)
480 }
481}
482
483impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
484 fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
485 self.get(name)
486 }
487
488 fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
489 self.create(config)
490 }
491
492 fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
493 self.remove(name, force)
494 }
495
496 fn list(&self) -> Result<Vec<VolumeConfig>> {
497 self.list()
498 }
499
500 fn update(&self, config: &VolumeConfig) -> Result<()> {
501 self.update(config)
502 }
503
504 fn prune(&self) -> Result<Vec<String>> {
505 self.prune()
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 fn temp_store() -> (tempfile::TempDir, VolumeStore) {
514 let dir = tempfile::tempdir().unwrap();
515 let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
516 (dir, store)
517 }
518
519 #[test]
520 fn test_load_empty() {
521 let (_dir, store) = temp_store();
522 let volumes = store.load().unwrap();
523 assert!(volumes.is_empty());
524 }
525
526 #[test]
527 fn test_create_and_load() {
528 let (_dir, store) = temp_store();
529 let vol = VolumeConfig::new("mydata", "");
530 store.create(vol).unwrap();
531
532 let volumes = store.load().unwrap();
533 assert_eq!(volumes.len(), 1);
534 assert!(volumes.contains_key("mydata"));
535 }
536
537 #[test]
538 fn test_create_sets_mount_point() {
539 let (_dir, store) = temp_store();
540 let vol = VolumeConfig::new("mydata", "");
541 let created = store.create(vol).unwrap();
542
543 assert!(created.mount_point.contains("mydata"));
544 assert!(PathBuf::from(&created.mount_point).exists());
545 }
546
547 #[test]
548 fn test_create_duplicate() {
549 let (_dir, store) = temp_store();
550 let v1 = VolumeConfig::new("mydata", "");
551 let v2 = VolumeConfig::new("mydata", "");
552
553 store.create(v1).unwrap();
554 assert!(store.create(v2).is_err());
555 }
556
557 #[test]
558 fn test_get_existing() {
559 let (_dir, store) = temp_store();
560 store.create(VolumeConfig::new("mydata", "")).unwrap();
561
562 let found = store.get("mydata").unwrap();
563 assert!(found.is_some());
564 assert_eq!(found.unwrap().name, "mydata");
565 }
566
567 #[test]
568 fn test_get_nonexistent() {
569 let (_dir, store) = temp_store();
570 let found = store.get("nope").unwrap();
571 assert!(found.is_none());
572 }
573
574 #[test]
575 fn test_remove() {
576 let (_dir, store) = temp_store();
577 store.create(VolumeConfig::new("mydata", "")).unwrap();
578
579 let removed = store.remove("mydata", false).unwrap();
580 assert_eq!(removed.name, "mydata");
581
582 let volumes = store.load().unwrap();
583 assert!(volumes.is_empty());
584 }
585
586 #[test]
587 fn test_remove_nonexistent() {
588 let (_dir, store) = temp_store();
589 assert!(store.remove("nope", false).is_err());
590 }
591
592 #[test]
593 fn test_remove_in_use_fails() {
594 let (_dir, store) = temp_store();
595 let mut vol = VolumeConfig::new("mydata", "");
596 vol.attach("box-1");
597 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
599 let mut updated = created;
600 updated.attach("box-1");
601 store.update(&updated).unwrap();
602
603 assert!(store.remove("mydata", false).is_err());
604 }
605
606 #[test]
607 fn test_remove_in_use_force() {
608 let (_dir, store) = temp_store();
609 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
610 let mut updated = created;
611 updated.attach("box-1");
612 store.update(&updated).unwrap();
613
614 let removed = store.remove("mydata", true).unwrap();
615 assert_eq!(removed.name, "mydata");
616 }
617
618 #[test]
619 fn test_list() {
620 let (_dir, store) = temp_store();
621 store.create(VolumeConfig::new("vol1", "")).unwrap();
622 store.create(VolumeConfig::new("vol2", "")).unwrap();
623
624 let list = store.list().unwrap();
625 assert_eq!(list.len(), 2);
626 }
627
628 #[test]
629 fn test_update() {
630 let (_dir, store) = temp_store();
631 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
632
633 let mut updated = created;
634 updated.attach("box-1");
635 store.update(&updated).unwrap();
636
637 let loaded = store.get("mydata").unwrap().unwrap();
638 assert_eq!(loaded.in_use_by, vec!["box-1"]);
639 }
640
641 #[test]
642 fn test_update_nonexistent() {
643 let (_dir, store) = temp_store();
644 let vol = VolumeConfig::new("nope", "/tmp");
645 assert!(store.update(&vol).is_err());
646 }
647
648 #[test]
649 fn test_prune() {
650 let (_dir, store) = temp_store();
651 store.create(VolumeConfig::new("unused1", "")).unwrap();
652 store.create(VolumeConfig::new("unused2", "")).unwrap();
653
654 let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
655 let mut updated = created;
656 updated.attach("box-1");
657 store.update(&updated).unwrap();
658
659 let pruned = store.prune().unwrap();
660 assert_eq!(pruned.len(), 2);
661 assert!(pruned.contains(&"unused1".to_string()));
662 assert!(pruned.contains(&"unused2".to_string()));
663
664 let remaining = store.list().unwrap();
666 assert_eq!(remaining.len(), 1);
667 assert_eq!(remaining[0].name, "in_use");
668 }
669
670 #[test]
671 fn test_atomic_write() {
672 let (_dir, store) = temp_store();
673 store.create(VolumeConfig::new("mydata", "")).unwrap();
674
675 let data = std::fs::read_to_string(store.path()).unwrap();
676 let _: serde_json::Value = serde_json::from_str(&data).unwrap();
677
678 let tmp = store.path().with_extension("json.tmp");
679 assert!(!tmp.exists());
680 }
681
682 #[test]
683 fn test_creates_parent_directory() {
684 let dir = tempfile::tempdir().unwrap();
685 let store = VolumeStore::new(
686 dir.path().join("subdir").join("volumes.json"),
687 dir.path().join("subdir").join("volumes"),
688 );
689
690 store.create(VolumeConfig::new("mydata", "")).unwrap();
691 assert!(store.path().exists());
692 }
693
694 #[test]
695 fn test_remove_cleans_up_directory() {
696 let (_dir, store) = temp_store();
697 let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
698 let vol_dir = PathBuf::from(&created.mount_point);
699 assert!(vol_dir.exists());
700
701 store.remove("mydata", false).unwrap();
702 assert!(!vol_dir.exists());
703 }
704
705 #[test]
706 fn test_get_or_create_is_idempotent() {
707 let (_dir, store) = temp_store();
708 let first = store
709 .get_or_create(VolumeConfig::new("shared", ""))
710 .unwrap();
711 let second = store
712 .get_or_create(VolumeConfig::new("shared", ""))
713 .unwrap();
714 assert_eq!(first.mount_point, second.mount_point);
715 assert_eq!(store.list().unwrap().len(), 1);
716 }
717
718 #[test]
719 fn test_get_or_create_preserves_existing_config() {
720 let (_dir, store) = temp_store();
721 let created = store
722 .get_or_create(VolumeConfig::with_size_limit("shared", "", 4096))
723 .unwrap();
724 let mut updated = created;
725 updated.attach("box-1");
726 store.update(&updated).unwrap();
727
728 let reused = store
729 .get_or_create(VolumeConfig::with_size_limit("shared", "/ignored", 8192))
730 .unwrap();
731
732 assert_eq!(reused.size_limit, 4096);
733 assert_eq!(reused.in_use_by, vec!["box-1"]);
734 assert!(PathBuf::from(&reused.mount_point).exists());
735 }
736
737 #[test]
738 fn claim_anonymous_is_idempotent_for_the_exact_owner() {
739 let (_dir, store) = temp_store();
740
741 let (first, first_created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
742 let (second, second_created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
743
744 assert!(first_created);
745 assert!(!second_created);
746 assert_eq!(first.mount_point, second.mount_point);
747 assert_eq!(second.in_use_by, vec!["box-owner"]);
748 assert_eq!(
749 second.labels.get(ANONYMOUS_LABEL).map(String::as_str),
750 Some("true")
751 );
752 assert_eq!(
753 second.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str),
754 Some(ANONYMOUS_KIND)
755 );
756 assert_eq!(
757 second.labels.get(ANONYMOUS_OWNER_LABEL).map(String::as_str),
758 Some("box-owner")
759 );
760 }
761
762 #[test]
763 fn claim_anonymous_upgrades_an_exact_owner_legacy_volume() {
764 let (_dir, store) = temp_store();
765 let mut legacy = VolumeConfig::new("anon_legacy", "");
766 legacy
767 .labels
768 .insert(ANONYMOUS_LABEL.to_string(), "true".to_string());
769 legacy.attach("box-owner");
770 store.create(legacy).unwrap();
771
772 let (claimed, created) = store.claim_anonymous("anon_legacy", "box-owner").unwrap();
773
774 assert!(!created);
775 assert_eq!(
776 claimed.labels.get(ANONYMOUS_KIND_LABEL).map(String::as_str),
777 Some(ANONYMOUS_KIND)
778 );
779 assert_eq!(
780 claimed
781 .labels
782 .get(ANONYMOUS_OWNER_LABEL)
783 .map(String::as_str),
784 Some("box-owner")
785 );
786 }
787
788 #[test]
789 fn claim_anonymous_rejects_named_volume_collision_without_mutation() {
790 let (_dir, store) = temp_store();
791 let named = store
792 .create(VolumeConfig::new("anon_collision", ""))
793 .unwrap();
794
795 let error = store
796 .claim_anonymous("anon_collision", "box-owner")
797 .expect_err("a named volume must never become anonymously owned");
798
799 assert!(error.to_string().contains("not an anonymous volume"));
800 assert_eq!(
801 store.get("anon_collision").unwrap().unwrap().in_use_by,
802 named.in_use_by
803 );
804 }
805
806 #[test]
807 fn claim_anonymous_rejects_a_different_owner_without_mutation() {
808 let (_dir, store) = temp_store();
809 store.claim_anonymous("anon_owned", "box-one").unwrap();
810
811 let error = store
812 .claim_anonymous("anon_owned", "box-two")
813 .expect_err("anonymous volumes have exactly one Box owner");
814
815 assert!(error.to_string().contains("owned by another execution"));
816 assert_eq!(
817 store.get("anon_owned").unwrap().unwrap().in_use_by,
818 vec!["box-one"]
819 );
820 }
821
822 #[test]
823 fn claim_anonymous_rejects_unsafe_identity_before_creating_a_directory() {
824 let (dir, store) = temp_store();
825
826 let error = store
827 .claim_anonymous("../escaped", "box-owner")
828 .expect_err("managed identities may not escape the volume root");
829
830 assert!(error.to_string().contains("invalid anonymous volume name"));
831 assert!(!dir.path().join("escaped").exists());
832 assert!(store.load().unwrap().is_empty());
833 }
834
835 #[test]
836 fn concurrent_anonymous_claims_publish_one_exact_owner() {
837 use std::sync::Arc;
838 use std::thread;
839
840 let dir = tempfile::tempdir().unwrap();
841 let store = Arc::new(VolumeStore::new(
842 dir.path().join("volumes.json"),
843 dir.path().join("volumes"),
844 ));
845 let handles = (0..16)
846 .map(|_| {
847 let store = Arc::clone(&store);
848 thread::spawn(move || {
849 store
850 .claim_anonymous("anon_concurrent", "box-owner")
851 .unwrap()
852 .1
853 })
854 })
855 .collect::<Vec<_>>();
856
857 let created = handles
858 .into_iter()
859 .map(|handle| usize::from(handle.join().unwrap()))
860 .sum::<usize>();
861 let claimed = store.get("anon_concurrent").unwrap().unwrap();
862
863 assert_eq!(created, 1);
864 assert_eq!(claimed.in_use_by, vec!["box-owner"]);
865 assert_eq!(store.list().unwrap().len(), 1);
866 }
867
868 #[test]
869 fn remove_anonymous_requires_and_removes_the_exact_owner() {
870 let (_dir, store) = temp_store();
871 let (claimed, _) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
872
873 let removed = store.remove_anonymous("anon_owned", "box-owner").unwrap();
874
875 assert!(removed);
876 assert!(store.get("anon_owned").unwrap().is_none());
877 assert!(!PathBuf::from(claimed.mount_point).exists());
878 }
879
880 #[test]
881 fn remove_anonymous_rejects_named_and_different_owner_collisions() {
882 let (_dir, store) = temp_store();
883 let named = store.create(VolumeConfig::new("anon_named", "")).unwrap();
884 store.claim_anonymous("anon_owned", "box-one").unwrap();
885
886 let named_error = store
887 .remove_anonymous("anon_named", "box-one")
888 .expect_err("named volume metadata must fail closed");
889 let owner_error = store
890 .remove_anonymous("anon_owned", "box-two")
891 .expect_err("another owner must fail closed");
892
893 assert!(named_error.to_string().contains("not an anonymous volume"));
894 assert!(owner_error
895 .to_string()
896 .contains("owned by another execution"));
897 assert!(PathBuf::from(named.mount_point).exists());
898 assert!(store.get("anon_owned").unwrap().is_some());
899 }
900
901 #[test]
902 fn remove_anonymous_cleans_an_unpublished_deterministic_directory() {
903 let (_dir, store) = temp_store();
904 let orphan = store.volume_dir("anon_unpublished");
905 std::fs::create_dir_all(&orphan).unwrap();
906 std::fs::write(orphan.join("partial"), b"partial").unwrap();
907
908 let removed = store
909 .remove_anonymous("anon_unpublished", "box-owner")
910 .unwrap();
911
912 assert!(!removed);
913 assert!(!orphan.exists());
914 assert!(store.get("anon_unpublished").unwrap().is_none());
915 }
916
917 #[test]
918 fn reclaim_anonymous_recreates_a_missing_owned_directory() {
919 let (_dir, store) = temp_store();
920 let (claimed, _) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
921 std::fs::remove_dir_all(&claimed.mount_point).unwrap();
922
923 let (reclaimed, created) = store.claim_anonymous("anon_owned", "box-owner").unwrap();
924
925 assert!(!created);
926 assert!(PathBuf::from(reclaimed.mount_point).is_dir());
927 }
928
929 #[test]
930 fn test_modify_missing_returns_false() {
931 let (_dir, store) = temp_store();
932 assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
933 }
934
935 #[test]
936 fn test_modify_existing_returns_true_and_persists() {
937 let (_dir, store) = temp_store();
938 store.create(VolumeConfig::new("shared", "")).unwrap();
939
940 let modified = store.modify("shared", |c| c.attach("box-1")).unwrap();
941
942 assert!(modified);
943 assert_eq!(
944 store.get("shared").unwrap().unwrap().in_use_by,
945 vec!["box-1"]
946 );
947 }
948
949 #[test]
950 fn test_volume_dir_joins_name_under_base_directory() {
951 let (dir, store) = temp_store();
952 assert_eq!(
953 store.volume_dir("mydata"),
954 dir.path().join("volumes").join("mydata")
955 );
956 }
957
958 #[test]
959 fn test_volume_store_backend_trait_dispatch() {
960 let (_dir, store) = temp_store();
961 let backend: &dyn a3s_box_core::traits::VolumeStoreBackend = &store;
962
963 let created = backend.create(VolumeConfig::new("shared", "")).unwrap();
964 assert_eq!(created.name, "shared");
965 assert!(backend.get("shared").unwrap().is_some());
966
967 let mut updated = created;
968 updated.attach("box-1");
969 backend.update(&updated).unwrap();
970 assert_eq!(backend.list().unwrap().len(), 1);
971
972 let err = backend.remove("shared", false).unwrap_err().to_string();
973 assert!(err.contains("is in use"));
974
975 let removed = backend.remove("shared", true).unwrap();
976 assert_eq!(removed.name, "shared");
977 assert!(backend.list().unwrap().is_empty());
978 }
979
980 #[test]
984 fn concurrent_attaches_accumulate_without_lost_update() {
985 use std::sync::Arc;
986 use std::thread;
987
988 let dir = tempfile::tempdir().unwrap();
989 let store = Arc::new(VolumeStore::new(
990 dir.path().join("volumes.json"),
991 dir.path().join("volumes"),
992 ));
993 store.create(VolumeConfig::new("shared", "")).unwrap();
994
995 let n = 16;
996 let handles: Vec<_> = (0..n)
997 .map(|i| {
998 let store = Arc::clone(&store);
999 thread::spawn(move || {
1000 store
1001 .modify("shared", |c| c.attach(&format!("box-{i}")))
1002 .unwrap();
1003 })
1004 })
1005 .collect();
1006 for h in handles {
1007 h.join().unwrap();
1008 }
1009
1010 let cfg = store.get("shared").unwrap().unwrap();
1011 assert_eq!(
1012 cfg.in_use_by.len(),
1013 n,
1014 "every concurrent attach must persist (no lost update): {:?}",
1015 cfg.in_use_by
1016 );
1017 for i in 0..n {
1018 assert!(cfg.in_use_by.contains(&format!("box-{i}")));
1019 }
1020 }
1021
1022 #[test]
1023 fn concurrent_creates_persist_every_volume() {
1024 use std::sync::Arc;
1025 use std::thread;
1026
1027 let dir = tempfile::tempdir().unwrap();
1028 let store = Arc::new(VolumeStore::new(
1029 dir.path().join("volumes.json"),
1030 dir.path().join("volumes"),
1031 ));
1032
1033 let n = 16;
1034 let handles: Vec<_> = (0..n)
1035 .map(|i| {
1036 let store = Arc::clone(&store);
1037 thread::spawn(move || {
1038 store
1039 .create(VolumeConfig::new(&format!("vol-{i}"), ""))
1040 .unwrap();
1041 })
1042 })
1043 .collect();
1044 for h in handles {
1045 h.join().unwrap();
1046 }
1047
1048 assert_eq!(
1049 store.list().unwrap().len(),
1050 n,
1051 "every concurrent create must persist (no lost update)"
1052 );
1053 }
1054
1055 #[test]
1056 fn corrupt_volumes_file_is_quarantined_not_fatal() {
1057 let dir = tempfile::tempdir().unwrap();
1058 let path = dir.path().join("volumes.json");
1059 std::fs::write(&path, "{ not valid json").unwrap();
1060 let store = VolumeStore::new(path.clone(), dir.path().join("volumes"));
1061
1062 assert!(store.load().unwrap().is_empty());
1064 let quarantined = std::fs::read_dir(dir.path())
1065 .unwrap()
1066 .filter_map(|e| e.ok())
1067 .any(|e| {
1068 e.file_name()
1069 .to_string_lossy()
1070 .contains("volumes.json.corrupt-")
1071 });
1072 assert!(quarantined, "corrupt volumes.json must be quarantined");
1073 }
1074}