diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index f28b16a49865..49fc576a4ea0 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -1,7 +1,7 @@ use crate::glob::split_pattern; use crate::GlobEntry; use bexpand::Expression; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use tracing::{event, Level}; use super::auto_source_detection::IGNORED_CONTENT_DIRS; @@ -116,12 +116,12 @@ impl PublicSourceEntry { PathBuf::from(&self.base).join(&self.pattern) }; - match dunce::canonicalize(combined_path) { - Ok(resolved_path) if resolved_path.is_dir() => { + match resolve_path(&combined_path, self.negated) { + Ok(resolved_path) if combined_path.is_dir() => { self.base = resolved_path.to_string_lossy().to_string(); self.pattern = "**/*".to_owned(); } - Ok(resolved_path) if resolved_path.is_file() => { + Ok(resolved_path) if combined_path.is_file() => { self.base = resolved_path .parent() .unwrap() @@ -144,7 +144,7 @@ impl PublicSourceEntry { Some(static_part) => { // TODO: If the base does not exist on disk, try removing the last slash and try // again. - match dunce::canonicalize(base.join(static_part)) { + match resolve_path(&base.join(static_part), self.negated) { Ok(base) => base, Err(err) => { event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err); @@ -171,6 +171,39 @@ impl PublicSourceEntry { } } +fn resolve_path(path: &Path, preserve_symlinks: bool) -> std::io::Result { + if preserve_symlinks { + path.metadata()?; + return Ok(normalize_path_lexically(path)); + } + + dunce::canonicalize(path) +} + +/// Lexically normalize an absolute path without resolving it through the +/// filesystem. +/// +/// This intentionally preserves symlink path shapes, so `..` segments after a +/// symlinked component are collapsed without resolving to the symlink target. +/// Current callers pass absolute paths; relative paths with leading `..` +/// segments would lose those leading components because `PathBuf::pop` on an +/// empty buffer is a no-op. +fn normalize_path_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + + normalized +} + /// For each public source entry: /// /// 1. Perform brace expansion diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 4003ff253048..62f7ba1b91a7 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1761,6 +1761,48 @@ mod scanner { assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); } + #[test] + fn test_source_not_can_ignore_symlinked_directory() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[("directory_a/a.html", "content-['directory_a/a.html']")], + ); + let symlink_path = dir.join("symlink"); + symlink(dir.join("directory_a"), &symlink_path).unwrap(); + assert!(fs::symlink_metadata(&symlink_path) + .unwrap() + .file_type() + .is_symlink()); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(dir.clone(), "@source './'"), + public_source_entry_from_pattern(dir.clone(), "@source not './symlink'"), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['directory_a/a.html']"]); + + let base_dir = + format!("{}{}", dunce::canonicalize(&dir).unwrap().display(), "/").replace('\\', "/"); + let files = scanner + .get_files() + .iter() + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + + assert_eq!(files, vec!["directory_a/a.html"]); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(dir.clone(), "@source './'"), + public_source_entry_from_pattern(dir.clone(), "@source not './symlink'"), + public_source_entry_from_pattern(dir.clone(), "@source not './directory_a'"), + ]); + let candidates = scanner.scan(); + + assert!(candidates.is_empty()); + } + #[test] fn test_extract_used_css_variables_from_css() { let dir = tempdir().unwrap().into_path();