From d95d75afaac2668be63aa102c741a07eec213d63 Mon Sep 17 00:00:00 2001 From: Ronen Ulanovsky Date: Sat, 14 Mar 2026 21:46:44 +0200 Subject: [PATCH 1/3] fix: support header_contents() with clang_macro_fallback Fixes #3351 clang_macro_fallback() silently produced no output when headers were provided via header_contents() instead of header(). This happened because try_ensure_fallback_translation_unit() only looked at input_headers (populated by .header()), returning None when it was empty. Fix: materialize input_header_contents to disk in the fallback build directory so clang can consume them for PCH compilation. Both input_headers and input_header_contents are now included in the fallback PCH, so mixed .header() + .header_contents() setups work. The materialized paths are sanitized (roots stripped, ".." resolved) to prevent escaping the build directory, and tracked in FallbackTranslationUnit for cleanup on drop. Also stop using std::mem::take on input_header_contents in Builder::generate(), preserving the data for the fallback TU (and fixing a pre-existing bug where serialize_items in codegen couldn't see header contents). Also store the original user-provided name in input_header_contents at header_contents() time so the fallback can reconstruct relative paths without depending on the current working directory. --- bindgen-tests/tests/tests.rs | 207 +++++++++++++++++++++++++++++++++++ bindgen/clang.rs | 8 ++ bindgen/codegen/mod.rs | 3 +- bindgen/ir/context.rs | 92 ++++++++++++---- bindgen/lib.rs | 17 +-- bindgen/options/mod.rs | 6 +- 6 files changed, 302 insertions(+), 31 deletions(-) diff --git a/bindgen-tests/tests/tests.rs b/bindgen-tests/tests/tests.rs index 6e3c358d3e..84e33f8ea9 100644 --- a/bindgen-tests/tests/tests.rs +++ b/bindgen-tests/tests/tests.rs @@ -617,6 +617,213 @@ fn test_macro_fallback_non_system_dir() { } } +#[test] +fn test_macro_fallback_header_contents() { + let tmpdir = tempfile::tempdir().unwrap(); + let actual = builder() + .disable_header_comment() + .header_contents( + "test.h", + "#define UINT32_C(c) c ## U\n\ + #define SIMPLE 42\n\ + #define COMPOUND UINT32_C(69)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(tmpdir.path()) + .clang_arg("--target=x86_64-unknown-linux") + .generate() + .unwrap() + .to_string(); + + let actual = format_code(actual).unwrap(); + + let expected = format_code( + "pub const SIMPLE: u32 = 42;\npub const COMPOUND: u32 = 69;\n", + ) + .unwrap(); + + assert_eq!(expected, actual); +} + +#[test] +fn test_macro_fallback_multiple_header_contents() { + let tmpdir = tempfile::tempdir().unwrap(); + let actual = builder() + .disable_header_comment() + .header_contents("defs.h", "#define UINT32_C(c) c ## U\n") + .header_contents( + "test.h", + "#include \"defs.h\"\n\ + #define MY_CONST UINT32_C(28)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(tmpdir.path()) + .clang_arg("--target=x86_64-unknown-linux") + .generate() + .unwrap() + .to_string(); + + let actual = format_code(actual).unwrap(); + + // UINT32_C is a function-like macro, should not appear as a constant. + // MY_CONST should be evaluated by the fallback. + assert!( + actual.contains("pub const MY_CONST: u32 = 28;"), + "Expected MY_CONST constant in output:\n{actual}" + ); +} + +#[test] +fn test_macro_fallback_mixed_header_and_header_contents() { + let tmpdir = tempfile::tempdir().unwrap(); + let actual = builder() + .disable_header_comment() + .header(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/headers/issue-753.h" + )) + .header_contents( + "extra.h", + "#define UINT32_C(c) c ## U\n\ + #define EXTRA_CONST UINT32_C(99)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(tmpdir.path()) + .generate() + .unwrap() + .to_string(); + + let actual = format_code(actual).unwrap(); + + // Constants from the real header (issue-753.h defines UINT32_C and uses it) + assert!( + actual.contains("pub const CONST: u32 = 5;"), + "Expected CONST from real header in output:\n{actual}" + ); + // Constants from the virtual header via fallback + assert!( + actual.contains("pub const EXTRA_CONST: u32 = 99;"), + "Expected EXTRA_CONST from header_contents in output:\n{actual}" + ); +} + +#[test] +fn test_macro_fallback_header_contents_duplicate_basename() { + let tmpdir = tempfile::tempdir().unwrap(); + let actual = builder() + .disable_header_comment() + .header_contents( + "defs.h", + "#define UINT32_C(c) c ## U\n\ + #define FROM_ROOT UINT32_C(11)\n", + ) + .header_contents( + "sub/defs.h", + "#define UINT32_C(c) c ## U\n\ + #define FROM_SUB UINT32_C(22)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(tmpdir.path()) + .clang_arg("--target=x86_64-unknown-linux") + .generate() + .unwrap() + .to_string(); + + let actual = format_code(actual).unwrap(); + + assert!( + actual.contains("pub const FROM_ROOT: u32 = 11;"), + "Expected FROM_ROOT in output:\n{actual}" + ); + assert!( + actual.contains("pub const FROM_SUB: u32 = 22;"), + "Expected FROM_SUB in output:\n{actual}" + ); +} + +#[test] +fn test_macro_fallback_header_contents_absolute_name() { + // Absolute names must not escape the build dir — they should be + // materialized safely under it, and the original file must not be + // clobbered or deleted. + let tmpdir = tempfile::tempdir().unwrap(); + let victim = tmpdir.path().join("victim.h"); + fs::write(&victim, "// original content\n").unwrap(); + + let abs_name = victim.to_str().unwrap(); + let build_dir = tmpdir.path().join("fallback_build"); + fs::create_dir_all(&build_dir).unwrap(); + + let actual = builder() + .disable_header_comment() + .header_contents( + abs_name, + "#define UINT32_C(c) c ## U\n\ + #define ABS_CONST UINT32_C(55)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(&build_dir) + .clang_arg("--target=x86_64-unknown-linux") + .generate() + .unwrap() + .to_string(); + + // The fallback-only constant should be evaluated. + assert!( + actual.contains("pub const ABS_CONST: u32 = 55;"), + "Expected ABS_CONST in output:\n{actual}" + ); + + // The original file must not have been deleted by FallbackTU drop. + assert!( + victim.exists(), + "Original file at {abs_name} was deleted by fallback cleanup" + ); + assert_eq!( + fs::read_to_string(&victim).unwrap(), + "// original content\n", + "Original file at {abs_name} was overwritten by materialization" + ); +} + +#[test] +fn test_macro_fallback_header_contents_parent_dir_escape() { + // Names with ".." must not escape the build dir or delete files + // outside it during FallbackTranslationUnit cleanup. + let tmpdir = tempfile::tempdir().unwrap(); + let victim = tmpdir.path().join("victim.h"); + fs::write(&victim, "// must survive\n").unwrap(); + + let build_dir = tmpdir.path().join("build"); + fs::create_dir_all(&build_dir).unwrap(); + + let actual = builder() + .disable_header_comment() + .header_contents( + "../victim.h", + "#define UINT32_C(c) c ## U\n\ + #define ESCAPE_CONST UINT32_C(77)\n", + ) + .clang_macro_fallback() + .clang_macro_fallback_build_dir(&build_dir) + .clang_arg("--target=x86_64-unknown-linux") + .generate() + .unwrap() + .to_string(); + + assert!( + actual.contains("pub const ESCAPE_CONST: u32 = 77;"), + "Expected ESCAPE_CONST in output:\n{actual}" + ); + + // The file outside build_dir must not have been clobbered. + assert!( + victim.exists(), + "File outside build dir was deleted by fallback cleanup" + ); + assert_eq!(fs::read_to_string(&victim).unwrap(), "// must survive\n",); +} + #[test] // Doesn't support executing sh file on Windows. // We may want to implement it in Rust so that we support all systems. diff --git a/bindgen/clang.rs b/bindgen/clang.rs index 9e614da9f8..d50f759382 100644 --- a/bindgen/clang.rs +++ b/bindgen/clang.rs @@ -1915,6 +1915,9 @@ impl Drop for TranslationUnit { pub(crate) struct FallbackTranslationUnit { file_path: String, pch_path: String, + /// Header files materialized from `header_contents()` that must remain on + /// disk while the PCH is in use (clang validates source file existence). + materialized_headers: Vec, idx: Box, tu: TranslationUnit, } @@ -1931,6 +1934,7 @@ impl FallbackTranslationUnit { file: String, pch_path: String, c_args: &[Box], + materialized_headers: Vec, ) -> Option { // Create empty file OpenOptions::new() @@ -1951,6 +1955,7 @@ impl FallbackTranslationUnit { Some(FallbackTranslationUnit { file_path: file, pch_path, + materialized_headers, tu: f_translation_unit, idx: f_index, }) @@ -1989,6 +1994,9 @@ impl Drop for FallbackTranslationUnit { fn drop(&mut self) { let _ = std::fs::remove_file(&self.file_path); let _ = std::fs::remove_file(&self.pch_path); + for path in &self.materialized_headers { + let _ = std::fs::remove_file(path); + } } } diff --git a/bindgen/codegen/mod.rs b/bindgen/codegen/mod.rs index 7a998c8fac..ab3aaa580d 100644 --- a/bindgen/codegen/mod.rs +++ b/bindgen/codegen/mod.rs @@ -5364,7 +5364,8 @@ pub(crate) mod utils { } if !context.options().input_header_contents.is_empty() { - for (name, contents) in &context.options().input_header_contents { + for (name, contents, _) in &context.options().input_header_contents + { writeln!(code, "// {name}\n{contents}")?; } diff --git a/bindgen/ir/context.rs b/bindgen/ir/context.rs index 8e4163df5e..83a4721608 100644 --- a/bindgen/ir/context.rs +++ b/bindgen/ir/context.rs @@ -2042,24 +2042,76 @@ If you encounter an error missing from this list, please file an issue or a PR!" &mut self, ) -> Option<&mut clang::FallbackTranslationUnit> { if self.fallback_tu.is_none() { - let file = format!( - "{}/.macro_eval.c", + let build_dir: String = match self.options().clang_macro_fallback_build_dir { - Some(ref path) => path.as_os_str().to_str()?, - None => ".", - } - ); + Some(ref path) => path.as_os_str().to_str()?.to_owned(), + None => ".".to_owned(), + }; + let file = format!("{build_dir}/.macro_eval.c"); let index = clang::Index::new(false, false); - let mut header_names_to_compile = Vec::new(); - let mut header_paths = Vec::new(); - let mut header_includes = Vec::new(); - let [input_headers @ .., single_header] = - &self.options().input_headers[..] + // Materialize input_header_contents to disk for the fallback + // translation unit. header_contents() provides virtual files + // that only exist as unsaved buffers in the main TU — the + // fallback TU needs real files on disk for PCH compilation. + // The files must remain on disk while the PCH is in use + // (clang validates source file existence when loading a PCH). + // + // Both input_headers and input_header_contents are included + // so the PCH captures macro definitions from all sources. + let materialized_paths: Vec = self + .options() + .input_header_contents + .iter() + .filter_map(|(_, contents, original_name)| { + // Sanitize the user-provided name so it always + // resolves under build_dir: + // - Root/prefix components are dropped (absolute + // paths can't bypass build_dir via Path::join). + // - ".." is resolved against the accumulated path + // but clamped to empty (can't escape build_dir). + // - "." is skipped. + // This preserves directory structure for collision + // avoidance (e.g. "a.h" vs "dir/a.h") while + // preventing any path from escaping build_dir. + use std::path::Component; + let mut parts: Vec<&std::ffi::OsStr> = Vec::new(); + for c in Path::new(original_name.as_ref()).components() { + match c { + Component::Normal(s) => parts.push(s), + Component::ParentDir => { + parts.pop(); + } + _ => {} + } + } + let relative: std::path::PathBuf = parts.iter().collect(); + let disk_path = Path::new(&build_dir).join(&relative); + if let Some(parent) = disk_path.parent() { + std::fs::create_dir_all(parent).ok()?; + } + std::fs::write(&disk_path, contents.as_ref()).ok()?; + Some(disk_path.to_str()?.to_owned()) + }) + .collect(); + + let effective_headers: Vec> = self + .options() + .input_headers + .iter() + .cloned() + .chain(materialized_paths.iter().map(|p| p.clone().into())) + .collect(); + + let [input_headers @ .., single_header] = &effective_headers[..] else { return None; }; + + let mut header_names_to_compile = Vec::new(); + let mut header_paths = Vec::new(); + let mut header_includes = Vec::new(); for input_header in input_headers { let path = Path::new(input_header.as_ref()); if let Some(header_path) = path.parent() { @@ -2071,17 +2123,15 @@ If you encounter an error missing from this list, please file an issue or a PR!" } else { header_paths.push("."); } + // Use full path for -include to avoid basename + // collisions between headers in different directories. + header_includes.push(input_header.as_ref().to_string()); let header_name = path.file_name()?.to_str()?; - header_includes.push(header_name.to_string()); header_names_to_compile .push(header_name.split(".h").next()?.to_string()); } let pch = format!( - "{}/{}", - match self.options().clang_macro_fallback_build_dir { - Some(ref path) => path.as_os_str().to_str()?, - None => ".", - }, + "{build_dir}/{}", header_names_to_compile.join("-") + "-precompile.h.pch" ); @@ -2118,8 +2168,12 @@ If you encounter an error missing from this list, please file an issue or a PR!" c_args.push(arg.clone()); } } - self.fallback_tu = - Some(clang::FallbackTranslationUnit::new(file, pch, &c_args)?); + self.fallback_tu = Some(clang::FallbackTranslationUnit::new( + file, + pch, + &c_args, + materialized_paths, + )?); } self.fallback_tu.as_mut() diff --git a/bindgen/lib.rs b/bindgen/lib.rs index 0305b5cd7b..68d26ecb48 100644 --- a/bindgen/lib.rs +++ b/bindgen/lib.rs @@ -361,13 +361,14 @@ impl Builder { .flat_map(|header| ["-include".into(), header.clone()]), ); - let input_unsaved_files = - std::mem::take(&mut self.options.input_header_contents) - .into_iter() - .map(|(name, contents)| { - clang::UnsavedFile::new(name.as_ref(), contents.as_ref()) - }) - .collect::>(); + let input_unsaved_files = self + .options + .input_header_contents + .iter() + .map(|(name, contents, _)| { + clang::UnsavedFile::new(name.as_ref(), contents.as_ref()) + }) + .collect::>(); Bindings::generate(self.options, &input_unsaved_files) } @@ -404,7 +405,7 @@ impl Builder { // For each input header content, add a prefix line of `#line 0 "$name"` // followed by the contents. - for (name, contents) in &self.options.input_header_contents { + for (name, contents, _) in &self.options.input_header_contents { is_cpp |= file_is_cpp(name); wrapper_contents.push_str("#line 0 \""); diff --git a/bindgen/options/mod.rs b/bindgen/options/mod.rs index baa541c5ac..6b3bfc0bb3 100644 --- a/bindgen/options/mod.rs +++ b/bindgen/options/mod.rs @@ -1302,8 +1302,8 @@ options! { methods: {}, as_args: ignore, }, - /// Tuples of unsaved file contents of the form (name, contents). - input_header_contents: Vec<(Box, Box)> { + /// Tuples of unsaved file contents of the form (absolute name, contents, original name). + input_header_contents: Vec<(Box, Box, Box)> { methods: { /// Add `contents` as an input C/C++ header named `name`. /// @@ -1320,7 +1320,7 @@ options! { .into(); self.options .input_header_contents - .push((absolute_path, contents.into())); + .push((absolute_path, contents.into(), name.into())); self } }, From cf7c69ec3bd798be73857404468d785a27e9f921 Mon Sep 17 00:00:00 2001 From: Ronen Ulanovsky Date: Sat, 14 Mar 2026 21:47:23 +0200 Subject: [PATCH 2/3] fix: propagate implicit --target to fallback_clang_args Fixes #3352 When bindgen infers the target triple from the Rust target (i.e., no explicit --target in clang_args), it inserts --target= into clang_args at Bindings::generate() time. However, fallback_clang_args was already populated from clang_args earlier in Builder::generate(), before this insertion. This meant the fallback translation unit used for clang_macro_fallback would use the host architecture instead of the target architecture. For cross-compilation, this caused sizeof-dependent macros (e.g., ioctl constants using _IOR/_IOW) to evaluate with the host's struct layouts instead of the target's, producing silently wrong values. Fix: when inserting the inferred target into clang_args, also insert it into fallback_clang_args. --- bindgen-tests/tests/tests.rs | 46 ++++++++++++++++++++++++++++++++++++ bindgen/lib.rs | 8 +++---- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/bindgen-tests/tests/tests.rs b/bindgen-tests/tests/tests.rs index 84e33f8ea9..ce0c2f23e2 100644 --- a/bindgen-tests/tests/tests.rs +++ b/bindgen-tests/tests/tests.rs @@ -824,6 +824,52 @@ fn test_macro_fallback_header_contents_parent_dir_escape() { assert_eq!(fs::read_to_string(&victim).unwrap(), "// must survive\n",); } +#[test] +fn test_macro_fallback_cross_target() { + // Subprocess-style test: setting TARGET as an env var in a parallel + // test is not robust, so we re-invoke the test binary as a child + // process with TARGET set to a non-host triple. The child runs the + // actual assertion; the parent just checks the exit status. + // + // __SIZEOF_POINTER__ is a clang builtin that equals the target's + // pointer width. On armv7 it's 4; on x86_64 it's 8. If the + // implicit --target isn't propagated to fallback_clang_args, the + // fallback TU evaluates with the host pointer size instead. + if env::var("__BINDGEN_CROSS_TARGET_INNER").is_ok() { + let tmpdir = tempfile::tempdir().unwrap(); + let actual = builder() + .disable_header_comment() + .header_contents("test.h", "#define PTR_BYTES __SIZEOF_POINTER__\n") + .clang_macro_fallback() + .clang_macro_fallback_build_dir(tmpdir.path()) + .generate() + .unwrap() + .to_string(); + assert!( + actual.contains("pub const PTR_BYTES: u32 = 4;"), + "Expected 4-byte pointers for armv7 target, got:\n{actual}" + ); + return; + } + + let exe = env::current_exe().unwrap(); + let output = std::process::Command::new(&exe) + .arg("test_macro_fallback_cross_target") + .arg("--exact") + .arg("--test-threads=1") + .env("TARGET", "armv7-unknown-linux-gnueabihf") + .env("__BINDGEN_CROSS_TARGET_INNER", "1") + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "Cross-target fallback test failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + #[test] // Doesn't support executing sh file on Windows. // We may want to implement it in Rust so that we support all systems. diff --git a/bindgen/lib.rs b/bindgen/lib.rs index 68d26ecb48..1a8b299edc 100644 --- a/bindgen/lib.rs +++ b/bindgen/lib.rs @@ -787,10 +787,10 @@ impl Bindings { // opening libclang.so, it has to be the same architecture and thus the // check is fine. if !explicit_target && !is_host_build { - options.clang_args.insert( - 0, - format!("--target={effective_target}").into_boxed_str(), - ); + let target_arg = + format!("--target={effective_target}").into_boxed_str(); + options.clang_args.insert(0, target_arg.clone()); + options.fallback_clang_args.insert(0, target_arg); } fn detect_include_paths(options: &mut BindgenOptions) { From e9fcc7ce1fe6f6d27190f1e42b5080c2da5f3bde Mon Sep 17 00:00:00 2001 From: Ronen Ulanovsky Date: Sun, 15 Mar 2026 00:16:24 +0200 Subject: [PATCH 3/3] fix: skip fallback header_contents tests on clang 9 clang 9's clang_Cursor_Evaluate returns CXEval_UnExposed instead of CXEval_Int for macro-expanded expressions loaded through a PCH, so clang_macro_fallback produces no constants on that version. The existing header-based fallback tests already handle this with clang-9-specific empty expectation files; apply the same pattern to the new inline tests by skipping when libclang 9 is detected. --- bindgen-tests/tests/tests.rs | 45 ++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/bindgen-tests/tests/tests.rs b/bindgen-tests/tests/tests.rs index ce0c2f23e2..d6c7236885 100644 --- a/bindgen-tests/tests/tests.rs +++ b/bindgen-tests/tests/tests.rs @@ -617,8 +617,19 @@ fn test_macro_fallback_non_system_dir() { } } +/// clang 9's `clang_Cursor_Evaluate` returns `CXEval_UnExposed` instead +/// of `CXEval_Int` for macro-expanded expressions loaded through a PCH +/// built from materialized `header_contents()` files, so fallback +/// constant evaluation produces no results in that specific path. +fn clang9_fallback_header_contents_works() -> bool { + !matches!(clang_version().parsed, Some((9, _))) +} + #[test] fn test_macro_fallback_header_contents() { + if !clang9_fallback_header_contents_works() { + return; + } let tmpdir = tempfile::tempdir().unwrap(); let actual = builder() .disable_header_comment() @@ -647,6 +658,9 @@ fn test_macro_fallback_header_contents() { #[test] fn test_macro_fallback_multiple_header_contents() { + if !clang9_fallback_header_contents_works() { + return; + } let tmpdir = tempfile::tempdir().unwrap(); let actual = builder() .disable_header_comment() @@ -675,6 +689,9 @@ fn test_macro_fallback_multiple_header_contents() { #[test] fn test_macro_fallback_mixed_header_and_header_contents() { + if !clang9_fallback_header_contents_works() { + return; + } let tmpdir = tempfile::tempdir().unwrap(); let actual = builder() .disable_header_comment() @@ -709,6 +726,9 @@ fn test_macro_fallback_mixed_header_and_header_contents() { #[test] fn test_macro_fallback_header_contents_duplicate_basename() { + if !clang9_fallback_header_contents_works() { + return; + } let tmpdir = tempfile::tempdir().unwrap(); let actual = builder() .disable_header_comment() @@ -768,11 +788,13 @@ fn test_macro_fallback_header_contents_absolute_name() { .unwrap() .to_string(); - // The fallback-only constant should be evaluated. - assert!( - actual.contains("pub const ABS_CONST: u32 = 55;"), - "Expected ABS_CONST in output:\n{actual}" - ); + // The fallback-only constant should be evaluated (not on clang 9). + if clang9_fallback_header_contents_works() { + assert!( + actual.contains("pub const ABS_CONST: u32 = 55;"), + "Expected ABS_CONST in output:\n{actual}" + ); + } // The original file must not have been deleted by FallbackTU drop. assert!( @@ -811,10 +833,12 @@ fn test_macro_fallback_header_contents_parent_dir_escape() { .unwrap() .to_string(); - assert!( - actual.contains("pub const ESCAPE_CONST: u32 = 77;"), - "Expected ESCAPE_CONST in output:\n{actual}" - ); + if clang9_fallback_header_contents_works() { + assert!( + actual.contains("pub const ESCAPE_CONST: u32 = 77;"), + "Expected ESCAPE_CONST in output:\n{actual}" + ); + } // The file outside build_dir must not have been clobbered. assert!( @@ -826,6 +850,9 @@ fn test_macro_fallback_header_contents_parent_dir_escape() { #[test] fn test_macro_fallback_cross_target() { + if !clang9_fallback_header_contents_works() { + return; + } // Subprocess-style test: setting TARGET as an env var in a parallel // test is not robust, so we re-invoke the test binary as a child // process with TARGET set to a non-host triple. The child runs the