From 797e677b6f671bf1827e87dda654170c3aa4592a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Carter=20=E7=A5=81=E6=98=8E=E6=80=9D?= Date: Fri, 11 Sep 2026 11:30:21 +0800 Subject: [PATCH 1/2] Harden FFI load path, bindings, and eq install Compile C to PIC objects instead of preprocessing, honor LoadOptions compile/link, and make demo-app/full-demo actually compile and link. Align compiler argv and version probing with the working polyglot build, reject dangerous extra compile args, and sanitize identifiers in generated Rust. eq install now requires sudo confirmation, matches compiler names exactly, and no longer treats "c" as C#. Co-authored-by: Max Carter --- examples/demo-app/build.rs | 23 ++- examples/full-demo/build.rs | 24 ++- examples/full-demo/src/main.rs | 32 ++-- examples/polyglot-gui/src/polyglot.rs | 194 ------------------------ src/bin/eq/main.rs | 140 +++++++++++------- src/bindings.rs | 205 +++++++++++++++++++++----- src/c_header.rs | 139 ++++++++++++++++- src/compiler.rs | 200 ++++++++++++++++++++++++- src/detector.rs | 112 +++++++++++--- src/imports.rs | 9 +- src/loader.rs | 174 +++++++++++++++++++--- 11 files changed, 895 insertions(+), 357 deletions(-) diff --git a/examples/demo-app/build.rs b/examples/demo-app/build.rs index a4ac3d0..2b4c873 100644 --- a/examples/demo-app/build.rs +++ b/examples/demo-app/build.rs @@ -3,9 +3,24 @@ use std::path::PathBuf; fn main() { let foreign = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("foreign-code"); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let lib = equilibrium_ffi::load(foreign.join("math.c")).expect("load math.c"); - if let Some(code) = lib.bindings_code() { - std::fs::write(out_dir.join("math_bindings.rs"), code).expect("write bindings"); + + if cfg!(target_os = "macos") && std::path::Path::new("/usr/bin/ar").exists() { + // SAFETY: build scripts are single-threaded at the point this runs. + unsafe { + std::env::set_var("AR", "/usr/bin/ar"); + } } - println!("cargo:rerun-if-changed=foreign-code/*"); + cc::Build::new() + .file(foreign.join("math.c")) + .compile("math"); + + let binding = equilibrium_ffi::generate_bindings( + &foreign.join("math.h"), + &equilibrium_ffi::BindingOptions::default(), + ) + .expect("generate bindings"); + std::fs::write(out_dir.join("math_bindings.rs"), binding.code).expect("write bindings"); + + println!("cargo:rerun-if-changed=foreign-code/math.c"); + println!("cargo:rerun-if-changed=foreign-code/math.h"); } diff --git a/examples/full-demo/build.rs b/examples/full-demo/build.rs index 6365fd4..e40110c 100644 --- a/examples/full-demo/build.rs +++ b/examples/full-demo/build.rs @@ -3,9 +3,25 @@ use std::path::PathBuf; fn main() { let foreign = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("foreign-code"); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let lib = equilibrium_ffi::load(foreign.join("calculator.c")).expect("load calculator.c"); - if let Some(code) = lib.bindings_code() { - std::fs::write(out_dir.join("calculator_bindings.rs"), code).expect("write bindings"); + + if cfg!(target_os = "macos") && std::path::Path::new("/usr/bin/ar").exists() { + // SAFETY: build scripts are single-threaded at the point this runs. + unsafe { + std::env::set_var("AR", "/usr/bin/ar"); + } } - println!("cargo:rerun-if-changed=foreign-code/*"); + cc::Build::new() + .file(foreign.join("calculator.c")) + .compile("calculator"); + println!("cargo:rustc-link-lib=m"); + + let binding = equilibrium_ffi::generate_bindings( + &foreign.join("calculator.h"), + &equilibrium_ffi::BindingOptions::default(), + ) + .expect("generate bindings"); + std::fs::write(out_dir.join("calculator_bindings.rs"), binding.code).expect("write bindings"); + + println!("cargo:rerun-if-changed=foreign-code/calculator.c"); + println!("cargo:rerun-if-changed=foreign-code/calculator.h"); } diff --git a/examples/full-demo/src/main.rs b/examples/full-demo/src/main.rs index acc45ac..0d6be83 100644 --- a/examples/full-demo/src/main.rs +++ b/examples/full-demo/src/main.rs @@ -1,37 +1,31 @@ //! Full Demo - Actually calling C functions from Rust via FFI -extern "C" { - fn calc_add(a: i32, b: i32) -> i32; - fn calc_subtract(a: i32, b: i32) -> i32; - fn calc_multiply(a: i32, b: i32) -> i32; - fn calc_divide(a: f64, b: f64) -> f64; - fn calc_power(base: i32, exp: i32) -> i32; - fn calc_sqrt(n: f64) -> f64; +mod ffi { + include!(concat!(env!("OUT_DIR"), "/calculator_bindings.rs")); } fn main() { println!("=== Full Equilibrium Demo ==="); println!("Calling C functions from Rust!\n"); - + unsafe { // Arithmetic operations println!("Arithmetic:"); - println!(" 10 + 5 = {}", calc_add(10, 5)); - println!(" 10 - 5 = {}", calc_subtract(10, 5)); - println!(" 10 * 5 = {}", calc_multiply(10, 5)); - println!(" 10.0 / 5.0 = {:.2}", calc_divide(10.0, 5.0)); - + println!(" 10 + 5 = {}", ffi::calc_add(10, 5)); + println!(" 10 - 5 = {}", ffi::calc_subtract(10, 5)); + println!(" 10 * 5 = {}", ffi::calc_multiply(10, 5)); + println!(" 10.0 / 5.0 = {:.2}", ffi::calc_divide(10.0, 5.0)); + // Advanced operations println!("\nAdvanced:"); - println!(" 2^8 = {}", calc_power(2, 8)); - println!(" sqrt(144) = {:.2}", calc_sqrt(144.0)); - println!(" sqrt(2) = {:.10}", calc_sqrt(2.0)); + println!(" 2^8 = {}", ffi::calc_power(2, 8)); + println!(" sqrt(144) = {:.2}", ffi::calc_sqrt(144.0)); + println!(" sqrt(2) = {:.10}", ffi::calc_sqrt(2.0)); } - + println!("\n✓ All C functions called successfully!"); println!("\nThis demonstrates:"); println!(" - C code compiled via cc crate"); - println!(" - Rust FFI declarations"); + println!(" - Rust FFI declarations generated by equilibrium-ffi"); println!(" - Calling C functions from Rust"); - println!(" - Equilibrium would automate the FFI declaration part"); } diff --git a/examples/polyglot-gui/src/polyglot.rs b/examples/polyglot-gui/src/polyglot.rs index db728a7..92cee1d 100644 --- a/examples/polyglot-gui/src/polyglot.rs +++ b/examples/polyglot-gui/src/polyglot.rs @@ -322,200 +322,6 @@ fn language_accent(lang: &str) -> &'static str { } } -pub fn constellation_lines(frame: &ConstellationFrame) -> Vec { - frame - .rows - .iter() - .map(|row| { - debug_assert!(row.spans.iter().all(|span| span.start <= span.end - && span.end <= row.text.len() - && span.color <= 0x00ff_ffff)); - row.text.clone() - }) - .collect() -} - -fn constellation_frame( - width: usize, - height: usize, - tick: f32, - stars: &[Star], - shooting_star: &ShootingStar, - bursts: &[Burst], -) -> ConstellationFrame { - let rows = height.clamp(6, 360); - let cols = width.clamp(20, 920); - let tick_bucket = (tick * 6.0) as usize; - let mut grid: Vec> = (0..rows) - .map(|y| constellation_base_row(y, rows, cols, tick, tick_bucket)) - .collect(); - - for star in stars { - let x = (star.x * cols as f32).clamp(0.0, (cols - 1) as f32) as usize; - let y = (star.y * rows as f32).clamp(0.0, (rows - 1) as f32) as usize; - let pulse = ((star.phase + tick * (0.35 + star.z)).sin() + 1.0) * 0.5; - let ch = match ((pulse * 5.0 + star.z * 3.0) as usize).min(7) { - 0 => '.', - 1 => '·', - 2 => ':', - 3 => '*', - 4 => '+', - 5 => 'o', - 6 => '✦', - _ => '✧', - }; - grid[y][x] = ConstellationCell { - ch, - color: language_color(7), - }; - } - - for i in 0..24 { - let fade = i as f32 / 24.0; - let tx = shooting_star.x - shooting_star.vx * i as f32 * 9.0; - let ty = shooting_star.y - shooting_star.vy * i as f32 * 9.0; - if (0.0..=1.0).contains(&tx) && (0.0..=1.0).contains(&ty) { - let x = (tx * cols as f32).clamp(0.0, (cols - 1) as f32) as usize; - let y = (ty * rows as f32).clamp(0.0, (rows - 1) as f32) as usize; - grid[y][x] = ConstellationCell { - ch: if fade < 0.2 { - '✦' - } else if fade < 0.45 { - '/' - } else if fade < 0.7 { - '·' - } else { - '.' - }, - color: 0xf59e0b, - }; - } - } - - for burst in bursts { - let bx = (burst.x * cols as f32).clamp(0.0, (cols - 1) as f32) as i32; - let by = (burst.y * rows as f32).clamp(0.0, (rows - 1) as f32) as i32; - let radius = 1 + (burst.age * 4.0) as i32; - for dy in -radius..=radius { - for dx in -radius..=radius { - let x = bx + dx; - let y = by + dy; - if x >= 0 - && y >= 0 - && (x as usize) < cols - && (y as usize) < rows - && dx.abs() + dy.abs() <= radius - { - grid[y as usize][x as usize] = ConstellationCell { - ch: match burst.seed % 4 { - 0 => '@', - 1 => '#', - 2 => '%', - _ => '&', - }, - color: if burst.seed % 2 == 0 { - language_color(5) - } else { - language_color(6) - }, - }; - } - } - } - } - - ConstellationFrame { - rows: grid.into_iter().map(constellation_row_data).collect(), - } -} - -fn constellation_base_row( - y: usize, - rows: usize, - cols: usize, - tick: f32, - tick_bucket: usize, -) -> Vec { - let fy = y as f32 / rows as f32; - let y_wave = (fy * 10.0 - tick * 0.25).cos(); - (0..cols) - .map(|x| { - let fx = x as f32 / cols as f32; - let ribbon = - ((fx * 12.0 + tick * 0.35).sin() + y_wave + ((fx + fy) * 18.0).sin() * 0.45) / 2.45; - let shimmer = (x * 17 + y * 29 + tick_bucket).is_multiple_of(113); - let ch = if shimmer { - ':' - } else if ribbon > 0.78 { - '.' - } else if ribbon > 0.62 { - '·' - } else if ribbon < -0.86 { - ',' - } else { - ' ' - }; - let color = match ch { - ':' => { - if (x + y + tick_bucket).is_multiple_of(2) { - language_color(3) - } else { - language_color(4) - } - } - '.' | '·' => language_color(0), - ',' => language_color(1), - _ => 0x334155, - }; - ConstellationCell { ch, color } - }) - .collect() -} - -fn language_color(index: usize) -> u32 { - match index % 8 { - 0 => 0x10b981, - 1 => 0x38bdf8, - 2 => 0xf59e0b, - 3 => 0x22d3ee, - 4 => 0x4ade80, - 5 => 0x60a5fa, - 6 => 0xfb7185, - _ => 0xe879f9, - } -} - -fn constellation_row_data(row: Vec) -> ConstellationRow { - let mut text = String::with_capacity(row.len() * 2); - let mut spans = Vec::new(); - let mut run_start = 0usize; - let mut run_color = row.first().map_or(0x334155, |cell| cell.color); - - for cell in row { - let start = text.len(); - if cell.color != run_color && start > run_start { - spans.push(ConstellationSpan { - start: run_start, - end: start, - color: run_color, - }); - run_start = start; - run_color = cell.color; - } - text.push(cell.ch); - } - - if text.len() > run_start { - spans.push(ConstellationSpan { - start: run_start, - end: text.len(), - color: run_color, - }); - } - - ConstellationRow { text, spans } -} - pub fn rust_is_prime(n: u64) -> bool { if n < 2 { return false; diff --git a/src/bin/eq/main.rs b/src/bin/eq/main.rs index b44e606..0aeb265 100644 --- a/src/bin/eq/main.rs +++ b/src/bin/eq/main.rs @@ -9,8 +9,9 @@ use clap::{Parser, Subcommand}; use console::{style, Style, Term}; -use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect}; use equilibrium_ffi::{compiler_version_at, find_binary}; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -164,7 +165,15 @@ impl PkgMgr { PkgMgr::Dnf => vec!["install".into(), "-y".into(), pkg.into()], PkgMgr::Pacman => vec!["-S".into(), "--noconfirm".into(), pkg.into()], // -e = exact match; --id avoids interactive prompts - PkgMgr::Winget => vec!["install".into(), "-e".into(), "--id".into(), pkg.into()], + PkgMgr::Winget => vec![ + "install".into(), + "-e".into(), + "--id".into(), + pkg.into(), + "--accept-package-agreements".into(), + "--accept-source-agreements".into(), + "--disable-interactivity".into(), + ], PkgMgr::Scoop => vec!["install".into(), pkg.into()], } } @@ -231,9 +240,7 @@ fn available_managers() -> Vec { v } -fn install_compiler(c: &Compiler) -> bool { - // On Windows, if the process cwd is a UNC path (e.g. \\wsl$\...) then - // cmd.exe subprocesses spawned by winget/scoop will fail. Move to %TEMP%. +fn install_cwd() -> Option { #[cfg(target_os = "windows")] { let cwd = std::env::current_dir() @@ -244,10 +251,20 @@ fn install_compiler(c: &Compiler) -> bool { let tmp = std::env::var("TEMP") .or_else(|_| std::env::var("TMP")) .unwrap_or_else(|_| r"C:\Windows\Temp".to_string()); - let _ = std::env::set_current_dir(&tmp); + return Some(PathBuf::from(tmp)); } } + None +} + +fn spawn_status(mut cmd: Command) -> std::io::Result { + if let Some(dir) = install_cwd() { + cmd.current_dir(dir); + } + cmd.status() +} +fn install_compiler(c: &Compiler) -> bool { let managers = available_managers(); if managers.is_empty() { println!( @@ -273,9 +290,9 @@ fn install_compiler(c: &Compiler) -> bool { let bucket_cmd = format!("scoop bucket add {bucket}"); println!(" {} {}", style("$").dim(), style(&bucket_cmd).cyan()); let scoop_bin = find_binary("scoop", &[]).unwrap_or_else(|| PathBuf::from("scoop")); - let _ = Command::new(&scoop_bin) - .args(["bucket", "add", bucket]) - .status(); + let mut bucket_add = Command::new(&scoop_bin); + bucket_add.args(["bucket", "add", bucket]); + let _ = spawn_status(bucket_add); } let args = mgr.install_args(pkg); @@ -288,7 +305,9 @@ fn install_compiler(c: &Compiler) -> bool { let status = if mgr.needs_sudo() { // Use argv directly — never invoke a shell around sudo/package managers. - Command::new("sudo").arg(mgr.cmd()).args(&args).status() + let mut cmd = Command::new("sudo"); + cmd.arg(mgr.cmd()).args(&args); + spawn_status(cmd) } else { // Resolve full path for wax/brew/winget in case they aren't on PATH let home = std::env::var("HOME").unwrap_or_default(); @@ -301,7 +320,9 @@ fn install_compiler(c: &Compiler) -> bool { ], ) .unwrap_or_else(|| PathBuf::from(mgr.cmd())); - Command::new(bin).args(&args).status() + let mut cmd = Command::new(bin); + cmd.args(&args); + spawn_status(cmd) }; if status.map(|s| s.success()).unwrap_or(false) { @@ -325,12 +346,7 @@ fn cmd_install(names: Vec) -> ExitCode { let mut had_error = false; for name in &names { - let name_lower = name.to_lowercase(); - if let Some(s) = statuses.iter().find(|s| { - s.compiler.id == name_lower - || s.compiler.bin == name_lower - || s.compiler.lang.to_lowercase().contains(&name_lower) - }) { + if let Some(s) = statuses.iter().find(|s| compiler_matches(s.compiler, name)) { if !s.compiler.supported { println!( "{} {} is not supported on this platform.", @@ -349,6 +365,9 @@ fn cmd_install(names: Vec) -> ExitCode { } } else { println!("{} Unknown compiler: {name}", style("✗").red()); + if name.eq_ignore_ascii_case("c") { + println!(" C (clang/gcc) is assumed present. For C#, use: eq install dotnet"); + } had_error = true; } } @@ -360,7 +379,11 @@ fn cmd_install(names: Vec) -> ExitCode { ExitCode::SUCCESS }; } - let install_ok = run_installs_parallel(&to_install); + if !confirm_privileged_install() { + println!("Aborted."); + return ExitCode::FAILURE; + } + let install_ok = run_installs(&to_install); return if had_error { ExitCode::FAILURE } else { @@ -397,7 +420,11 @@ fn cmd_install(names: Vec) -> ExitCode { Ok(Some(chosen)) if !chosen.is_empty() => { let selected: Vec<&'static Compiler> = chosen.iter().map(|&i| missing[i].compiler).collect(); - run_installs_parallel(&selected) + if !confirm_privileged_install() { + println!("Aborted."); + return ExitCode::FAILURE; + } + run_installs(&selected) } _ => { println!("Nothing selected."); @@ -406,44 +433,55 @@ fn cmd_install(names: Vec) -> ExitCode { } } -/// Install multiple compilers in parallel, one thread each. -fn run_installs_parallel(compilers: &[&'static Compiler]) -> ExitCode { - use std::sync::{Arc, Mutex}; - use std::thread; +fn compiler_matches(c: &Compiler, name: &str) -> bool { + let n = name.to_lowercase(); + c.id.eq_ignore_ascii_case(&n) + || c.bin.eq_ignore_ascii_case(&n) + || c.lang.eq_ignore_ascii_case(name) + || (c.id == "dotnet" && matches!(n.as_str(), "csharp" | "c#" | "cs")) +} + +fn sudo_may_be_used() -> bool { + available_managers().iter().any(|mgr| mgr.needs_sudo()) +} + +fn confirm_privileged_install() -> bool { + if !sudo_may_be_used() { + return true; + } + if std::env::var_os("EQ_INSTALL_YES").is_some() { + return true; + } + if !std::io::stdin().is_terminal() { + eprintln!( + "{} refusing to run sudo without a TTY. Re-run interactively, or set EQ_INSTALL_YES=1 (or EQ_INSTALL_NO_SUDO=1 to skip system package managers).", + style("!").yellow() + ); + return false; + } + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Installing system packages requires sudo. Continue?") + .default(false) + .interact() + .unwrap_or(false) +} +fn run_installs(compilers: &[&'static Compiler]) -> ExitCode { println!( - "\n{} Installing {} compiler(s) in parallel…\n", + "\n{} Installing {} compiler(s)…\n", style("→").cyan(), compilers.len() ); - // Shared output buffer so lines from different threads don't interleave. - let log: Arc>> = Arc::new(Mutex::new(vec![])); - - let handles: Vec<_> = compilers - .iter() - .map(|c| { - let log = Arc::clone(&log); - let name = c.lang.as_str(); - let compiler: &'static Compiler = c; - thread::spawn(move || { - let ok = install_compiler(compiler); - let msg = if ok { - format!("{} {} installed", style("✓").green(), name) - } else { - format!("{} {} failed", style("✗").red(), name) - }; - log.lock().unwrap().push((msg, ok)); - ok - }) - }) - .collect(); - - let all_ok = handles.into_iter().all(|h| h.join().unwrap_or(false)); - - println!(); - for (msg, _) in log.lock().unwrap().iter() { - println!("{msg}"); + let mut all_ok = true; + for compiler in compilers { + let ok = install_compiler(compiler); + if ok { + println!("{} {} installed", style("✓").green(), compiler.lang); + } else { + println!("{} {} failed", style("✗").red(), compiler.lang); + all_ok = false; + } } if all_ok { diff --git a/src/bindings.rs b/src/bindings.rs index 096646c..011c5dc 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use crate::c_header::{ - c_type_to_rust, parse_c_header, EnumDef, FunctionDef, ParsedHeader, StructDef, TypedefDef, + c_type_to_rust_checked, parse_c_header, parse_enum_discriminant, rust_ident, EnumDef, + FunctionDef, ParsedHeader, StructDef, TypedefDef, }; use crate::limits::read_header_content; @@ -99,15 +100,19 @@ fn emit_bindings_from_parsed( ) { for enum_def in &parsed.enums { if should_include(&enum_def.name, &options.allowlist_types) { - code.push_str(&generate_enum(enum_def, options)); - code.push('\n'); + if let Some(generated) = generate_enum(enum_def, warnings) { + code.push_str(&generated); + code.push('\n'); + } } } for struct_def in &parsed.structs { if should_include(&struct_def.name, &options.allowlist_types) { - code.push_str(&generate_struct(struct_def, options)); - code.push('\n'); + if let Some(generated) = generate_struct(struct_def, options, warnings) { + code.push_str(&generated); + code.push('\n'); + } } } @@ -124,8 +129,10 @@ fn emit_bindings_from_parsed( .iter() .any(|e| format!("enum {}", e.name) == typedef.target); if !is_struct_alias && !is_enum_alias { - code.push_str(&generate_typedef(typedef, options)); - code.push('\n'); + if let Some(generated) = generate_typedef(typedef, warnings) { + code.push_str(&generated); + code.push('\n'); + } } } } @@ -134,7 +141,10 @@ fn emit_bindings_from_parsed( code.push_str("extern \"C\" {\n"); for func in &parsed.functions { if should_include(&func.name, &options.allowlist_functions) { - code.push_str(&generate_function(func)); + match generate_function(func) { + Ok(generated) => code.push_str(&generated), + Err(reason) => warnings.push(reason), + } } else { warnings.push(format!("Skipped function: {}", func.name)); } @@ -142,28 +152,73 @@ fn emit_bindings_from_parsed( code.push_str("}\n"); } -fn generate_typedef(typedef: &TypedefDef, _options: &BindingOptions) -> String { - let rust_type = c_type_to_rust(&typedef.target); - format!("pub type {} = {};\n", typedef.name, rust_type) +fn generate_typedef(typedef: &TypedefDef, warnings: &mut Vec) -> Option { + let Some(name) = rust_ident(&typedef.name) else { + warnings.push(format!( + "Skipped typedef with invalid name: {}", + typedef.name + )); + return None; + }; + match c_type_to_rust_checked(&typedef.target) { + Ok(rust_type) => Some(format!("pub type {name} = {rust_type};\n")), + Err(reason) => { + warnings.push(format!("Skipped typedef {name}: {reason}")); + None + } + } } -fn generate_enum(enum_def: &EnumDef, _options: &BindingOptions) -> String { +fn generate_enum(enum_def: &EnumDef, warnings: &mut Vec) -> Option { + let Some(name) = rust_ident(&enum_def.name) else { + warnings.push(format!("Skipped enum with invalid name: {}", enum_def.name)); + return None; + }; let mut code = String::new(); code.push_str("#[repr(C)]\n"); code.push_str("#[derive(Debug, Copy, Clone, PartialEq, Eq)]\n"); - code.push_str(&format!("pub enum {} {{\n", enum_def.name)); + code.push_str(&format!("pub enum {name} {{\n")); + let mut any = false; for (variant_name, variant_value) in &enum_def.variants { + let Some(variant) = rust_ident(variant_name) else { + warnings.push(format!( + "Skipped enum variant with invalid name: {variant_name}" + )); + continue; + }; if let Some(value) = variant_value { - code.push_str(&format!(" {} = {},\n", variant_name, value)); + let Some(n) = parse_enum_discriminant(value) else { + warnings.push(format!( + "Skipped enum discriminant for {name}::{variant}: `{value}`" + )); + continue; + }; + code.push_str(&format!(" {variant} = {n},\n")); } else { - code.push_str(&format!(" {},\n", variant_name)); + code.push_str(&format!(" {variant},\n")); } + any = true; + } + if !any { + warnings.push(format!("Skipped empty enum: {name}")); + return None; } code.push_str("}\n"); - code + Some(code) } -fn generate_struct(struct_def: &StructDef, options: &BindingOptions) -> String { +fn generate_struct( + struct_def: &StructDef, + options: &BindingOptions, + warnings: &mut Vec, +) -> Option { + let Some(name) = rust_ident(&struct_def.name) else { + warnings.push(format!( + "Skipped struct with invalid name: {}", + struct_def.name + )); + return None; + }; let mut code = String::new(); let mut derives = vec!["Copy", "Clone"]; if options.derive_debug { @@ -174,33 +229,49 @@ fn generate_struct(struct_def: &StructDef, options: &BindingOptions) -> String { } code.push_str(&format!("#[derive({})]\n", derives.join(", "))); code.push_str("#[repr(C)]\n"); - code.push_str(&format!("pub struct {} {{\n", struct_def.name)); + code.push_str(&format!("pub struct {name} {{\n")); for (field_type, field_name) in &struct_def.fields { - let rust_type = c_type_to_rust(field_type); - code.push_str(&format!(" pub {}: {},\n", field_name, rust_type)); + let Some(field) = rust_ident(field_name) else { + warnings.push(format!( + "Skipped field with invalid name on {name}: {field_name}" + )); + continue; + }; + match c_type_to_rust_checked(field_type) { + Ok(rust_type) => { + code.push_str(&format!(" pub {field}: {rust_type},\n")); + } + Err(reason) => { + warnings.push(format!("Skipped field {name}.{field}: {reason}")); + } + } } code.push_str("}\n"); - code + Some(code) } -fn generate_function(func: &FunctionDef) -> String { - let rust_return = c_type_to_rust(&func.return_type); - let params: Vec = func - .params - .iter() - .map(|(typ, name)| format!("{}: {}", name, c_type_to_rust(typ))) - .collect(); +fn generate_function(func: &FunctionDef) -> Result { + let name = rust_ident(&func.name) + .ok_or_else(|| format!("Skipped function with invalid name: {}", func.name))?; + let rust_return = c_type_to_rust_checked(&func.return_type) + .map_err(|reason| format!("Skipped function {name}: {reason}"))?; + let mut params = Vec::new(); + for (typ, param_name) in &func.params { + let pname = rust_ident(param_name) + .ok_or_else(|| format!("Skipped function {name}: invalid parameter `{param_name}`"))?; + let rust_type = c_type_to_rust_checked(typ) + .map_err(|reason| format!("Skipped function {name}: {reason}"))?; + params.push(format!("{pname}: {rust_type}")); + } let return_clause = if rust_return == "()" { String::new() } else { - format!(" -> {}", rust_return) + format!(" -> {rust_return}") }; - format!( - " pub fn {}({}){};\n", - func.name, + Ok(format!( + " pub fn {name}({}){return_clause};\n", params.join(", "), - return_clause - ) + )) } #[cfg(test)] @@ -383,4 +454,70 @@ mod tests { assert!(binding.code.contains("pub type handle_t = c_int;")); assert!(binding.code.contains("pub fn open()")); } + + #[test] + fn test_generate_bindings_rejects_malicious_enum_discriminant() { + let dir = tempdir().unwrap(); + let header = dir.path().join("evil.h"); + std::fs::write( + &header, + "typedef enum { OK = 1, BAD = 1; include!(\"/tmp/pwn.rs\"); 0 } Evil;\nint foo(void);\n", + ) + .unwrap(); + + let opts = BindingOptions::default(); + let binding = generate_bindings(&header, &opts).unwrap(); + assert!(!binding.code.contains("include!")); + assert!(!binding.code.contains("/tmp/pwn")); + assert!(binding.code.contains("pub fn foo()")); + assert!(binding + .warnings + .iter() + .any(|w| w.contains("discriminant") || w.contains("Skipped"))); + } + + #[test] + fn test_generate_bindings_char_double_pointer() { + let dir = tempdir().unwrap(); + let header = dir.path().join("argv.h"); + std::fs::write(&header, "int count_args(char **argv);\n").unwrap(); + + let opts = BindingOptions::default(); + let binding = generate_bindings(&header, &opts).unwrap(); + assert!( + binding.code.contains("*mut *mut c_char"), + "got: {}", + binding.code + ); + assert!(binding.warnings.is_empty(), "{:?}", binding.warnings); + } + + #[test] + fn test_generate_bindings_skips_multiline_prototype() { + let dir = tempdir().unwrap(); + let header = dir.path().join("multi.h"); + std::fs::write( + &header, + "int sneaky(\n int a,\n int b);\nint ok(void);\n", + ) + .unwrap(); + + let opts = BindingOptions::default(); + let binding = generate_bindings(&header, &opts).unwrap(); + assert!(!binding.code.contains("sneaky")); + assert!(binding.code.contains("pub fn ok()")); + } + + #[test] + fn test_generate_bindings_skips_unknown_type_passthrough() { + let dir = tempdir().unwrap(); + let header = dir.path().join("weird.h"); + std::fs::write(&header, "void evil(not a type x);\nint ok(void);\n").unwrap(); + + let opts = BindingOptions::default(); + let binding = generate_bindings(&header, &opts).unwrap(); + assert!(!binding.code.contains("not a type")); + assert!(!binding.code.contains("pub fn evil(")); + assert!(binding.code.contains("pub fn ok()")); + } } diff --git a/src/c_header.rs b/src/c_header.rs index 687cf8b..97132ee 100644 --- a/src/c_header.rs +++ b/src/c_header.rs @@ -278,10 +278,134 @@ pub(crate) fn c_type_to_rust(c_type: &str) -> String { } } s if s.starts_with("const ") => c_type_to_rust(s.strip_prefix("const ").unwrap()), - other => other.to_string(), + other if is_c_identifier(other) => other.to_string(), + _ => "*mut c_void".to_string(), } } +pub(crate) fn c_type_to_rust_checked(c_type: &str) -> Result { + let mapped = c_type_to_rust(c_type); + let trimmed = c_type.trim(); + if mapped == "*mut c_void" + && trimmed != "void *" + && trimmed != "void*" + && !trimmed.ends_with("void *") + && !trimmed.ends_with("void*") + && !is_known_or_ident_type(trimmed) + { + return Err(format!("unsupported C type `{trimmed}`")); + } + Ok(mapped) +} + +fn is_known_or_ident_type(c_type: &str) -> bool { + let c_type = c_type.trim(); + if c_type.ends_with('*') { + return is_known_or_ident_type(c_type.strip_suffix('*').unwrap().trim()); + } + if let Some(inner) = c_type.strip_prefix("const ") { + return is_known_or_ident_type(inner); + } + is_c_abi_safe_scalar(c_type) || is_c_identifier(c_type) +} + +pub(crate) fn is_c_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +pub(crate) fn rust_ident(name: &str) -> Option { + if !is_c_identifier(name) { + return None; + } + if is_rust_keyword(name) { + Some(format!("r#{name}")) + } else { + Some(name.to_string()) + } +} + +fn is_rust_keyword(name: &str) -> bool { + matches!( + name, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + | "abstract" + | "become" + | "box" + | "do" + | "final" + | "macro" + | "override" + | "priv" + | "typeof" + | "unsized" + | "virtual" + | "yield" + | "try" + | "gen" + ) +} + +pub(crate) fn parse_enum_discriminant(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + return i64::from_str_radix(hex, 16).ok(); + } + if value.starts_with('+') || value.starts_with('-') { + return value.parse().ok(); + } + if value.bytes().all(|b| b.is_ascii_digit()) { + return value.parse().ok(); + } + None +} + pub(crate) fn is_c_abi_safe_type(c_type: &str) -> bool { let c_type = c_type.trim(); if c_type.is_empty() { @@ -290,9 +414,18 @@ pub(crate) fn is_c_abi_safe_type(c_type: &str) -> bool { if c_type.starts_with("struct ") || c_type.starts_with("enum ") { return false; } - if c_type.ends_with('*') { - return true; + if let Some(inner) = c_type.strip_suffix('*') { + let inner = inner.trim(); + let inner = inner.strip_prefix("const ").unwrap_or(inner).trim(); + if inner == "void" { + return true; + } + return is_c_abi_safe_type(inner); } + is_c_abi_safe_scalar(c_type) +} + +fn is_c_abi_safe_scalar(c_type: &str) -> bool { matches!( c_type, "void" diff --git a/src/compiler.rs b/src/compiler.rs index ae2b730..1b9f6b3 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -22,6 +22,8 @@ pub enum CompileError { Io(std::io::Error), /// Language doesn't support C output. UnsupportedCOutput { language: Language }, + /// Extra compiler/link argument was rejected. + InvalidExtraArg { arg: String }, } impl std::fmt::Display for CompileError { @@ -37,6 +39,9 @@ impl std::fmt::Display for CompileError { CompileError::UnsupportedCOutput { language } => { write!(f, "{:?} doesn't support direct C output", language) } + CompileError::InvalidExtraArg { arg } => { + write!(f, "rejected extra compiler argument: {arg}") + } } } } @@ -119,10 +124,17 @@ pub fn compile_to_c_with_lang_and_extra( ))); } + std::fs::create_dir_all(output_dir)?; + validate_extra_args(compile_args)?; + // Find compiler let info = find_compiler(language).ok_or(CompileError::CompilerNotFound { language })?; - let compiler = info.compiler.as_ref().unwrap(); + let compiler = info + .compiler_path + .clone() + .or_else(|| info.compiler.as_ref().map(PathBuf::from)) + .ok_or(CompileError::CompilerNotFound { language })?; // Determine output file name let stem = input @@ -130,7 +142,7 @@ pub fn compile_to_c_with_lang_and_extra( .and_then(|s| s.to_str()) .unwrap_or("output"); - let c_output = output_dir.join(format!("{stem}.c")); + let c_output = output_dir.join(artifact_filename(language, stem)); let header_output = output_dir.join(format!("{stem}.h")); // Build command @@ -140,7 +152,7 @@ pub fn compile_to_c_with_lang_and_extra( let mut args = language.to_c_args(&input_str, &output_str); args.extend(compile_args.iter().cloned()); - let output = Command::new(compiler) + let output = Command::new(&compiler) .args(&args) .current_dir(input.parent().unwrap_or(Path::new("."))) .output()?; @@ -155,16 +167,35 @@ pub fn compile_to_c_with_lang_and_extra( }); } + let output_path = if c_output.exists() { + c_output + } else { + let alt = output_dir.join(format!("lib{stem}.a")); + if alt.exists() { + alt + } else { + return Err(CompileError::CompilationFailed { + stderr: format!( + "compiler succeeded but output is missing: {} (stderr: {stderr})", + c_output.display() + ), + exit_code: output.status.code(), + }); + } + }; + // Check if header was generated (language-specific) let header_path = if header_output.exists() { Some(header_output) + } else if let Some(copied) = copy_sibling_header(input, output_dir) { + Some(copied) } else { // Try to generate header for some languages generate_header(input, output_dir, language).ok() }; Ok(CompileResult { - output_path: c_output, + output_path, header_path, language, stdout, @@ -172,6 +203,133 @@ pub fn compile_to_c_with_lang_and_extra( }) } +fn artifact_filename(language: Language, stem: &str) -> String { + match language { + Language::C + | Language::Cpp + | Language::Zig + | Language::D + | Language::Odin + | Language::Hare => format!("{stem}.o"), + Language::V => format!("{stem}.c"), + Language::Nim => format!("{stem}.a"), + Language::CSharp => format!("{stem}.dll"), + Language::Rust => { + if cfg!(target_os = "windows") { + format!("{stem}.dll") + } else if cfg!(target_os = "macos") { + format!("{stem}.dylib") + } else { + format!("{stem}.so") + } + } + } +} + +fn copy_sibling_header(input: &Path, output_dir: &Path) -> Option { + let header = input.with_extension("h"); + if !header.is_file() { + return None; + } + let dest = output_dir.join(header.file_name()?); + std::fs::copy(&header, &dest).ok()?; + Some(dest) +} + +pub(crate) fn validate_extra_args(args: &[String]) -> Result<(), CompileError> { + for arg in args { + if !extra_compile_arg_allowed(arg) { + return Err(CompileError::InvalidExtraArg { arg: arg.clone() }); + } + } + Ok(()) +} + +pub(crate) fn extra_compile_arg_allowed(arg: &str) -> bool { + if arg.is_empty() || !arg.is_ascii() { + return false; + } + if arg.chars().any(|c| c.is_control() || c.is_whitespace()) { + return false; + } + if arg.starts_with('@') || !arg.starts_with('-') { + return false; + } + if let Some(rest) = arg.strip_prefix("-O") { + return rest.chars().all(|c| c.is_ascii_alphanumeric()); + } + let lower = arg.to_ascii_lowercase(); + const DENY: &[&str] = &[ + "-fplugin", + "-load", + "-plugin", + "-wl,", + "-xlinker", + "-wrapper", + "-specs", + "-b", + "-femit-bin", + "--output", + "-o", + "-of", + ]; + for prefix in DENY { + if lower == *prefix + || lower.starts_with(&format!("{prefix}=")) + || lower.starts_with(&format!("{prefix}:")) + { + return false; + } + if *prefix == "-o" && lower.starts_with("-o") && !lower.starts_with("-objc") { + return false; + } + if *prefix == "-of" && lower.starts_with("-of") { + return false; + } + if *prefix == "-b" + && (lower == "-b" || lower.starts_with("-b/") || lower.starts_with("-b=")) + { + return false; + } + } + if arg.starts_with("-I") && arg.len() > 2 { + let path = &arg[2..]; + return !path.contains("..") && path.chars().all(|c| c.is_ascii_graphic()); + } + extra_flag_syntax(arg) +} + +fn extra_flag_syntax(arg: &str) -> bool { + let bytes = arg.as_bytes(); + if bytes.len() < 2 || bytes[0] != b'-' { + return false; + } + let rest = if bytes[1] == b'-' { + &arg[2..] + } else { + &arg[1..] + }; + if rest.is_empty() { + return false; + } + let mut chars = rest.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return false, + } + let remaining: String = chars.collect(); + if remaining.is_empty() { + return true; + } + remaining + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '+' | '-' | '=' | ':' | '/')) +} + +pub(crate) fn extra_link_arg_allowed(arg: &str) -> bool { + extra_compile_arg_allowed(arg) && (arg.starts_with("-l") || arg.starts_with("-L")) +} + /// Generate a C header file for the compiled code. fn generate_header( input: &Path, @@ -350,6 +508,40 @@ mod tests { } } + #[test] + fn test_compile_rejects_plugin_arg() { + if find_compiler(Language::C).is_none() { + return; + } + let dir = tempdir().unwrap(); + let c_file = dir.path().join("test.c"); + std::fs::write(&c_file, "int add(int a, int b) { return a + b; }\n").unwrap(); + let output_dir = dir.path().join("out"); + let err = compile_to_c_with_extra( + &c_file, + &output_dir, + &["-fplugin=/tmp/x.so".to_string()], + &[], + ) + .unwrap_err(); + assert!(err.to_string().contains("rejected extra compiler argument")); + } + + #[test] + fn test_rejects_dangerous_compile_args() { + assert!(!extra_compile_arg_allowed("-fplugin=/tmp/x.so")); + assert!(!extra_compile_arg_allowed("@response")); + assert!(!extra_compile_arg_allowed("-o")); + assert!(!extra_compile_arg_allowed("-ofoo.o")); + assert!(!extra_compile_arg_allowed("-Wl,-rpath,/tmp")); + assert!(!extra_compile_arg_allowed("-fPIC\n-o/tmp/x")); + assert!(extra_compile_arg_allowed("-fPIC")); + assert!(extra_compile_arg_allowed("-O2")); + assert!(extra_compile_arg_allowed("-std=c11")); + assert!(extra_compile_arg_allowed("-I/usr/include")); + assert!(!extra_compile_arg_allowed("-I../secret")); + } + #[test] fn test_compile_batch_c_files() { // Skip if no C compiler is available diff --git a/src/detector.rs b/src/detector.rs index 45cb3ce..d948c2f 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -33,6 +33,7 @@ pub enum Language { pub struct LanguageInfo { pub language: Language, pub compiler: Option, + pub compiler_path: Option, pub version: Option, } @@ -106,10 +107,18 @@ impl Language { Language::D => &["dmd", "gdc"], Language::C => &["gcc", "cc"], Language::Cpp => &["g++", "c++"], + Language::CSharp => &["dotnet"], _ => &[], } } + pub fn version_args(&self) -> &'static [&'static str] { + match self { + Language::Zig | Language::Odin | Language::V | Language::Hare => &["version"], + _ => &["--version"], + } + } + /// Get the command to compile to C intermediate. pub fn to_c_args(&self, input: &str, output: &str) -> Vec { match self { @@ -125,14 +134,16 @@ impl Language { // For actual code, we emit object files vec![ "build-obj".to_string(), + "-fPIC".to_string(), + "-OReleaseFast".to_string(), format!("-femit-bin={output}"), input.to_string(), ] } Language::C => { - // C is already C, just preprocess vec![ - "-E".to_string(), + "-c".to_string(), + "-fPIC".to_string(), "-o".to_string(), output.to_string(), input.to_string(), @@ -142,6 +153,7 @@ impl Language { // Compile to object, we'll need headers separately vec![ "-c".to_string(), + "-fPIC".to_string(), "-o".to_string(), output.to_string(), input.to_string(), @@ -168,17 +180,24 @@ impl Language { // D can emit C headers with -HC flag (LDC2) vec![ "-c".to_string(), - "-of".to_string(), - output.to_string(), + "--relocation-model=pic".to_string(), + format!("-of={output}"), "-HC".to_string(), // Generate C header input.to_string(), ] } Language::Nim => { - // Nim compiles to C by default + let cache = Path::new(output) + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("nimcache"); vec![ "c".to_string(), - "--nimcache:.".to_string(), + format!("--nimcache:{}", cache.display()), + "--noMain".to_string(), + "--app:staticlib".to_string(), + "--mm:none".to_string(), + "--passC:-fPIC".to_string(), format!("-o:{output}"), input.to_string(), ] @@ -188,8 +207,10 @@ impl Language { vec![ "build".to_string(), input.to_string(), - "-out:".to_string() + output, + "-file".to_string(), + format!("-out:{output}"), "-build-mode:obj".to_string(), + "-reloc-mode:pic".to_string(), ] } Language::Hare => { @@ -234,6 +255,14 @@ pub fn detect_language(path: &Path) -> Option { None } +const WELL_KNOWN_BIN_DIRS: &[&str] = &[ + "/home/linuxbrew/.linuxbrew/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/local/odin", +]; + /// Resolve a compiler binary on `PATH` or under `extra_paths`. pub fn find_binary(bin: &str, extra_paths: &[&str]) -> Option { which::which(bin).ok().or_else(|| { @@ -271,25 +300,27 @@ pub fn compiler_version_at(path: &Path, version_args: &[&str]) -> Option } } +fn language_info(language: Language, name: &str, path: PathBuf) -> LanguageInfo { + let version = compiler_version_at(&path, language.version_args()); + LanguageInfo { + language, + compiler: Some(name.to_string()), + compiler_path: Some(path), + version, + } +} + /// Check if a compiler is available on the system. pub fn find_compiler(language: Language) -> Option { let compiler_name = language.default_compiler(); - if let Some(path) = find_binary(compiler_name, &[]) { - return Some(LanguageInfo { - language, - compiler: Some(compiler_name.to_string()), - version: compiler_version_at(&path, &["--version"]), - }); + if let Some(path) = find_binary(compiler_name, WELL_KNOWN_BIN_DIRS) { + return Some(language_info(language, compiler_name, path)); } for alt in language.alternative_compilers() { - if let Some(path) = find_binary(alt, &[]) { - return Some(LanguageInfo { - language, - compiler: Some((*alt).to_string()), - version: compiler_version_at(&path, &["--version"]), - }); + if let Some(path) = find_binary(alt, WELL_KNOWN_BIN_DIRS) { + return Some(language_info(language, alt, path)); } } @@ -431,6 +462,7 @@ mod tests { ); let info = info.unwrap(); assert!(info.compiler.is_some()); + assert!(info.compiler_path.is_some()); } #[test] @@ -442,21 +474,53 @@ mod tests { } #[test] - fn test_to_c_args_c_preprocess() { - let args = Language::C.to_c_args("foo.c", "foo.i"); - assert!(args.contains(&"-E".to_string())); + fn test_to_c_args_c_object() { + let args = Language::C.to_c_args("foo.c", "foo.o"); + assert!(args.contains(&"-c".to_string())); + assert!(args.contains(&"-fPIC".to_string())); + assert!(!args.contains(&"-E".to_string())); assert!(args.contains(&"foo.c".to_string())); - assert!(args.contains(&"foo.i".to_string())); + assert!(args.contains(&"foo.o".to_string())); } #[test] - fn test_to_c_args_zig_no_duplicate_flag() { + fn test_to_c_args_zig_pic_releasefast() { let args = Language::Zig.to_c_args("foo.zig", "foo.o"); assert!(args.contains(&"build-obj".to_string())); + assert!(args.contains(&"-fPIC".to_string())); + assert!(args.contains(&"-OReleaseFast".to_string())); let femit_count = args.iter().filter(|a| a.starts_with("-femit-bin")).count(); assert_eq!(femit_count, 1, "should have exactly one -femit-bin flag"); } + #[test] + fn test_to_c_args_nim_cache_under_output() { + let args = Language::Nim.to_c_args("foo.nim", "/tmp/out/foo.a"); + assert!(args + .iter() + .any(|a| a.starts_with("--nimcache:") && a.contains("out"))); + assert!(!args.iter().any(|a| a == "--nimcache:.")); + assert!(args.contains(&"--app:staticlib".to_string())); + assert!(args.contains(&"--passC:-fPIC".to_string())); + } + + #[test] + fn test_to_c_args_odin_pic() { + let args = Language::Odin.to_c_args("foo.odin", "foo.o"); + assert!(args.contains(&"-file".to_string())); + assert!(args.contains(&"-reloc-mode:pic".to_string())); + } + + #[test] + fn test_version_args_match_catalogue() { + assert_eq!(Language::Zig.version_args(), &["version"]); + assert_eq!(Language::Odin.version_args(), &["version"]); + assert_eq!(Language::V.version_args(), &["version"]); + assert_eq!(Language::Hare.version_args(), &["version"]); + assert_eq!(Language::C.version_args(), &["--version"]); + assert_eq!(Language::CSharp.alternative_compilers(), &["dotnet"]); + } + #[test] fn test_scan_directory_empty() { let dir = tempdir().unwrap(); diff --git a/src/imports.rs b/src/imports.rs index 716a7b5..5dc5c2b 100644 --- a/src/imports.rs +++ b/src/imports.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; -use crate::c_header::{header_stem, is_c_abi_safe_type, parse_c_header, FunctionDef, ParsedHeader}; +use crate::c_header::{ + header_stem, is_c_abi_safe_type, is_c_identifier, parse_c_header, FunctionDef, ParsedHeader, +}; use crate::detector::Language; use crate::limits::read_header_content; @@ -76,11 +78,12 @@ pub fn generate_imports_from_parsed( } fn supports_import(function: &FunctionDef) -> bool { - is_c_abi_safe_type(&function.return_type) + is_c_identifier(&function.name) + && is_c_abi_safe_type(&function.return_type) && function .params .iter() - .all(|(param_type, _)| is_c_abi_safe_type(param_type)) + .all(|(param_type, name)| is_c_identifier(name) && is_c_abi_safe_type(param_type)) } fn render_imports( diff --git a/src/loader.rs b/src/loader.rs index d8a4257..1329ba0 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -4,7 +4,9 @@ use std::path::{Path, PathBuf}; use crate::bindings::{generate_bindings_from_content, BindingOptions, GeneratedBinding}; use crate::c_header::parse_c_header; -use crate::compiler::compile_to_c_with_extra; +use crate::compiler::{ + compile_to_c_with_extra, extra_link_arg_allowed, validate_extra_args, CompileResult, +}; use crate::detector::{detect_language, find_compiler, Language}; use crate::exports::{discover_exports_with_options, ExportOptions, ExportSource}; use crate::imports::{generate_imports, GeneratedImport, ImportOptions}; @@ -69,6 +71,25 @@ impl LoadOptions { self } + pub fn compile(mut self, compile: bool) -> Self { + self.compile = compile; + self + } + + pub fn link(mut self, link: bool) -> Self { + self.link = link; + self + } + + pub fn compile_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.compile_args = args.into_iter().map(Into::into).collect(); + self + } + pub fn config_path>(mut self, path: P) -> Self { self.config_path = Some(path.as_ref().to_path_buf()); self @@ -167,15 +188,29 @@ pub fn load_with_options>( error: e, })?; - let _compiler = find_compiler(lang).ok_or(LoadError::CompilerNotFound(lang))?; + if options.compile { + find_compiler(lang).ok_or(LoadError::CompilerNotFound(lang))?; + validate_extra_args(&options.compile_args) + .map_err(|e| LoadError::CompilationFailed(lang, e.to_string()))?; + } - let result = compile_to_c_with_extra( - &source, - &output_dir, - &options.compile_args, - &options.link_args, - ) - .map_err(|e| LoadError::CompilationFailed(lang, e.to_string()))?; + let result = if options.compile { + compile_to_c_with_extra( + &source, + &output_dir, + &options.compile_args, + &options.link_args, + ) + .map_err(|e| LoadError::CompilationFailed(lang, e.to_string()))? + } else { + CompileResult { + output_path: source.clone(), + header_path: sibling_header(&source), + language: lang, + stdout: String::new(), + stderr: String::new(), + } + }; let import_source = result.header_path.clone().unwrap_or_else(|| source.clone()); let header_content = if import_source @@ -199,7 +234,10 @@ pub fn load_with_options>( let bindings = if options.generate_bindings { if let (Some(content), Some(header_path)) = (&header_content, result.header_path.as_ref()) { - generate_bindings_from_content(header_path, content, binding_opts).ok() + Some( + generate_bindings_from_content(header_path, content, binding_opts) + .map_err(LoadError::BindingFailed)?, + ) } else { None } @@ -211,6 +249,12 @@ pub fn load_with_options>( ImportOptions::default().allowlist_functions(export_discovery.exports.clone()); let mut imports = Vec::new(); let mut warnings = export_discovery.warnings; + if options.generate_bindings && bindings.is_none() { + warnings.push("no header found; bindings not generated".to_string()); + } + if options.link { + emit_cargo_link(lang, &result.output_path, &options.link_args, &mut warnings)?; + } for language in &options.consumer_languages { let generated = if let Some(parsed) = &parsed_header { crate::imports::generate_imports_from_parsed( @@ -286,20 +330,116 @@ impl std::fmt::Display for LoadError { impl std::error::Error for LoadError {} +fn sibling_header(source: &Path) -> Option { + let header = source.with_extension("h"); + header.is_file().then_some(header) +} + +fn emit_cargo_link( + language: Language, + output_path: &Path, + link_args: &[String], + warnings: &mut Vec, +) -> Result<(), LoadError> { + if std::env::var_os("CARGO").is_none() || std::env::var_os("OUT_DIR").is_none() || cfg!(test) { + return Ok(()); + } + if !output_path.is_file() { + warnings.push(format!( + "link requested but output is missing: {}", + output_path.display() + )); + return Ok(()); + } + for arg in link_args { + if !extra_link_arg_allowed(arg) { + return Err(LoadError::CompilationFailed( + language, + format!("rejected extra link argument: {arg}"), + )); + } + } + let ext = output_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "o" | "obj" => { + println!("cargo:rustc-link-arg={}", output_path.display()); + } + "a" | "lib" => { + if let (Some(dir), Some(stem)) = (output_path.parent(), output_path.file_stem()) { + let stem = stem.to_string_lossy(); + let libname = stem.strip_prefix("lib").unwrap_or(&stem); + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-lib=static={libname}"); + } + } + "so" | "dylib" | "dll" => { + if let (Some(dir), Some(stem)) = (output_path.parent(), output_path.file_stem()) { + let stem = stem.to_string_lossy(); + let libname = stem.strip_prefix("lib").unwrap_or(&stem); + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-lib=dylib={libname}"); + } + } + _ => { + println!("cargo:rustc-link-arg={}", output_path.display()); + } + } + for arg in link_args { + println!("cargo:rustc-link-arg={arg}"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use tempfile::Builder; #[test] fn test_load_c() { - let tmp = Builder::new().tempfile_in(std::env::temp_dir()).unwrap(); - let path = tmp.path(); - std::fs::write(path, "int add(int a, int b) { return a + b; }").unwrap(); + if find_compiler(Language::C).is_none() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("add.c"); + let header = dir.path().join("add.h"); + std::fs::write(&header, "int add(int a, int b);\n").unwrap(); + std::fs::write(&path, "int add(int a, int b) { return a + b; }\n").unwrap(); + let out = dir.path().join("out"); + + let result = load_with_options(&path, LoadOptions::default().output_dir(&out).link(false)) + .expect("load C source"); + assert!(result.output_path.exists()); + assert_eq!(result.output_path.extension().unwrap(), "o"); + let code = result.bindings_code().expect("bindings"); + assert!(code.contains("pub fn add(")); + } - let result = load(path); - // May fail if no C compiler available, but shouldn't panic - println!("{:?}", result); + #[test] + fn test_load_honors_compile_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("add.c"); + let header = dir.path().join("add.h"); + std::fs::write(&header, "int add(int a, int b);\n").unwrap(); + std::fs::write(&path, "int add(int a, int b) { return a + b; }\n").unwrap(); + let out = dir.path().join("out"); + + let result = load_with_options( + &path, + LoadOptions::default() + .output_dir(&out) + .compile(false) + .link(false), + ) + .expect("load without compile"); + assert_eq!( + result.output_path.canonicalize().unwrap(), + path.canonicalize().unwrap() + ); + assert!(result.bindings_code().unwrap().contains("pub fn add(")); + assert!(!out.join("add.o").exists()); } #[test] From 97c6553c0c17db9a35f5c20971ecf31dbc8952a3 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 16 Sep 2026 01:46:22 +0000 Subject: [PATCH 2/2] fix(windows): omit -fPIC for MSVC clang Clang targeting x86_64-pc-windows-msvc rejects -fPIC. Keep PIC flags on Unix; skip them (and Nim/D/Odin PIC equivalents) on Windows. Amp-Thread-ID: https://ampcode.com/threads/T-01a0a631-cbad-764e-a59c-662f4c6e0902 Co-authored-by: Max Carter --- src/detector.rs | 131 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/src/detector.rs b/src/detector.rs index d948c2f..80ac357 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -119,6 +119,39 @@ impl Language { } } + /// PIC flags for ELF/Mach-O. MSVC clang rejects `-fPIC`. + fn pic_c_flag() -> Option<&'static str> { + if cfg!(windows) { + None + } else { + Some("-fPIC") + } + } + + fn pic_nim_pass_c() -> Option<&'static str> { + if cfg!(windows) { + None + } else { + Some("--passC:-fPIC") + } + } + + fn pic_d_reloc() -> Option<&'static str> { + if cfg!(windows) { + None + } else { + Some("--relocation-model=pic") + } + } + + fn pic_odin_reloc() -> Option<&'static str> { + if cfg!(windows) { + None + } else { + Some("-reloc-mode:pic") + } + } + /// Get the command to compile to C intermediate. pub fn to_c_args(&self, input: &str, output: &str) -> Vec { match self { @@ -132,32 +165,31 @@ impl Language { Language::Zig => { // Zig doesn't have direct C output, but we can use translate-c for headers // For actual code, we emit object files - vec![ - "build-obj".to_string(), - "-fPIC".to_string(), - "-OReleaseFast".to_string(), - format!("-femit-bin={output}"), - input.to_string(), - ] + let mut args = vec!["build-obj".to_string()]; + if let Some(pic) = Self::pic_c_flag() { + args.push(pic.to_string()); + } + args.push("-OReleaseFast".to_string()); + args.push(format!("-femit-bin={output}")); + args.push(input.to_string()); + args } Language::C => { - vec![ - "-c".to_string(), - "-fPIC".to_string(), - "-o".to_string(), - output.to_string(), - input.to_string(), - ] + let mut args = vec!["-c".to_string()]; + if let Some(pic) = Self::pic_c_flag() { + args.push(pic.to_string()); + } + args.extend(["-o".to_string(), output.to_string(), input.to_string()]); + args } Language::Cpp => { // Compile to object, we'll need headers separately - vec![ - "-c".to_string(), - "-fPIC".to_string(), - "-o".to_string(), - output.to_string(), - input.to_string(), - ] + let mut args = vec!["-c".to_string()]; + if let Some(pic) = Self::pic_c_flag() { + args.push(pic.to_string()); + } + args.extend(["-o".to_string(), output.to_string(), input.to_string()]); + args } Language::CSharp => { // C# to native requires AOT compilation @@ -178,40 +210,49 @@ impl Language { } Language::D => { // D can emit C headers with -HC flag (LDC2) - vec![ - "-c".to_string(), - "--relocation-model=pic".to_string(), + let mut args = vec!["-c".to_string()]; + if let Some(pic) = Self::pic_d_reloc() { + args.push(pic.to_string()); + } + args.extend([ format!("-of={output}"), "-HC".to_string(), // Generate C header input.to_string(), - ] + ]); + args } Language::Nim => { let cache = Path::new(output) .parent() .unwrap_or_else(|| Path::new(".")) .join("nimcache"); - vec![ + let mut args = vec![ "c".to_string(), format!("--nimcache:{}", cache.display()), "--noMain".to_string(), "--app:staticlib".to_string(), "--mm:none".to_string(), - "--passC:-fPIC".to_string(), - format!("-o:{output}"), - input.to_string(), - ] + ]; + if let Some(pic) = Self::pic_nim_pass_c() { + args.push(pic.to_string()); + } + args.push(format!("-o:{output}")); + args.push(input.to_string()); + args } Language::Odin => { // Odin compiles to object files - vec![ + let mut args = vec![ "build".to_string(), input.to_string(), "-file".to_string(), format!("-out:{output}"), "-build-mode:obj".to_string(), - "-reloc-mode:pic".to_string(), - ] + ]; + if let Some(pic) = Self::pic_odin_reloc() { + args.push(pic.to_string()); + } + args } Language::Hare => { // Hare compiles to object files via QBE @@ -477,7 +518,11 @@ mod tests { fn test_to_c_args_c_object() { let args = Language::C.to_c_args("foo.c", "foo.o"); assert!(args.contains(&"-c".to_string())); - assert!(args.contains(&"-fPIC".to_string())); + if cfg!(windows) { + assert!(!args.contains(&"-fPIC".to_string())); + } else { + assert!(args.contains(&"-fPIC".to_string())); + } assert!(!args.contains(&"-E".to_string())); assert!(args.contains(&"foo.c".to_string())); assert!(args.contains(&"foo.o".to_string())); @@ -487,7 +532,11 @@ mod tests { fn test_to_c_args_zig_pic_releasefast() { let args = Language::Zig.to_c_args("foo.zig", "foo.o"); assert!(args.contains(&"build-obj".to_string())); - assert!(args.contains(&"-fPIC".to_string())); + if cfg!(windows) { + assert!(!args.contains(&"-fPIC".to_string())); + } else { + assert!(args.contains(&"-fPIC".to_string())); + } assert!(args.contains(&"-OReleaseFast".to_string())); let femit_count = args.iter().filter(|a| a.starts_with("-femit-bin")).count(); assert_eq!(femit_count, 1, "should have exactly one -femit-bin flag"); @@ -501,14 +550,22 @@ mod tests { .any(|a| a.starts_with("--nimcache:") && a.contains("out"))); assert!(!args.iter().any(|a| a == "--nimcache:.")); assert!(args.contains(&"--app:staticlib".to_string())); - assert!(args.contains(&"--passC:-fPIC".to_string())); + if cfg!(windows) { + assert!(!args.contains(&"--passC:-fPIC".to_string())); + } else { + assert!(args.contains(&"--passC:-fPIC".to_string())); + } } #[test] fn test_to_c_args_odin_pic() { let args = Language::Odin.to_c_args("foo.odin", "foo.o"); assert!(args.contains(&"-file".to_string())); - assert!(args.contains(&"-reloc-mode:pic".to_string())); + if cfg!(windows) { + assert!(!args.contains(&"-reloc-mode:pic".to_string())); + } else { + assert!(args.contains(&"-reloc-mode:pic".to_string())); + } } #[test]