diff --git a/src/cache.rs b/src/cache.rs index 0ec9745a..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; @@ -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,18 @@ 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?; + // 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)); } Ok(None) }) 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 `..`"); +}