Skip to main content

wdl_engine/
path.rs

1//! Representation of evaluation paths that support URLs.
2
3use std::fmt;
4use std::path::Path;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use anyhow::Context;
9use anyhow::Result;
10use anyhow::anyhow;
11use anyhow::bail;
12use path_clean::PathClean;
13use url::Url;
14
15use crate::ContentKind;
16use crate::config::ContentDigestMode;
17use crate::digest::Digest;
18use crate::digest::calculate_local_digest;
19use crate::digest::calculate_remote_digest;
20use crate::http::Transferer;
21
22/// The URL schemes supported by this crate.
23const SUPPORTED_SCHEMES: &[&str] = &["http://", "https://", "file://", "az://", "s3://", "gs://"];
24
25/// Helper to check if a given string starts with the given prefix, ignoring
26/// ASCII case.
27fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
28    s.get(0..prefix.len())
29        .map(|s| s.eq_ignore_ascii_case(prefix))
30        .unwrap_or(false)
31}
32
33/// Determines if the given string is prefixed with a `file` URL scheme.
34pub(crate) fn is_file_url(s: &str) -> bool {
35    starts_with_ignore_ascii_case(s.trim_start(), "file://")
36}
37
38/// Determines if the given string is prefixed with a supported URL scheme.
39pub(crate) fn is_supported_url(s: &str) -> bool {
40    SUPPORTED_SCHEMES
41        .iter()
42        .any(|scheme| starts_with_ignore_ascii_case(s.trim_start(), scheme))
43}
44
45/// Represents the kind of an evaluation path.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub(crate) enum EvaluationPathKind {
48    /// The path is local (i.e. on the host).
49    Local(PathBuf),
50    /// The path is remote.
51    Remote(Url),
52}
53
54impl fmt::Display for EvaluationPathKind {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Local(path) => write!(f, "{path}", path = path.display()),
58            Self::Remote(url) => write!(f, "{url}"),
59        }
60    }
61}
62
63/// Represents a path used in evaluation that may be either local or remote.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct EvaluationPath(EvaluationPathKind);
66
67impl EvaluationPath {
68    /// Constructs an `EvaluationPath` from a local path.
69    ///
70    /// This is an internal method where we assume the path is already "clean".
71    pub(crate) fn from_local_path(path: PathBuf) -> Self {
72        Self(EvaluationPathKind::Local(path))
73    }
74
75    /// Joins the given path to this path.
76    pub fn join(&self, path: &str) -> Result<Self> {
77        // URLs are absolute, so they can't be joined
78        if is_supported_url(path) {
79            return path.parse();
80        }
81
82        // We can't join an absolute local path either
83        let p = Path::new(path);
84        if p.is_absolute() {
85            return Ok(Self(EvaluationPathKind::Local(p.clean())));
86        }
87
88        match &self.0 {
89            EvaluationPathKind::Local(dir) => {
90                Ok(Self(EvaluationPathKind::Local(dir.join(path).clean())))
91            }
92            EvaluationPathKind::Remote(dir) => Ok(Self(
93                dir.join(path)
94                    .map(EvaluationPathKind::Remote)
95                    .with_context(|| format!("failed to join `{path}` to URL `{dir}`"))?,
96            )),
97        }
98    }
99
100    /// Gets the underlying evaluation path kind.
101    pub(crate) fn kind(&self) -> &EvaluationPathKind {
102        &self.0
103    }
104
105    /// Converts to the underlying evaluation path kind.
106    pub(crate) fn into_kind(self) -> EvaluationPathKind {
107        self.0
108    }
109
110    /// Gets a string representation of the path.
111    ///
112    /// Returns `None` if the path can't be represented as UTF-8.
113    pub fn as_str(&self) -> Option<&str> {
114        match &self.0 {
115            EvaluationPathKind::Local(path) => path.to_str(),
116            EvaluationPathKind::Remote(url) => Some(url.as_str()),
117        }
118    }
119
120    /// Returns `true` if the path is local.
121    pub fn is_local(&self) -> bool {
122        matches!(&self.0, EvaluationPathKind::Local(_))
123    }
124
125    /// Converts the path to a local path.
126    ///
127    /// Returns `None` if the path is remote.
128    pub fn as_local(&self) -> Option<&Path> {
129        match &self.0 {
130            EvaluationPathKind::Local(path) => Some(path),
131            EvaluationPathKind::Remote(_) => None,
132        }
133    }
134
135    /// Unwraps the path to a local path.
136    ///
137    /// # Panics
138    ///
139    /// Panics if the path is remote.
140    pub fn unwrap_local(self) -> PathBuf {
141        match self.0 {
142            EvaluationPathKind::Local(path) => path,
143            EvaluationPathKind::Remote(_) => panic!("path is remote"),
144        }
145    }
146
147    /// Returns `true` if the path is remote.
148    pub fn is_remote(&self) -> bool {
149        matches!(&self.0, EvaluationPathKind::Remote(_))
150    }
151
152    /// Converts the path to a remote URL.
153    ///
154    /// Returns `None` if the path is local.
155    pub fn as_remote(&self) -> Option<&Url> {
156        match &self.0 {
157            EvaluationPathKind::Local(_) => None,
158            EvaluationPathKind::Remote(url) => Some(url),
159        }
160    }
161
162    /// Unwraps the path to a remote URL.
163    ///
164    /// # Panics
165    ///
166    /// Panics if the path is local.
167    pub fn unwrap_remote(self) -> Url {
168        match self.0 {
169            EvaluationPathKind::Local(_) => panic!("path is local"),
170            EvaluationPathKind::Remote(url) => url,
171        }
172    }
173
174    /// Gets the parent of the given path.
175    ///
176    /// Returns `None` if the evaluation path isn't valid or has no parent.
177    pub fn parent_of(path: &str) -> Option<Self> {
178        let path: EvaluationPath = path.parse().ok()?;
179        match path.0 {
180            EvaluationPathKind::Local(path) => path
181                .parent()
182                .map(|p| Self(EvaluationPathKind::Local(p.to_path_buf()))),
183            EvaluationPathKind::Remote(mut url) => {
184                if url.path() == "/" {
185                    return None;
186                }
187
188                if let Ok(mut segments) = url.path_segments_mut() {
189                    segments.pop_if_empty().pop();
190                }
191
192                Some(Self(EvaluationPathKind::Remote(url)))
193            }
194        }
195    }
196
197    /// Gets the file name of the path.
198    ///
199    /// Returns `Ok(None)` if the path does not contain a file name (i.e. is
200    /// root).
201    ///
202    /// Returns an error if the file name is not UTF-8.
203    pub fn file_name(&self) -> Result<Option<&str>> {
204        match &self.0 {
205            EvaluationPathKind::Local(path) => path
206                .file_name()
207                .map(|n| {
208                    n.to_str().with_context(|| {
209                        format!("path `{path}` is not UTF-8", path = path.display())
210                    })
211                })
212                .transpose(),
213            EvaluationPathKind::Remote(url) => {
214                Ok(url.path_segments().and_then(|mut s| s.next_back()))
215            }
216        }
217    }
218
219    /// Calculates the content digest of the evaluation path.
220    pub(crate) async fn calculate_digest(
221        &self,
222        transferer: &dyn Transferer,
223        kind: ContentKind,
224        mode: ContentDigestMode,
225    ) -> Result<Digest> {
226        match &self.0 {
227            EvaluationPathKind::Local(path) => calculate_local_digest(path, kind, mode).await,
228            EvaluationPathKind::Remote(url) => calculate_remote_digest(transferer, url, kind).await,
229        }
230    }
231}
232
233impl FromStr for EvaluationPath {
234    type Err = anyhow::Error;
235
236    fn from_str(s: &str) -> Result<Self> {
237        // Store `file` schemed URLs as local paths.
238        if is_file_url(s) {
239            let url = s
240                .parse::<Url>()
241                .with_context(|| format!("invalid `file` schemed URL `{s}`"))?;
242            return url
243                .to_file_path()
244                .map(|p| Self(EvaluationPathKind::Local(p.clean())))
245                .map_err(|_| anyhow!("URL `{s}` cannot be represented as a local file path"));
246        }
247
248        if is_supported_url(s) {
249            return Ok(Self(EvaluationPathKind::Remote(
250                s.parse().with_context(|| format!("URL `{s}` is invalid"))?,
251            )));
252        }
253
254        Ok(Self(EvaluationPathKind::Local(Path::new(s).clean())))
255    }
256}
257
258impl fmt::Display for EvaluationPath {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        self.0.fmt(f)
261    }
262}
263
264impl TryFrom<EvaluationPath> for String {
265    type Error = anyhow::Error;
266
267    fn try_from(path: EvaluationPath) -> Result<Self> {
268        match path.0 {
269            EvaluationPathKind::Local(path) => match path.into_os_string().into_string() {
270                Ok(s) => Ok(s),
271                Err(path) => bail!(
272                    "path `{path}` cannot be represented with UTF-8",
273                    path = path.display()
274                ),
275            },
276            EvaluationPathKind::Remote(url) => Ok(url.into()),
277        }
278    }
279}
280
281impl From<&Path> for EvaluationPath {
282    fn from(path: &Path) -> Self {
283        Self(EvaluationPathKind::Local(path.clean()))
284    }
285}
286
287impl TryFrom<Url> for EvaluationPath {
288    type Error = anyhow::Error;
289
290    fn try_from(url: Url) -> std::result::Result<Self, Self::Error> {
291        if !is_supported_url(url.as_str()) {
292            bail!("URL `{url}` is not supported");
293        }
294
295        Ok(Self(EvaluationPathKind::Remote(url)))
296    }
297}
298
299#[cfg(test)]
300mod test {
301    use pretty_assertions::assert_eq;
302
303    use super::*;
304
305    #[test]
306    fn test_file_urls() {
307        assert!(is_file_url("file:///foo/bar/baz"));
308        assert!(is_file_url("FiLe:///foo/bar/baz"));
309        assert!(is_file_url("FILE:///foo/bar/baz"));
310        assert!(!is_file_url("https://example.com/bar/baz"));
311        assert!(!is_file_url("az://foo/bar/baz"));
312    }
313
314    #[test]
315    fn test_urls() {
316        assert!(is_supported_url("http://example.com/foo/bar/baz"));
317        assert!(is_supported_url("HtTp://example.com/foo/bar/baz"));
318        assert!(is_supported_url("HTTP://example.com/foo/bar/baz"));
319        assert!(is_supported_url("https://example.com/foo/bar/baz"));
320        assert!(is_supported_url("HtTpS://example.com/foo/bar/baz"));
321        assert!(is_supported_url("HTTPS://example.com/foo/bar/baz"));
322        assert!(is_supported_url("file:///foo/bar/baz"));
323        assert!(is_supported_url("FiLe:///foo/bar/baz"));
324        assert!(is_supported_url("FILE:///foo/bar/baz"));
325        assert!(is_supported_url("az://foo/bar/baz"));
326        assert!(is_supported_url("aZ://foo/bar/baz"));
327        assert!(is_supported_url("AZ://foo/bar/baz"));
328        assert!(is_supported_url("s3://foo/bar/baz"));
329        assert!(is_supported_url("S3://foo/bar/baz"));
330        assert!(is_supported_url("gs://foo/bar/baz"));
331        assert!(is_supported_url("gS://foo/bar/baz"));
332        assert!(is_supported_url("GS://foo/bar/baz"));
333        assert!(!is_supported_url("foo://foo/bar/baz"));
334    }
335
336    #[test]
337    fn test_evaluation_path_parsing() {
338        let p: EvaluationPath = "/foo/bar/baz".parse().expect("should parse");
339        assert_eq!(
340            p.unwrap_local().to_str().unwrap().replace("\\", "/"),
341            "/foo/bar/baz"
342        );
343
344        let p: EvaluationPath = "foo".parse().expect("should parse");
345        assert_eq!(p.unwrap_local().as_os_str(), "foo");
346
347        #[cfg(unix)]
348        {
349            let p: EvaluationPath = "file:///foo/bar/baz".parse().expect("should parse");
350            assert_eq!(p.unwrap_local().as_os_str(), "/foo/bar/baz");
351        }
352
353        #[cfg(windows)]
354        {
355            let p: EvaluationPath = "file:///C:/foo/bar/baz".parse().expect("should parse");
356            assert_eq!(p.unwrap_local().as_os_str(), "C:\\foo\\bar\\baz");
357        }
358
359        let p: EvaluationPath = "https://example.com/foo/bar/baz"
360            .parse()
361            .expect("should parse");
362        assert_eq!(
363            p.unwrap_remote().as_str(),
364            "https://example.com/foo/bar/baz"
365        );
366
367        let p: EvaluationPath = "az://foo/bar/baz".parse().expect("should parse");
368        assert_eq!(p.unwrap_remote().as_str(), "az://foo/bar/baz");
369
370        let p: EvaluationPath = "s3://foo/bar/baz".parse().expect("should parse");
371        assert_eq!(p.unwrap_remote().as_str(), "s3://foo/bar/baz");
372
373        let p: EvaluationPath = "gs://foo/bar/baz".parse().expect("should parse");
374        assert_eq!(p.unwrap_remote().as_str(), "gs://foo/bar/baz");
375    }
376
377    #[test]
378    fn test_evaluation_path_join() {
379        let p: EvaluationPath = "/foo/bar/baz".parse().expect("should parse");
380        assert_eq!(
381            p.join("qux/../quux")
382                .expect("should join")
383                .unwrap_local()
384                .to_str()
385                .unwrap()
386                .replace("\\", "/"),
387            "/foo/bar/baz/quux"
388        );
389
390        let p: EvaluationPath = "foo".parse().expect("should parse");
391        assert_eq!(
392            p.join("qux/../quux")
393                .expect("should join")
394                .unwrap_local()
395                .to_str()
396                .unwrap()
397                .replace("\\", "/"),
398            "foo/quux"
399        );
400
401        #[cfg(unix)]
402        {
403            let p: EvaluationPath = "file:///foo/bar/baz".parse().expect("should parse");
404            assert_eq!(
405                p.join("qux/../quux")
406                    .expect("should join")
407                    .unwrap_local()
408                    .as_os_str(),
409                "/foo/bar/baz/quux"
410            );
411        }
412
413        #[cfg(windows)]
414        {
415            let p: EvaluationPath = "file:///C:/foo/bar/baz".parse().expect("should parse");
416            assert_eq!(
417                p.join("qux/../quux")
418                    .expect("should join")
419                    .unwrap_local()
420                    .as_os_str(),
421                "C:\\foo\\bar\\baz\\quux"
422            );
423        }
424
425        let p: EvaluationPath = "https://example.com/foo/bar/baz"
426            .parse()
427            .expect("should parse");
428        assert_eq!(
429            p.join("qux/../quux")
430                .expect("should join")
431                .unwrap_remote()
432                .as_str(),
433            "https://example.com/foo/bar/quux"
434        );
435
436        let p: EvaluationPath = "https://example.com/foo/bar/baz/"
437            .parse()
438            .expect("should parse");
439        assert_eq!(
440            p.join("qux/../quux")
441                .expect("should join")
442                .unwrap_remote()
443                .as_str(),
444            "https://example.com/foo/bar/baz/quux"
445        );
446
447        let p: EvaluationPath = "az://foo/bar/baz/".parse().expect("should parse");
448        assert_eq!(
449            p.join("qux/../quux")
450                .expect("should join")
451                .unwrap_remote()
452                .as_str(),
453            "az://foo/bar/baz/quux"
454        );
455
456        let p: EvaluationPath = "s3://foo/bar/baz/".parse().expect("should parse");
457        assert_eq!(
458            p.join("qux/../quux")
459                .expect("should join")
460                .unwrap_remote()
461                .as_str(),
462            "s3://foo/bar/baz/quux"
463        );
464
465        let p: EvaluationPath = "gs://foo/bar/baz/".parse().expect("should parse");
466        assert_eq!(
467            p.join("qux/../quux")
468                .expect("should join")
469                .unwrap_remote()
470                .as_str(),
471            "gs://foo/bar/baz/quux"
472        );
473    }
474}