spatialrust_io/
storage.rs1use std::path::{Component, Path, PathBuf};
4
5#[cfg(feature = "storage-preflight")]
6use fs2::available_space;
7
8use crate::IoError;
9
10#[cfg(feature = "storage-preflight")]
12pub const DEFAULT_MIN_OUTPUT_FREE_BYTES: u64 = 20 * 1024 * 1024 * 1024;
13
14#[cfg(feature = "storage-preflight")]
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct StoragePreflight {
18 pub root: PathBuf,
20 pub available_bytes: u64,
22 pub required_free_bytes: u64,
24}
25
26#[cfg(feature = "storage-preflight")]
27impl StoragePreflight {
28 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct StorageRoots {
70 input_root: Option<PathBuf>,
71 output_root: Option<PathBuf>,
72}
73
74impl StorageRoots {
75 #[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 #[must_use]
83 pub fn input_root(&self) -> Option<&Path> {
84 self.input_root.as_deref()
85 }
86
87 #[must_use]
89 pub fn output_root(&self) -> Option<&Path> {
90 self.output_root.as_deref()
91 }
92
93 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 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 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 #[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}