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