Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
6 changes: 6 additions & 0 deletions .changeset/fix-preserve-symlinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
swc_ecma_transforms_module: patch
swc: 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
19 changes: 18 additions & 1 deletion 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,9 +1614,24 @@ 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()))
let cleaned = if v.is_absolute() {
v.clean()
} else {
env::current_dir()
.map(|cwd| cwd.join(v).clean())
.unwrap_or_else(|_| v.to_path_buf())
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 Resolve missing relative base against baseUrl

When base is relative, this new logic always makes it absolute via env::current_dir(). In in-memory/virtual-file compiles (relative filename that does not exist on disk), the previous behavior kept base relative, so NodeImportResolver::try_resolve_import could rebase it through absolute_path(base_dir, ...) and keep rewrites in the jsc.baseUrl path space; with this change, base is pinned to process CWD instead, and diff_paths can emit incorrect long paths (for example ../../project/...) when cwd differs from baseUrl.

Useful? React with 👍 / 👎.

};
FileName::Real(cleaned)
}
_ => base.clone(),
};
Expand Down
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
71 changes: 71 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,73 @@ 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"
);
}