Skip to main content

timeseries_table_format/storage/
table_location.rs

1use 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/// Table root location with table-scoped semantics.
13///
14/// This wraps `StorageLocation` and is used when callers need to treat the
15/// location as a table root (e.g. log layout and segment paths).
16#[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    /// Creates a new `TableLocation` for a local filesystem path.
33    pub fn local(root: impl Into<PathBuf>) -> Self {
34        TableLocation(StorageLocation::Local(root.into()))
35    }
36
37    /// Parse a user-facing table location string into a TableLocation.
38    /// v0.1: only local filesystem paths are supported.
39    pub fn parse(spec: &str) -> StorageResult<Self> {
40        StorageLocation::parse(spec).map(TableLocation)
41    }
42
43    /// Return the underlying StorageLocation
44    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
77/// Normalize a portable table-relative path into its storage key and native path.
78pub(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
123/// Verify that a table-relative storage key is already in canonical form.
124pub(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}