Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions crates/oxide/src/scanner/sources.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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);
Expand All @@ -171,6 +171,39 @@ impl PublicSourceEntry {
}
}

fn resolve_path(path: &Path, preserve_symlinks: bool) -> std::io::Result<PathBuf> {
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
Expand Down
42 changes: 42 additions & 0 deletions crates/oxide/tests/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

assert_eq!(files, vec!["directory_a/a.html"]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
Expand Down