1use 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
22const SUPPORTED_SCHEMES: &[&str] = &["http://", "https://", "file://", "az://", "s3://", "gs://"];
24
25fn 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
33pub(crate) fn is_file_url(s: &str) -> bool {
35 starts_with_ignore_ascii_case(s.trim_start(), "file://")
36}
37
38pub(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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub(crate) enum EvaluationPathKind {
48 Local(PathBuf),
50 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct EvaluationPath(EvaluationPathKind);
66
67impl EvaluationPath {
68 pub(crate) fn from_local_path(path: PathBuf) -> Self {
72 Self(EvaluationPathKind::Local(path))
73 }
74
75 pub fn join(&self, path: &str) -> Result<Self> {
77 if is_supported_url(path) {
79 return path.parse();
80 }
81
82 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 pub(crate) fn kind(&self) -> &EvaluationPathKind {
102 &self.0
103 }
104
105 pub(crate) fn into_kind(self) -> EvaluationPathKind {
107 self.0
108 }
109
110 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 pub fn is_local(&self) -> bool {
122 matches!(&self.0, EvaluationPathKind::Local(_))
123 }
124
125 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 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 pub fn is_remote(&self) -> bool {
149 matches!(&self.0, EvaluationPathKind::Remote(_))
150 }
151
152 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 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 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 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 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 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}