bevy_asset/
path.rs

1use crate::io::AssetSourceId;
2use alloc::{
3    borrow::ToOwned,
4    string::{String, ToString},
5};
6use atomicow::CowArc;
7use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize};
8use core::{
9    fmt::{Debug, Display},
10    hash::Hash,
11    ops::Deref,
12};
13use serde::{de::Visitor, Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17/// Represents a path to an asset in a "virtual filesystem".
18///
19/// Asset paths consist of three main parts:
20/// * [`AssetPath::source`]: The name of the [`AssetSource`](crate::io::AssetSource) to load the asset from.
21///   This is optional. If one is not set the default source will be used (which is the `assets` folder by default).
22/// * [`AssetPath::path`]: The "virtual filesystem path" pointing to an asset source file.
23/// * [`AssetPath::label`]: An optional "named sub asset". When assets are loaded, they are
24///   allowed to load "sub assets" of any type, which are identified by a named "label".
25///
26/// Asset paths are generally constructed (and visualized) as strings:
27///
28/// ```no_run
29/// # use bevy_asset::{Asset, AssetServer, Handle};
30/// # use bevy_reflect::TypePath;
31/// #
32/// # #[derive(Asset, TypePath, Default)]
33/// # struct Mesh;
34/// #
35/// # #[derive(Asset, TypePath, Default)]
36/// # struct Scene;
37/// #
38/// # let asset_server: AssetServer = panic!();
39/// // This loads the `my_scene.scn` base asset from the default asset source.
40/// let scene: Handle<Scene> = asset_server.load("my_scene.scn");
41///
42/// // This loads the `PlayerMesh` labeled asset from the `my_scene.scn` base asset in the default asset source.
43/// let mesh: Handle<Mesh> = asset_server.load("my_scene.scn#PlayerMesh");
44///
45/// // This loads the `my_scene.scn` base asset from a custom 'remote' asset source.
46/// let scene: Handle<Scene> = asset_server.load("remote://my_scene.scn");
47/// ```
48///
49/// [`AssetPath`] implements [`From`] for `&'static str`, `&'static Path`, and `&'a String`,
50/// which allows us to optimize the static cases.
51/// This means that the common case of `asset_server.load("my_scene.scn")` when it creates and
52/// clones internal owned [`AssetPaths`](AssetPath).
53/// This also means that you should use [`AssetPath::parse`] in cases where `&str` is the explicit type.
54#[derive(Eq, PartialEq, Hash, Clone, Default, Reflect)]
55#[reflect(opaque)]
56#[reflect(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
57pub struct AssetPath<'a> {
58    source: AssetSourceId<'a>,
59    path: CowArc<'a, Path>,
60    label: Option<CowArc<'a, str>>,
61}
62
63impl<'a> Debug for AssetPath<'a> {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        Display::fmt(self, f)
66    }
67}
68
69impl<'a> Display for AssetPath<'a> {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        if let AssetSourceId::Name(name) = self.source() {
72            write!(f, "{name}://")?;
73        }
74        write!(f, "{}", self.path.display())?;
75        if let Some(label) = &self.label {
76            write!(f, "#{label}")?;
77        }
78        Ok(())
79    }
80}
81
82/// An error that occurs when parsing a string type to create an [`AssetPath`] fails, such as during [`AssetPath::parse`].
83#[derive(Error, Debug, PartialEq, Eq)]
84pub enum ParseAssetPathError {
85    /// Error that occurs when the [`AssetPath::source`] section of a path string contains the [`AssetPath::label`] delimiter `#`. E.g. `bad#source://file.test`.
86    #[error("Asset source must not contain a `#` character")]
87    InvalidSourceSyntax,
88    /// Error that occurs when the [`AssetPath::label`] section of a path string contains the [`AssetPath::source`] delimiter `://`. E.g. `source://file.test#bad://label`.
89    #[error("Asset label must not contain a `://` substring")]
90    InvalidLabelSyntax,
91    /// Error that occurs when a path string has an [`AssetPath::source`] delimiter `://` with no characters preceding it. E.g. `://file.test`.
92    #[error("Asset source must be at least one character. Either specify the source before the '://' or remove the `://`")]
93    MissingSource,
94    /// Error that occurs when a path string has an [`AssetPath::label`] delimiter `#` with no characters succeeding it. E.g. `file.test#`
95    #[error("Asset label must be at least one character. Either specify the label after the '#' or remove the '#'")]
96    MissingLabel,
97}
98
99impl<'a> AssetPath<'a> {
100    /// Creates a new [`AssetPath`] from a string in the asset path format:
101    /// * An asset at the root: `"scene.gltf"`
102    /// * An asset nested in some folders: `"some/path/scene.gltf"`
103    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
104    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
105    ///
106    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
107    /// and reference counting for [`AssetPath::into_owned`].
108    ///
109    /// # Panics
110    /// Panics if the asset path is in an invalid format. Use [`AssetPath::try_parse`] for a fallible variant
111    pub fn parse(asset_path: &'a str) -> AssetPath<'a> {
112        Self::try_parse(asset_path).unwrap()
113    }
114
115    /// Creates a new [`AssetPath`] from a string in the asset path format:
116    /// * An asset at the root: `"scene.gltf"`
117    /// * An asset nested in some folders: `"some/path/scene.gltf"`
118    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
119    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
120    ///
121    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
122    /// and reference counting for [`AssetPath::into_owned`].
123    ///
124    /// This will return a [`ParseAssetPathError`] if `asset_path` is in an invalid format.
125    pub fn try_parse(asset_path: &'a str) -> Result<AssetPath<'a>, ParseAssetPathError> {
126        let (source, path, label) = Self::parse_internal(asset_path)?;
127        Ok(Self {
128            source: match source {
129                Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)),
130                None => AssetSourceId::Default,
131            },
132            path: CowArc::Borrowed(path),
133            label: label.map(CowArc::Borrowed),
134        })
135    }
136
137    // Attempts to Parse a &str into an `AssetPath`'s `AssetPath::source`, `AssetPath::path`, and `AssetPath::label` components.
138    fn parse_internal(
139        asset_path: &str,
140    ) -> Result<(Option<&str>, &Path, Option<&str>), ParseAssetPathError> {
141        let chars = asset_path.char_indices();
142        let mut source_range = None;
143        let mut path_range = 0..asset_path.len();
144        let mut label_range = None;
145
146        // Loop through the characters of the passed in &str to accomplish the following:
147        // 1. Search for the first instance of the `://` substring. If the `://` substring is found,
148        //  store the range of indices representing everything before the `://` substring as the `source_range`.
149        // 2. Search for the last instance of the `#` character. If the `#` character is found,
150        //  store the range of indices representing everything after the `#` character as the `label_range`
151        // 3. Set the `path_range` to be everything in between the `source_range` and `label_range`,
152        //  excluding the `://` substring and `#` character.
153        // 4. Verify that there are no `#` characters in the `AssetPath::source` and no `://` substrings in the `AssetPath::label`
154        let mut source_delimiter_chars_matched = 0;
155        let mut last_found_source_index = 0;
156        for (index, char) in chars {
157            match char {
158                ':' => {
159                    source_delimiter_chars_matched = 1;
160                }
161                '/' => {
162                    match source_delimiter_chars_matched {
163                        1 => {
164                            source_delimiter_chars_matched = 2;
165                        }
166                        2 => {
167                            // If we haven't found our first `AssetPath::source` yet, check to make sure it is valid and then store it.
168                            if source_range.is_none() {
169                                // If the `AssetPath::source` contains a `#` character, it is invalid.
170                                if label_range.is_some() {
171                                    return Err(ParseAssetPathError::InvalidSourceSyntax);
172                                }
173                                source_range = Some(0..index - 2);
174                                path_range.start = index + 1;
175                            }
176                            last_found_source_index = index - 2;
177                            source_delimiter_chars_matched = 0;
178                        }
179                        _ => {}
180                    }
181                }
182                '#' => {
183                    path_range.end = index;
184                    label_range = Some(index + 1..asset_path.len());
185                    source_delimiter_chars_matched = 0;
186                }
187                _ => {
188                    source_delimiter_chars_matched = 0;
189                }
190            }
191        }
192        // If we found an `AssetPath::label`
193        if let Some(range) = label_range.clone() {
194            // If the `AssetPath::label` contained a `://` substring, it is invalid.
195            if range.start <= last_found_source_index {
196                return Err(ParseAssetPathError::InvalidLabelSyntax);
197            }
198        }
199        // Try to parse the range of indices that represents the `AssetPath::source` portion of the `AssetPath` to make sure it is not empty.
200        // This would be the case if the input &str was something like `://some/file.test`
201        let source = match source_range {
202            Some(source_range) => {
203                if source_range.is_empty() {
204                    return Err(ParseAssetPathError::MissingSource);
205                }
206                Some(&asset_path[source_range])
207            }
208            None => None,
209        };
210        // Try to parse the range of indices that represents the `AssetPath::label` portion of the `AssetPath` to make sure it is not empty.
211        // This would be the case if the input &str was something like `some/file.test#`.
212        let label = match label_range {
213            Some(label_range) => {
214                if label_range.is_empty() {
215                    return Err(ParseAssetPathError::MissingLabel);
216                }
217                Some(&asset_path[label_range])
218            }
219            None => None,
220        };
221
222        let path = Path::new(&asset_path[path_range]);
223        Ok((source, path, label))
224    }
225
226    /// Creates a new [`AssetPath`] from a [`PathBuf`].
227    #[inline]
228    pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> {
229        AssetPath {
230            path: CowArc::Owned(path_buf.into()),
231            source: AssetSourceId::Default,
232            label: None,
233        }
234    }
235
236    /// Creates a new [`AssetPath`] from a [`Path`].
237    #[inline]
238    pub fn from_path(path: &'a Path) -> AssetPath<'a> {
239        AssetPath {
240            path: CowArc::Borrowed(path),
241            source: AssetSourceId::Default,
242            label: None,
243        }
244    }
245
246    /// Gets the "asset source", if one was defined. If none was defined, the default source
247    /// will be used.
248    #[inline]
249    pub fn source(&self) -> &AssetSourceId<'_> {
250        &self.source
251    }
252
253    /// Gets the "sub-asset label".
254    #[inline]
255    pub fn label(&self) -> Option<&str> {
256        self.label.as_deref()
257    }
258
259    /// Gets the "sub-asset label".
260    #[inline]
261    pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
262        self.label.clone()
263    }
264
265    /// Gets the path to the asset in the "virtual filesystem".
266    #[inline]
267    pub fn path(&self) -> &Path {
268        self.path.deref()
269    }
270
271    /// Gets the path to the asset in the "virtual filesystem" without a label (if a label is currently set).
272    #[inline]
273    pub fn without_label(&self) -> AssetPath<'_> {
274        Self {
275            source: self.source.clone(),
276            path: self.path.clone(),
277            label: None,
278        }
279    }
280
281    /// Removes a "sub-asset label" from this [`AssetPath`], if one was set.
282    #[inline]
283    pub fn remove_label(&mut self) {
284        self.label = None;
285    }
286
287    /// Takes the "sub-asset label" from this [`AssetPath`], if one was set.
288    #[inline]
289    pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
290        self.label.take()
291    }
292
293    /// Returns this asset path with the given label. This will replace the previous
294    /// label if it exists.
295    #[inline]
296    pub fn with_label(self, label: impl Into<CowArc<'a, str>>) -> AssetPath<'a> {
297        AssetPath {
298            source: self.source,
299            path: self.path,
300            label: Some(label.into()),
301        }
302    }
303
304    /// Returns this asset path with the given asset source. This will replace the previous asset
305    /// source if it exists.
306    #[inline]
307    pub fn with_source(self, source: impl Into<AssetSourceId<'a>>) -> AssetPath<'a> {
308        AssetPath {
309            source: source.into(),
310            path: self.path,
311            label: self.label,
312        }
313    }
314
315    /// Returns an [`AssetPath`] for the parent folder of this path, if there is a parent folder in the path.
316    pub fn parent(&self) -> Option<AssetPath<'a>> {
317        let path = match &self.path {
318            CowArc::Borrowed(path) => CowArc::Borrowed(path.parent()?),
319            CowArc::Static(path) => CowArc::Static(path.parent()?),
320            CowArc::Owned(path) => path.parent()?.to_path_buf().into(),
321        };
322        Some(AssetPath {
323            source: self.source.clone(),
324            label: None,
325            path,
326        })
327    }
328
329    /// Converts this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
330    /// If internally a value is a static reference, the static reference will be used unchanged.
331    /// If internally a value is an "owned [`Arc`]", it will remain unchanged.
332    ///
333    /// [`Arc`]: alloc::sync::Arc
334    pub fn into_owned(self) -> AssetPath<'static> {
335        AssetPath {
336            source: self.source.into_owned(),
337            path: self.path.into_owned(),
338            label: self.label.map(CowArc::into_owned),
339        }
340    }
341
342    /// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
343    /// If internally a value is a static reference, the static reference will be used unchanged.
344    /// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned.
345    ///
346    /// [`Arc`]: alloc::sync::Arc
347    #[inline]
348    pub fn clone_owned(&self) -> AssetPath<'static> {
349        self.clone().into_owned()
350    }
351
352    /// Resolves a relative asset path via concatenation. The result will be an `AssetPath` which
353    /// is resolved relative to this "base" path.
354    ///
355    /// ```
356    /// # use bevy_asset::AssetPath;
357    /// assert_eq!(AssetPath::parse("a/b").resolve("c"), Ok(AssetPath::parse("a/b/c")));
358    /// assert_eq!(AssetPath::parse("a/b").resolve("./c"), Ok(AssetPath::parse("a/b/c")));
359    /// assert_eq!(AssetPath::parse("a/b").resolve("../c"), Ok(AssetPath::parse("a/c")));
360    /// assert_eq!(AssetPath::parse("a/b").resolve("c.png"), Ok(AssetPath::parse("a/b/c.png")));
361    /// assert_eq!(AssetPath::parse("a/b").resolve("/c"), Ok(AssetPath::parse("c")));
362    /// assert_eq!(AssetPath::parse("a/b.png").resolve("#c"), Ok(AssetPath::parse("a/b.png#c")));
363    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve("#d"), Ok(AssetPath::parse("a/b.png#d")));
364    /// ```
365    ///
366    /// There are several cases:
367    ///
368    /// If the `path` argument begins with `#`, then it is considered an asset label, in which case
369    /// the result is the base path with the label portion replaced.
370    ///
371    /// If the path argument begins with '/', then it is considered a 'full' path, in which
372    /// case the result is a new `AssetPath` consisting of the base path asset source
373    /// (if there is one) with the path and label portions of the relative path. Note that a 'full'
374    /// asset path is still relative to the asset source root, and not necessarily an absolute
375    /// filesystem path.
376    ///
377    /// If the `path` argument begins with an asset source (ex: `http://`) then the entire base
378    /// path is replaced - the result is the source, path and label (if any) of the `path`
379    /// argument.
380    ///
381    /// Otherwise, the `path` argument is considered a relative path. The result is concatenated
382    /// using the following algorithm:
383    ///
384    /// * The base path and the `path` argument are concatenated.
385    /// * Path elements consisting of "/." or "&lt;name&gt;/.." are removed.
386    ///
387    /// If there are insufficient segments in the base path to match the ".." segments,
388    /// then any left-over ".." segments are left as-is.
389    pub fn resolve(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
390        self.resolve_internal(path, false)
391    }
392
393    /// Resolves an embedded asset path via concatenation. The result will be an `AssetPath` which
394    /// is resolved relative to this path. This is similar in operation to `resolve`, except that
395    /// the 'file' portion of the base path (that is, any characters after the last '/')
396    /// is removed before concatenation, in accordance with the behavior specified in
397    /// IETF RFC 1808 "Relative URIs".
398    ///
399    /// The reason for this behavior is that embedded URIs which start with "./" or "../" are
400    /// relative to the *directory* containing the asset, not the asset file. This is consistent
401    /// with the behavior of URIs in `JavaScript`, CSS, HTML and other web file formats. The
402    /// primary use case for this method is resolving relative paths embedded within asset files,
403    /// which are relative to the asset in which they are contained.
404    ///
405    /// ```
406    /// # use bevy_asset::AssetPath;
407    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c"), Ok(AssetPath::parse("a/c")));
408    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("./c"), Ok(AssetPath::parse("a/c")));
409    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("../c"), Ok(AssetPath::parse("c")));
410    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c.png"), Ok(AssetPath::parse("a/c.png")));
411    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("/c"), Ok(AssetPath::parse("c")));
412    /// assert_eq!(AssetPath::parse("a/b.png").resolve_embed("#c"), Ok(AssetPath::parse("a/b.png#c")));
413    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve_embed("#d"), Ok(AssetPath::parse("a/b.png#d")));
414    /// ```
415    pub fn resolve_embed(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
416        self.resolve_internal(path, true)
417    }
418
419    fn resolve_internal(
420        &self,
421        path: &str,
422        replace: bool,
423    ) -> Result<AssetPath<'static>, ParseAssetPathError> {
424        if let Some(label) = path.strip_prefix('#') {
425            // It's a label only
426            Ok(self.clone_owned().with_label(label.to_owned()))
427        } else {
428            let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
429            let mut base_path = PathBuf::from(self.path());
430            if replace && !self.path.to_str().unwrap().ends_with('/') {
431                // No error if base is empty (per RFC 1808).
432                base_path.pop();
433            }
434
435            // Strip off leading slash
436            let mut is_absolute = false;
437            let rpath = match rpath.strip_prefix("/") {
438                Ok(p) => {
439                    is_absolute = true;
440                    p
441                }
442                _ => rpath,
443            };
444
445            let mut result_path = if !is_absolute && source.is_none() {
446                base_path
447            } else {
448                PathBuf::new()
449            };
450            result_path.push(rpath);
451            result_path = normalize_path(result_path.as_path());
452
453            Ok(AssetPath {
454                source: match source {
455                    Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
456                    None => self.source.clone_owned(),
457                },
458                path: CowArc::Owned(result_path.into()),
459                label: rlabel.map(|l| CowArc::Owned(l.into())),
460            })
461        }
462    }
463
464    /// Returns the full extension (including multiple '.' values).
465    /// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
466    ///
467    /// Also strips out anything following a `?` to handle query parameters in URIs
468    pub fn get_full_extension(&self) -> Option<String> {
469        let file_name = self.path().file_name()?.to_str()?;
470        let index = file_name.find('.')?;
471        let mut extension = file_name[index + 1..].to_owned();
472
473        // Strip off any query parameters
474        let query = extension.find('?');
475        if let Some(offset) = query {
476            extension.truncate(offset);
477        }
478
479        Some(extension)
480    }
481
482    pub(crate) fn iter_secondary_extensions(full_extension: &str) -> impl Iterator<Item = &str> {
483        full_extension.char_indices().filter_map(|(i, c)| {
484            if c == '.' {
485                Some(&full_extension[i + 1..])
486            } else {
487                None
488            }
489        })
490    }
491
492    /// Returns `true` if this [`AssetPath`] points to a file that is
493    /// outside of its [`AssetSource`](crate::io::AssetSource) folder.
494    ///
495    /// ## Example
496    /// ```
497    /// # use bevy_asset::AssetPath;
498    /// // Inside the default AssetSource.
499    /// let path = AssetPath::parse("thingy.png");
500    /// assert!( ! path.is_unapproved());
501    /// let path = AssetPath::parse("gui/thingy.png");
502    /// assert!( ! path.is_unapproved());
503    ///
504    /// // Inside a different AssetSource.
505    /// let path = AssetPath::parse("embedded://thingy.png");
506    /// assert!( ! path.is_unapproved());
507    ///
508    /// // Exits the `AssetSource`s directory.
509    /// let path = AssetPath::parse("../thingy.png");
510    /// assert!(path.is_unapproved());
511    /// let path = AssetPath::parse("folder/../../thingy.png");
512    /// assert!(path.is_unapproved());
513    ///
514    /// // This references the linux root directory.
515    /// let path = AssetPath::parse("/home/thingy.png");
516    /// assert!(path.is_unapproved());
517    /// ```
518    pub fn is_unapproved(&self) -> bool {
519        use std::path::Component;
520        let mut simplified = PathBuf::new();
521        for component in self.path.components() {
522            match component {
523                Component::Prefix(_) | Component::RootDir => return true,
524                Component::CurDir => {}
525                Component::ParentDir => {
526                    if !simplified.pop() {
527                        return true;
528                    }
529                }
530                Component::Normal(os_str) => simplified.push(os_str),
531            }
532        }
533
534        false
535    }
536}
537
538// This is only implemented for static lifetimes to ensure `Path::clone` does not allocate
539// by ensuring that this is stored as a `CowArc::Static`.
540// Please read https://github.com/bevyengine/bevy/issues/19844 before changing this!
541impl From<&'static str> for AssetPath<'static> {
542    #[inline]
543    fn from(asset_path: &'static str) -> Self {
544        let (source, path, label) = Self::parse_internal(asset_path).unwrap();
545        AssetPath {
546            source: source.into(),
547            path: CowArc::Static(path),
548            label: label.map(CowArc::Static),
549        }
550    }
551}
552
553impl<'a> From<&'a String> for AssetPath<'a> {
554    #[inline]
555    fn from(asset_path: &'a String) -> Self {
556        AssetPath::parse(asset_path.as_str())
557    }
558}
559
560impl From<String> for AssetPath<'static> {
561    #[inline]
562    fn from(asset_path: String) -> Self {
563        AssetPath::parse(asset_path.as_str()).into_owned()
564    }
565}
566
567impl From<&'static Path> for AssetPath<'static> {
568    #[inline]
569    fn from(path: &'static Path) -> Self {
570        Self {
571            source: AssetSourceId::Default,
572            path: CowArc::Static(path),
573            label: None,
574        }
575    }
576}
577
578impl From<PathBuf> for AssetPath<'static> {
579    #[inline]
580    fn from(path: PathBuf) -> Self {
581        Self {
582            source: AssetSourceId::Default,
583            path: path.into(),
584            label: None,
585        }
586    }
587}
588
589impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
590    fn from(value: &'a AssetPath<'b>) -> Self {
591        value.clone()
592    }
593}
594
595impl<'a> From<AssetPath<'a>> for PathBuf {
596    fn from(value: AssetPath<'a>) -> Self {
597        value.path().to_path_buf()
598    }
599}
600
601impl<'a> Serialize for AssetPath<'a> {
602    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
603    where
604        S: serde::Serializer,
605    {
606        self.to_string().serialize(serializer)
607    }
608}
609
610impl<'de> Deserialize<'de> for AssetPath<'static> {
611    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
612    where
613        D: serde::Deserializer<'de>,
614    {
615        deserializer.deserialize_string(AssetPathVisitor)
616    }
617}
618
619struct AssetPathVisitor;
620
621impl<'de> Visitor<'de> for AssetPathVisitor {
622    type Value = AssetPath<'static>;
623
624    fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
625        formatter.write_str("string AssetPath")
626    }
627
628    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
629    where
630        E: serde::de::Error,
631    {
632        Ok(AssetPath::parse(v).into_owned())
633    }
634
635    fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
636    where
637        E: serde::de::Error,
638    {
639        Ok(AssetPath::from(v))
640    }
641}
642
643/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
644/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
645pub(crate) fn normalize_path(path: &Path) -> PathBuf {
646    let mut result_path = PathBuf::new();
647    for elt in path.iter() {
648        if elt == "." {
649            // Skip
650        } else if elt == ".." {
651            if !result_path.pop() {
652                // Preserve ".." if insufficient matches (per RFC 1808).
653                result_path.push(elt);
654            }
655        } else {
656            result_path.push(elt);
657        }
658    }
659    result_path
660}
661
662#[cfg(test)]
663mod tests {
664    use crate::AssetPath;
665    use alloc::string::ToString;
666    use std::path::Path;
667
668    #[test]
669    fn parse_asset_path() {
670        let result = AssetPath::parse_internal("a/b.test");
671        assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
672
673        let result = AssetPath::parse_internal("http://a/b.test");
674        assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
675
676        let result = AssetPath::parse_internal("http://a/b.test#Foo");
677        assert_eq!(
678            result,
679            Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
680        );
681
682        let result = AssetPath::parse_internal("localhost:80/b.test");
683        assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
684
685        let result = AssetPath::parse_internal("http://localhost:80/b.test");
686        assert_eq!(
687            result,
688            Ok((Some("http"), Path::new("localhost:80/b.test"), None))
689        );
690
691        let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
692        assert_eq!(
693            result,
694            Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
695        );
696
697        let result = AssetPath::parse_internal("#insource://a/b.test");
698        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
699
700        let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
701        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
702
703        let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
704        assert!(
705            result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
706                || result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
707        );
708
709        let result = AssetPath::parse_internal("http://");
710        assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
711
712        let result = AssetPath::parse_internal("://x");
713        assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
714
715        let result = AssetPath::parse_internal("a/b.test#");
716        assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
717    }
718
719    #[test]
720    fn test_parent() {
721        // Parent consumes path segments, returns None when insufficient
722        let result = AssetPath::from("a/b.test");
723        assert_eq!(result.parent(), Some(AssetPath::from("a")));
724        assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
725        assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
726
727        // Parent cannot consume asset source
728        let result = AssetPath::from("http://a");
729        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
730        assert_eq!(result.parent().unwrap().parent(), None);
731
732        // Parent consumes labels
733        let result = AssetPath::from("http://a#Foo");
734        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
735    }
736
737    #[test]
738    fn test_with_source() {
739        let result = AssetPath::from("http://a#Foo");
740        assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
741    }
742
743    #[test]
744    fn test_without_label() {
745        let result = AssetPath::from("http://a#Foo");
746        assert_eq!(result.without_label(), AssetPath::from("http://a"));
747    }
748
749    #[test]
750    fn test_resolve_full() {
751        // A "full" path should ignore the base path.
752        let base = AssetPath::from("alice/bob#carol");
753        assert_eq!(
754            base.resolve("/joe/next").unwrap(),
755            AssetPath::from("joe/next")
756        );
757        assert_eq!(
758            base.resolve_embed("/joe/next").unwrap(),
759            AssetPath::from("joe/next")
760        );
761        assert_eq!(
762            base.resolve("/joe/next#dave").unwrap(),
763            AssetPath::from("joe/next#dave")
764        );
765        assert_eq!(
766            base.resolve_embed("/joe/next#dave").unwrap(),
767            AssetPath::from("joe/next#dave")
768        );
769    }
770
771    #[test]
772    fn test_resolve_implicit_relative() {
773        // A path with no initial directory separator should be considered relative.
774        let base = AssetPath::from("alice/bob#carol");
775        assert_eq!(
776            base.resolve("joe/next").unwrap(),
777            AssetPath::from("alice/bob/joe/next")
778        );
779        assert_eq!(
780            base.resolve_embed("joe/next").unwrap(),
781            AssetPath::from("alice/joe/next")
782        );
783        assert_eq!(
784            base.resolve("joe/next#dave").unwrap(),
785            AssetPath::from("alice/bob/joe/next#dave")
786        );
787        assert_eq!(
788            base.resolve_embed("joe/next#dave").unwrap(),
789            AssetPath::from("alice/joe/next#dave")
790        );
791    }
792
793    #[test]
794    fn test_resolve_explicit_relative() {
795        // A path which begins with "./" or "../" is treated as relative
796        let base = AssetPath::from("alice/bob#carol");
797        assert_eq!(
798            base.resolve("./martin#dave").unwrap(),
799            AssetPath::from("alice/bob/martin#dave")
800        );
801        assert_eq!(
802            base.resolve_embed("./martin#dave").unwrap(),
803            AssetPath::from("alice/martin#dave")
804        );
805        assert_eq!(
806            base.resolve("../martin#dave").unwrap(),
807            AssetPath::from("alice/martin#dave")
808        );
809        assert_eq!(
810            base.resolve_embed("../martin#dave").unwrap(),
811            AssetPath::from("martin#dave")
812        );
813    }
814
815    #[test]
816    fn test_resolve_trailing_slash() {
817        // A path which begins with "./" or "../" is treated as relative
818        let base = AssetPath::from("alice/bob/");
819        assert_eq!(
820            base.resolve("./martin#dave").unwrap(),
821            AssetPath::from("alice/bob/martin#dave")
822        );
823        assert_eq!(
824            base.resolve_embed("./martin#dave").unwrap(),
825            AssetPath::from("alice/bob/martin#dave")
826        );
827        assert_eq!(
828            base.resolve("../martin#dave").unwrap(),
829            AssetPath::from("alice/martin#dave")
830        );
831        assert_eq!(
832            base.resolve_embed("../martin#dave").unwrap(),
833            AssetPath::from("alice/martin#dave")
834        );
835    }
836
837    #[test]
838    fn test_resolve_canonicalize() {
839        // Test that ".." and "." are removed after concatenation.
840        let base = AssetPath::from("alice/bob#carol");
841        assert_eq!(
842            base.resolve("./martin/stephan/..#dave").unwrap(),
843            AssetPath::from("alice/bob/martin#dave")
844        );
845        assert_eq!(
846            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
847            AssetPath::from("alice/martin#dave")
848        );
849        assert_eq!(
850            base.resolve("../martin/.#dave").unwrap(),
851            AssetPath::from("alice/martin#dave")
852        );
853        assert_eq!(
854            base.resolve_embed("../martin/.#dave").unwrap(),
855            AssetPath::from("martin#dave")
856        );
857        assert_eq!(
858            base.resolve("/martin/stephan/..#dave").unwrap(),
859            AssetPath::from("martin#dave")
860        );
861        assert_eq!(
862            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
863            AssetPath::from("martin#dave")
864        );
865    }
866
867    #[test]
868    fn test_resolve_canonicalize_base() {
869        // Test that ".." and "." are removed after concatenation even from the base path.
870        let base = AssetPath::from("alice/../bob#carol");
871        assert_eq!(
872            base.resolve("./martin/stephan/..#dave").unwrap(),
873            AssetPath::from("bob/martin#dave")
874        );
875        assert_eq!(
876            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
877            AssetPath::from("martin#dave")
878        );
879        assert_eq!(
880            base.resolve("../martin/.#dave").unwrap(),
881            AssetPath::from("martin#dave")
882        );
883        assert_eq!(
884            base.resolve_embed("../martin/.#dave").unwrap(),
885            AssetPath::from("../martin#dave")
886        );
887        assert_eq!(
888            base.resolve("/martin/stephan/..#dave").unwrap(),
889            AssetPath::from("martin#dave")
890        );
891        assert_eq!(
892            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
893            AssetPath::from("martin#dave")
894        );
895    }
896
897    #[test]
898    fn test_resolve_canonicalize_with_source() {
899        // Test that ".." and "." are removed after concatenation.
900        let base = AssetPath::from("source://alice/bob#carol");
901        assert_eq!(
902            base.resolve("./martin/stephan/..#dave").unwrap(),
903            AssetPath::from("source://alice/bob/martin#dave")
904        );
905        assert_eq!(
906            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
907            AssetPath::from("source://alice/martin#dave")
908        );
909        assert_eq!(
910            base.resolve("../martin/.#dave").unwrap(),
911            AssetPath::from("source://alice/martin#dave")
912        );
913        assert_eq!(
914            base.resolve_embed("../martin/.#dave").unwrap(),
915            AssetPath::from("source://martin#dave")
916        );
917        assert_eq!(
918            base.resolve("/martin/stephan/..#dave").unwrap(),
919            AssetPath::from("source://martin#dave")
920        );
921        assert_eq!(
922            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
923            AssetPath::from("source://martin#dave")
924        );
925    }
926
927    #[test]
928    fn test_resolve_absolute() {
929        // Paths beginning with '/' replace the base path
930        let base = AssetPath::from("alice/bob#carol");
931        assert_eq!(
932            base.resolve("/martin/stephan").unwrap(),
933            AssetPath::from("martin/stephan")
934        );
935        assert_eq!(
936            base.resolve_embed("/martin/stephan").unwrap(),
937            AssetPath::from("martin/stephan")
938        );
939        assert_eq!(
940            base.resolve("/martin/stephan#dave").unwrap(),
941            AssetPath::from("martin/stephan/#dave")
942        );
943        assert_eq!(
944            base.resolve_embed("/martin/stephan#dave").unwrap(),
945            AssetPath::from("martin/stephan/#dave")
946        );
947    }
948
949    #[test]
950    fn test_resolve_asset_source() {
951        // Paths beginning with 'source://' replace the base path
952        let base = AssetPath::from("alice/bob#carol");
953        assert_eq!(
954            base.resolve("source://martin/stephan").unwrap(),
955            AssetPath::from("source://martin/stephan")
956        );
957        assert_eq!(
958            base.resolve_embed("source://martin/stephan").unwrap(),
959            AssetPath::from("source://martin/stephan")
960        );
961        assert_eq!(
962            base.resolve("source://martin/stephan#dave").unwrap(),
963            AssetPath::from("source://martin/stephan/#dave")
964        );
965        assert_eq!(
966            base.resolve_embed("source://martin/stephan#dave").unwrap(),
967            AssetPath::from("source://martin/stephan/#dave")
968        );
969    }
970
971    #[test]
972    fn test_resolve_label() {
973        // A relative path with only a label should replace the label portion
974        let base = AssetPath::from("alice/bob#carol");
975        assert_eq!(
976            base.resolve("#dave").unwrap(),
977            AssetPath::from("alice/bob#dave")
978        );
979        assert_eq!(
980            base.resolve_embed("#dave").unwrap(),
981            AssetPath::from("alice/bob#dave")
982        );
983    }
984
985    #[test]
986    fn test_resolve_insufficient_elements() {
987        // Ensure that ".." segments are preserved if there are insufficient elements to remove them.
988        let base = AssetPath::from("alice/bob#carol");
989        assert_eq!(
990            base.resolve("../../joe/next").unwrap(),
991            AssetPath::from("joe/next")
992        );
993        assert_eq!(
994            base.resolve_embed("../../joe/next").unwrap(),
995            AssetPath::from("../joe/next")
996        );
997    }
998
999    #[test]
1000    fn test_get_extension() {
1001        let result = AssetPath::from("http://a.tar.gz#Foo");
1002        assert_eq!(result.get_full_extension(), Some("tar.gz".to_string()));
1003
1004        let result = AssetPath::from("http://a#Foo");
1005        assert_eq!(result.get_full_extension(), None);
1006
1007        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1008        assert_eq!(result.get_full_extension(), Some("tar.bz2".to_string()));
1009
1010        let result = AssetPath::from("asset.Custom");
1011        assert_eq!(result.get_full_extension(), Some("Custom".to_string()));
1012    }
1013}