Skip to main content

a3s_box_runtime/volume/
store.rs

1//! Persistent storage for volume configurations.
2//!
3//! Volumes are stored as JSON in `~/.a3s/volumes.json` with atomic writes
4//! (write to tmp file, then rename) to prevent corruption.
5//! Volume data is stored under `~/.a3s/volumes/<name>/`.
6
7use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::volume::VolumeConfig;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Persistent store for volume configurations.
13#[derive(Debug)]
14pub struct VolumeStore {
15    /// Path to the JSON file.
16    path: PathBuf,
17    /// Base directory for volume data (~/.a3s/volumes/).
18    volumes_dir: PathBuf,
19}
20
21/// Serializable wrapper for the volumes file.
22#[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    /// Create a new store at the given path.
34    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    /// Create a store at the default location (`~/.a3s/volumes.json`).
42    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    /// Load all volumes from disk.
48    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        // A corrupt/old-schema volumes file must not brick the runtime: quarantine
62        // it and start from an empty set (create repopulates) rather than failing
63        // every volume operation. Mirrors the boxes.json hardening.
64        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    /// Save all volumes to disk (atomic write).
81    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    /// Run `f` over the volume map under a cross-process advisory lock,
122    /// re-loading fresh from disk inside the lock and saving the result.
123    ///
124    /// `create`/`remove`/`update`/`modify`/`get_or_create` all funnel through
125    /// here so concurrent `a3s-box` processes cannot lose each other's writes.
126    /// The atomic tmp+rename in `save` only prevents a *torn* read — two
127    /// processes that both load, mutate a different entry, and save would still
128    /// clobber one update (and, for attach/detach, silently drop a volume's
129    /// `in_use_by` entry, letting `prune`/`remove` delete data a live box still
130    /// has mounted). `save` itself stays lock-free: the guard is held here for
131    /// the whole load → mutate → save, and the lock is non-reentrant.
132    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    /// Get a single volume by name.
149    pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
150        let volumes = self.load()?;
151        Ok(volumes.get(name).cloned())
152    }
153
154    /// Create a new named volume. Returns the host mount point path.
155    ///
156    /// Creates the volume data directory under `~/.a3s/volumes/<name>/`.
157    /// Errors if the name already exists (use [`Self::get_or_create`] for the
158    /// idempotent auto-create on the `run -v name:/path` path).
159    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    /// Return the existing volume, or create it if absent — atomic under the
172    /// cross-process lock. Two concurrent first-time `run -v name:/path` then
173    /// share one volume instead of one racing to an "already exists" error.
174    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    /// Atomically create or reclaim one Box-owned anonymous volume.
184    ///
185    /// Anonymous identities are single-owner capabilities. An existing named
186    /// volume, a volume owned by another execution, or metadata pointing away
187    /// from the canonical managed directory all fail closed without mutation.
188    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    /// Remove only the anonymous volume capability owned by `owner`.
223    ///
224    /// The directory is removed while the metadata lock is held, before the
225    /// atomic metadata update. If a previous claim created the deterministic
226    /// directory but crashed before publishing metadata, the durable Box
227    /// record can use this operation to remove that orphan safely.
228    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    /// Create the volume's data directory, set its mount point, and insert it
248    /// into `volumes`. Caller must already hold the write lock.
249    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    /// Remove a volume by name. Returns error if in use.
262    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            // Keep the directory removal under the same lock as the metadata
278            // mutation. If this happened after the lock were released, a
279            // concurrent get_or_create could materialize a new volume with the
280            // same name and this stale cleanup would delete its data.
281            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    /// List all volumes.
296    pub fn list(&self) -> Result<Vec<VolumeConfig>> {
297        let volumes = self.load()?;
298        Ok(volumes.into_values().collect())
299    }
300
301    /// Replace a volume's config wholesale under the cross-process lock.
302    ///
303    /// For attach/detach prefer [`Self::modify`]: a `get` → mutate → `update`
304    /// reads outside the lock and would lose a concurrent update made between
305    /// the two calls.
306    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    /// Atomically mutate one volume's config under the cross-process lock.
320    ///
321    /// Re-reads the current entry inside the lock so concurrent attach/detach
322    /// accumulate correctly — the canonical fix for the split `get` → mutate →
323    /// `update` race that could drop a volume's `in_use_by` entry. Returns
324    /// `false` if the volume does not exist.
325    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    /// Remove all volumes that are not in use. Returns names of removed volumes.
339    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    /// Get the volume data directory for a named volume.
357    pub fn volume_dir(&self, name: &str) -> PathBuf {
358        self.volumes_dir.join(name)
359    }
360
361    /// Get the store file path.
362    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        // Manually insert since create() doesn't set in_use_by
603        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        // in_use should remain
670        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    // The advisory lock is per-open-file-description, so separate
986    // FileLock::acquire calls serialize even across threads in one process —
987    // which is exactly what lets this exercise the lost-update fix in-process.
988    #[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        // load() must succeed (empty) instead of erroring every volume op.
1068        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}