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
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
50 changes: 49 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,55 @@ 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").
//
// On Windows, we still canonicalize absolute paths to keep the base
// path in UNC form. `build_resolver` canonicalizes `jsc.baseUrl` to
// UNC as well, and `diff_paths` requires both paths to be in the same
// form to produce relative paths consistently.
//
// 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 {
let relative = v.clean();

// If the relative input filename points to an existing file from
// cwd (CLI/manual use-cases), keep it in cwd path space.
// Otherwise (virtual/in-memory filenames), keep it relative so
// resolver logic can rebase through `jsc.baseUrl`.
env::current_dir()
.ok()
.map(|cwd| cwd.join(&relative).clean())
.filter(|abs| abs.exists())
.unwrap_or(relative)
};

#[cfg(target_os = "windows")]
let cleaned = if cleaned.is_absolute()
&& !matches!(
cleaned.components().next(),
Some(std::path::Component::Prefix(prefix))
if matches!(
prefix.kind(),
std::path::Prefix::Verbatim(_)
| std::path::Prefix::VerbatimDisk(_)
| std::path::Prefix::VerbatimUNC(_, _)
)
) {
cleaned.canonicalize().unwrap_or(cleaned)
} else {
cleaned
};

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

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

let cwd = env::current_dir().expect("should get current_dir");
let uniq = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic")
.as_nanos();
let tmp_root = cwd.join(format!(
"swc-issue-11584-existing-{}-{}",
std::process::id(),
uniq
));
let base_url = tmp_root.join("src");
let base_file = base_url.join("index.ts");
let dep_file = base_url.join("modules").join("moduleA").join("index.ts");

fs::create_dir_all(dep_file.parent().expect("dep parent should exist"))
.expect("should create fixture directories");
fs::write(&base_file, "import { moduleA } from '@modules/moduleA';\n")
.expect("should create base file");
fs::write(&dep_file, "export const moduleA = () => {};\n").expect("should create dep file");

let base_relative = base_file
.strip_prefix(&cwd)
.expect("fixture path should be under cwd")
.to_path_buf();
let base = FileName::Real(base_relative);
let paths = vec![("@modules/*".to_string(), vec!["./modules/*".to_string()])];

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

let normalized_path = match &normalized_base {
FileName::Real(path) => path,
other => panic!("unexpected base filename: {other:?}"),
};
assert!(
normalized_path.is_absolute(),
"existing relative filename should be normalized into cwd path space"
);

let resolved = resolver
.resolve_import(&normalized_base, "@modules/moduleA")
.expect("import should resolve");
assert_eq!(
&*resolved, "./modules/moduleA",
"resolved import should stay relative to src/ for existing relative filenames"
);

let _ = fs::remove_dir_all(&tmp_root);
}

#[cfg(all(feature = "module", target_os = "windows"))]
#[test]
fn issue_11584_windows_absolute_base_is_unc() {
use std::{
env, fs,
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-win-{}-{}",
std::process::id(),
uniq
));
let base_url = tmp_root.join("project");
let src_dir = base_url.join("src");
let entry = src_dir.join("index.ts");

fs::create_dir_all(&src_dir).expect("should create fixture directories");
fs::write(&entry, "export const value = 1;\n").expect("should create fixture file");

let base = FileName::Real(entry);
let paths = vec![("@app/*".to_string(), vec!["src/*".to_string()])];
let (normalized_base, _) = ModuleConfig::get_resolver(&base_url, paths, &base, None)
.expect("resolver should be created");

let normalized_path = match &normalized_base {
FileName::Real(path) => path,
other => panic!("unexpected base filename: {other:?}"),
};

let is_unc = matches!(
normalized_path.components().next(),
Some(std::path::Component::Prefix(prefix))
if matches!(
prefix.kind(),
std::path::Prefix::Verbatim(_)
| std::path::Prefix::VerbatimDisk(_)
| std::path::Prefix::VerbatimUNC(_, _)
)
);
assert!(
is_unc,
"normalized Windows base path should be UNC, got: {}",
normalized_path.display()
);

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
Loading
Loading