unitycatalog_server/services/
location_policy.rs1use std::path::{Component, Path, PathBuf};
16
17use super::location::StorageLocationUrl;
18use crate::{Error, Result};
19
20#[derive(Debug, Clone, Default)]
27pub struct LocalStoragePolicy {
28 allowed_roots: Vec<PathBuf>,
30}
31
32impl LocalStoragePolicy {
33 pub fn new<I, P>(roots: I) -> Result<Self>
40 where
41 I: IntoIterator<Item = P>,
42 P: AsRef<Path>,
43 {
44 let allowed_roots = roots
45 .into_iter()
46 .map(|root| {
47 let root = root.as_ref();
48 root.canonicalize().map_err(|e| {
49 Error::invalid_argument(format!(
50 "allowed local storage root '{}' is not accessible: {e}",
51 root.display()
52 ))
53 })
54 })
55 .collect::<Result<Vec<_>>>()?;
56 Ok(Self { allowed_roots })
57 }
58
59 pub fn deny_all() -> Self {
62 Self::default()
63 }
64
65 pub(crate) fn allowed_roots(&self) -> &[PathBuf] {
68 &self.allowed_roots
69 }
70
71 pub fn check(&self, location: &StorageLocationUrl) -> Result<()> {
78 if !location.is_local() {
79 return Ok(());
80 }
81
82 let path = location.raw().to_file_path().map_err(|_| {
83 Error::invalid_argument(format!("not a valid local file path: {}", location.raw()))
84 })?;
85
86 if path.components().any(|c| matches!(c, Component::ParentDir)) {
90 return Err(Error::invalid_argument(format!(
91 "local storage path '{}' must not contain '..'",
92 path.display()
93 )));
94 }
95 let normalized = lexically_normalize(&path);
96
97 if self.allowed_roots.is_empty() {
98 return Err(Error::invalid_argument(format!(
99 "local (file://) storage is not enabled on this server; \
100 path '{}' is not within any allowed root",
101 normalized.display()
102 )));
103 }
104
105 if self
106 .allowed_roots
107 .iter()
108 .any(|root| is_within(&normalized, root))
109 {
110 Ok(())
111 } else {
112 Err(Error::invalid_argument(format!(
113 "local storage path '{}' is not within any allowed root",
114 normalized.display()
115 )))
116 }
117 }
118}
119
120fn lexically_normalize(path: &Path) -> PathBuf {
125 path.components()
126 .filter(|c| !matches!(c, Component::CurDir))
127 .collect()
128}
129
130fn is_within(path: &Path, root: &Path) -> bool {
133 let mut root_parts = root.components();
134 let mut path_parts = path.components();
135 loop {
136 match (root_parts.next(), path_parts.next()) {
137 (None, _) => return true,
139 (Some(_), None) => return false,
141 (Some(a), Some(b)) if a != b => return false,
142 (Some(_), Some(_)) => continue,
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn loc(url: &str) -> StorageLocationUrl {
152 StorageLocationUrl::parse(url).unwrap()
153 }
154
155 #[test]
156 fn deny_all_rejects_every_file_url() {
157 let policy = LocalStoragePolicy::deny_all();
158 assert!(policy.check(&loc("file:///tmp/anything")).is_err());
159 assert!(policy.check(&loc("file:///")).is_err());
160 }
161
162 #[test]
163 fn cloud_schemes_pass_through_even_with_empty_policy() {
164 let policy = LocalStoragePolicy::deny_all();
165 assert!(policy.check(&loc("s3://bucket/data")).is_ok());
166 assert!(
167 policy
168 .check(&loc("abfss://c@acct.dfs.core.windows.net/x"))
169 .is_ok()
170 );
171 }
172
173 #[cfg(not(windows))]
181 #[test]
182 fn allows_paths_within_a_configured_root() {
183 let dir = tempfile::tempdir().unwrap();
184 let root = dir.path().canonicalize().unwrap();
185 let policy = LocalStoragePolicy::new([&root]).unwrap();
186
187 let inside = url::Url::from_directory_path(root.join("catalog/table"))
188 .unwrap()
189 .to_string();
190 assert!(policy.check(&loc(&inside)).is_ok());
191 let at_root = url::Url::from_directory_path(&root).unwrap().to_string();
193 assert!(policy.check(&loc(&at_root)).is_ok());
194 }
195
196 #[cfg(not(windows))]
197 #[test]
198 fn rejects_paths_outside_every_root() {
199 let dir = tempfile::tempdir().unwrap();
200 let root = dir.path().canonicalize().unwrap();
201 let policy = LocalStoragePolicy::new([&root]).unwrap();
202 assert!(policy.check(&loc("file:///etc/passwd")).is_err());
203 }
204
205 #[cfg(not(windows))]
206 #[test]
207 fn rejects_sibling_prefix() {
208 let dir = tempfile::tempdir().unwrap();
211 let root = dir.path().canonicalize().unwrap();
212 let policy = LocalStoragePolicy::new([&root]).unwrap();
213
214 let mut sibling = root.clone();
215 let name = format!("{}-secret", root.file_name().unwrap().to_string_lossy());
216 sibling.set_file_name(name);
217 sibling.push("data");
218 let url = url::Url::from_directory_path(&sibling).unwrap().to_string();
219 assert!(policy.check(&loc(&url)).is_err());
220 }
221
222 #[cfg(not(windows))]
223 #[test]
224 fn rejects_parent_dir_traversal() {
225 let dir = tempfile::tempdir().unwrap();
226 let root = dir.path().canonicalize().unwrap();
227 let policy = LocalStoragePolicy::new([&root]).unwrap();
228
229 let escape = url::Url::from_directory_path(root.join("..").join("escape"))
233 .unwrap()
234 .to_string();
235 assert!(policy.check(&loc(&escape)).is_err());
236 }
237
238 #[test]
239 fn new_errors_on_missing_root() {
240 let result = LocalStoragePolicy::new(["/this/path/does/not/exist/uc-xyz"]);
241 assert!(result.is_err());
242 }
243
244 #[test]
245 fn is_within_matches_on_component_boundaries() {
246 assert!(is_within(Path::new("/data/t1"), Path::new("/data")));
247 assert!(is_within(Path::new("/data"), Path::new("/data")));
248 assert!(!is_within(Path::new("/data-secret/t1"), Path::new("/data")));
249 assert!(!is_within(Path::new("/other/t1"), Path::new("/data")));
250 }
251}