feat(imports): resolve workspace subpath exports via package.json exports map (#1308)

Workspace imports with subpath exports (e.g.
`import { x } from "@scope/pkg/browser"`) now resolve through the
package's `exports` map instead of falling back to a bare path. Supports
string values, condition objects, nested conditions, and single-`*`
wildcard patterns (`"./*": "./src/*.js"`), falling back to the existing
bare-path/index resolution when there is no exports map or no match.

Adapted from #1541, taking only the exports-map resolver and not that
PR's competing import-node-ID normalization (current v8 already resolves
the node-ID mismatch via the #1529 id-remap post-pass, and the PR's
_file_stem approach regressed the relative-input alias case). Two
hardening changes over the original:
- `default` is consulted LAST in the condition priority (it is Node's
  catch-all), so a matching `import`/`module`/`svelte` condition wins.
- Export targets that escape the package directory are rejected
  (`_contained_in_package`), so a malicious `exports` value like
  `"./x": "../../../etc/..."` cannot resolve to a file outside the package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guy oron
2026-06-29 13:57:16 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 7a9cda2452
commit e8dabadeb0
2 changed files with 249 additions and 8 deletions
+63 -8
View File
@@ -391,6 +391,42 @@ def _load_workspace_packages(start_dir: Path) -> dict[str, Path]:
return packages
# Condition keys consulted when resolving an `exports` target, in priority
# order. `default` is Node's catch-all and must be consulted LAST so a more
# specific condition (source/import/module/etc.) wins when several match.
_EXPORT_CONDITION_PRIORITY = (
"source", "import", "module", "svelte", "types", "require", "default",
)
def _resolve_export_target(value: Any) -> str | None:
"""Resolve an `exports` map value (string or condition object) to a
relative target string, honouring _EXPORT_CONDITION_PRIORITY for objects
and recursing into nested condition objects."""
if isinstance(value, str):
return value
if isinstance(value, dict):
for cond in _EXPORT_CONDITION_PRIORITY:
v = value.get(cond)
if isinstance(v, str):
return v
if isinstance(v, dict):
nested = _resolve_export_target(v)
if nested:
return nested
return None
def _contained_in_package(resolved: Path, package_dir: Path) -> bool:
"""Guard against `exports` targets that escape the package directory
(e.g. "./evil": "../../../etc/passwd"). Only accept paths that stay
within package_dir after resolution."""
try:
return resolved.resolve().is_relative_to(package_dir.resolve())
except ValueError:
return False
def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]:
manifest = package_dir / "package.json"
manifest_data: dict[str, Any] = {}
@@ -400,20 +436,39 @@ def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]:
pass
if subpath:
# Consult the package's `exports` subpath map before the bare-path
# fallback (#1308): "./browser" -> conditions -> file, plus single
# wildcard "./*" patterns. Targets that escape the package dir are
# rejected; resolution then falls through to the bare path.
exports = manifest_data.get("exports")
if isinstance(exports, dict):
subpath_key = "./" + subpath
target = _resolve_export_target(exports.get(subpath_key))
if target:
candidate = package_dir / target
if _contained_in_package(candidate, package_dir):
return [candidate]
else:
for pattern, pattern_value in exports.items():
if "*" in pattern and pattern.count("*") == 1:
prefix, suffix = pattern.split("*", 1)
if (subpath_key.startswith(prefix)
and (not suffix or subpath_key.endswith(suffix))):
matched = subpath_key[len(prefix):len(subpath_key) - len(suffix) if suffix else None]
resolved = _resolve_export_target(pattern_value)
if resolved and "*" in resolved:
candidate = package_dir / resolved.replace("*", matched)
if _contained_in_package(candidate, package_dir):
return [candidate]
return [package_dir / subpath]
exports = manifest_data.get("exports")
if isinstance(exports, str):
return [package_dir / exports]
if isinstance(exports, dict):
dot_export = exports.get(".")
if isinstance(dot_export, str):
return [package_dir / dot_export]
if isinstance(dot_export, dict):
for key in ("types", "import", "default", "svelte"):
value = dot_export.get(key)
if isinstance(value, str):
return [package_dir / value]
dot_target = _resolve_export_target(exports.get("."))
if dot_target:
return [package_dir / dot_target]
candidates: list[Path] = []
for key in ("svelte", "module", "main", "types"):
+186
View File
@@ -532,6 +532,192 @@ def test_pnpm_workspace_takes_precedence_over_package_json_workspaces(tmp_path:
assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts")
def test_workspace_subpath_export_string_resolves(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({
"name": "@example/pkg-a",
"exports": {
".": "./src/index.ts",
"./browser": "./src/browser.ts",
},
}),
)
target = _write(
tmp_path / "packages/pkg-a/src/browser.ts",
'export const value = "ok"\n',
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { value } from '@example/pkg-a/browser'\nexport const v = value\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/browser.ts")
def test_workspace_subpath_export_condition_object_resolves(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({
"name": "@example/pkg-a",
"exports": {
"./browser": {
"source": "./src/browser.ts",
"import": "./dist/esm/browser.js",
"require": "./dist/cjs/browser.js",
"types": "./dist/types/browser.d.ts",
},
},
}),
)
target = _write(
tmp_path / "packages/pkg-a/src/browser.ts",
'export const value = "ok"\n',
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { value } from '@example/pkg-a/browser'\nexport const v = value\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/browser.ts")
def test_workspace_subpath_export_wildcard_resolves(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({
"name": "@example/pkg-a",
"exports": {
"./*": {"source": "./src/*.ts"},
},
}),
)
target = _write(
tmp_path / "packages/pkg-a/src/utils.ts",
"export function add(a: number, b: number) { return a + b }\n",
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { add } from '@example/pkg-a/utils'\nexport const sum = add(1, 2)\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/utils.ts")
def test_workspace_subpath_export_falls_back_to_filesystem(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({"name": "@example/pkg-a"}),
)
target = _write(
tmp_path / "packages/pkg-a/browser.ts",
'export const value = "ok"\n',
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { value } from '@example/pkg-a/browser'\nexport const v = value\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/browser.ts")
def test_workspace_subpath_export_rejects_path_escape(tmp_path: Path):
# An exports target that escapes the package dir must NOT resolve to the
# outside path (path-containment security guard). Resolution falls through
# to the bare-path fallback, which has no real file here, so no edge lands
# on the escaped target.
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({
"name": "@example/pkg-a",
"exports": {
"./evil": "../../../../secret.ts",
},
}),
)
# A real file outside the package that the malicious export points at.
outside = _write(
tmp_path / "secret.ts",
'export const leak = "secret"\n',
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { leak } from '@example/pkg-a/evil'\nexport const v = leak\n",
)
result = _extract_for([outside, importer], tmp_path)
# The import must NOT resolve to the escaped outside file.
assert not _has_edge(result, "apps/web/src/consumer.ts", "secret.ts")
def test_workspace_subpath_export_default_consulted_last(tmp_path: Path):
# When both `default` and an earlier condition match, the earlier
# condition (import) must win -- `default` is Node's catch-all.
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/pkg-a/package.json",
json.dumps({
"name": "@example/pkg-a",
"exports": {
"./browser": {
"default": "./src/default-entry.ts",
"import": "./src/import-entry.ts",
},
},
}),
)
import_entry = _write(
tmp_path / "packages/pkg-a/src/import-entry.ts",
'export const value = "import"\n',
)
default_entry = _write(
tmp_path / "packages/pkg-a/src/default-entry.ts",
'export const value = "default"\n',
)
importer = _write(
tmp_path / "apps/web/src/consumer.ts",
"import { value } from '@example/pkg-a/browser'\nexport const v = value\n",
)
result = _extract_for([import_entry, default_entry, importer], tmp_path)
# `import` wins over `default`.
assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/import-entry.ts")
assert not _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/default-entry.ts")
def test_js_import_resolution_ignores_stale_importer_cache_when_target_appears(tmp_path: Path):
importer = _write(
tmp_path / "src/lib/page.ts",