Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
7 changes: 7 additions & 0 deletions .changeset/fix-preserve-symlinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
swc_ecma_transforms_module: patch
swc: patch
swc_core: patch
---

fix(transforms/module): replace `canonicalize()` with `path_clean` to avoid resolving symlinks during module resolution
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/swc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ indexmap = { workspace = true, features = ["serde"] }
jsonc-parser = { workspace = true, features = ["serde"] }
once_cell = { workspace = true }
par-core = { workspace = true }
path-clean = { workspace = true }
par-iter = { workspace = true }
parking_lot = { workspace = true }
regex = { workspace = true }
Expand Down
14 changes: 11 additions & 3 deletions crates/swc/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use dashmap::DashMap;
use either::Either;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
#[cfg(feature = "module")]
use path_clean::PathClean;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use swc_atoms::Atom;
Expand Down Expand Up @@ -1612,10 +1614,16 @@ impl ModuleConfig {
return None;
}

// Normalize the base path without resolving symlinks.
// Using `.clean()` instead of `.canonicalize()` keeps symlinked
// paths intact, which is required for correct relative-path
// computation in `diff_paths` (both base and target must live
// in the same "path space").
//
// https://github.com/swc-project/swc/issues/8265
// https://github.com/swc-project/swc/issues/11584
let base = match base {
FileName::Real(v) if !skip_resolver => {
FileName::Real(v.canonicalize().unwrap_or_else(|_| v.to_path_buf()))
}
FileName::Real(v) if !skip_resolver => FileName::Real(v.clean()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Windows base path in UNC form before diffing

Replacing canonicalize() with clean() here leaves base as a drive-letter path on Windows, but build_resolver still canonicalizes jsc.baseUrl to UNC (\\?\...) before creating NodeImportResolver; that means try_resolve_import can compare two absolute paths with different prefixes and skip normalization (it only normalizes when absolute-vs-relative differs), so relative rewriting via diff_paths no longer works and imports can fall back to absolute //?/C:/... specifiers for jsc.baseUrl/paths resolutions.

Useful? React with 👍 / 👎.

_ => base.clone(),
};

Expand Down
54 changes: 54 additions & 0 deletions crates/swc/src/config/tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
#[cfg(feature = "module")]
use swc_common::FileName;

#[cfg(feature = "module")]
use super::ModuleConfig;
use crate::parse_swcrc;

#[test]
Expand Down Expand Up @@ -30,3 +35,52 @@ fn issue_6996() {
let rc = parse_swcrc(include_str!("issue-6996.json")).expect("failed to parse");
dbg!(&rc);
}

#[cfg(feature = "module")]
#[test]
fn issue_11584_relative_base_is_rebased_against_base_url() {
use std::{
env, fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};

let uniq = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic")
.as_nanos();
let tmp_root = env::temp_dir().join(format!("swc-issue-11584-{}-{}", std::process::id(), uniq));
let base_url = tmp_root.join("project");

fs::create_dir_all(base_url.join("src")).expect("should create fixture directories");
fs::write(
base_url.join("src").join("foo.ts"),
"export const foo = 1;\n",
)
.expect("should create fixture file");

let base = FileName::Real(PathBuf::from("virtual/index.ts"));
let paths = vec![("@app/*".to_string(), vec!["src/*".to_string()])];

let (normalized_base, resolver) = ModuleConfig::get_resolver(&base_url, paths, &base, None)
.expect("resolver should be created");

let base_path = match &normalized_base {
FileName::Real(path) => path,
other => panic!("unexpected base filename: {other:?}"),
};
assert!(
!base_path.is_absolute(),
"relative input filename should stay relative so resolver can rebase against jsc.baseUrl"
);

let resolved = resolver
.resolve_import(&normalized_base, "@app/foo")
.expect("import should resolve");
assert_eq!(
&*resolved, "../src/foo",
"resolved import should stay in the jsc.baseUrl path space"
);

let _ = fs::remove_dir_all(&tmp_root);
}
1 change: 1 addition & 0 deletions crates/swc_ecma_transforms_module/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ swc_ecma_visit = { version = "20.0.0", path = "../swc_ecma_visit" }
[dev-dependencies]
indexmap = { workspace = true, features = ["serde"] }
serde_json = { workspace = true }
tempfile = { workspace = true }

swc_ecma_loader = { version = "18.0.0", path = "../swc_ecma_loader", features = [
"node",
Expand Down
13 changes: 7 additions & 6 deletions crates/swc_ecma_transforms_module/src/path.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use std::{
borrow::Cow,
env::current_dir,
fs::canonicalize,
io,
path::{Component, Path, PathBuf},
sync::Arc,
Expand Down Expand Up @@ -254,13 +253,15 @@ where
}
};

// Bazel uses symlink
// Clean the resolved path to normalize `.` and `..` components
// without resolving symlinks. Previously this used `canonicalize()`
// which resolved symlinks, breaking setups where symlinked source
// files need imports resolved relative to the symlink location.
//
// https://github.com/swc-project/swc/issues/8265
if let FileName::Real(resolved) = &target.filename {
if let Ok(orig) = canonicalize(resolved) {
target.filename = FileName::Real(orig);
}
// https://github.com/swc-project/swc/issues/11584
if let FileName::Real(resolved) = &mut target.filename {
*resolved = resolved.clean();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize Windows target paths before import diffing

On Windows, replacing canonicalization with resolved.clean() leaves absolute targets in drive-letter form (for example C:\...), while resolver setup still canonicalizes jsc.baseUrl to UNC (\\?\C:\...) in build_resolver; TsConfigResolver can also return absolute FileName::Real(tp.into()) directly for exact paths mappings (crates/swc_ecma_loader/src/resolvers/tsc.rs, Pattern::Exact). In that case diff_paths receives two absolute paths with different prefixes, fails to compute a relative path, and the rewriter falls back to absolute specifiers, which is a regression for Windows projects using absolute path mappings.

Useful? React with 👍 / 👎.

}

let Resolution {
Expand Down
117 changes: 117 additions & 0 deletions crates/swc_ecma_transforms_module/tests/path_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use swc_ecma_transforms_module::{
rewriter::import_rewriter,
};
use swc_ecma_transforms_testing::{test_fixture, FixtureTestConfig};
use tempfile::TempDir;
use testing::run_test2;

type TestProvider = NodeImportResolver<NodeModulesResolver>;
Expand Down Expand Up @@ -157,3 +158,119 @@ fn fixture(input_dir: PathBuf) {
},
);
}

/// Test for https://github.com/swc-project/swc/issues/11584
///
/// `NodeImportResolver` should not resolve symlinks when computing
/// relative import paths. This ensures that symlinked source files
/// resolve imports relative to the symlink location, not the real
/// file location.
///
/// Directory structure:
/// tmpdir/
/// real/
/// lib/
/// dep.js <- real file
/// project/
/// lib/ <- symlink -> ../../real/lib
/// src/
/// index.js <- real file, imports ../lib/dep
#[cfg(unix)]
#[test]
fn issue_11584_symlink_not_canonicalized() {
use std::{fs, os::unix::fs as unix_fs};

let tmpdir = TempDir::new().unwrap();
let base_dir = tmpdir.path().canonicalize().unwrap();

let real_lib = base_dir.join("real").join("lib");
let project_dir = base_dir.join("project");
let project_src = project_dir.join("src");

fs::create_dir_all(&real_lib).unwrap();
fs::create_dir_all(&project_src).unwrap();

// Create the real dep file
fs::write(
real_lib.join("dep.js"),
"module.exports.VALUE = \"hello\";\n",
)
.unwrap();

// Create source file in project/src/
fs::write(
project_src.join("index.js"),
"import { VALUE } from \"../lib/dep\";\n",
)
.unwrap();

// Create symlink: project/lib -> ../real/lib
unix_fs::symlink(&real_lib, project_dir.join("lib")).unwrap();

// The base filename is in project/src/ (a real file, not a symlink).
// The import ../lib/dep resolves to project/lib/dep.js through the symlink.
let base = FileName::Real(project_src.join("index.js"));

let resolver = NodeImportResolver::with_config(
NodeModulesResolver::new(swc_ecma_loader::TargetEnv::Node, Default::default(), true),
swc_ecma_transforms_module::path::Config {
base_dir: Some(base_dir.clone()),
..Default::default()
},
);

let result = resolver.resolve_import(&base, "../lib/dep").unwrap();
// The resolved path should stay relative to the symlink location
// (project/lib/) instead of being canonicalized to the real location
// (real/lib/), which would produce ../../real/lib/dep.js.
assert_eq!(
&*result, "../lib/dep.js",
"Symlink path should be preserved, not canonicalized to real path"
);
}

/// Test for use cases discussed in
/// https://github.com/swc-project/swc/pull/11585#issuecomment-3993466331
///
/// In a pnpm-like layout, `node_modules/@a/pkg` may be a symlink to
/// `node_modules/.pnpm/.../node_modules/@a/pkg`. Both a package import and
/// an explicit `./node_modules` import should preserve the original specifier.
#[cfg(unix)]
#[test]
fn issue_11585_pnpm_node_modules_symlink() {
use std::{fs, os::unix::fs as unix_fs};

let tmpdir = TempDir::new().unwrap();
let project_dir = tmpdir.path().join("project");
let node_modules = project_dir.join("node_modules");
let pnpm_pkg = node_modules
.join(".pnpm")
.join("@a+pkg@1.0.0")
.join("node_modules")
.join("@a")
.join("pkg");

fs::create_dir_all(&pnpm_pkg).unwrap();
fs::create_dir_all(node_modules.join("@a")).unwrap();
fs::write(pnpm_pkg.join("index.js"), "export const value = 1;\n").unwrap();
fs::write(project_dir.join("index.js"), "import '@a/pkg';\n").unwrap();

unix_fs::symlink(&pnpm_pkg, node_modules.join("@a").join("pkg")).unwrap();

let base = FileName::Real(project_dir.join("index.js"));
let resolver = NodeImportResolver::with_config(
NodeModulesResolver::new(swc_ecma_loader::TargetEnv::Node, Default::default(), true),
swc_ecma_transforms_module::path::Config {
base_dir: Some(project_dir.clone()),
..Default::default()
},
);

let package_import = resolver.resolve_import(&base, "@a/pkg").unwrap();
assert_eq!(&*package_import, "@a/pkg");

let explicit_node_modules_import = resolver
.resolve_import(&base, "./node_modules/@a/pkg")
.unwrap();
assert_eq!(&*explicit_node_modules_import, "./node_modules/@a/pkg");
}
Loading