From 52ba3c6484bfafec91517324e6e4a7fb7ac704e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 14:57:34 +0200 Subject: [PATCH 1/2] fix(bun): package project Node-API addon paths --- Cargo.lock | 2 + crates/perry/Cargo.toml | 4 +- .../src/commands/compile/collect_modules.rs | 9 + .../collect_modules/import_meta_require.rs | 485 ++++++++++++++++++ .../compile/collect_modules/native_addon.rs | 78 ++- .../static_require_transform.rs | 2 +- .../commands/compile/collect_modules/tests.rs | 25 + .../perry/src/commands/compile/host_config.rs | 101 +++- .../commands/compile/native_addon_sidecar.rs | 6 + crates/perry/src/commands/compile/types.rs | 11 + crates/perry/tests/node_api_host_e2e.rs | 170 ++++++ 11 files changed, 878 insertions(+), 15 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/import_meta_require.rs diff --git a/Cargo.lock b/Cargo.lock index d036a0ac70..11da77c331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5714,6 +5714,8 @@ dependencies = [ "sha2 0.11.0", "swc_common", "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_visit", "tar", "tempfile", "tokio", diff --git a/crates/perry/Cargo.toml b/crates/perry/Cargo.toml index d5ce3e4e0f..94b99fbc6c 100644 --- a/crates/perry/Cargo.toml +++ b/crates/perry/Cargo.toml @@ -21,7 +21,10 @@ path = "src/main.rs" [dependencies] perry-parser.workspace = true +swc_common.workspace = true swc_ecma_ast.workspace = true +swc_ecma_transforms_base.workspace = true +swc_ecma_visit.workspace = true perry-hir.workspace = true perry-transform.workspace = true perry-codegen.workspace = true @@ -172,7 +175,6 @@ all-codegen-backends = [ [dev-dependencies] tempfile.workspace = true -swc_common.workspace = true [build-dependencies] winresource = "0.1.31" diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 82d0dc6ace..c50faaee4b 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -36,6 +36,7 @@ mod dynamic_glob; mod eval_worker; mod feature_detect; mod import_helpers; +mod import_meta_require; mod json_module; mod native_addon; mod parse_error; @@ -56,6 +57,7 @@ use import_helpers::{ cached_resolve_import_with_lexical_base, collect_js_module_imports, ensure_bunfs_import_resolves, env_defines_for_lowering, }; +use import_meta_require::rewrite_import_meta_require_addons; use json_module::synthesize_json_module; pub(super) use native_addon::package_has_unsupported_node_addon; use native_addon::{collect_or_refuse_node_addon, refuse_compile_package_native_addon}; @@ -338,6 +340,13 @@ fn collect_module_one( fs::read_to_string(&canonical) .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))? }; + // Bun exposes `import.meta.require`; unlike CommonJS `require`, aliases of + // that function and URL-derived addon paths are invisible to the ordinary + // static-require scan. Recover and rewrite exact Node-API loads before HIR + // lowering so runtime execution uses only the authenticated sidecar id. + // Run before the Bun virtual-literal asset scan so a `.node` call target + // ships once in the sidecar rather than also being embedded as inert data. + let raw_source = rewrite_import_meta_require_addons(&raw_source, &canonical, ctx)?; register_bunfs_literal_assets(&raw_source, ctx); // CJS wrapping consumes literal `require()` sites and replaces them with // generated loader calls. Queue native targets before that rewrite so the diff --git a/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs new file mode 100644 index 0000000000..7a68082bac --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/import_meta_require.rs @@ -0,0 +1,485 @@ +//! Static recovery for Bun's `import.meta.require` Node-API addon loads. +//! +//! Bun standalone extraction commonly aliases the function and computes an +//! absolute path through `new URL("./addon.node", import.meta.url).pathname`. +//! Neither shape is visible to the CommonJS `require()` scanner. This pass +//! follows immutable local aliases/path constants, authorizes the resolved +//! binary, and replaces the load with `process.dlopen` against the sidecar's +//! portable logical id. + +use anyhow::{anyhow, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use swc_common::{Globals, Mark, SyntaxContext, GLOBALS}; +use swc_ecma_ast as ast; +use swc_ecma_ast::Pass; +use swc_ecma_transforms_base::resolver; +use swc_ecma_visit::{Visit, VisitWith}; + +use super::native_addon::collect_node_addon_request; +use super::static_require_transform::resolve_static_require; +use super::CompilationContext; + +fn strip_transparent_expr(mut expression: &ast::Expr) -> &ast::Expr { + loop { + expression = match expression { + ast::Expr::Paren(value) => &value.expr, + ast::Expr::TsAs(value) => &value.expr, + ast::Expr::TsNonNull(value) => &value.expr, + ast::Expr::TsTypeAssertion(value) => &value.expr, + ast::Expr::TsConstAssertion(value) => &value.expr, + ast::Expr::TsSatisfies(value) => &value.expr, + ast::Expr::TsInstantiation(value) => &value.expr, + _ => return expression, + }; + } +} + +fn static_member_name(member: &ast::MemberExpr) -> Option<&str> { + match &member.prop { + ast::MemberProp::Ident(name) => Some(name.sym.as_ref()), + ast::MemberProp::Computed(name) => match strip_transparent_expr(&name.expr) { + ast::Expr::Lit(ast::Lit::Str(value)) => value.value.as_str(), + _ => None, + }, + ast::MemberProp::PrivateName(_) => None, + } +} + +fn is_import_meta_member(expression: &ast::Expr, expected: &str) -> bool { + let ast::Expr::Member(member) = strip_transparent_expr(expression) else { + return false; + }; + static_member_name(member) == Some(expected) + && matches!( + strip_transparent_expr(&member.obj), + ast::Expr::MetaProp(meta) if meta.kind == ast::MetaPropKind::ImportMeta + ) +} + +fn identifier_id(expression: &ast::Expr) -> Option { + match strip_transparent_expr(expression) { + ast::Expr::Ident(identifier) => Some(identifier.to_id()), + _ => None, + } +} + +fn url_pathname_specifier( + expression: &ast::Expr, + unresolved_ctxt: SyntaxContext, +) -> Option { + let ast::Expr::Member(pathname) = strip_transparent_expr(expression) else { + return None; + }; + if static_member_name(pathname) != Some("pathname") { + return None; + } + let ast::Expr::New(url) = strip_transparent_expr(&pathname.obj) else { + return None; + }; + let ast::Expr::Ident(constructor) = strip_transparent_expr(&url.callee) else { + return None; + }; + if constructor.sym.as_ref() != "URL" || constructor.ctxt != unresolved_ctxt { + return None; + } + let arguments = url.args.as_ref()?; + if arguments.len() != 2 || arguments.iter().any(|argument| argument.spread.is_some()) { + return None; + } + if !is_import_meta_member(&arguments[1].expr, "url") { + return None; + } + let ast::Expr::Lit(ast::Lit::Str(specifier)) = strip_transparent_expr(&arguments[0].expr) + else { + return None; + }; + specifier.value.as_str().map(str::to_string) +} + +fn static_specifier( + expression: &ast::Expr, + path_bindings: &HashMap, + unresolved_ctxt: SyntaxContext, +) -> Option { + match strip_transparent_expr(expression) { + ast::Expr::Lit(ast::Lit::Str(value)) => value.value.as_str().map(str::to_string), + ast::Expr::Ident(identifier) => path_bindings.get(&identifier.to_id()).cloned(), + expression => url_pathname_specifier(expression, unresolved_ctxt), + } +} + +#[derive(Default)] +struct BindingScan { + counts: HashMap, + const_initializers: HashMap>>, + modified: HashSet, +} + +impl Visit for BindingScan { + fn visit_binding_ident(&mut self, binding: &ast::BindingIdent) { + *self.counts.entry(binding.id.to_id()).or_default() += 1; + binding.visit_children_with(self); + } + + fn visit_var_decl(&mut self, declaration: &ast::VarDecl) { + // Only `const` bindings are forwarding facts. `let`/`var` require a + // complete mutation analysis (destructuring and loop heads included), + // while Bun's emitted aliases and path constants use `const`. + if declaration.kind == ast::VarDeclKind::Const { + for declarator in &declaration.decls { + if let (ast::Pat::Ident(binding), Some(initializer)) = + (&declarator.name, &declarator.init) + { + self.const_initializers + .entry(binding.id.to_id()) + .or_default() + .push(initializer.clone()); + } + } + } + declaration.visit_children_with(self); + } + + fn visit_assign_expr(&mut self, assignment: &ast::AssignExpr) { + if let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Ident(binding)) = &assignment.left + { + self.modified.insert(binding.id.to_id()); + } + assignment.visit_children_with(self); + } + + fn visit_update_expr(&mut self, update: &ast::UpdateExpr) { + if let ast::Expr::Ident(identifier) = strip_transparent_expr(&update.arg) { + self.modified.insert(identifier.to_id()); + } + update.visit_children_with(self); + } +} + +fn immutable_initializers(scan: &BindingScan) -> HashMap { + scan.const_initializers + .iter() + .filter_map(|(name, initializers)| { + (scan.counts.get(name) == Some(&1) + && initializers.len() == 1 + && !scan.modified.contains(name)) + .then(|| (name.clone(), initializers[0].as_ref())) + }) + .collect() +} + +fn recover_require_aliases(initializers: &HashMap) -> HashSet { + let mut aliases = HashSet::new(); + loop { + let mut changed = false; + for (name, initializer) in initializers { + if aliases.contains(name) { + continue; + } + let is_alias = is_import_meta_member(initializer, "require") + || identifier_id(initializer).is_some_and(|source| aliases.contains(&source)); + if is_alias { + changed |= aliases.insert(name.clone()); + } + } + if !changed { + return aliases; + } + } +} + +fn recover_path_bindings( + initializers: &HashMap, + unresolved_ctxt: SyntaxContext, +) -> HashMap { + let mut paths = HashMap::new(); + loop { + let mut changed = false; + for (name, initializer) in initializers { + if paths.contains_key(name) { + continue; + } + if let Some(specifier) = static_specifier(initializer, &paths, unresolved_ctxt) { + paths.insert(name.clone(), specifier); + changed = true; + } + } + if !changed { + return paths; + } + } +} + +struct RequireCall { + start: usize, + end: usize, + specifier: Option, +} + +struct CallScan<'a> { + aliases: &'a HashSet, + paths: &'a HashMap, + unresolved_ctxt: SyntaxContext, + calls: Vec, +} + +impl Visit for CallScan<'_> { + fn visit_call_expr(&mut self, call: &ast::CallExpr) { + let recognized = match &call.callee { + ast::Callee::Expr(callee) => { + is_import_meta_member(callee, "require") + || identifier_id(callee).is_some_and(|id| self.aliases.contains(&id)) + } + _ => false, + }; + if recognized { + let specifier = (call.args.len() == 1 && call.args[0].spread.is_none()) + .then(|| static_specifier(&call.args[0].expr, self.paths, self.unresolved_ctxt)) + .flatten(); + self.calls.push(RequireCall { + start: call.span.lo.0.saturating_sub(1) as usize, + end: call.span.hi.0.saturating_sub(1) as usize, + specifier, + }); + } + call.visit_children_with(self); + } +} + +fn looks_like_node_addon(specifier: &str) -> bool { + specifier + .split(['?', '#']) + .next() + .is_some_and(|path| path.ends_with(".node")) +} + +fn unique_identifier(source: &str, prefix: &str, index: usize) -> String { + let mut suffix = index; + loop { + let name = format!("{prefix}_{suffix}"); + if !source.contains(&name) { + return name; + } + suffix += 1; + } +} + +pub(super) fn rewrite_import_meta_require_addons( + source: &str, + module_path: &Path, + ctx: &mut CompilationContext, +) -> Result { + if !source.contains("import.meta") || !source.contains("require") { + return Ok(source.to_string()); + } + let filename = module_path.to_string_lossy(); + // A `.js` file that only uses `import.meta` has no import/export token for + // the parser's script-vs-module heuristic. Append a zero-width-for-existing + // spans module marker for this analysis parse; the emitted source and all + // original byte offsets remain unchanged. + let analysis_source = format!("{source}\nexport {{}};\n"); + let module = perry_parser::parse_typescript(&analysis_source, &filename).map_err(|error| { + anyhow!( + "failed to analyze `import.meta.require` in {}: {error}", + module_path.display() + ) + })?; + // SWC's resolver assigns a distinct syntax context to every lexical + // binding and its references. That makes the following dataflow safe + // across nested scopes: a parameter named `load` cannot be mistaken for + // an outer `const load = import.meta.require` alias. + let mut program = ast::Program::Module(module); + let unresolved_ctxt = GLOBALS.set(&Globals::new(), || { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + resolver(unresolved_mark, top_level_mark, true).process(&mut program); + SyntaxContext::empty().apply_mark(unresolved_mark) + }); + let ast::Program::Module(module) = program else { + unreachable!("the resolver preserves a module program") + }; + let mut bindings = BindingScan::default(); + module.visit_with(&mut bindings); + let initializers = immutable_initializers(&bindings); + let aliases = recover_require_aliases(&initializers); + let paths = recover_path_bindings(&initializers, unresolved_ctxt); + let mut calls = CallScan { + aliases: &aliases, + paths: &paths, + unresolved_ctxt, + calls: Vec::new(), + }; + module.visit_with(&mut calls); + + let mut replacements = Vec::new(); + let process_alias = unique_identifier(source, "__perry_import_meta_process", 0); + for (index, call) in calls.calls.into_iter().enumerate() { + let Some(specifier) = call.specifier else { + anyhow::bail!( + "cannot statically prove the Node-API addon path passed to `import.meta.require` in {}. Declare every project-owned addon with an exact `perry.nativeAddonPaths` entry and call the unmodified binding with a string literal or `new URL(\"./addon.node\", import.meta.url).pathname`.", + module_path.display() + ); + }; + let target = resolve_static_require( + module_path.parent().unwrap_or_else(|| Path::new(".")), + &specifier, + ctx.bunfs_root.as_deref(), + ); + let Some(target) = target else { + if looks_like_node_addon(&specifier) { + anyhow::bail!( + "statically declared Node-API addon `{specifier}` from {} could not be resolved. Project-owned addons must be listed by exact path in `perry.nativeAddonPaths`.", + module_path.display() + ); + } + continue; + }; + if target.extension().and_then(|extension| extension.to_str()) != Some("node") { + continue; + } + let Some(logical_id) = collect_node_addon_request(ctx, &target)? else { + continue; + }; + if call.start > call.end + || call.end > source.len() + || !source.is_char_boundary(call.start) + || !source.is_char_boundary(call.end) + { + anyhow::bail!( + "invalid source span while rewriting `import.meta.require` in {}", + module_path.display() + ); + } + let temporary = unique_identifier(source, "__perry_import_meta_addon", index); + let request = serde_json::to_string(&logical_id)?; + let replacement = format!( + "(function() {{ const {temporary} = {{ exports: {{}} }}; {process_alias}.dlopen({temporary}, {request}); return {temporary}.exports; }})()" + ); + replacements.push((call.start, call.end, replacement)); + } + + replacements.sort_by_key(|(start, _, _)| *start); + let mut rewritten = source.to_string(); + for (start, end, replacement) in replacements.into_iter().rev() { + rewritten.replace_range(start..end, &replacement); + } + if rewritten != source { + let import = format!("import * as {process_alias} from \"node:process\";\n"); + if rewritten.starts_with("#!") { + if let Some(line_end) = rewritten.find('\n') { + rewritten.insert_str(line_end + 1, &import); + } else { + rewritten.push('\n'); + rewritten.push_str(&import); + } + } else { + rewritten.insert_str(0, &import); + } + } + Ok(rewritten) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture() -> (tempfile::TempDir, PathBuf, CompilationContext) { + let dir = tempfile::tempdir().unwrap(); + let native = dir.path().join("native"); + std::fs::create_dir_all(&native).unwrap(); + let addon = native.join("addon.node"); + std::fs::copy(std::env::current_exe().unwrap(), &addon).unwrap(); + let addon = addon.canonicalize().unwrap(); + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + ctx.bunfs_root = Some(dir.path().canonicalize().unwrap()); + ctx.native_addon_paths + .insert(addon, "native/addon.node".to_string()); + (dir, PathBuf::from("main.js"), ctx) + } + + #[test] + fn follows_aliases_url_path_constants_and_bun_virtual_paths() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let source = r#" +const load = import.meta.require; +const forwarded = load; +const addonPath = new URL("./native/addon.node", import.meta.url).pathname; +const a = forwarded(addonPath); +const b = load("./native/addon.node"); +const c = load("/$bunfs/root/native/addon.node"); +const d = import.meta.require(new URL("./native/addon.node", import.meta.url).pathname); +"#; + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + assert_eq!(rewritten.matches(".dlopen(").count(), 4, "{rewritten}"); + assert_eq!( + rewritten.matches("\"$project/native/addon.node\"").count(), + 4 + ); + assert_eq!(ctx.native_addons.len(), 1); + assert!(!ctx.native_addons["$project/native/addon.node"].ship_package_payload); + } + + #[test] + fn rejects_a_dynamic_path_with_policy_guidance() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let error = rewrite_import_meta_require_addons( + "const load = import.meta.require; load(process.env.ADDON_PATH);", + &entry, + &mut ctx, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("cannot statically prove"), "{error}"); + assert!(error.contains("perry.nativeAddonPaths"), "{error}"); + } + + #[test] + fn does_not_follow_a_modified_binding() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let source = + r#"let load = import.meta.require; load = other; load("./native/addon.node");"#; + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + assert_eq!(rewritten, source); + assert!(ctx.native_addons.is_empty()); + } + + #[test] + fn does_not_treat_a_shadowed_url_constructor_as_static() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let error = rewrite_import_meta_require_addons( + r#"const URL = CustomURL; const load = import.meta.require; load(new URL("./native/addon.node", import.meta.url).pathname);"#, + &entry, + &mut ctx, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("cannot statically prove"), "{error}"); + } + + #[test] + fn follows_nested_aliases_without_rewriting_same_named_bindings() { + let (dir, relative_entry, mut ctx) = fixture(); + let entry = dir.path().join(relative_entry); + let source = r#" +function loadAddon() { + const load = import.meta.require; + return load("./native/addon.node"); +} +function unrelated(load) { + return load("./native/addon.node"); +} +"#; + let rewritten = rewrite_import_meta_require_addons(source, &entry, &mut ctx).unwrap(); + assert_eq!(rewritten.matches(".dlopen(").count(), 1, "{rewritten}"); + assert!( + rewritten.contains("return load(\"./native/addon.node\");\n}"), + "{rewritten}" + ); + } +} diff --git a/crates/perry/src/commands/compile/collect_modules/native_addon.rs b/crates/perry/src/commands/compile/collect_modules/native_addon.rs index 030d8ec1b9..b9f83878f6 100644 --- a/crates/perry/src/commands/compile/collect_modules/native_addon.rs +++ b/crates/perry/src/commands/compile/collect_modules/native_addon.rs @@ -151,26 +151,58 @@ fn validate_node_api_binary(path: &std::path::Path) -> Result<()> { Ok(()) } +fn path_is_inside_node_modules(path: &std::path::Path) -> bool { + path.components().any(|component| { + matches!(component, std::path::Component::Normal(part) if part == "node_modules") + }) +} + /// Record an approved `.node` graph member or emit the existing actionable -/// unsupported-addon diagnostic. Returns true exactly for `.node` inputs so -/// the caller can stop before attempting to parse the native binary. -pub(super) fn collect_or_refuse_node_addon( +/// unsupported-addon diagnostic. The returned logical id is the only path +/// that generated code may pass to the authenticated runtime loader. +pub(super) fn collect_node_addon_request( ctx: &mut CompilationContext, canonical: &std::path::Path, -) -> Result { +) -> Result> { if canonical.extension().and_then(|ext| ext.to_str()) != Some("node") { - return Ok(false); + return Ok(None); + } + if let Some(project_path) = ctx.native_addon_paths.get(canonical).cloned() { + // Keep project entries in a namespace that cannot collide with the + // existing `/` logical-id scheme. + let logical_id = format!("$project/{project_path}"); + validate_node_api_binary(canonical)?; + let package_dir = canonical + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .to_path_buf(); + let entry_relative = canonical + .file_name() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Node-API addon path has no filename"))?; + ctx.native_addons + .entry(logical_id.clone()) + .or_insert_with(|| NativeAddonModule { + logical_id: logical_id.clone(), + package: "$project".to_string(), + version: "0.0.0".to_string(), + source_path: canonical.to_path_buf(), + package_dir, + entry_relative, + ship_package_payload: false, + }); + return Ok(Some(logical_id)); } let package_root = nearest_package_root(canonical); if package_root .as_deref() .is_some_and(package_is_parcel_watcher_facade) { - return Ok(true); + return Ok(None); } - let Some(package_root) = package_root else { + let Some(package_root) = package_root.filter(|root| path_is_inside_node_modules(root)) else { anyhow::bail!( - "`{}` is a Node native addon outside an npm package. Addons must be selected through an exact `perry.nativeAddons` package entry.", + "`{}` is a project-owned Node native addon and is not authorized. Add its exact project-relative path to `perry.nativeAddonPaths` (for example `\"nativeAddonPaths\": [\"native/addon.node\"]`).", canonical.display() ); }; @@ -199,14 +231,31 @@ pub(super) fn collect_or_refuse_node_addon( ctx.native_addons .entry(logical_id.clone()) .or_insert_with(|| NativeAddonModule { - logical_id, + logical_id: logical_id.clone(), package: owner_package, version, source_path: canonical.to_path_buf(), package_dir: package_root, entry_relative, + ship_package_payload: true, }); - Ok(true) + Ok(Some(logical_id)) +} + +/// Returns true exactly for `.node` inputs handled as sidecar graph members so +/// the caller can stop before attempting to parse native bytes as source. +pub(super) fn collect_or_refuse_node_addon( + ctx: &mut CompilationContext, + canonical: &std::path::Path, +) -> Result { + if canonical.extension().and_then(|ext| ext.to_str()) == Some("node") + && nearest_package_root(canonical) + .as_deref() + .is_some_and(package_is_parcel_watcher_facade) + { + return Ok(true); + } + collect_node_addon_request(ctx, canonical).map(|request| request.is_some()) } fn package_is_parcel_watcher_facade(package_root: &std::path::Path) -> bool { @@ -340,6 +389,15 @@ pub(super) fn refuse_compile_package_native_addon( let Some(package_root) = package_root_for_compile_package(ctx, canonical) else { return Ok(()); }; + // The host project can legitimately contain exact path-authorized addons + // (#9606). This package-wide preflight exists for dependencies selected by + // `compilePackages`; project members are checked individually when their + // `.node` edge is collected, so scanning the host root here would reject + // an authorized addon merely because Bun-root routing selected the host + // JS. Keep checking symlinked/file: dependency roots outside node_modules. + if ctx.cache_root.starts_with(&package_root) { + return Ok(()); + } if !ctx .checked_compile_package_native_addon_roots .insert(package_root.clone()) diff --git a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs index e196d647f8..e7e9fc99ba 100644 --- a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs +++ b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs @@ -186,7 +186,7 @@ pub(super) fn transform_static_literal_requires_with_bunfs( prepend_imports_preserving_shebang(&transformed, &imports) } -fn resolve_static_require( +pub(super) fn resolve_static_require( module_dir: &Path, specifier: &str, bunfs_root: Option<&Path>, diff --git a/crates/perry/src/commands/compile/collect_modules/tests.rs b/crates/perry/src/commands/compile/collect_modules/tests.rs index ce0a94c323..5af8ee041e 100644 --- a/crates/perry/src/commands/compile/collect_modules/tests.rs +++ b/crates/perry/src/commands/compile/collect_modules/tests.rs @@ -42,6 +42,7 @@ fn approved_node_addon_is_recorded_as_a_relocatable_graph_member() { assert_eq!(record.package, "demo-addon"); assert_eq!(record.version, "1.2.3"); assert_eq!(record.entry_relative, std::path::Path::new("binding.node")); + assert!(record.ship_package_payload); } #[test] @@ -903,6 +904,30 @@ fn compile_package_with_node_file_is_rejected() { ); } +#[test] +fn external_compile_package_root_still_gets_native_addon_preflight() { + let dir = tempfile::tempdir().expect("tempdir"); + let host = dir.path().join("host"); + let package = dir.path().join("linked-addon"); + std::fs::create_dir_all(&host).expect("host directory"); + std::fs::create_dir_all(&package).expect("linked package directory"); + std::fs::write(package.join("package.json"), r#"{"name":"linked-addon"}"#) + .expect("linked package manifest"); + std::fs::write(package.join("binding.gyp"), "{}\n").expect("native addon marker"); + let entry = package.join("index.js"); + std::fs::write(&entry, "module.exports = 1\n").expect("linked package entry"); + + let mut ctx = CompilationContext::new(host); + ctx.compile_package_dirs + .insert(package.canonicalize().expect("canonical linked package")); + let error = refuse_compile_package_native_addon( + &mut ctx, + &entry.canonicalize().expect("canonical linked entry"), + ) + .expect_err("an external file: dependency must retain package-wide preflight"); + assert!(error.to_string().contains("binding.gyp"), "{error}"); +} + #[test] fn compile_package_with_node_directory_is_allowed() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 19f2da8755..01da27af50 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -14,7 +14,7 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use anyhow::Result; use perry_codegen::FpContractMode; @@ -58,6 +58,29 @@ fn is_exact_npm_package_name(name: &str) -> bool { valid_segment(name) } +fn normalized_project_addon_path(value: &str) -> Option<(PathBuf, String)> { + let path = Path::new(value.trim()); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.extension().and_then(|extension| extension.to_str()) != Some("node") + { + return None; + } + let mut relative = PathBuf::new(); + let mut portable = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) if part != "node_modules" => { + relative.push(part); + portable.push(part.to_string_lossy().into_owned()); + } + _ => return None, + } + } + (!portable.is_empty()).then(|| (relative, portable.join("/"))) +} + fn parse_boolean_switch(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" => Some(true), @@ -257,6 +280,54 @@ pub(super) fn apply_pkg_and_toml_config( ctx.native_addon_packages.insert(name.to_string()); } } + // #9606: extracted Bun standalones can carry project-owned + // `.node` files that have no npm package identity. Keep those + // exact path grants separate from package grants: authorizing + // `native/addon.node` must not trust its directory, another + // addon, or a package under node_modules. + if let Some(native_addon_paths) = pkg + .get("perry") + .and_then(|perry| perry.get("nativeAddonPaths")) + { + let entries = native_addon_paths.as_array().ok_or_else(|| { + anyhow::anyhow!( + "`perry.nativeAddonPaths` must be an array of exact project-relative `.node` paths" + ) + })?; + let config_root = pkg_json_path + .parent() + .ok_or_else(|| { + anyhow::anyhow!("project package.json has no parent directory") + })? + .canonicalize()?; + for (index, entry) in entries.iter().enumerate() { + let value = entry.as_str().ok_or_else(|| { + anyhow::anyhow!( + "`perry.nativeAddonPaths[{index}]` must be a project-relative path string" + ) + })?; + let (relative, logical_id) = normalized_project_addon_path(value) + .ok_or_else(|| { + anyhow::anyhow!( + "`perry.nativeAddonPaths[{index}]` must name one exact project-relative `.node` file outside `node_modules`; invalid entry `{value}`" + ) + })?; + let configured = config_root.join(&relative); + let canonical = configured.canonicalize().map_err(|error| { + anyhow::anyhow!( + "configured Node-API addon `{}` is unavailable: {error}", + configured.display() + ) + })?; + if !canonical.starts_with(&config_root) || !canonical.is_file() { + anyhow::bail!( + "configured Node-API addon `{}` must resolve to a file inside the host project", + configured.display() + ); + } + ctx.native_addon_paths.insert(canonical, logical_id); + } + } // #1680 (Phase 2 of #1677): build-time codegen steps. Each // entry is a shell command (or `{ command, label }`) run // before module collection so codegen libraries with an @@ -1262,7 +1333,7 @@ pub(super) fn apply_pkg_and_toml_config( } } - if !ctx.native_addon_packages.is_empty() { + if !ctx.native_addon_packages.is_empty() || !ctx.native_addon_paths.is_empty() { let target = args.target.as_deref().unwrap_or("native"); let unsupported = matches!( target, @@ -1284,7 +1355,7 @@ pub(super) fn apply_pkg_and_toml_config( ); if unsupported { anyhow::bail!( - "`perry.nativeAddons` is unavailable for target `{target}`; prebuilt Node-API sidecars are supported only on desktop/server targets" + "`perry.nativeAddons` / `perry.nativeAddonPaths` are unavailable for target `{target}`; prebuilt Node-API sidecars are supported only on desktop/server targets" ); } } @@ -1318,6 +1389,30 @@ mod tests { } } + #[test] + fn project_addon_policy_accepts_only_exact_relative_node_paths() { + for (value, expected) in [ + ("native/addon.node", "native/addon.node"), + ("./addon.node", "addon.node"), + ("native/platform/addon.node", "native/platform/addon.node"), + ] { + let (_, portable) = normalized_project_addon_path(value).expect(value); + assert_eq!(portable, expected); + } + for value in [ + "", + ".", + "native", + "native/addon.so", + "../addon.node", + "/tmp/addon.node", + "node_modules/pkg/addon.node", + "native/../../addon.node", + ] { + assert!(normalized_project_addon_path(value).is_none(), "{value}"); + } + } + #[test] fn auto_switch_accepts_only_documented_values() { for value in [ diff --git a/crates/perry/src/commands/compile/native_addon_sidecar.rs b/crates/perry/src/commands/compile/native_addon_sidecar.rs index 4976c6a4f8..8c1a4a5765 100644 --- a/crates/perry/src/commands/compile/native_addon_sidecar.rs +++ b/crates/perry/src/commands/compile/native_addon_sidecar.rs @@ -19,6 +19,7 @@ struct SidecarManifest { shipping_model: &'static str, target: String, allowlist: Vec, + path_allowlist: Vec, addons: Vec, } @@ -82,6 +83,9 @@ fn payload_key(addon: &NativeAddonModule) -> String { /// artifact. Nested node_modules and VCS state are separate packages, not /// part of the selected platform payload. pub(super) fn addon_payload_files(addon: &NativeAddonModule) -> Vec { + if !addon.ship_package_payload { + return vec![addon.source_path.clone()]; + } let mut files = walkdir::WalkDir::new(&addon.package_dir) .follow_links(false) .into_iter() @@ -189,6 +193,7 @@ pub(super) fn stage_native_addon_sidecar( shipping_model: SHIPPING_MODEL, target: target_tuple(target), allowlist: ctx.native_addon_packages.iter().cloned().collect(), + path_allowlist: ctx.native_addon_paths.values().cloned().collect(), addons: manifest_addons, }; fs::write( @@ -236,6 +241,7 @@ mod tests { source_path: entry, package_dir: package, entry_relative: PathBuf::from("demo.node"), + ship_package_payload: true, }, ); let root = stage_native_addon_sidecar(&ctx, &output, None) diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index d76e8372bb..c19f502c1e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -673,6 +673,12 @@ pub struct CompilationContext { /// Approved `.node` entries reached by the compile graph, keyed by their /// relocatable package-relative logical id. pub native_addons: BTreeMap, + /// Exact project-relative `.node` paths authorized by the host manifest, + /// keyed by canonical source path. The value is the portable declared path + /// used to derive the sidecar logical id; unlike `native_addon_packages`, + /// these entries never confer trust on an npm package or a containing + /// directory. + pub native_addon_paths: BTreeMap, /// Package aliases: maps npm package name → replacement package name (from perry.packageAliases) pub package_aliases: HashMap, /// Packages to compile natively instead of routing to V8 (from perry.compilePackages) @@ -1202,6 +1208,7 @@ impl CompilationContext { native_libraries: Vec::new(), native_addon_packages: BTreeSet::new(), native_addons: BTreeMap::new(), + native_addon_paths: BTreeMap::new(), package_aliases: HashMap::new(), compile_packages: HashSet::new(), auto_skipped_node_addon_packages: HashSet::new(), @@ -1308,6 +1315,10 @@ pub struct NativeAddonModule { pub source_path: PathBuf, pub package_dir: PathBuf, pub entry_relative: PathBuf, + /// Package entries ship their complete package-local payload so adjacent + /// data/shared libraries remain available. Exact project-path entries ship + /// only the explicitly authorized `.node` file. + pub ship_package_payload: bool, } /// External native library manifest parsed from package.json `perry.nativeLibrary` field diff --git a/crates/perry/tests/node_api_host_e2e.rs b/crates/perry/tests/node_api_host_e2e.rs index 62731a7bb4..a498478361 100644 --- a/crates/perry/tests/node_api_host_e2e.rs +++ b/crates/perry/tests/node_api_host_e2e.rs @@ -127,6 +127,24 @@ fn compile_app(root: &Path, entry: &Path, output: &Path) -> Output { command.output().expect("run perry compile") } +fn compile_bunfs_app(root: &Path, entry: &Path, output: &Path) -> Output { + let mut command = Command::new(perry_bin()); + command + .current_dir(root) + .env("PERRY_WORKSPACE_ROOT", workspace_root()) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(output) + .arg("--bunfs-root") + .arg(root) + .arg("--no-cache"); + if std::env::var_os("PERRY_E2E_VERBOSE").is_some() { + command.arg("-vv"); + } + command.output().expect("run bunfs perry compile") +} + fn find_node_file(path: &Path) -> Option { let mut entries = std::fs::read_dir(path) .ok()? @@ -521,6 +539,158 @@ console.log("node-api-cache", direct.exports === addon) ); } +#[test] +fn bun_import_meta_require_project_addon_survives_source_removal() { + if !require_tool("clang") { + return; + } + #[cfg(windows)] + if !require_tool("llvm-dlltool") { + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let extracted = dir.path().join("extracted"); + let native = extracted.join("native"); + let build = dir.path().join("build"); + std::fs::create_dir_all(&native).expect("create project addon directory"); + std::fs::create_dir_all(&build).expect("create build directory"); + std::fs::write( + extracted.join("package.json"), + r#"{ + "name": "perry-bun-root-addon-e2e", + "private": true, + "perry": { + "nativeAddonPaths": ["native/addon.node"] + } +}"#, + ) + .expect("write project addon policy"); + compile_addon(&extracted, &native); + + let entry = extracted.join("main.js"); + std::fs::write( + &entry, + r#"const load = import.meta.require; +const addonPath = new URL("./native/addon.node", import.meta.url).pathname; +const first = load(addonPath); +const r = import.meta.require; +const second = r("./native/addon.node"); +const third = r("/$bunfs/root/native/addon.node"); +const fourth = import.meta.require(new URL("./native/addon.node", import.meta.url).pathname); +console.log("bun-root-node-api", first.add(19, 23), second.answer, third === first, fourth === first); +"#, + ) + .expect("write import.meta.require entry"); + + let executable = build.join(if cfg!(windows) { "app.exe" } else { "app" }); + let compile = compile_bunfs_app(&extracted, &entry, &executable); + assert!( + compile.status.success(), + "project Node-API compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let sidecar = executable.with_file_name(format!( + "{}.perry-native", + executable.file_name().unwrap().to_string_lossy() + )); + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(sidecar.join("manifest.json")).expect("read project addon manifest"), + ) + .expect("parse project addon manifest"); + assert_eq!( + manifest["path_allowlist"], + serde_json::json!(["native/addon.node"]) + ); + assert_eq!( + manifest["addons"][0]["logical_id"], + "$project/native/addon.node" + ); + assert_eq!(manifest["addons"][0]["package"], "$project"); + assert_eq!( + manifest["addons"][0]["files"] + .as_array() + .expect("project addon files") + .len(), + 1, + "an exact project path must not implicitly ship its directory" + ); + + let install = dir.path().join("install"); + std::fs::create_dir_all(&install).expect("create install directory"); + let installed = install.join(executable.file_name().unwrap()); + let installed_sidecar = install.join(sidecar.file_name().unwrap()); + std::fs::rename(&executable, &installed).expect("relocate executable"); + std::fs::rename(&sidecar, &installed_sidecar).expect("relocate addon sidecar"); + std::fs::remove_dir_all(&extracted).expect("remove Bun extraction source tree"); + assert!( + !extracted.exists(), + "source tree must be gone before runtime" + ); + + let output = run( + Command::new(&installed), + "relocated Bun import.meta.require Node-API host", + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("bun-root-node-api 42 8523 true true"), + "stdout: {stdout}" + ); +} + +#[test] +fn dynamic_import_meta_require_addon_path_is_rejected_with_declaration_help() { + let dir = tempfile::tempdir().expect("tempdir"); + let native = dir.path().join("native"); + std::fs::create_dir_all(&native).expect("create native directory"); + // Configuration validates existence and path containment before module + // collection. The dynamic-path diagnostic fires before binary inspection, + // so an inert marker is sufficient for this negative gate. + std::fs::write(native.join("addon.node"), b"not loaded").expect("write addon marker"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "name": "perry-dynamic-root-addon-e2e", + "private": true, + "perry": { + "nativeAddonPaths": ["native/addon.node"] + } +}"#, + ) + .expect("write project addon policy"); + let entry = dir.path().join("main.js"); + std::fs::write( + &entry, + "const load = import.meta.require; load(process.env.ADDON_PATH);\n", + ) + .expect("write dynamic addon entry"); + let output = dir.path().join(if cfg!(windows) { + "dynamic.exe" + } else { + "dynamic" + }); + let compile = compile_app(dir.path(), &entry, &output); + assert!( + !compile.status.success(), + "dynamic import.meta.require addon path unexpectedly compiled" + ); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + assert!( + diagnostic.contains("cannot statically prove"), + "{diagnostic}" + ); + assert!( + diagnostic.contains("perry.nativeAddonPaths"), + "{diagnostic}" + ); +} + #[test] fn published_napi_rs_addon_runs_sync_and_async_work() { let dir = tempfile::tempdir().expect("tempdir"); From 8ca1fa202124a969d8702d20f78bb6f9294977bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 15:00:26 +0200 Subject: [PATCH 2/2] docs(changelog): record Bun project addon support --- changelog.d/9632-bun-project-node-addons.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/9632-bun-project-node-addons.md diff --git a/changelog.d/9632-bun-project-node-addons.md b/changelog.d/9632-bun-project-node-addons.md new file mode 100644 index 0000000000..209bec29ec --- /dev/null +++ b/changelog.d/9632-bun-project-node-addons.md @@ -0,0 +1,12 @@ +### Bun compatibility + +- **Root/project Node-API addons loaded through `import.meta.require` now ship + and run after a Bun extraction tree is removed.** Declare each file by its + exact project-relative path, for example + `"perry": { "nativeAddonPaths": ["native/addon.node"] }`. Perry follows + immutable aliases and simple path constants, including + `new URL("./native/addon.node", import.meta.url).pathname`, and maps relative, + absolute, and `/$bunfs/root/` spellings to the same authenticated sidecar + entry. Dynamic or otherwise unprovable paths fail compilation with guidance + instead of producing a binary that depends on the build machine's source + tree.