Skip to main content

vortex_file/multi/
uri.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Parsing of user-supplied URI-or-path strings into URLs, shared by the language bindings
5//! that construct a [`MultiFileDataSource`](super::MultiFileDataSource).
6
7#[cfg(any(
8    unix,
9    windows,
10    target_os = "redox",
11    target_os = "wasi",
12    target_os = "hermit"
13))]
14use std::path::Component;
15#[cfg(any(
16    unix,
17    windows,
18    target_os = "redox",
19    target_os = "wasi",
20    target_os = "hermit"
21))]
22use std::path::Path;
23#[cfg(any(
24    unix,
25    windows,
26    target_os = "redox",
27    target_os = "wasi",
28    target_os = "hermit"
29))]
30use std::path::PathBuf;
31#[cfg(any(
32    unix,
33    windows,
34    target_os = "redox",
35    target_os = "wasi",
36    target_os = "hermit"
37))]
38use std::path::absolute;
39
40use url::Url;
41use vortex_error::VortexResult;
42use vortex_error::vortex_err;
43
44/// Parse a URI-or-path string into a [`Url`]:
45/// * full URLs (`s3://...`, `file:///...`) are used as-is,
46/// * bare (relative or absolute) file paths are made absolute, have `.`/`..` components
47///   normalized, and become `file://` URLs. On targets without a filesystem (e.g.
48///   `wasm32-unknown-unknown`), bare paths are rejected instead.
49///
50/// Glob characters are preserved, so glob patterns like `/data/*.vortex` parse as expected.
51pub fn parse_uri_or_path(uri_or_path: &str) -> VortexResult<Url> {
52    // `Url::parse` accepts Windows absolute paths like `C:\foo` as a URL with a
53    // single-letter scheme (`c`). No real URL scheme is one character, so treat any
54    // single-letter scheme as a filesystem path instead.
55    if let Ok(url) = Url::parse(uri_or_path)
56        && url.scheme().len() > 1
57    {
58        return Ok(url);
59    }
60    file_path_to_url(uri_or_path)
61}
62
63/// Convert a bare filesystem path into a `file://` URL, absolutizing it and normalizing
64/// `.`/`..` components. The cfg predicate matches `Url::from_file_path`'s availability in
65/// the `url` crate.
66#[cfg(any(
67    unix,
68    windows,
69    target_os = "redox",
70    target_os = "wasi",
71    target_os = "hermit"
72))]
73fn file_path_to_url(path: &str) -> VortexResult<Url> {
74    let abs =
75        absolute(Path::new(path)).map_err(|e| vortex_err!("failed to absolutize {path}: {e}"))?;
76    Url::from_file_path(normalize_path(abs))
77        .map_err(|_| vortex_err!("neither URL nor path: {path}"))
78}
79
80/// `Url::from_file_path` does not exist on targets without a filesystem, such as
81/// `wasm32-unknown-unknown`, so bare paths cannot be interpreted there.
82#[cfg(not(any(
83    unix,
84    windows,
85    target_os = "redox",
86    target_os = "wasi",
87    target_os = "hermit"
88)))]
89fn file_path_to_url(path: &str) -> VortexResult<Url> {
90    Err(vortex_err!(
91        "bare file paths are not supported on this platform: {path}"
92    ))
93}
94
95/// Normalize `.` and `..` without touching the filesystem.
96#[cfg(any(
97    unix,
98    windows,
99    target_os = "redox",
100    target_os = "wasi",
101    target_os = "hermit"
102))]
103fn normalize_path(path: PathBuf) -> PathBuf {
104    let mut out = PathBuf::new();
105    for component in path.components() {
106        match component {
107            Component::CurDir => {}
108            Component::ParentDir => {
109                out.pop();
110            }
111            c => out.push(c),
112        }
113    }
114    out
115}
116
117#[cfg(test)]
118mod tests {
119    use rstest::rstest;
120
121    use super::*;
122
123    #[test]
124    fn test_full_url() -> VortexResult<()> {
125        let url = parse_uri_or_path("s3://bucket/prefix/*.vortex")?;
126        assert_eq!(url.scheme(), "s3");
127        assert_eq!(url.host_str(), Some("bucket"));
128        assert_eq!(url.path(), "/prefix/*.vortex");
129        Ok(())
130    }
131
132    #[test]
133    fn test_file_scheme() -> VortexResult<()> {
134        let url = parse_uri_or_path("file:///absolute/path/data.vortex")?;
135        assert_eq!(url.scheme(), "file");
136        assert_eq!(url.path(), "/absolute/path/data.vortex");
137        Ok(())
138    }
139
140    #[test]
141    fn test_absolute_path() -> VortexResult<()> {
142        // Use a drive-prefixed input on Windows so `absolute()` doesn't inject the cwd drive
143        // and the expected URL path is predictable. The path does not need to exist.
144        #[cfg(unix)]
145        let (input, expected_path) = ("/tmp/data/*.vortex", "/tmp/data/*.vortex");
146        #[cfg(windows)]
147        let (input, expected_path) = (r"C:\tmp\data\*.vortex", "/C:/tmp/data/*.vortex");
148        let url = parse_uri_or_path(input)?;
149        assert_eq!(url.scheme(), "file");
150        assert_eq!(url.path(), expected_path);
151        Ok(())
152    }
153
154    #[test]
155    fn test_relative_path_resolves_against_cwd() -> VortexResult<()> {
156        for input in ["data/table", "./data/table", "../data/table"] {
157            let url = parse_uri_or_path(input)?;
158            assert_eq!(url.scheme(), "file");
159            assert!(url.path().starts_with('/'));
160            assert!(url.path().ends_with("/data/table"));
161            assert!(
162                !url.path().contains("%2E"),
163                "path must not contain percent-encoded dots"
164            );
165        }
166        Ok(())
167    }
168
169    #[test]
170    fn test_single_letter_scheme_is_path() -> VortexResult<()> {
171        // Regression: `Url::parse("C:\\tmp")` succeeds with scheme="c"; the function must
172        // treat that as a filesystem path, not a URL. Exercised on all platforms because
173        // the check lives in `parse_uri_or_path`, not in an OS-specific branch.
174        let url = parse_uri_or_path(r"C:\tmp\data\*.vortex")?;
175        assert_eq!(url.scheme(), "file");
176        assert_ne!(url.scheme(), "c");
177        Ok(())
178    }
179
180    // Use absolute paths so the expected result is cwd-independent.
181    #[cfg(unix)]
182    #[rstest]
183    #[case("/a/./b", "/a/b")]
184    #[case("/a/b/./c", "/a/b/c")]
185    #[case("/a/../b", "/b")]
186    #[case("/a/b/../c", "/a/c")]
187    #[case("/a/b/../../c", "/c")]
188    #[case("/a/./b/.././c", "/a/c")]
189    #[case("/a/b/../..", "/")]
190    fn test_dot_normalization(
191        #[case] input: &str,
192        #[case] expected_path: &str,
193    ) -> VortexResult<()> {
194        let url = parse_uri_or_path(input)?;
195        assert_eq!(url.scheme(), "file");
196        assert_eq!(
197            url.path(),
198            expected_path,
199            "input {input:?} should normalize to {expected_path:?}"
200        );
201        Ok(())
202    }
203}