Skip to main content

spatialrust_io/
storage.rs

1//! Explicit input/output roots for local spatial data.
2
3use std::path::{Component, Path, PathBuf};
4
5#[cfg(feature = "storage-preflight")]
6use fs2::available_space;
7
8use crate::IoError;
9
10/// Initial v1.3 minimum free-space floor for an external output root.
11#[cfg(feature = "storage-preflight")]
12pub const DEFAULT_MIN_OUTPUT_FREE_BYTES: u64 = 20 * 1024 * 1024 * 1024;
13
14/// Result of a fail-closed output-root free-space check.
15#[cfg(feature = "storage-preflight")]
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct StoragePreflight {
18    /// Absolute output root that was inspected.
19    pub root: PathBuf,
20    /// Bytes available on the filesystem containing [`Self::root`].
21    pub available_bytes: u64,
22    /// Minimum bytes required by the caller.
23    pub required_free_bytes: u64,
24}
25
26#[cfg(feature = "storage-preflight")]
27impl StoragePreflight {
28    /// Checks an existing absolute directory before opening a data source.
29    pub fn check(root: impl AsRef<Path>, required_free_bytes: u64) -> Result<Self, IoError> {
30        let root = root.as_ref();
31        if !root.is_absolute() {
32            return Err(IoError::Storage(format!(
33                "output preflight root `{}` must be absolute",
34                root.display()
35            )));
36        }
37        let metadata = std::fs::metadata(root).map_err(|error| {
38            IoError::Storage(format!(
39                "cannot inspect output preflight root `{}`: {error}",
40                root.display()
41            ))
42        })?;
43        if !metadata.is_dir() {
44            return Err(IoError::Storage(format!(
45                "output preflight root `{}` is not a directory",
46                root.display()
47            )));
48        }
49        let available_bytes = available_space(root).map_err(|error| {
50            IoError::Storage(format!("cannot read free space for `{}`: {error}", root.display()))
51        })?;
52        if available_bytes < required_free_bytes {
53            return Err(IoError::Storage(format!(
54                "output root `{}` has {available_bytes} available bytes, below required {required_free_bytes}",
55                root.display()
56            )));
57        }
58        Ok(Self { root: root.to_path_buf(), available_bytes, required_free_bytes })
59    }
60}
61
62/// Optional roots used to resolve logical input and output paths.
63///
64/// Relative paths are joined to their corresponding root. Absolute paths are
65/// always treated as explicit locations and bypass the roots. Relative paths
66/// containing `..` are rejected so a logical dataset path cannot escape its
67/// configured root.
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct StorageRoots {
70    input_root: Option<PathBuf>,
71    output_root: Option<PathBuf>,
72}
73
74impl StorageRoots {
75    /// Creates a root mapping. Either root may be omitted.
76    #[must_use]
77    pub const fn new(input_root: Option<PathBuf>, output_root: Option<PathBuf>) -> Self {
78        Self { input_root, output_root }
79    }
80
81    /// Returns the configured input root, if any.
82    #[must_use]
83    pub fn input_root(&self) -> Option<&Path> {
84        self.input_root.as_deref()
85    }
86
87    /// Returns the configured output root, if any.
88    #[must_use]
89    pub fn output_root(&self) -> Option<&Path> {
90        self.output_root.as_deref()
91    }
92
93    /// Resolves a logical input path against the configured input root.
94    pub fn resolve_input(&self, path: impl AsRef<Path>) -> Result<PathBuf, IoError> {
95        resolve_path(self.input_root.as_deref(), path.as_ref(), "input")
96    }
97
98    /// Resolves a logical output path against the configured output root.
99    pub fn resolve_output(&self, path: impl AsRef<Path>) -> Result<PathBuf, IoError> {
100        resolve_path(self.output_root.as_deref(), path.as_ref(), "output")
101    }
102
103    /// Creates the parent directory for an explicit output path when needed.
104    pub fn ensure_output_parent(&self, path: impl AsRef<Path>) -> Result<(), IoError> {
105        if let Some(parent) = path.as_ref().parent() {
106            if !parent.as_os_str().is_empty() {
107                std::fs::create_dir_all(parent)?;
108            }
109        }
110        Ok(())
111    }
112
113    /// Checks the configured output root before opening an external data source.
114    #[cfg(feature = "storage-preflight")]
115    pub fn preflight_output(&self, required_free_bytes: u64) -> Result<StoragePreflight, IoError> {
116        let root = self
117            .output_root
118            .as_deref()
119            .ok_or_else(|| IoError::Storage("output preflight requires an output root".into()))?;
120        StoragePreflight::check(root, required_free_bytes)
121    }
122}
123
124fn resolve_path(root: Option<&Path>, path: &Path, kind: &str) -> Result<PathBuf, IoError> {
125    if path.is_absolute() || root.is_none() {
126        return Ok(path.to_path_buf());
127    }
128
129    if path.components().any(|component| component == Component::ParentDir) {
130        return Err(IoError::Storage(format!(
131            "relative {kind} path `{}` must not contain `..`",
132            path.display()
133        )));
134    }
135
136    Ok(root.expect("root checked above").join(path))
137}
138
139#[cfg(test)]
140mod tests {
141    #[cfg(feature = "storage-preflight")]
142    use super::StoragePreflight;
143    use super::StorageRoots;
144    use std::path::PathBuf;
145
146    #[test]
147    fn resolves_relative_paths_per_direction() {
148        let roots = StorageRoots::new(
149            Some(PathBuf::from("/mnt/input")),
150            Some(PathBuf::from("/mnt/output")),
151        );
152        assert_eq!(roots.resolve_input("scan.las").unwrap(), PathBuf::from("/mnt/input/scan.las"));
153        assert_eq!(
154            roots.resolve_output("runs/result.las").unwrap(),
155            PathBuf::from("/mnt/output/runs/result.las")
156        );
157    }
158
159    #[test]
160    fn absolute_paths_bypass_roots() {
161        let roots = StorageRoots::new(
162            Some(PathBuf::from("/mnt/input")),
163            Some(PathBuf::from("/mnt/output")),
164        );
165        assert_eq!(roots.resolve_input("/tmp/scan.las").unwrap(), PathBuf::from("/tmp/scan.las"));
166        assert_eq!(
167            roots.resolve_output("/tmp/result.las").unwrap(),
168            PathBuf::from("/tmp/result.las")
169        );
170    }
171
172    #[test]
173    fn rejects_relative_root_escape() {
174        let roots = StorageRoots::new(Some(PathBuf::from("/mnt/input")), None);
175        let error = roots.resolve_input("../private/scan.las").unwrap_err();
176        assert!(error.to_string().contains("must not contain `..`"));
177    }
178
179    #[cfg(feature = "storage-preflight")]
180    #[test]
181    fn preflight_reports_available_space_for_absolute_directory() {
182        let directory = tempfile::tempdir().unwrap();
183        let roots = StorageRoots::new(None, Some(directory.path().to_path_buf()));
184        let report = roots.preflight_output(1).unwrap();
185        assert_eq!(report.root, directory.path());
186        assert!(report.available_bytes >= report.required_free_bytes);
187    }
188
189    #[cfg(feature = "storage-preflight")]
190    #[test]
191    fn preflight_rejects_missing_output_root_and_relative_path() {
192        let roots = StorageRoots::default();
193        assert!(roots
194            .preflight_output(1)
195            .unwrap_err()
196            .to_string()
197            .contains("requires an output root"));
198        let error = StoragePreflight::check("relative", 1).unwrap_err();
199        assert!(error.to_string().contains("must be absolute"));
200    }
201}