Skip to main content

acyclic_fs/
mount.rs

1//! Composition of independent volume checkouts at distinct mount paths.
2
3use crate::async_storage::{AsyncAuthorityStore, AsyncObjectStore};
4use crate::facade::Checkout;
5use crate::foundation::{GenerationId, MountId, VolumeId};
6use crate::kernel::{NamespacePath, NamespacePathError};
7use crate::model::{AccessMode, ConsistencyMode};
8use crate::path::{PathError, PortablePath};
9use std::collections::BTreeMap;
10use thiserror::Error;
11
12/// One independently configured checkout mounted into a composed view.
13pub struct MountedCheckout<A, O> {
14    mount_id: MountId,
15    path: PortablePath,
16    checkout: Checkout<A, O>,
17}
18
19impl<A, O> MountedCheckout<A, O> {
20    /// Stable identity of this mount-table entry.
21    #[must_use]
22    pub const fn mount_id(&self) -> MountId {
23        self.mount_id
24    }
25
26    /// Canonical path where the checkout becomes visible.
27    #[must_use]
28    pub const fn path(&self) -> &PortablePath {
29        &self.path
30    }
31
32    /// Independently atomic owning volume.
33    #[must_use]
34    pub const fn volume_id(&self) -> VolumeId {
35        self.checkout.volume_id()
36    }
37
38    /// Borrows the mounted checkout.
39    #[must_use]
40    pub const fn checkout(&self) -> &Checkout<A, O> {
41        &self.checkout
42    }
43
44    /// Mutably borrows the mounted checkout.
45    #[must_use]
46    pub const fn checkout_mut(&mut self) -> &mut Checkout<A, O> {
47        &mut self.checkout
48    }
49}
50
51/// One resolved operation route into a volume-relative namespace.
52pub struct RoutedCheckout<'a, A, O> {
53    /// Stable selected mount identity.
54    pub mount_id: MountId,
55    /// Canonical path relative to the selected volume root.
56    pub path: NamespacePath,
57    /// The selected independently configured checkout.
58    pub checkout: &'a mut Checkout<A, O>,
59}
60
61/// Deterministic longest-prefix router that owns independently configured checkouts.
62pub struct MountedView<A, O> {
63    bindings: BTreeMap<PortablePath, MountedCheckout<A, O>>,
64}
65
66impl<A, O> MountedView<A, O> {
67    /// Starts constructing an empty mounted view.
68    #[must_use]
69    pub fn builder() -> MountedViewBuilder<A, O> {
70        MountedViewBuilder::default()
71    }
72
73    /// Resolves one path by longest component-prefix match.
74    #[must_use]
75    pub fn resolve(&self, path: &PortablePath) -> Option<&MountedCheckout<A, O>> {
76        self.bindings
77            .values()
78            .filter(|binding| path.is_within(&binding.path))
79            .max_by_key(|binding| binding.path.depth())
80    }
81
82    /// Resolves and mutably borrows one checkout with a volume-relative path.
83    ///
84    /// # Errors
85    ///
86    /// Returns a typed error for unmapped paths or an impossible relative-path
87    /// conversion under the selected volume's explicit limits.
88    pub fn route_mut(
89        &mut self,
90        path: &PortablePath,
91    ) -> Result<RoutedCheckout<'_, A, O>, MountError> {
92        let selected = self
93            .bindings
94            .keys()
95            .filter(|candidate| path.is_within(candidate))
96            .max_by_key(|candidate| candidate.depth())
97            .cloned()
98            .ok_or(MountError::UnmappedPath)?;
99        let binding = self
100            .bindings
101            .get_mut(&selected)
102            .ok_or(MountError::UnmappedPath)?;
103        let relative = relative_path(path, &selected, binding.checkout.volume_config().limits)?;
104        Ok(RoutedCheckout {
105            mount_id: binding.mount_id,
106            path: NamespacePath::from_portable_in_profile(
107                &relative,
108                binding.checkout.volume_config().profile,
109                binding.checkout.volume_config().limits,
110            )
111            .map_err(MountError::InvalidNamespacePath)?,
112            checkout: &mut binding.checkout,
113        })
114    }
115
116    /// Captures the exact generation selected for every mount.
117    #[must_use]
118    pub fn snapshot(&self) -> MountedViewSnapshot {
119        MountedViewSnapshot {
120            bindings: self
121                .bindings
122                .values()
123                .map(|binding| MountedGeneration {
124                    mount_id: binding.mount_id,
125                    path: binding.path.clone(),
126                    volume_id: binding.checkout.volume_id(),
127                    generation: binding.checkout.generation_id(),
128                    access: binding.checkout.mode().access,
129                    consistency: binding.checkout.mode().consistency,
130                })
131                .collect(),
132        }
133    }
134
135    /// Validates that a rename stays within one independently atomic checkout.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`MountError::CrossVolume`] when endpoints resolve to different
140    /// mount entries, even when both entries happen to select the same volume.
141    pub fn validate_rename(
142        &self,
143        source: &PortablePath,
144        destination: &PortablePath,
145    ) -> Result<(), MountError> {
146        let source_binding = self.resolve(source).ok_or(MountError::UnmappedPath)?;
147        let destination_binding = self.resolve(destination).ok_or(MountError::UnmappedPath)?;
148        if source_binding.mount_id != destination_binding.mount_id {
149            return Err(MountError::CrossVolume);
150        }
151        Ok(())
152    }
153}
154
155/// Builder that rejects ambiguous mount tables.
156pub struct MountedViewBuilder<A, O> {
157    bindings: BTreeMap<PortablePath, MountedCheckout<A, O>>,
158}
159
160impl<A, O> Default for MountedViewBuilder<A, O> {
161    fn default() -> Self {
162        Self {
163            bindings: BTreeMap::new(),
164        }
165    }
166}
167
168impl<A: AsyncAuthorityStore, O: AsyncObjectStore> MountedViewBuilder<A, O> {
169    /// Adds one checkout at a distinct canonical path.
170    ///
171    /// # Errors
172    ///
173    /// Rejects malformed or duplicate paths before changing the builder.
174    pub fn mount(mut self, path: &str, checkout: Checkout<A, O>) -> Result<Self, MountError> {
175        let path = PortablePath::parse(path, checkout.volume_config().limits)
176            .map_err(MountError::InvalidPath)?;
177        if self.bindings.contains_key(&path) {
178            return Err(MountError::DuplicatePath);
179        }
180        let mount_id = MountId::new();
181        self.bindings.insert(
182            path.clone(),
183            MountedCheckout {
184                mount_id,
185                path,
186                checkout,
187            },
188        );
189        Ok(self)
190    }
191
192    /// Seals the table after requiring a root binding.
193    ///
194    /// # Errors
195    ///
196    /// A mounted filesystem view must route every absolute path through `/`.
197    pub fn build(self) -> Result<MountedView<A, O>, MountError> {
198        if !self
199            .bindings
200            .keys()
201            .any(|path| path.as_str() == PortablePath::ROOT)
202        {
203            return Err(MountError::MissingRoot);
204        }
205        Ok(MountedView {
206            bindings: self.bindings,
207        })
208    }
209}
210
211fn relative_path(
212    path: &PortablePath,
213    mount: &PortablePath,
214    limits: crate::model::VolumeLimits,
215) -> Result<PortablePath, MountError> {
216    if mount.as_str() == PortablePath::ROOT {
217        return Ok(path.clone());
218    }
219    if path == mount {
220        return PortablePath::parse(PortablePath::ROOT, limits).map_err(MountError::InvalidPath);
221    }
222    let relative = path
223        .as_str()
224        .strip_prefix(mount.as_str())
225        .ok_or(MountError::UnmappedPath)?;
226    PortablePath::parse(relative, limits).map_err(MountError::InvalidPath)
227}
228
229/// Reproducible selection of one generation for each mounted volume.
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct MountedViewSnapshot {
232    /// Bindings sorted by canonical mount path.
233    pub bindings: Vec<MountedGeneration>,
234}
235
236/// One mounted generation in a reproducible view snapshot.
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct MountedGeneration {
239    /// Stable mount identity.
240    pub mount_id: MountId,
241    /// Canonical mount path.
242    pub path: PortablePath,
243    /// Independent volume identity.
244    pub volume_id: VolumeId,
245    /// Selected immutable generation.
246    pub generation: GenerationId,
247    /// Access admitted through this binding.
248    pub access: AccessMode,
249    /// Refresh behavior selected for this binding.
250    pub consistency: ConsistencyMode,
251}
252
253/// Mount composition failures.
254#[derive(Clone, Debug, Eq, Error, PartialEq)]
255pub enum MountError {
256    /// Two volumes were assigned the same path.
257    #[error("mount path is already bound")]
258    DuplicatePath,
259    /// A complete mounted view requires `/`.
260    #[error("mounted view has no root binding")]
261    MissingRoot,
262    /// No volume owns the supplied path.
263    #[error("path is not mapped to a volume")]
264    UnmappedPath,
265    /// Portable rename cannot cross independently atomic checkouts.
266    #[error("cross-volume rename is not supported")]
267    CrossVolume,
268    /// A mount or routed path violates canonical path bounds.
269    #[error("invalid mounted path: {0}")]
270    InvalidPath(PathError),
271    /// A routed portable path could not become an exact namespace path.
272    #[error("invalid routed namespace path: {0}")]
273    InvalidNamespacePath(NamespacePathError),
274}
275
276#[cfg(test)]
277#[path = "tests/mount.rs"]
278mod tests;