Skip to content
Merged
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
3 changes: 3 additions & 0 deletions changelog.d/8529-aot-reachable-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed AOT module collection so statically reachable JavaScript and CommonJS
dependencies allowed by the host trust policy compile natively without each
package also needing a `perry.compilePackages` routing entry.
15 changes: 8 additions & 7 deletions crates/perry/src/commands/compile/collect_modules/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,18 @@ pub(crate) fn is_nextjs_runtime_module(path: &Path) -> bool {
.any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server"))
}

/// #6769: a statically resolved import must be compiled natively — Perry has
/// no runtime JavaScript engine for it to fall back to. Promoting the FILE
/// alone (rather than its whole package) keeps `perry.compilePackages`
/// meaningful: a runtime-computed load inside an unauthorized package still
/// routes the old way. Both promotion sites — the import walk and the
/// #6769 / #8518: a statically resolved import must be compiled natively —
/// Perry has no runtime JavaScript engine for it to fall back to. Promote the
/// FILE alone when the host trust policy allows its package. `compilePackages`
/// remains an eager whole-package routing hint, but it must not also be a
/// second, ever-growing list of every ordinary JS file reached by the graph.
/// A runtime-computed load inside an unauthorized package still routes to the
/// V8-free refusal gate. Both promotion sites — the import walk and the
/// re-export walk — ask this, so the boundary cannot drift between them.
pub(super) fn aot_promotion_is_authorized(resolved_path: &Path, ctx: &CompilationContext) -> bool {
super::super::audit_manifest::package_name_for_path(&resolved_path.to_string_lossy())
.is_none_or(|package| {
(ctx.compile_packages.contains(&package)
&& super::super::allowlist_matches(&package, &ctx.allow_compile_packages))
super::super::allowlist_matches(&package, &ctx.allow_compile_packages)
// Automatic whole-package routing deliberately omits Node
// native addons, but a package can expose a pure JS/TS helper
// subpath. A static edge to that exact file is safe to
Expand Down
96 changes: 96 additions & 0 deletions crates/perry/src/commands/compile/collect_modules/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,102 @@ fn collect_compile_package(
.map(|_| ())
}

fn collect_with_package_policy(
root: &std::path::Path,
entry: &std::path::Path,
compile_packages: &[&str],
allow_compile_packages: &[&str],
) -> anyhow::Result<CompilationContext> {
let mut ctx = CompilationContext::new(root.to_path_buf());
ctx.compile_packages
.extend(compile_packages.iter().map(|name| (*name).to_string()));
ctx.allow_compile_packages.extend(
allow_compile_packages
.iter()
.map(|name| (*name).to_string()),
);
ctx.entry_canonical = Some(entry.canonicalize().unwrap());
let mut visited = HashSet::new();
let mut next_class_id: perry_hir::ClassId = 1;
let progress = VerboseProgress::new(OutputFormat::Text, 0);

collect_modules(
&entry.to_path_buf(),
&mut ctx,
&mut visited,
OutputFormat::Text,
None,
&mut next_class_id,
false,
&progress,
None,
)?;
Ok(ctx)
}

#[test]
fn statically_reachable_trusted_js_package_is_aot_compiled_without_route_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let entry = write_compile_package_fixture(
root,
"reachable-js",
r#",
"exports": {
".": {
"node": "./lib/index.js",
"default": "./lib/browser.js"
}
}"#,
);
let package = root.join("node_modules/reachable-js/lib");
let package_entry = package.join("index.js");
let helper = package.join("helper.cjs");
let browser_decoy = package.join("browser.js");
std::fs::write(&helper, "exports.value = 42;\n").expect("write CJS helper");
std::fs::write(&browser_decoy, "throw new Error('wrong condition');\n")
.expect("write condition decoy");
std::fs::write(
&package_entry,
"exports.value = require('./helper.cjs').value;\n",
)
.expect("write package entry");

// Model a host that retains a small explicit compilePackages list while
// trusting the reachable dependency graph. Before #8518, the wildcard
// allow was ignored for routing and reachable-js landed in js_modules.
let ctx = collect_with_package_policy(root, &entry, &["some-eager-package"], &["*"])
.expect("collect trusted reachable JS");
let canonical_entry = package_entry.canonicalize().unwrap();
let canonical_helper = helper.canonicalize().unwrap();
let canonical_browser_decoy = browser_decoy.canonicalize().unwrap();

assert!(ctx.aot_discovered_modules.contains(&canonical_entry));
assert!(ctx.aot_discovered_modules.contains(&canonical_helper));
assert!(ctx.native_modules.contains_key(&canonical_entry));
assert!(ctx.native_modules.contains_key(&canonical_helper));
assert!(!ctx.native_modules.contains_key(&canonical_browser_decoy));
assert!(ctx.js_modules.is_empty());
}

#[test]
fn statically_reachable_untrusted_js_package_keeps_runtime_refusal_routing() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let entry = write_compile_package_fixture(root, "untrusted-js", "");
let package_entry = root.join("node_modules/untrusted-js/lib/index.js");

let ctx = collect_with_package_policy(root, &entry, &["trusted-only"], &["trusted-only"])
.expect("collect graph through the refusal classification");
let canonical_entry = package_entry.canonicalize().unwrap();

assert!(!ctx.aot_discovered_modules.contains(&canonical_entry));
assert!(!ctx.native_modules.contains_key(&canonical_entry));
assert!(ctx
.js_modules
.contains_key(&canonical_entry.to_string_lossy().to_string()));
}

fn guard_compile_package(
root: &std::path::Path,
package_name: &str,
Expand Down
Loading