mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-28 18:22:06 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
7a9cda2452
commit
e8dabadeb0
+63
-8
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user