timeseries_table_format/storage/
table_location.rs1use std::path::{Path, PathBuf};
2
3#[cfg(feature = "datafusion")]
4use object_store::path::Path as ObjectStorePath;
5#[cfg(feature = "datafusion")]
6use snafu::{IntoError, ResultExt};
7
8#[cfg(feature = "datafusion")]
9use crate::storage::{OtherIoSnafu, StorageBackendError};
10use crate::storage::{StorageError, StorageLocation, StorageResult};
11
12#[derive(Debug, Clone)]
17pub struct TableLocation(StorageLocation);
18
19impl From<TableLocation> for StorageLocation {
20 fn from(t: TableLocation) -> Self {
21 t.0
22 }
23}
24
25impl AsRef<StorageLocation> for TableLocation {
26 fn as_ref(&self) -> &StorageLocation {
27 &self.0
28 }
29}
30
31impl TableLocation {
32 pub fn local(root: impl Into<PathBuf>) -> Self {
34 TableLocation(StorageLocation::Local(root.into()))
35 }
36
37 pub fn parse(spec: &str) -> StorageResult<Self> {
40 StorageLocation::parse(spec).map(TableLocation)
41 }
42
43 pub fn storage(&self) -> &StorageLocation {
45 &self.0
46 }
47
48 #[cfg(feature = "datafusion")]
49 pub(crate) fn object_store_url(&self) -> String {
50 match self.as_ref() {
51 StorageLocation::Local(_) => "file://".to_owned(),
52 }
53 }
54
55 #[cfg(feature = "datafusion")]
56 pub(crate) fn object_store_path(&self, relative_path: &Path) -> StorageResult<ObjectStorePath> {
57 let (normalized, native_path) = normalize_relative_storage_path(relative_path)?;
58
59 match self.as_ref() {
60 StorageLocation::Local(root) => {
61 let absolute = std::path::absolute(root.join(native_path))
62 .map_err(StorageBackendError::from)
63 .context(OtherIoSnafu {
64 path: normalized.clone(),
65 })?;
66
67 ObjectStorePath::from_absolute_path(absolute).map_err(|source| {
68 OtherIoSnafu { path: normalized }.into_error(StorageBackendError::from(
69 std::io::Error::new(std::io::ErrorKind::InvalidInput, source),
70 ))
71 })
72 }
73 }
74 }
75}
76
77pub(crate) fn normalize_relative_storage_path(path: &Path) -> StorageResult<(String, PathBuf)> {
79 let supplied = path
80 .to_str()
81 .ok_or_else(|| invalid_relative_storage_path(path, "path is not valid UTF-8"))?;
82 let portable = supplied.replace('\\', "/");
83
84 if portable.is_empty() {
85 return Err(invalid_relative_storage_path(path, "path is empty"));
86 }
87 if portable.starts_with('/') {
88 return Err(invalid_relative_storage_path(path, "path must be relative"));
89 }
90
91 let mut components = Vec::new();
92 for component in portable
93 .split('/')
94 .filter(|component| !component.is_empty())
95 {
96 if component == "." || component == ".." {
97 return Err(invalid_relative_storage_path(
98 path,
99 "path contains a current- or parent-directory component",
100 ));
101 }
102 let bytes = component.as_bytes();
103 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
104 return Err(invalid_relative_storage_path(
105 path,
106 "path contains a platform prefix",
107 ));
108 }
109 components.push(component);
110 }
111
112 if components.is_empty() {
113 return Err(invalid_relative_storage_path(path, "path is empty"));
114 }
115 let normalized = components.join("/");
116 let mut native_path = PathBuf::new();
117 for component in components {
118 native_path.push(component);
119 }
120 Ok((normalized, native_path))
121}
122
123pub(crate) fn ensure_canonical_relative_storage_path(path: &str) -> StorageResult<()> {
125 let (canonical, _) = normalize_relative_storage_path(Path::new(path))?;
126 if canonical != path {
127 return Err(invalid_relative_storage_path(
128 Path::new(path),
129 format!("path is not canonical; expected {canonical:?}"),
130 ));
131 }
132 Ok(())
133}
134
135fn invalid_relative_storage_path(path: &Path, reason: impl Into<String>) -> StorageError {
136 let path = if path.as_os_str().is_empty() {
137 "<empty>".to_owned()
138 } else {
139 path.display().to_string()
140 };
141 StorageError::InvalidRelativePath {
142 path,
143 reason: reason.into(),
144 backtrace: Box::new(snafu::Backtrace::capture()),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use crate::storage::StorageError;
151
152 use super::*;
153
154 #[test]
155 fn normalize_relative_storage_path_normalizes_separators_and_rejects_unsafe_components() {
156 assert_eq!(
157 normalize_relative_storage_path(Path::new("data/seg.parquet")).unwrap(),
158 (
159 "data/seg.parquet".to_string(),
160 PathBuf::from("data").join("seg.parquet")
161 )
162 );
163 assert_eq!(
164 normalize_relative_storage_path(Path::new(r"data\seg.parquet")).unwrap(),
165 (
166 "data/seg.parquet".to_string(),
167 PathBuf::from("data").join("seg.parquet")
168 )
169 );
170
171 for invalid in [
172 "",
173 "/data/seg.parquet",
174 r"C:\data\seg.parquet",
175 "data/C:/seg.parquet",
176 "data/C:seg.parquet",
177 "data/./seg.parquet",
178 "data/../seg.parquet",
179 ] {
180 let err = normalize_relative_storage_path(Path::new(invalid))
181 .expect_err("path must be rejected");
182 assert!(
183 matches!(err, StorageError::InvalidRelativePath { .. }),
184 "{invalid}"
185 );
186 }
187 }
188
189 #[test]
190 fn canonical_relative_storage_path_rejects_normalizable_spellings() {
191 ensure_canonical_relative_storage_path("data/seg.parquet").unwrap();
192
193 for path in [r"data\seg.parquet", "data//seg.parquet"] {
194 let error = ensure_canonical_relative_storage_path(path)
195 .expect_err("non-canonical path must be rejected");
196 assert!(
197 matches!(error, StorageError::InvalidRelativePath { .. }),
198 "{path}"
199 );
200 assert!(error.to_string().contains("data/seg.parquet"));
201 }
202 }
203}