From 3793736db0489eb533f7fd92a3e4dacefaea586d Mon Sep 17 00:00:00 2001 From: pshu Date: Thu, 11 Jun 2026 06:58:16 +0800 Subject: [PATCH 1/2] perf: reuse owned parent realpath buffer in realpath_uncached --- src/cache.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 0ec9745a..a445c0d4 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -18,7 +18,6 @@ use tokio::sync::OnceCell as OnceLock; use crate::{ context::ResolveContext as Ctx, package_json::{off_to_location, PackageJson}, - path::PathUtil, resolver_path::{hash_path, ResolverPath}, FileMetadata, FileSystem, JSONError, ResolveError, ResolveOptions, TsConfig, }; @@ -272,10 +271,15 @@ impl CachedPathImpl { .map(|path| Some(Utf8PathBuf::from_path_buf(path).expect("path should be UTF-8"))); } if let Some(parent) = self.parent() { - let parent_path = parent.realpath(fs).await?; - return Ok(Some( - parent_path.normalize_with(self.path.strip_prefix(parent.path()).unwrap()), - )); + let mut real_path = parent.realpath(fs).await?; + // The cache parent is `self.path`'s lexical parent, so its realpath is + // just the parent's realpath with `self.path`'s final segment appended. + // Reusing the owned `real_path` avoids re-copying the parent buffer and + // re-walking components that `strip_prefix` + `normalize_with` would do. + if let Some(segment) = self.path.file_name() { + real_path.push(segment); + } + return Ok(Some(real_path)); } Ok(None) }) From 99becacfd98f9922a290db9192de5a8c5067bd56 Mon Sep 17 00:00:00 2001 From: pshu Date: Thu, 11 Jun 2026 10:37:33 +0800 Subject: [PATCH 2/2] fix: preserve trailing ".." when rebuilding realpath from parent file_name() returns None for paths ending in "..", silently dropping the component and returning the parent realpath unchanged. Match the last component explicitly instead: Normal pushes as before, ParentDir pops, restoring the POSIX semantics of the previous normalize_with-based code. Unnormalized paths reach realpath from public entry points: absolute specifiers (kept verbatim on non-Windows), exact-match alias values, and the directory argument of resolve(). Add a regression test covering the absolute-specifier and directory channels. --- src/cache.rs | 17 ++++++++++------- src/tests/symlink.rs | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index a445c0d4..14acda2b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -9,7 +9,7 @@ use std::{ sync::Arc, }; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use dashmap::{DashMap, DashSet}; use futures::future::BoxFuture; use rustc_hash::FxHasher; @@ -272,12 +272,15 @@ impl CachedPathImpl { } if let Some(parent) = self.parent() { let mut real_path = parent.realpath(fs).await?; - // The cache parent is `self.path`'s lexical parent, so its realpath is - // just the parent's realpath with `self.path`'s final segment appended. - // Reusing the owned `real_path` avoids re-copying the parent buffer and - // re-walking components that `strip_prefix` + `normalize_with` would do. - if let Some(segment) = self.path.file_name() { - real_path.push(segment); + // Unnormalized paths (e.g. from alias values or absolute specifiers) can + // end in `..`, where `file_name()` returns `None` and would silently drop + // the component; POSIX semantics pop it after the parent is resolved. + match self.path.components().next_back() { + Some(Utf8Component::Normal(segment)) => real_path.push(segment), + Some(Utf8Component::ParentDir) => { + real_path.pop(); + } + _ => {} } return Ok(Some(real_path)); } diff --git a/src/tests/symlink.rs b/src/tests/symlink.rs index 07e0bf33..949c3677 100644 --- a/src/tests/symlink.rs +++ b/src/tests/symlink.rs @@ -157,3 +157,27 @@ async fn test() -> io::Result<()> { Ok(()) } + +// With `symlinks` enabled, realpath must apply `..` components after resolving +// the parent, not drop them. Unnormalized paths reach the resolver from user +// input: absolute specifiers (kept verbatim on non-Windows), exact-match alias +// values, and the `directory` argument of `resolve`. +#[tokio::test] +async fn dotdot_in_unnormalized_input() { + let root = super::fixture_root().join("enhanced_resolve"); + let expected = Ok(root.join("lib/index.js")); + let resolver = Resolver::default(); + + let specifier = root.join("test/../lib/index.js"); + let resolution = resolver + .resolve(root.join("test"), specifier.to_str().unwrap()) + .await + .map(|r| r.full_path()); + assert_eq!(resolution, expected, "absolute specifier containing `..`"); + + let resolution = resolver + .resolve(root.join("test/.."), "./lib/index.js") + .await + .map(|r| r.full_path()); + assert_eq!(resolution, expected, "directory ending in `..`"); +}