From bb7a40f882bdbfc9bd69163370ffe26a21f45b2d Mon Sep 17 00:00:00 2001 From: janis Date: Fri, 19 Sep 2025 22:43:09 +0200 Subject: [PATCH 01/11] normalize path before waiting in processor gated reader since the gated reader tries to look up processed assets by their path, the path should be normalized for this operation --- crates/bevy_asset/src/io/processor_gated.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/io/processor_gated.rs b/crates/bevy_asset/src/io/processor_gated.rs index da439f56f5e18..c3675f58bb605 100644 --- a/crates/bevy_asset/src/io/processor_gated.rs +++ b/crates/bevy_asset/src/io/processor_gated.rs @@ -1,5 +1,6 @@ use crate::{ io::{AssetReader, AssetReaderError, AssetSourceId, PathStream, Reader}, + normalize_path, processor::{AssetProcessorData, ProcessStatus}, AssetPath, }; @@ -52,7 +53,8 @@ impl ProcessorGatedReader { impl AssetReader for ProcessorGatedReader { async fn read<'a>(&'a self, path: &'a Path) -> Result { - let asset_path = AssetPath::from(path.to_path_buf()).with_source(self.source.clone()); + let normalized_path = normalize_path(path); + let asset_path = AssetPath::from(normalized_path).with_source(self.source.clone()); trace!("Waiting for processing to finish before reading {asset_path}"); let process_result = self .processor_data From 1c3ac208b9ac0c1905968ac268ab8bcaf21775f7 Mon Sep 17 00:00:00 2001 From: janis Date: Fri, 19 Sep 2025 22:51:19 +0200 Subject: [PATCH 02/11] same for read_meta --- crates/bevy_asset/src/io/processor_gated.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/io/processor_gated.rs b/crates/bevy_asset/src/io/processor_gated.rs index c3675f58bb605..90accdad04afc 100644 --- a/crates/bevy_asset/src/io/processor_gated.rs +++ b/crates/bevy_asset/src/io/processor_gated.rs @@ -74,7 +74,8 @@ impl AssetReader for ProcessorGatedReader { } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { - let asset_path = AssetPath::from(path.to_path_buf()).with_source(self.source.clone()); + let normalized_path = normalize_path(path); + let asset_path = AssetPath::from(normalized_path).with_source(self.source.clone()); trace!("Waiting for processing to finish before reading meta for {asset_path}",); let process_result = self .processor_data From dd97b80331543bc1196a22231b29dab2ee312d52 Mon Sep 17 00:00:00 2001 From: janis Date: Sat, 20 Sep 2025 11:51:05 +0200 Subject: [PATCH 03/11] normalization method on assetpath --- crates/bevy_asset/src/path.rs | 75 ++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index de9dfa5ca583a..97310670d29a8 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -339,6 +339,16 @@ impl<'a> AssetPath<'a> { } } + /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible. + /// See [`normalize_cow_path`] for more details. + pub fn normalized(self) -> AssetPath<'a> { + AssetPath { + source: self.source, + path: normalize_cow_path(self.path), + label: self.label, + } + } + /// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]". /// If internally a value is a static reference, the static reference will be used unchanged. /// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned. @@ -659,12 +669,75 @@ pub(crate) fn normalize_path(path: &Path) -> PathBuf { result_path } +/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible +/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) +/// Returns a borrowed path if no normalization was necessary, otherwise returns an owned normalized path. +pub(crate) fn maybe_normalize_path(path: &Path) -> alloc::borrow::Cow<'_, Path> { + let mut result_path: core::cell::OnceCell = core::cell::OnceCell::new(); + let init = |i: usize| -> PathBuf { path.iter().take(i).collect() }; + + for (i, elt) in path.iter().enumerate() { + if elt == "." { + result_path.get_or_init(|| init(i)); + } else if elt == ".." { + result_path.get_or_init(|| init(i)); + + if let Some(path) = result_path.get_mut() + && !path.pop() + { + path.push(elt); + } + } else if let Some(path) = result_path.get_mut() { + path.push(elt); + } + } + + match result_path.into_inner() { + Some(path_buf) => path_buf.into(), + None => path.into(), + } +} + +/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible +/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) +pub(crate) fn normalize_cow_path(path: CowArc<'_, Path>) -> CowArc<'_, Path> { + match path { + CowArc::Borrowed(p) | CowArc::Static(p) => match maybe_normalize_path(p) { + alloc::borrow::Cow::Borrowed(path) => CowArc::Borrowed(path), + alloc::borrow::Cow::Owned(path) => CowArc::Owned(path.into()), + }, + CowArc::Owned(p) => CowArc::Owned(normalize_path(&p).into()), + } +} + #[cfg(test)] mod tests { - use crate::AssetPath; + use crate::{normalize_cow_path, AssetPath}; use alloc::string::ToString; + use atomicow::CowArc; use std::path::Path; + #[test] + fn normalize_cow_paths() { + let path: CowArc = "a/../a/b".into(); + + assert_eq!( + normalize_cow_path(path), + CowArc::Owned(Path::new("a/b").into()) + ); + + let path: CowArc = "a/b".into(); + assert_eq!(normalize_cow_path(path), CowArc::Static(Path::new("a/b"))); + + let path = "a/b"; + let donor = 3; + fn steal_lifetime<'a, 'b: 'a, T, U: ?Sized>(_: &'a T, u: &'b U) -> &'a U { + u + } + let path = CowArc::::Borrowed(steal_lifetime(&donor, Path::new(path))); + assert_eq!(normalize_cow_path(path), CowArc::Borrowed(Path::new("a/b"))); + } + #[test] fn parse_asset_path() { let result = AssetPath::parse_internal("a/b.test"); From b0269d4d3cfd074f6a50a29554da11abddbb1ee9 Mon Sep 17 00:00:00 2001 From: janis Date: Sat, 20 Sep 2025 13:52:20 +0200 Subject: [PATCH 04/11] normalize_cow_path trait method used in AssetPath constructors/from impls --- crates/bevy_asset/src/path.rs | 52 +++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index 97310670d29a8..a6ea223e9f5b3 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -129,7 +129,7 @@ impl<'a> AssetPath<'a> { Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)), None => AssetSourceId::Default, }, - path: CowArc::Borrowed(path), + path: CowArc::Borrowed(path).normalize_cow_path(), label: label.map(CowArc::Borrowed), }) } @@ -227,7 +227,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> { AssetPath { - path: CowArc::Owned(path_buf.into()), + path: CowArc::<'_, Path>::Owned(path_buf.into()).normalize_cow_path(), source: AssetSourceId::Default, label: None, } @@ -237,7 +237,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path(path: &'a Path) -> AssetPath<'a> { AssetPath { - path: CowArc::Borrowed(path), + path: CowArc::Borrowed(path).normalize_cow_path(), source: AssetSourceId::Default, label: None, } @@ -344,7 +344,7 @@ impl<'a> AssetPath<'a> { pub fn normalized(self) -> AssetPath<'a> { AssetPath { source: self.source, - path: normalize_cow_path(self.path), + path: self.path.normalize_cow_path(), label: self.label, } } @@ -554,7 +554,7 @@ impl From<&'static str> for AssetPath<'static> { let (source, path, label) = Self::parse_internal(asset_path).unwrap(); AssetPath { source: source.into(), - path: CowArc::Static(path), + path: CowArc::Static(path).normalize_cow_path(), label: label.map(CowArc::Static), } } @@ -579,7 +579,7 @@ impl From<&'static Path> for AssetPath<'static> { fn from(path: &'static Path) -> Self { Self { source: AssetSourceId::Default, - path: CowArc::Static(path), + path: CowArc::Static(path).normalize_cow_path(), label: None, } } @@ -588,9 +588,10 @@ impl From<&'static Path> for AssetPath<'static> { impl From for AssetPath<'static> { #[inline] fn from(path: PathBuf) -> Self { + let path: CowArc<'_, Path> = path.into(); Self { source: AssetSourceId::Default, - path: path.into(), + path: path.normalize_cow_path(), label: None, } } @@ -672,7 +673,10 @@ pub(crate) fn normalize_path(path: &Path) -> PathBuf { /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible /// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) /// Returns a borrowed path if no normalization was necessary, otherwise returns an owned normalized path. -pub(crate) fn maybe_normalize_path(path: &Path) -> alloc::borrow::Cow<'_, Path> { +pub(crate) fn maybe_normalize_path<'a, P: AsRef + Into> + 'a>( + as_path: P, +) -> CowArc<'a, Path> { + let path = as_path.as_ref(); let mut result_path: core::cell::OnceCell = core::cell::OnceCell::new(); let init = |i: usize| -> PathBuf { path.iter().take(i).collect() }; @@ -685,6 +689,7 @@ pub(crate) fn maybe_normalize_path(path: &Path) -> alloc::borrow::Cow<'_, Path> if let Some(path) = result_path.get_mut() && !path.pop() { + // Preserve ".." if insufficient matches (per RFC 1808). path.push(elt); } } else if let Some(path) = result_path.get_mut() { @@ -693,26 +698,24 @@ pub(crate) fn maybe_normalize_path(path: &Path) -> alloc::borrow::Cow<'_, Path> } match result_path.into_inner() { - Some(path_buf) => path_buf.into(), - None => path.into(), + Some(path_buf) => CowArc::Owned(path_buf.into()), + None => as_path.into(), } } -/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible -/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) -pub(crate) fn normalize_cow_path(path: CowArc<'_, Path>) -> CowArc<'_, Path> { - match path { - CowArc::Borrowed(p) | CowArc::Static(p) => match maybe_normalize_path(p) { - alloc::borrow::Cow::Borrowed(path) => CowArc::Borrowed(path), - alloc::borrow::Cow::Owned(path) => CowArc::Owned(path.into()), - }, - CowArc::Owned(p) => CowArc::Owned(normalize_path(&p).into()), +trait NormalizeCowPath { + fn normalize_cow_path(self) -> Self; +} + +impl NormalizeCowPath for CowArc<'_, Path> { + fn normalize_cow_path(self) -> Self { + maybe_normalize_path(self) } } #[cfg(test)] mod tests { - use crate::{normalize_cow_path, AssetPath}; + use crate::{path::NormalizeCowPath, AssetPath}; use alloc::string::ToString; use atomicow::CowArc; use std::path::Path; @@ -722,12 +725,12 @@ mod tests { let path: CowArc = "a/../a/b".into(); assert_eq!( - normalize_cow_path(path), + path.normalize_cow_path(), CowArc::Owned(Path::new("a/b").into()) ); let path: CowArc = "a/b".into(); - assert_eq!(normalize_cow_path(path), CowArc::Static(Path::new("a/b"))); + assert_eq!(path.normalize_cow_path(), CowArc::Static(Path::new("a/b"))); let path = "a/b"; let donor = 3; @@ -735,7 +738,10 @@ mod tests { u } let path = CowArc::::Borrowed(steal_lifetime(&donor, Path::new(path))); - assert_eq!(normalize_cow_path(path), CowArc::Borrowed(Path::new("a/b"))); + assert_eq!( + path.normalize_cow_path(), + CowArc::Borrowed(Path::new("a/b")) + ); } #[test] From 6f1458f9d86b0acd2c7d4fb269441169f655474a Mon Sep 17 00:00:00 2001 From: janis Date: Sat, 20 Sep 2025 13:56:18 +0200 Subject: [PATCH 05/11] remove manual normalization in gated reader --- crates/bevy_asset/src/io/processor_gated.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/bevy_asset/src/io/processor_gated.rs b/crates/bevy_asset/src/io/processor_gated.rs index 90accdad04afc..da439f56f5e18 100644 --- a/crates/bevy_asset/src/io/processor_gated.rs +++ b/crates/bevy_asset/src/io/processor_gated.rs @@ -1,6 +1,5 @@ use crate::{ io::{AssetReader, AssetReaderError, AssetSourceId, PathStream, Reader}, - normalize_path, processor::{AssetProcessorData, ProcessStatus}, AssetPath, }; @@ -53,8 +52,7 @@ impl ProcessorGatedReader { impl AssetReader for ProcessorGatedReader { async fn read<'a>(&'a self, path: &'a Path) -> Result { - let normalized_path = normalize_path(path); - let asset_path = AssetPath::from(normalized_path).with_source(self.source.clone()); + let asset_path = AssetPath::from(path.to_path_buf()).with_source(self.source.clone()); trace!("Waiting for processing to finish before reading {asset_path}"); let process_result = self .processor_data @@ -74,8 +72,7 @@ impl AssetReader for ProcessorGatedReader { } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { - let normalized_path = normalize_path(path); - let asset_path = AssetPath::from(normalized_path).with_source(self.source.clone()); + let asset_path = AssetPath::from(path.to_path_buf()).with_source(self.source.clone()); trace!("Waiting for processing to finish before reading meta for {asset_path}",); let process_result = self .processor_data From 325ef3aab7962d9be65521b057243fdddd5ae59f Mon Sep 17 00:00:00 2001 From: janis Date: Sun, 21 Sep 2025 15:18:26 +0200 Subject: [PATCH 06/11] fix doc comment no longer refer to pub(crate) implementation detail --- crates/bevy_asset/src/path.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index a6ea223e9f5b3..a8817737d2499 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -339,8 +339,10 @@ impl<'a> AssetPath<'a> { } } - /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible. - /// See [`normalize_cow_path`] for more details. + /// Normalizes the path component of the `AssetPath` by collapsing all + /// occurrences of '.' and '..' dot-segments where possible as per [RFC + /// 1808](https://datatracker.ietf.org/doc/html/rfc1808). + /// If the path is already normalized, this will return `self` unchanged. pub fn normalized(self) -> AssetPath<'a> { AssetPath { source: self.source, From cfebf24f420238116a63c07fc8b5dde08e137a24 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 24 Sep 2025 11:59:46 +0200 Subject: [PATCH 07/11] replace trait with simple function calls --- crates/bevy_asset/src/path.rs | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index a8817737d2499..efb544a6bbf4a 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -129,7 +129,7 @@ impl<'a> AssetPath<'a> { Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)), None => AssetSourceId::Default, }, - path: CowArc::Borrowed(path).normalize_cow_path(), + path: maybe_normalize_path::<'a, _>(CowArc::Borrowed(path)), label: label.map(CowArc::Borrowed), }) } @@ -227,7 +227,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> { AssetPath { - path: CowArc::<'_, Path>::Owned(path_buf.into()).normalize_cow_path(), + path: maybe_normalize_path(CowArc::<'_, Path>::Owned(path_buf.into())), source: AssetSourceId::Default, label: None, } @@ -237,7 +237,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path(path: &'a Path) -> AssetPath<'a> { AssetPath { - path: CowArc::Borrowed(path).normalize_cow_path(), + path: maybe_normalize_path(CowArc::Borrowed(path)), source: AssetSourceId::Default, label: None, } @@ -346,7 +346,7 @@ impl<'a> AssetPath<'a> { pub fn normalized(self) -> AssetPath<'a> { AssetPath { source: self.source, - path: self.path.normalize_cow_path(), + path: maybe_normalize_path(self.path), label: self.label, } } @@ -556,7 +556,7 @@ impl From<&'static str> for AssetPath<'static> { let (source, path, label) = Self::parse_internal(asset_path).unwrap(); AssetPath { source: source.into(), - path: CowArc::Static(path).normalize_cow_path(), + path: maybe_normalize_path(CowArc::Static(path)), label: label.map(CowArc::Static), } } @@ -581,7 +581,7 @@ impl From<&'static Path> for AssetPath<'static> { fn from(path: &'static Path) -> Self { Self { source: AssetSourceId::Default, - path: CowArc::Static(path).normalize_cow_path(), + path: maybe_normalize_path(CowArc::Static(path)), label: None, } } @@ -593,7 +593,7 @@ impl From for AssetPath<'static> { let path: CowArc<'_, Path> = path.into(); Self { source: AssetSourceId::Default, - path: path.normalize_cow_path(), + path: maybe_normalize_path(path), label: None, } } @@ -705,19 +705,9 @@ pub(crate) fn maybe_normalize_path<'a, P: AsRef + Into> + } } -trait NormalizeCowPath { - fn normalize_cow_path(self) -> Self; -} - -impl NormalizeCowPath for CowArc<'_, Path> { - fn normalize_cow_path(self) -> Self { - maybe_normalize_path(self) - } -} - #[cfg(test)] mod tests { - use crate::{path::NormalizeCowPath, AssetPath}; + use crate::{path::maybe_normalize_path, AssetPath}; use alloc::string::ToString; use atomicow::CowArc; use std::path::Path; @@ -727,12 +717,12 @@ mod tests { let path: CowArc = "a/../a/b".into(); assert_eq!( - path.normalize_cow_path(), + maybe_normalize_path(path), CowArc::Owned(Path::new("a/b").into()) ); let path: CowArc = "a/b".into(); - assert_eq!(path.normalize_cow_path(), CowArc::Static(Path::new("a/b"))); + assert_eq!(maybe_normalize_path(path), CowArc::Static(Path::new("a/b"))); let path = "a/b"; let donor = 3; @@ -741,7 +731,7 @@ mod tests { } let path = CowArc::::Borrowed(steal_lifetime(&donor, Path::new(path))); assert_eq!( - path.normalize_cow_path(), + maybe_normalize_path(path), CowArc::Borrowed(Path::new("a/b")) ); } From ddaeb6d315d6c23a0571be6f2b5a87b0c486ee62 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 24 Sep 2025 12:31:46 +0200 Subject: [PATCH 08/11] use maybe_normalize_path for normalize_path, returns cow now --- crates/bevy_asset/src/path.rs | 80 ++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index efb544a6bbf4a..3d72bca7636b2 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -11,7 +11,10 @@ use core::{ ops::Deref, }; use serde::{de::Visitor, Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; use thiserror::Error; /// Represents a path to an asset in a "virtual filesystem". @@ -129,7 +132,7 @@ impl<'a> AssetPath<'a> { Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)), None => AssetSourceId::Default, }, - path: maybe_normalize_path::<'a, _>(CowArc::Borrowed(path)), + path: normalize_atomicow_path(CowArc::Borrowed(path)), label: label.map(CowArc::Borrowed), }) } @@ -227,7 +230,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> { AssetPath { - path: maybe_normalize_path(CowArc::<'_, Path>::Owned(path_buf.into())), + path: normalize_atomicow_path(CowArc::Owned(path_buf.into())), source: AssetSourceId::Default, label: None, } @@ -237,7 +240,7 @@ impl<'a> AssetPath<'a> { #[inline] pub fn from_path(path: &'a Path) -> AssetPath<'a> { AssetPath { - path: maybe_normalize_path(CowArc::Borrowed(path)), + path: normalize_atomicow_path(CowArc::Borrowed(path)), source: AssetSourceId::Default, label: None, } @@ -346,7 +349,7 @@ impl<'a> AssetPath<'a> { pub fn normalized(self) -> AssetPath<'a> { AssetPath { source: self.source, - path: maybe_normalize_path(self.path), + path: normalize_atomicow_path(self.path), label: self.label, } } @@ -460,14 +463,20 @@ impl<'a> AssetPath<'a> { PathBuf::new() }; result_path.push(rpath); - result_path = normalize_path(result_path.as_path()); + + // Boxing the result_path into a CowArc after normalization to + // avoid a potential unnecessary allocation. + let path: CowArc = maybe_normalize_path(&result_path).map_or_else( + || CowArc::Owned(result_path.into()), + |path| CowArc::Owned(path.into()), + ); Ok(AssetPath { source: match source { Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())), None => self.source.clone_owned(), }, - path: CowArc::Owned(result_path.into()), + path, label: rlabel.map(|l| CowArc::Owned(l.into())), }) } @@ -556,7 +565,7 @@ impl From<&'static str> for AssetPath<'static> { let (source, path, label) = Self::parse_internal(asset_path).unwrap(); AssetPath { source: source.into(), - path: maybe_normalize_path(CowArc::Static(path)), + path: normalize_atomicow_path(CowArc::Static(path)), label: label.map(CowArc::Static), } } @@ -581,7 +590,7 @@ impl From<&'static Path> for AssetPath<'static> { fn from(path: &'static Path) -> Self { Self { source: AssetSourceId::Default, - path: maybe_normalize_path(CowArc::Static(path)), + path: normalize_atomicow_path(CowArc::Static(path)), label: None, } } @@ -593,7 +602,7 @@ impl From for AssetPath<'static> { let path: CowArc<'_, Path> = path.into(); Self { source: AssetSourceId::Default, - path: maybe_normalize_path(path), + path: normalize_atomicow_path(path), label: None, } } @@ -655,29 +664,17 @@ impl<'de> Visitor<'de> for AssetPathVisitor { /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible /// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) -pub(crate) fn normalize_path(path: &Path) -> PathBuf { - let mut result_path = PathBuf::new(); - for elt in path.iter() { - if elt == "." { - // Skip - } else if elt == ".." { - if !result_path.pop() { - // Preserve ".." if insufficient matches (per RFC 1808). - result_path.push(elt); - } - } else { - result_path.push(elt); - } +pub(crate) fn normalize_path(path: &Path) -> Cow<'_, Path> { + match maybe_normalize_path(path) { + Some(pathbuf) => Cow::Owned(pathbuf), + None => Cow::Borrowed(path), } - result_path } /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible /// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) -/// Returns a borrowed path if no normalization was necessary, otherwise returns an owned normalized path. -pub(crate) fn maybe_normalize_path<'a, P: AsRef + Into> + 'a>( - as_path: P, -) -> CowArc<'a, Path> { +/// Returns `None` if no normalization was performed, otherwise returns a normalized `PathBuf`. +pub(crate) fn maybe_normalize_path<'a, P: AsRef + 'a>(as_path: P) -> Option { let path = as_path.as_ref(); let mut result_path: core::cell::OnceCell = core::cell::OnceCell::new(); let init = |i: usize| -> PathBuf { path.iter().take(i).collect() }; @@ -699,30 +696,37 @@ pub(crate) fn maybe_normalize_path<'a, P: AsRef + Into> + } } - match result_path.into_inner() { - Some(path_buf) => CowArc::Owned(path_buf.into()), - None => as_path.into(), + result_path.into_inner() +} + +pub(crate) fn normalize_atomicow_path<'a>(path: CowArc<'a, Path>) -> CowArc<'a, Path> { + match maybe_normalize_path(&path) { + Some(normalized) => CowArc::Owned(normalized.into()), + None => path, } } #[cfg(test)] mod tests { - use crate::{path::maybe_normalize_path, AssetPath}; + use crate::{normalize_atomicow_path, AssetPath}; use alloc::string::ToString; use atomicow::CowArc; use std::path::Path; #[test] fn normalize_cow_paths() { - let path: CowArc = "a/../a/b".into(); + let path: CowArc = Path::new("a/../a/b").into(); assert_eq!( - maybe_normalize_path(path), - CowArc::Owned(Path::new("a/b").into()) + normalize_atomicow_path(path), + CowArc::::Owned(Path::new("a/b").into()) ); - let path: CowArc = "a/b".into(); - assert_eq!(maybe_normalize_path(path), CowArc::Static(Path::new("a/b"))); + let path: CowArc = Path::new("a/b").into(); + assert_eq!( + normalize_atomicow_path(path), + CowArc::Static(Path::new("a/b")) + ); let path = "a/b"; let donor = 3; @@ -731,7 +735,7 @@ mod tests { } let path = CowArc::::Borrowed(steal_lifetime(&donor, Path::new(path))); assert_eq!( - maybe_normalize_path(path), + normalize_atomicow_path(path), CowArc::Borrowed(Path::new("a/b")) ); } From 791151799ac3092af9dd354647640558e594d756 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 24 Sep 2025 13:20:54 +0200 Subject: [PATCH 09/11] test assuring assets loaded via relative paths have equal handles --- crates/bevy_asset/src/lib.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index bd210d3218c26..9a2182d37aadc 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -926,6 +926,35 @@ mod tests { storage.0.extend(reader.read().cloned()); } + #[test] + fn load_relative_path() { + let dir = Dir::default(); + let d_path = "a/b/c/d.cool.ron"; + let d_ron = r#" +( + text: "hello", + dependencies: [], + embedded_dependencies: [], + sub_texts: [], +)"#; + dir.insert_asset_text(Path::new(d_path), d_ron); + + let (mut app, gate_opener) = test_app(dir); + gate_opener.open(d_path); + app.init_asset::() + .register_asset_loader(CoolTextLoader); + let asset_server = app.world().resource::().clone(); + let handle: Handle = asset_server.load("a/b/c/../c/d.cool.ron"); + let d_id = handle.id(); + app.update(); + + let handle2: Handle = asset_server.load("a/b/../b/c/d.cool.ron"); + let handle3: Handle = asset_server.load("a/b/c/./d.cool.ron"); + + assert_eq!(handle2.id(), d_id); + assert_eq!(handle3.id(), d_id); + } + #[test] fn load_dependencies() { let dir = Dir::default(); From 0059521ef4f6ea5ecc45bfb76bd69f904b8c7858 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 24 Sep 2025 13:55:03 +0200 Subject: [PATCH 10/11] add expect when not using file_watcher feature because normalize_path is now only used there. --- crates/bevy_asset/src/path.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index 3d72bca7636b2..544ec219d2738 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -664,6 +664,10 @@ impl<'de> Visitor<'de> for AssetPathVisitor { /// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible /// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808) +#[cfg_attr( + not(feature = "file_watcher"), + expect(dead_code, reason = "used in file_watcher feature") +)] pub(crate) fn normalize_path(path: &Path) -> Cow<'_, Path> { match maybe_normalize_path(path) { Some(pathbuf) => Cow::Owned(pathbuf), From f487a5f7b4da9e0d4c8fce32286b791c9750df67 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 24 Sep 2025 14:03:05 +0200 Subject: [PATCH 11/11] imports --- crates/bevy_asset/src/path.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/bevy_asset/src/path.rs b/crates/bevy_asset/src/path.rs index 544ec219d2738..319720d1d47a0 100644 --- a/crates/bevy_asset/src/path.rs +++ b/crates/bevy_asset/src/path.rs @@ -11,10 +11,7 @@ use core::{ ops::Deref, }; use serde::{de::Visitor, Deserialize, Serialize}; -use std::{ - borrow::Cow, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use thiserror::Error; /// Represents a path to an asset in a "virtual filesystem". @@ -668,10 +665,10 @@ impl<'de> Visitor<'de> for AssetPathVisitor { not(feature = "file_watcher"), expect(dead_code, reason = "used in file_watcher feature") )] -pub(crate) fn normalize_path(path: &Path) -> Cow<'_, Path> { +pub(crate) fn normalize_path(path: &Path) -> alloc::borrow::Cow<'_, Path> { match maybe_normalize_path(path) { - Some(pathbuf) => Cow::Owned(pathbuf), - None => Cow::Borrowed(path), + Some(pathbuf) => alloc::borrow::Cow::Owned(pathbuf), + None => alloc::borrow::Cow::Borrowed(path), } }