From 255cb4ee6aa6024f78a8157fe3d5cc63694511b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 09:37:41 +0200 Subject: [PATCH 1/2] fix(compile): AOT-promote trusted reachable JavaScript --- .../compile/collect_modules/discovery.rs | 15 +-- .../commands/compile/collect_modules/tests.rs | 96 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/perry/src/commands/compile/collect_modules/discovery.rs b/crates/perry/src/commands/compile/collect_modules/discovery.rs index 28c3c7a8a4..9dca333309 100644 --- a/crates/perry/src/commands/compile/collect_modules/discovery.rs +++ b/crates/perry/src/commands/compile/collect_modules/discovery.rs @@ -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 diff --git a/crates/perry/src/commands/compile/collect_modules/tests.rs b/crates/perry/src/commands/compile/collect_modules/tests.rs index 7c5c71d160..ac1853ab8d 100644 --- a/crates/perry/src/commands/compile/collect_modules/tests.rs +++ b/crates/perry/src/commands/compile/collect_modules/tests.rs @@ -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 { + 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, From 9f9af7614aa43e9db6af62e71a448fbfa8a7a53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 21 Aug 2026 09:38:44 +0200 Subject: [PATCH 2/2] docs(changelog): note reachable JS AOT routing --- changelog.d/8529-aot-reachable-js.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/8529-aot-reachable-js.md diff --git a/changelog.d/8529-aot-reachable-js.md b/changelog.d/8529-aot-reachable-js.md new file mode 100644 index 0000000000..115daec0e8 --- /dev/null +++ b/changelog.d/8529-aot-reachable-js.md @@ -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.