module_info/lib.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Embed metadata into ELF binaries as `.note.package` sections so it
5//! **survives crashes**, visible to `coredumpctl`, `readelf -n`, and any
6//! other consumer of the [systemd package-metadata
7//! format](https://uapi-group.org/specifications/specs/package_metadata_for_executable_files/).
8//! The crate's main feature is crash-dump preservation: when your process dies,
9//! the version of code that crashed is recoverable from the core dump without external
10//! symbol files or build-system context.
11//!
12//! Runtime read-back via the [`get_module_info!`] macro is a *convenience
13//! accessor*, useful while the process is still alive but not the reason
14//! the crate exists.
15//!
16//! Consumers call [`generate_project_metadata_and_linker_script`] from
17//! `build.rs` to generate the linker script and Cargo directives. At
18//! runtime, metadata fields can be read via [`get_module_info!`] (returns
19//! `ModuleInfoResult<String>` for a single field, or a `HashMap` of all
20//! readable fields when called with no arguments). On non-Linux platforms
21//! the crate exposes no-op stubs so cross-platform builds still compile;
22//! runtime accessors return `ModuleInfoError::NotAvailable`.
23//!
24//! See the README (and `docs/GUIDE.md`) and the `examples/` directory for an
25//! end-to-end integration.
26//!
27//! # Limitations
28//!
29//! **`rlib` consumers read the host binary's metadata, not their own.**
30//! When a downstream library's `build.rs` calls
31//! [`generate_project_metadata_and_linker_script`], the resulting
32//! `cargo:rustc-link-arg=-T<linker_script>.ld` directive is attached to that
33//! library's own build and does not propagate to the final executable's link
34//! command, so the library's linker script never runs at the link step that
35//! produces the binary. Meanwhile, every [`get_module_info!`] call inside
36//! the library expands to an `extern "C" { static module_info_*: u8; }`
37//! declaration. At the final link those undefined references resolve
38//! against the executable's linker script, which defines a single set of
39//! `module_info_*` symbols pointing at the executable's `.note.package`
40//! payload, so library code reading them gets the executable's values. The
41//! same applies to anything statically linked into a Rust executable:
42//! `rlib`, `staticlib` linked via `#[link(kind = "static")]`, or in-tree
43//! workspace libraries.
44//!
45//! **`staticlib` consumed by an outer (non-cargo) build can embed its own
46//! metadata.** Set `EmbedOptions::emit_cargo_link_arg` to `false`; the
47//! crate then writes `linker_script.ld` to `out_dir` without emitting the
48//! `cargo:rustc-link-arg` directive. The outer build (Make, CMake, MSBuild,
49//! …) passes that script to its own linker, at which point the
50//! `module_info_*` symbols are defined by the staticlib's linker script and
51//! the staticlib reads its own metadata. See "Option B" in `docs/GUIDE.md`
52//! for the full flow.
53//!
54//! **`cdylib` shared libraries loaded via `dlopen` are not affected.** A
55//! `cdylib` runs its own link step and applies its own linker script, so
56//! the `module_info_*` symbols inside the resulting `.so` are local to it
57//! (not exported in the dynamic symbol table). Code inside the library
58//! reads its own metadata correctly, even when the host process's main
59//! executable also embeds `.note.package`. To consume a `cdylib`'s metadata
60//! at runtime, expose an `extern "C"` accessor and call it via `dlopen`;
61//! see `examples/sample_elf_bin_with_lib` for the full pattern. Reading a
62//! library file's metadata without loading it (e.g. for crash triage) is
63//! always possible by parsing the ELF note section from the `.so` on disk.
64//!
65//! **Little-endian targets only.** The ELF note header is serialized with
66//! `u32::to_le_bytes` at `build.rs` time. Supported targets today are
67//! `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, and
68//! `i686-unknown-linux-gnu` (all little-endian). Cross-compiling for a
69//! big-endian Linux target (s390x, powerpc-be, mips-be) will silently emit
70//! a byte-swapped note section that `readelf -n` and `systemd-coredump`
71//! cannot parse. Adding big-endian support would mean selecting `to_le_bytes`
72//! vs `to_be_bytes` from `CARGO_CFG_TARGET_ENDIAN`.
73
74mod error;
75mod fields;
76// `#[macro_use]` makes the non-exported build-time helpers (note!/error!/
77// warn!/debug!) visible to sibling modules without exporting them.
78#[macro_use]
79mod macros;
80use cfg_if::cfg_if;
81pub use error::{ModuleInfoError, ModuleInfoResult};
82pub use fields::ModuleInfoField;
83
84cfg_if! {
85 if #[cfg(target_os = "linux")] {
86 use std::{env, path::{Path, PathBuf}};
87
88 mod constants;
89 mod metadata;
90 mod note_section;
91 mod utils;
92
93 pub use metadata::PackageMetadata;
94
95 pub(crate) use constants::*;
96 }
97}
98
99cfg_if! {
100 if #[cfg(all(feature = "embed-module-info", target_os = "linux"))] {
101 /// Static symbol that marks the beginning of our custom note section
102 ///
103 /// This empty array is placed in the .note.package section and serves as an anchor
104 /// for the linker script to place our metadata properly.
105 #[link_section = ".note.package"]
106 #[no_mangle]
107 #[used]
108 #[doc(hidden)]
109 pub static PACKAGE_NOTE_SECTION: [u8; 0] = [];
110
111 /// Force the `module_info` rlib to be linked into the consuming binary so the
112 /// `.note.package` section is emitted with ELF type `SHT_NOTE`.
113 ///
114 /// # Why this is needed
115 ///
116 /// The note data is produced by the linker script that `build.rs` generates.
117 /// GNU ld assigns `SHT_NOTE` to the output `.note.package` only when an
118 /// input object file contributes a same-named input section already typed
119 /// `SHT_NOTE`; this crate provides exactly that input section through the
120 /// `#[link_section = ".note.package"]` static `PACKAGE_NOTE_SECTION`.
121 /// Without a source-level reference to this crate, cargo/rustc drops the
122 /// `module_info` rlib from the final link, no `SHT_NOTE` input section is
123 /// present, and ld synthesizes the output section from the script's
124 /// `BYTE(...)` directives alone, which yields `SHT_PROGBITS`. The bytes
125 /// are present, but tools like `readelf -n` and `systemd-coredump` filter
126 /// by section type and ignore it.
127 ///
128 /// Invoking `module_info::embed!()` at the crate root creates a `#[used]`
129 /// reference to [`PACKAGE_NOTE_SECTION`], which forces the rlib to link and
130 /// restores the correct section type.
131 ///
132 /// # When to use it
133 ///
134 /// Use `embed!()` when the consuming crate does **not** call `get_module_info!`
135 /// or reference any other `module_info` item at runtime (pure build-time
136 /// embedding). When the consuming crate already calls
137 /// `module_info::get_module_info!(...)` or imports any item from the crate,
138 /// this macro is unnecessary; the rlib is already linked.
139 ///
140 /// # Example
141 ///
142 /// ```ignore
143 /// // Top of src/main.rs or src/lib.rs:
144 /// module_info::embed!();
145 ///
146 /// fn main() {
147 /// // No other module_info references needed for the .note.package
148 /// // section to end up in the binary with SHT_NOTE type.
149 /// }
150 /// ```
151 #[macro_export]
152 macro_rules! embed {
153 () => {
154 #[allow(dead_code)]
155 const _: () = {
156 #[used]
157 static __MODULE_INFO_FORCE_LINK: &'static [u8; 0] =
158 &$crate::PACKAGE_NOTE_SECTION;
159 };
160 };
161 }
162 } else if #[cfg(all(feature = "embed-module-info", not(target_os = "linux")))] {
163 /// No-op stub of `embed!` for non-Linux targets. Present so
164 /// cross-platform builds compile without `#[cfg]` guards at each call site.
165 #[macro_export]
166 macro_rules! embed {
167 () => {};
168 }
169 } else {
170 /// No-op stub of `embed!` for feature-off builds (the
171 /// `embed-module-info` feature is disabled). Present so a consumer that
172 /// uses `module_info` only for `get_version()` / `get_module_version()`
173 /// can still call `module_info::embed!()` in their crate root without a
174 /// feature-gated `#[cfg]` guard. The macro expands to nothing because
175 /// there is no note section to anchor when the feature is off.
176 #[macro_export]
177 macro_rules! embed {
178 () => {};
179 }
180 }
181}
182
183/// Options controlling how [`embed_package_metadata`] writes artifacts and
184/// whether it emits cargo link-arg directives.
185///
186/// `EmbedOptions::default()` preserves the original zero-config behavior:
187/// write to `$OUT_DIR` and emit `cargo:rustc-link-arg=-T<linker_script.ld>`.
188/// Override when the crate is a static library whose final link happens later
189/// in the outer build system.
190///
191/// # Non-exhaustive
192///
193/// This struct is `#[non_exhaustive]` so new options can land without a
194/// SemVer break. Use `..Default::default()` when constructing.
195///
196/// # Example
197/// ```rust,no_run
198/// # use module_info::EmbedOptions;
199/// // Static-library flow: write the linker script to a directory the outer
200/// // build system knows about, so it can pass the script to the final linker.
201/// // In practice `out_dir` comes from an env var the outer build sets, or a
202/// // subdirectory of `OUT_DIR`; here we use `env::temp_dir()` as a portable
203/// // placeholder. `EmbedOptions` is `#[non_exhaustive]`, so construct via
204/// // `Default` and assign fields rather than using struct-literal syntax.
205/// let mut opts = EmbedOptions::default();
206/// opts.out_dir = Some(std::env::temp_dir().join("module_info_linker"));
207/// opts.emit_cargo_link_arg = false;
208/// ```
209#[cfg(target_os = "linux")]
210#[derive(Debug, Clone)]
211#[non_exhaustive]
212pub struct EmbedOptions {
213 /// Directory where `linker_script.ld`, `note.package.bin`, and
214 /// `module_info.json` are written. When `None`, the `OUT_DIR` environment
215 /// variable is used (the normal cargo build-script case).
216 pub out_dir: Option<PathBuf>,
217
218 /// When `true`, emit `cargo:rustc-link-arg=-T<path-to-linker_script.ld>`
219 /// on stdout so cargo passes the script to the final link step.
220 ///
221 /// Set to `false` when the current crate is a static library whose final
222 /// link happens later in the outer build system. Have that system pass
223 /// the linker script to its own linker.
224 pub emit_cargo_link_arg: bool,
225}
226
227#[cfg(target_os = "linux")]
228impl Default for EmbedOptions {
229 fn default() -> Self {
230 Self {
231 out_dir: None,
232 emit_cargo_link_arg: true,
233 }
234 }
235}
236
237/// Artifacts written by [`embed_package_metadata`].
238///
239/// Returned so consumers can log, inspect, or pass paths to a later build
240/// step (for the static-library flow, typically `linker_script_path`).
241///
242/// # Non-exhaustive
243///
244/// `#[non_exhaustive]`. Constructed by the crate, not by consumers.
245#[cfg(target_os = "linux")]
246#[derive(Debug, Clone)]
247#[non_exhaustive]
248pub struct EmbedArtifacts {
249 /// Absolute path to the generated linker script (`linker_script.ld`).
250 pub linker_script_path: PathBuf,
251 /// Absolute path to the raw `.note.package` binary dump.
252 pub note_bin_path: PathBuf,
253 /// Absolute path to the embedded JSON metadata (`module_info.json`). One
254 /// key:value pair per line; matches the bytes the linker script writes
255 /// into the `.note.package` descriptor (see `json` below).
256 pub json_path: PathBuf,
257 /// JSON string written to `module_info.json` and embedded as the note
258 /// section's descriptor. One key:value pair per line (not strictly
259 /// "compact"); the runtime scan in `extract_module_info` tolerates the
260 /// embedded newlines.
261 pub json: String,
262 /// Byte-encoded linker script body that produced `linker_script.ld`.
263 pub linker_script_body: String,
264}
265
266/// Convenience struct-literal view over [`PackageMetadata`] with field names
267/// shaped like the JSON keys rather than the internal Rust snake_case names.
268///
269/// `Info` exists so call sites can read the same way the embedded JSON reads:
270/// `r#type`, `moduleVersion`, `osVersion` instead of `module_type`,
271/// `module_version`, `os_version`. It's deliberately **not** `#[non_exhaustive]`:
272/// struct-literal construction is the whole point. Pass it to [`new`] to build
273/// the note artifacts in one call:
274///
275/// # Forward compatibility
276///
277/// **Always terminate the struct literal with `..Default::default()`.** Unlike
278/// [`PackageMetadata`] (which is `#[non_exhaustive]` and forbids struct-literal
279/// construction from outside the crate, forcing consumers into the
280/// field-assignment pattern that is intrinsically forward-compatible), `Info`
281/// permits a fully-exhaustive literal. That means a minor release of this
282/// crate that adds a new field will break any `Info { … }` call site that
283/// listed every field by name. The `..Default::default()` terminator is how
284/// consumers buy forward compatibility: new fields fall back to their
285/// `Default` value (empty string / disabled) instead of failing to compile.
286/// This is the *only* reason `Info` is safe to add fields to in minor
287/// releases. Omit the terminator and the crate can no longer do that
288/// without breaking you.
289///
290/// ```rust,no_run
291/// # use module_info::Info;
292/// let _ = module_info::new(Info {
293/// binary: "my_tool".into(),
294/// name: "my_tool".into(),
295/// maintainer: "team@contoso.com".into(),
296/// version: "1.2.3".into(),
297/// moduleVersion: "1.2.3.4".into(),
298/// os: "linux".into(),
299/// osVersion: "22.04".into(),
300/// r#type: "agent".into(),
301/// hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
302/// ..Default::default()
303/// });
304/// ```
305///
306/// Under the hood `new` converts this to a [`PackageMetadata`] and calls
307/// [`embed_package_metadata`] with [`EmbedOptions::default()`].
308///
309/// # No auto-detection on this path
310///
311/// Every field in the `Info` literal ships verbatim. `os`/`osVersion` are
312/// **not** read from `/etc/os-release`, and `repo`/`branch`/`hash` are
313/// **not** read from git. The caller owns every value. If you want the
314/// `/etc/os-release` + git auto-detection that the zero-config entry point
315/// provides, reach for [`PackageMetadata::from_cargo_toml`] instead,
316/// mutate the fields you want to override, and pass the result to
317/// [`embed_package_metadata`].
318///
319/// # Disabling fields
320///
321/// Seven keys are required at validation time:
322/// `binary`, `version`, `moduleVersion`, `name`, `maintainer`, `os`, and
323/// `osVersion`. The rest (`r#type`, `repo`, `branch`, `hash`, `copyright`)
324/// may be left as the empty string (the `Default` value);
325/// `..Default::default()` in the literal above is the idiomatic way to
326/// opt out. The embedded JSON still carries every key (the
327/// `.note.package` layout is fixed), but the value ships as `""`, which
328/// downstream tooling can treat as "disabled."
329///
330/// # `r#type` tradeoff
331///
332/// The JSON key is `type`, which collides with Rust's `type` keyword. We use
333/// the raw-identifier form `r#type` rather than a `#[serde(rename = "type")]`
334/// alias on a differently-named field (say, `module_type`), because the
335/// latter would require call sites to remember the rename when constructing
336/// the struct literal, re-creating the original mismatch this type is meant
337/// to solve. `r#type` is ugly but pays off once: downstream construction
338/// reads `r#type: "agent".into()` and the JSON reads `"type":"agent"`.
339#[cfg(target_os = "linux")]
340#[allow(non_snake_case)] // JSON-key-shaped field names (moduleVersion, osVersion) are intentional.
341#[derive(Debug, Clone, Default)]
342pub struct Info {
343 /// Binary name (matches JSON key `binary`).
344 pub binary: String,
345 /// Crate version from Cargo.toml (matches JSON key `version`).
346 pub version: String,
347 /// Full 4-part module version (matches JSON key `moduleVersion`).
348 pub moduleVersion: String,
349 /// Maintainer contact information (matches JSON key `maintainer`).
350 pub maintainer: String,
351 /// Package name (matches JSON key `name`).
352 pub name: String,
353 /// Module type: agent, library, executable, etc. (matches JSON key `type`).
354 pub r#type: String,
355 /// Git repository name (matches JSON key `repo`).
356 pub repo: String,
357 /// Git branch name (matches JSON key `branch`).
358 pub branch: String,
359 /// Git commit hash (matches JSON key `hash`).
360 pub hash: String,
361 /// Copyright information (matches JSON key `copyright`).
362 pub copyright: String,
363 /// Operating system name (matches JSON key `os`).
364 pub os: String,
365 /// Operating system version (matches JSON key `osVersion`).
366 pub osVersion: String,
367}
368
369#[cfg(target_os = "linux")]
370impl From<Info> for PackageMetadata {
371 fn from(info: Info) -> Self {
372 PackageMetadata {
373 binary: info.binary,
374 version: info.version,
375 module_version: info.moduleVersion,
376 maintainer: info.maintainer,
377 name: info.name,
378 module_type: info.r#type,
379 repo: info.repo,
380 branch: info.branch,
381 hash: info.hash,
382 copyright: info.copyright,
383 os: info.os,
384 os_version: info.osVersion,
385 }
386 }
387}
388
389/// One-call entry point: convert [`Info`] → [`PackageMetadata`] and embed via
390/// [`embed_package_metadata`] with [`EmbedOptions::default()`].
391///
392/// Use this from `build.rs` when you want to supply metadata programmatically
393/// without touching `Cargo.toml` and don't need to override any
394/// [`EmbedOptions`] (custom `out_dir`, suppressed `cargo:rustc-link-arg`, …).
395/// For those cases, convert `Info` to `PackageMetadata` with `.into()` and
396/// call [`embed_package_metadata`] directly.
397///
398/// # Errors
399/// Propagates everything [`embed_package_metadata`] can return, plus
400/// `ModuleInfoError::MalformedJson` if `moduleVersion` is not four
401/// dot-separated numeric parts that each fit in a `u16`.
402#[cfg(target_os = "linux")]
403#[must_use = "new returns EmbedArtifacts; discarding it hides both the written paths and any I/O errors"]
404pub fn new(info: Info) -> ModuleInfoResult<EmbedArtifacts> {
405 embed_package_metadata(&info.into(), &EmbedOptions::default())
406}
407
408/// Validate that `module_version` is exactly four dot-separated numeric parts,
409/// each of which fits in a `u16` (0..=65535).
410///
411/// This mirrors the Windows `VS_FIXEDFILEINFO::FILEVERSION` shape (four
412/// `WORD`-sized components) that Windows-style crash consumers expect to
413/// parse. An out-of-range value silently truncating on the consumer side
414/// would be worse than failing the build, so we enforce the range at embed
415/// time.
416#[cfg(target_os = "linux")]
417fn validate_module_version(module_version: &str) -> ModuleInfoResult<()> {
418 // When `allow_prerelease_suffix = true` in `[package.metadata.module_info]`,
419 // `format_version_parts` re-attaches the SemVer-style tail to the numeric
420 // core (e.g. `"7.5.3.0-PullRequest-12345"`). Validate only the numeric core
421 // so the u16/4-part guarantee still holds; the suffix is informational and
422 // ships verbatim. Inputs without a `-` or `+` behave identically to before.
423 let core = match metadata::suffix_start(module_version) {
424 Some(end) => module_version.get(..end).unwrap_or(module_version),
425 None => module_version,
426 };
427 let parts: Vec<&str> = core.split('.').collect();
428 if parts.len() != 4 {
429 return Err(ModuleInfoError::MalformedJson(format!(
430 "moduleVersion must have exactly 4 dot-separated parts, got {} in {module_version:?}",
431 parts.len()
432 )));
433 }
434 for (i, part) in parts.iter().enumerate() {
435 if part.is_empty() {
436 return Err(ModuleInfoError::MalformedJson(format!(
437 "moduleVersion part {i} is empty in {module_version:?}"
438 )));
439 }
440 if part.parse::<u16>().is_err() {
441 return Err(ModuleInfoError::MalformedJson(format!(
442 "moduleVersion part {i} ({part:?}) must be a non-negative integer \
443 that fits in 16 bits (0..=65535) in {module_version:?}"
444 )));
445 }
446 }
447 Ok(())
448}
449
450/// Embed a [`PackageMetadata`] value into ELF note artifacts on disk.
451///
452/// Consumers that want to supply metadata programmatically (e.g. from
453/// `build.rs` without editing `Cargo.toml`) or suppress the
454/// `cargo:rustc-link-arg` directive (e.g. a static library whose final link
455/// happens in a later build step) call this directly; the zero-config
456/// [`generate_project_metadata_and_linker_script`] is a thin wrapper over
457/// this function with the default options.
458///
459/// # Errors
460/// Returns `ModuleInfoError::MetadataTooLarge` if the serialized JSON exceeds
461/// the 1 KiB `.note.package` payload limit, or `ModuleInfoError::MalformedJson`
462/// if a required field is missing. `IoError` on filesystem failures.
463#[cfg(target_os = "linux")]
464#[must_use = "embed_package_metadata returns EmbedArtifacts; discarding it hides both the written paths and any I/O errors"]
465pub fn embed_package_metadata(
466 md: &PackageMetadata,
467 opts: &EmbedOptions,
468) -> ModuleInfoResult<EmbedArtifacts> {
469 // Emit rerun directives *before* any failure path. Emitting any `cargo:`
470 // directive opts out of cargo's default "rerun on any file change", so
471 // without these the build script wouldn't re-run when Cargo.toml, git
472 // HEAD, or env vars change. Stamped metadata would silently go stale.
473 emit_rerun_if_directives();
474
475 let (compact_json, linker_script_body) = metadata::render_note_payloads(md)?;
476
477 validate_embedded_json(&compact_json)?;
478
479 note!();
480 note!("-- Module Info --");
481 emit_metadata_notes(&compact_json);
482
483 let out_dir: PathBuf = match &opts.out_dir {
484 Some(p) => p.clone(),
485 None => PathBuf::from(env::var("OUT_DIR")?),
486 };
487 debug!("OUT_DIR: {}", out_dir.display());
488
489 std::fs::create_dir_all(&out_dir)?;
490 // `.ld.inc` signals include fragment (no SECTIONS/INSERT wrapper;
491 // inlined inside linker_script.ld).
492 let linker_script_body_path = out_dir.join("linker_script_body.ld.inc");
493 debug!(
494 "Writing linker script body to: {}",
495 linker_script_body_path.display()
496 );
497 // Header comment + trim of leading blank line prevents the standalone file
498 // from looking like a truncated linker script.
499 let linker_script_body_on_disk = format!(
500 "/* Linker-script fragment. Inlined inside linker_script.ld; not a standalone script. */\n{}",
501 linker_script_body.trim_start_matches('\n')
502 );
503 std::fs::write(
504 &linker_script_body_path,
505 linker_script_body_on_disk.as_bytes(),
506 )?;
507
508 let json_path = out_dir.join("module_info.json");
509 debug!("Writing module info to: {}", json_path.display());
510 std::fs::write(&json_path, compact_json.as_bytes())?;
511
512 // Descriptor must include the same NUL padding the linker script emits
513 // after the JSON (see `render_note_payloads`); otherwise `descsz` covers
514 // only JSON bytes while the section includes padding, and `readelf -n`
515 // warns "Corrupt note: only N bytes remain".
516 let padding = NOTE_ALIGN - (compact_json.len() % NOTE_ALIGN);
517 let mut descriptor = String::with_capacity(compact_json.len() + padding);
518 descriptor.push_str(&compact_json);
519 for _ in 0..padding {
520 descriptor.push('\0');
521 }
522
523 let note = note_section::NoteSection::new(
524 N_TYPE,
525 OWNER,
526 &descriptor,
527 &linker_script_body,
528 NOTE_ALIGN,
529 )?;
530 debug!(
531 "Created note section with {} bytes of data",
532 note.note_section.len()
533 );
534
535 // Strip the leading `.` so the dump isn't a dotfile hidden by default.
536 let note_bin_path = out_dir.join(format!("{}.bin", NOTE_SECTION_NAME.trim_start_matches('.')));
537 debug!("Saving binary note section to: {}", note_bin_path.display());
538 note.save_section(¬e_bin_path)?;
539
540 debug!("Saving linker script...");
541 let linker_script_path = note.save_linker_script(&out_dir)?;
542 debug!("Linker script saved to: {}", linker_script_path.display());
543
544 match link_arg_directive(&linker_script_path, opts.emit_cargo_link_arg) {
545 Some(d) => {
546 debug!("Adding cargo directive: {}", d);
547 println!("{d}");
548 }
549 None => {
550 debug!(
551 "emit_cargo_link_arg=false: caller will pass {} to the final linker",
552 linker_script_path.display()
553 );
554 }
555 }
556
557 Ok(EmbedArtifacts {
558 linker_script_path,
559 note_bin_path,
560 json_path,
561 json: compact_json,
562 linker_script_body,
563 })
564}
565
566/// Validate the serialized metadata JSON: size limit, object shape, required fields.
567#[cfg(target_os = "linux")]
568fn validate_embedded_json(desc_json: &str) -> ModuleInfoResult<()> {
569 if desc_json.len() > constants::MAX_JSON_SIZE {
570 return Err(ModuleInfoError::MetadataTooLarge(format!(
571 "Metadata size {} exceeds limit of {} bytes",
572 desc_json.len(),
573 constants::MAX_JSON_SIZE
574 )));
575 }
576
577 let value: serde_json::Value = serde_json::from_str(desc_json)
578 .map_err(|e| ModuleInfoError::MalformedJson(e.to_string()))?;
579
580 if !value.is_object() {
581 return Err(ModuleInfoError::MalformedJson(
582 "Metadata must be a JSON object".to_string(),
583 ));
584 }
585
586 for field in constants::REQUIRED_JSON_KEYS {
587 // `PackageMetadata` derives `Serialize` with no skip_if, so every key
588 // is always present in the JSON; a bare `is_none()` check here would
589 // pass a `PackageMetadata::default()` value through untouched. Treat
590 // both "missing key" and "empty string value" as missing so a
591 // Default-constructed `PackageMetadata` with a forgotten required
592 // field fails the build instead of silently embedding `""`.
593 let present_and_nonempty = value
594 .get(field)
595 .and_then(|v| v.as_str())
596 .map(|s| !s.is_empty())
597 .unwrap_or(false);
598 if !present_and_nonempty {
599 return Err(ModuleInfoError::MalformedJson(format!(
600 "Required field '{field}' is missing or empty"
601 )));
602 }
603 }
604
605 // `moduleVersion` is a required key and the loop above has already
606 // rejected any payload where it's missing, non-string, or empty. Fetch
607 // it unconditionally here; an `if let Some(...) = ...` arm would silently
608 // no-op if a future refactor ever split the required-keys check from the
609 // presence check, letting malformed payloads slip through a code path
610 // that is supposed to be the range guardrail. Using `.ok_or_else` makes
611 // the dependency on the loop above load-bearing and visible.
612 let mv = value
613 .get("moduleVersion")
614 .and_then(|v| v.as_str())
615 .ok_or_else(|| {
616 ModuleInfoError::MalformedJson(
617 "moduleVersion must be a non-empty string by this point (required-keys check above enforces it)"
618 .to_string(),
619 )
620 })?;
621 validate_module_version(mv)?;
622
623 Ok(())
624}
625
626/// Print metadata key/value pairs as cargo `note!` lines in a stable order.
627#[cfg(target_os = "linux")]
628fn emit_metadata_notes(desc_json: &str) {
629 // Presentation step only; validation already ran. Log via `debug!` so
630 // `MODULE_INFO_DEBUG=true` reveals why the notes pane is empty on the
631 // impossible case of a parse failure slipping through.
632 let map = match serde_json::from_str::<serde_json::Value>(desc_json) {
633 Ok(serde_json::Value::Object(map)) => map,
634 Ok(other) => {
635 debug!("emit_metadata_notes: expected a JSON object, got {}", other);
636 return;
637 }
638 Err(e) => {
639 debug!("emit_metadata_notes: JSON parse failed: {}", e);
640 return;
641 }
642 };
643
644 // Walk `ModuleInfoField::ALL` for stable order. No extra-keys fallback
645 // needed: `PackageMetadata` is `#[non_exhaustive]`, so the key set is
646 // always exactly `ModuleInfoField::ALL`.
647 for field in ModuleInfoField::ALL {
648 let key = field.to_key();
649 if let Some(value) = map.get(key) {
650 match value.as_str() {
651 Some(s) => note!("{}: {}", key, s),
652 None => note!("{}: {}", key, value.to_string()),
653 }
654 }
655 }
656}
657
658/// Format the `cargo:rustc-link-arg=-T<path>` directive, or `None` when the
659/// caller opted out via `EmbedOptions::emit_cargo_link_arg = false`. Free
660/// function so tests can observe the gating without capturing stdout.
661#[cfg(target_os = "linux")]
662fn link_arg_directive(linker_script_path: &Path, emit: bool) -> Option<String> {
663 if emit {
664 Some(format!(
665 "cargo:rustc-link-arg=-T{}",
666 linker_script_path.display()
667 ))
668 } else {
669 None
670 }
671}
672
673/// Emit `cargo:rerun-if-changed` / `cargo:rerun-if-env-changed` directives
674/// covering the inputs this crate reads.
675///
676/// Cargo's default behavior is "rerun build.rs on any file change in the
677/// crate directory." Emitting *any* `cargo:` directive (we emit
678/// `rustc-link-arg` further down) flips cargo into explicit-only mode,
679/// after which it reruns only when a path/env we name here changes. So we
680/// have to list every input, or builds silently reuse stale stamped metadata.
681///
682/// What we cover:
683/// - `Cargo.toml` - `[package]` version/name + `[package.metadata.module_info]`
684/// - `build.rs` - the caller's build script itself
685/// - `.git/HEAD` + `.git/refs` - so branch switches and new commits retrigger
686/// the git-derived fields (`branch`, `hash`, `repo`)
687/// - `/etc/os-release` - so a distro upgrade retriggers `os` / `osVersion`
688/// - `MODULE_INFO_DEBUG` - the crate's own debug knob
689/// - `CARGO_PKG_*` env vars - Cargo sets these from Cargo.toml so they're
690/// technically redundant with `rerun-if-changed=Cargo.toml`, but listing
691/// them is cheap and removes a foot-gun if a caller ever sets them
692/// externally.
693///
694/// What we *don't* cover: caller-custom env vars (e.g. `BUILD_BUILDNUMBER`
695/// named via `[package.metadata.module_info].version_env_var_name`). The
696/// zero-config path emits those itself in `collect_package_metadata` because
697/// only that path knows the names. Builder-API consumers that read arbitrary
698/// env vars must emit their own `cargo:rerun-if-env-changed=<name>` for
699/// each, the crate can't guess.
700#[cfg(target_os = "linux")]
701fn emit_rerun_if_directives() {
702 // Paths the crate reads during build. Using forward-slash relative paths
703 // makes these valid on all Cargo-supported hosts; the git and
704 // os-release watches silently no-op when the path doesn't exist (e.g.
705 // building a tarballed source tree, or on a non-Linux host).
706 for path in [
707 "Cargo.toml",
708 "build.rs",
709 ".git/HEAD",
710 ".git/refs",
711 ".git/packed-refs",
712 "/etc/os-release",
713 ] {
714 println!("cargo:rerun-if-changed={path}");
715 }
716
717 // Env vars the crate itself reads. Custom ones named in Cargo.toml are
718 // handled in `collect_package_metadata`.
719 for env_var in ["MODULE_INFO_DEBUG", "CARGO_PKG_NAME", "CARGO_PKG_VERSION"] {
720 println!("cargo:rerun-if-env-changed={env_var}");
721 }
722}
723
724/// Zero-configuration build-script entry point.
725///
726/// Reads metadata from `Cargo.toml`, env overrides, git, and OS release info,
727/// then embeds it via [`embed_package_metadata`] with
728/// [`EmbedOptions::default()`]. Reach for [`embed_package_metadata`] directly
729/// when you need to supply metadata programmatically or suppress the
730/// `cargo:rustc-link-arg` directive.
731///
732/// # IMPORTANT
733/// Only call from `build.rs`. Cargo sets `OUT_DIR` and related variables for
734/// build scripts; outside that context the call will fail.
735///
736/// # Example
737/// ```rust,no_run
738/// // In build.rs
739/// fn main() -> Result<(), Box<dyn std::error::Error>> {
740/// module_info::generate_project_metadata_and_linker_script()?;
741/// Ok(())
742/// }
743/// ```
744///
745/// # Errors
746/// Returns an error if metadata generation or file operations fail.
747#[cfg(target_os = "linux")]
748#[must_use = "build.rs must propagate errors from this function, otherwise a missing linker script will silently break the ELF note section"]
749pub fn generate_project_metadata_and_linker_script() -> Result<(), Box<dyn std::error::Error>> {
750 let md = PackageMetadata::from_cargo_toml().map_err(|e| {
751 error!("Failed to get project metadata: {}", e);
752 e
753 })?;
754 // Named binding (not `let _`) so `#[must_use]` keeps firing on future
755 // signature changes; paths below are useful in build logs.
756 let artifacts = embed_package_metadata(&md, &EmbedOptions::default())?;
757 debug!(
758 "Wrote linker script: {}",
759 artifacts.linker_script_path.display()
760 );
761 Ok(())
762}
763
764#[cfg(not(target_os = "linux"))]
765#[must_use = "build.rs must propagate errors from this function, otherwise a missing linker script will silently break the ELF note section"]
766pub fn generate_project_metadata_and_linker_script() -> Result<(), Box<dyn std::error::Error>> {
767 Ok(())
768}
769
770/// Prints all available module info to stdout and returns a result indicating success or failure
771///
772/// This utility function retrieves all embedded module information and
773/// outputs it to the console with labels. It's useful for debugging or displaying
774/// version information in command-line tools.
775///
776/// # Examples
777///
778/// Basic usage with simple error handling:
779/// ```rust,no_run
780/// if module_info::print_module_info().is_ok() {
781/// println!("Module info displayed successfully");
782/// }
783/// ```
784///
785/// Error handling:
786/// ```rust,no_run
787/// use module_info::{print_module_info, ModuleInfoError};
788///
789/// match print_module_info() {
790/// Ok(_) => println!("Module info displayed successfully"),
791/// Err(ModuleInfoError::NotAvailable(msg)) => eprintln!("Module info not available: {}", msg),
792/// Err(e) => eprintln!("Failed to display module info: {}", e),
793/// }
794/// ```
795///
796/// # Errors
797///
798/// This function will return an error in the following situations:
799/// - If any of the seven required identity-plus-platform fields (`binary`,
800/// `version`, `moduleVersion`, `name`, `maintainer`, `os`, `osVersion`) is
801/// missing or empty, suggesting the metadata is missing or corrupted
802/// (returns `ModuleInfoError::NotAvailable`)
803/// - If running on a non-Linux platform where module info isn't supported (returns `ModuleInfoError::NotAvailable`)
804///
805/// # Note
806/// This function is only available when the "embed-module-info" feature is
807/// enabled *and* the target OS is Linux. On other platforms the function
808/// exists as a no-op stub that returns `NotAvailable`, matching the
809/// non-Linux `get_module_info!` macro behavior so cross-platform callers
810/// compile unchanged.
811#[cfg(all(feature = "embed-module-info", target_os = "linux"))]
812#[must_use = "print_module_info returns a Result indicating whether the embedded note section was readable; ignoring it will hide missing-metadata errors"]
813pub fn print_module_info() -> ModuleInfoResult<()> {
814 // Delegate to the `get_module_info!()` macro: it handles the extern-static
815 // declarations, the per-field `extract_module_info` call, platform gating,
816 // and error swallowing for individual fields. On non-Linux it returns
817 // `NotAvailable` directly, which propagates via `?`.
818 let info = get_module_info!()?;
819
820 // Optional fields may legitimately be empty (see "Disabling optional
821 // fields" in docs/GUIDE.md),
822 // so only required keys are checked here.
823 let missing: Vec<&str> = constants::REQUIRED_JSON_KEYS
824 .iter()
825 .filter(|key| info.get(**key).map_or(true, |v| v.is_empty()))
826 .copied()
827 .collect();
828 if !missing.is_empty() {
829 return Err(ModuleInfoError::NotAvailable(format!(
830 "Module info appears to be missing or corrupted: required field(s) missing or empty: {}",
831 missing.join(", ")
832 )));
833 }
834
835 for field in ModuleInfoField::ALL {
836 let key = field.to_key();
837 match info.get(key) {
838 Some(value) => println!("{key}: {value}"),
839 None => println!("{key}: <unavailable>"),
840 }
841 }
842 Ok(())
843}
844
845/// Non-Linux stub: the embedded note section only exists on Linux, so there's
846/// nothing to read. Returns `NotAvailable` with a platform-specific message,
847/// matching the non-Linux `get_module_info!` macro so cross-platform callers
848/// don't need their own `#[cfg]` gate.
849#[cfg(any(not(feature = "embed-module-info"), not(target_os = "linux")))]
850#[must_use = "print_module_info returns a Result indicating whether the embedded note section was readable; ignoring it will hide missing-metadata errors"]
851pub fn print_module_info() -> ModuleInfoResult<()> {
852 Err(ModuleInfoError::NotAvailable(
853 "Module info is only available on Linux platforms with the embed-module-info feature enabled.".to_string(),
854 ))
855}
856
857/// Returns the embedded `version` field (from `Cargo.toml`'s `package.version`
858/// or `version_env_var_name`) as a `String`.
859///
860/// Thin wrapper around `get_module_info!(ModuleInfoField::Version)`. See the
861/// crate-level "Limitations" section for shared-library symbol-resolution
862/// caveats.
863///
864/// # Errors
865///
866/// Returns `ModuleInfoError::NotAvailable` on non-Linux targets or when the
867/// `embed-module-info` feature is not enabled. Returns `NullPointer`,
868/// `Utf8Error`, or `MalformedJson` if the note section is missing or corrupt.
869#[cfg(feature = "embed-module-info")]
870#[must_use = "get_version returns the embedded version string; discarding it hides missing-metadata errors"]
871pub fn get_version() -> ModuleInfoResult<String> {
872 get_module_info!(ModuleInfoField::Version)
873}
874
875/// Returns the embedded `moduleVersion` field (a 4-part identifier typically
876/// produced by the build pipeline; see `module_version_env_var_name` in
877/// `Cargo.toml`'s `[package.metadata.module_info]`).
878///
879/// See [`get_version`] for symbol-resolution and error semantics.
880#[cfg(feature = "embed-module-info")]
881#[must_use = "get_module_version returns the embedded 4-part module version; discarding it hides missing-metadata errors"]
882pub fn get_module_version() -> ModuleInfoResult<String> {
883 get_module_info!(ModuleInfoField::ModuleVersion)
884}
885
886/// Extract a single module-info field from a linker-script-placed symbol.
887///
888/// Reads a JSON string value (`"..."`) starting at `ptr`, terminated by NUL,
889/// and returns the bytes between the first two `"` characters.
890///
891/// Prefer the [`get_module_info!`] macro: it declares the matching extern
892/// static and forwards its address here, so the caller never holds a raw
893/// pointer.
894///
895/// # Safety
896/// `ptr` must point to a valid, properly aligned, null-terminated byte
897/// sequence inside the read-only `.note.package` payload (i.e. the address
898/// of one of the `module_info_*` symbols emitted by the linker script). The
899/// memory must remain valid for the duration of the call. Passing any other
900/// pointer is undefined behavior. The internal scan is bounded by
901/// `MAX_JSON_SIZE + NOTE_ALIGN`, so a missing/corrupted section produces
902/// `MalformedJson` rather than reading off the end.
903///
904/// # Errors
905/// - `ModuleInfoError::NullPointer` if `ptr` is null
906/// - `ModuleInfoError::Utf8Error` if the bytes are not valid UTF-8
907/// - `ModuleInfoError::MalformedJson` if the section is missing/stripped or
908/// the value is not surrounded by `"` characters
909/// - `ModuleInfoError::NotAvailable` on non-Linux targets
910///
911/// # Example
912/// ```rust,no_run
913/// use module_info::{get_module_info, ModuleInfoField, ModuleInfoResult};
914/// let binary: ModuleInfoResult<String> = get_module_info!(ModuleInfoField::Binary);
915/// ```
916///
917/// Available only when the `embed-module-info` feature is enabled on Linux.
918#[cfg(all(feature = "embed-module-info", target_os = "linux"))]
919#[must_use = "extract_module_info returns the parsed field value; discarding it defeats the point of calling it"]
920pub unsafe fn extract_module_info(ptr: *const u8) -> ModuleInfoResult<String> {
921 if ptr.is_null() {
922 return Err(ModuleInfoError::NullPointer);
923 }
924
925 // Single-pass scan: walk forward looking for the opening `"` and then
926 // the closing `"` of the JSON value. Exits as soon as both are found,
927 // so a healthy field read costs O(value_len) rather than walking the
928 // entire `.note.package` payload to the trailing NUL. The cap still
929 // bounds the worst case so a stripped/missing/corrupted section can't
930 // read off the end of the mapped region.
931 //
932 // Why `MAX_JSON_SIZE + NOTE_ALIGN`: pre-refactor the scan went all
933 // the way to NUL (the JSON body up to `MAX_JSON_SIZE` plus the
934 // `1..=NOTE_ALIGN` padding). The new short-circuit only walks to the
935 // closing `"`, but keeping the same upper bound preserves the prior
936 // worst-case safety margin at no correctness cost.
937 const MAX_NOTE_VALUE_LEN: usize = constants::MAX_JSON_SIZE + constants::NOTE_ALIGN;
938
939 // SAFETY: Caller (via `get_module_info!`) passes the address of an
940 // `extern "C" static: u8` placed by the linker script inside the
941 // `.note.package` payload (read-only for program lifetime, never
942 // mutated). The loop is bounded by `MAX_NOTE_VALUE_LEN`, so a
943 // stripped/missing/corrupted section produces an error rather than
944 // walking off the end of the mapped region.
945 let mut open_quote: Option<usize> = None;
946 for i in 0..MAX_NOTE_VALUE_LEN {
947 let byte = unsafe { *ptr.add(i) };
948 if byte == 0 {
949 // NUL inside the value means the section is truncated or
950 // malformed (sanitization strips embedded NULs from every
951 // embedded value at build time). Distinguish "no opening
952 // quote yet" from "opening found, missing closing" so a
953 // stripped/zeroed memory region is easier to triage than a
954 // payload that just lost its trailing `"`.
955 let message = if open_quote.is_none() {
956 "Unexpected NUL before opening quote of JSON value"
957 } else {
958 "Unexpected NUL before closing quote of JSON value"
959 };
960 return Err(ModuleInfoError::MalformedJson(message.to_string()));
961 }
962 if byte == b'"' {
963 match open_quote {
964 None => open_quote = Some(i),
965 Some(open) => {
966 // Found both quotes. Bytes between are the value.
967 // Sanitization strips `"` and `\` from values at embed
968 // time, so a direct slice between the quotes is
969 // sufficient (no JSON escapes to unescape).
970 let len = i - open - 1;
971 let bytes = unsafe { std::slice::from_raw_parts(ptr.add(open + 1), len) };
972 let value = std::str::from_utf8(bytes)?;
973 return Ok(value.to_string());
974 }
975 }
976 }
977 }
978
979 // Cap hit without finding both quotes. Branch on `open_quote` so the
980 // diagnostic distinguishes "no opening quote ever seen" (section
981 // absent, stripped, or zeroed) from "opening found but trailing `"`
982 // missing" (truncation mid-value). Both still imply a build-vs-runtime
983 // mismatch worth surfacing in core dumps, but the cause is different.
984 let detail = if open_quote.is_none() {
985 "no opening quote found"
986 } else {
987 "opening quote found but no closing quote"
988 };
989 Err(ModuleInfoError::MalformedJson(format!(
990 "{detail} within {MAX_NOTE_VALUE_LEN} bytes; \
991 .note.package section is missing, stripped, or corrupted"
992 )))
993}
994
995/// Non-Linux stub of [`extract_module_info`]. Always returns
996/// `ModuleInfoError::NotAvailable`. The ELF `.note.package` section this
997/// reads only exists on Linux, so there's nothing to extract.
998///
999/// # Safety
1000/// No safety requirements on this platform: the pointer is never dereferenced
1001/// (the function returns before touching it). The `unsafe` qualifier is kept
1002/// only so the signature matches the Linux implementation, letting
1003/// cross-platform callers use a single call site.
1004#[cfg(all(feature = "embed-module-info", not(target_os = "linux")))]
1005#[must_use = "extract_module_info returns the parsed field value; discarding it defeats the point of calling it"]
1006pub unsafe fn extract_module_info(_ptr: *const u8) -> ModuleInfoResult<String> {
1007 Err(ModuleInfoError::NotAvailable(
1008 "Extract module info is only available on Linux platforms with embed-module-info feature."
1009 .to_string(),
1010 ))
1011}
1012
1013#[cfg(all(test, target_os = "linux"))]
1014mod tests {
1015 use std::{error::Error, fs::File, io::Read, path::Path};
1016
1017 use tempfile::NamedTempFile;
1018
1019 use super::*;
1020
1021 /// Shorthand for tests that propagate with `?`. `Result<(), Box<dyn Error>>`
1022 /// lets us replace `.expect(...)` with `?` and keeps the test module free
1023 /// of the workspace-wide `clippy::disallowed_methods` ban on `expect`.
1024 type TestResult = Result<(), Box<dyn Error>>;
1025
1026 /// Test-only helper: returns true when `git --version` runs cleanly on
1027 /// the test host. Tests that depend on a real git checkout (branch/hash
1028 /// lookup, repo-name parsing) skip gracefully when this returns false so
1029 /// the suite stays green in stripped-down CI images. Lives inside the
1030 /// tests module rather than in `utils.rs` so `#[cfg(test)]` doesn't have
1031 /// to be scattered across production files.
1032 fn git_is_available() -> bool {
1033 match std::process::Command::new("git")
1034 .arg("--version")
1035 .stdin(std::process::Stdio::null())
1036 .output()
1037 {
1038 Ok(output) => output.status.success(),
1039 Err(_) => false,
1040 }
1041 }
1042
1043 #[cfg(feature = "embed-module-info")]
1044 #[test]
1045 #[allow(clippy::unnecessary_cast)]
1046 fn test_extract_module_info() -> TestResult {
1047 let test_str = "\"test_value\"";
1048 let c_str = std::ffi::CString::new(test_str)?;
1049 let ptr = c_str.as_ptr() as *const u8;
1050 // SAFETY: This is safe because we're creating a valid null-terminated C string
1051 // using std::ffi::CString which guarantees that the pointer is valid and properly
1052 // null-terminated for the duration of this function call
1053 let value = unsafe { extract_module_info(ptr) }?;
1054 assert_eq!(value, "test_value");
1055 Ok(())
1056 }
1057
1058 /// Lock in the early-exit refactor: a value followed by a long
1059 /// non-NUL trailer must still return only the bytes between the
1060 /// quotes. Pre-refactor the function walked all the way to NUL; the
1061 /// new implementation should never read past the closing quote, so
1062 /// the trailer never affects the parsed value.
1063 #[cfg(feature = "embed-module-info")]
1064 #[test]
1065 fn extract_module_info_stops_at_closing_quote() -> TestResult {
1066 // `"hello",\n"version":..." then NUL: a snapshot of how the
1067 // bytes look in a real `.note.package` payload between fields.
1068 let bytes: Vec<u8> = b"\"hello\",\n\"version\":\"1.2.3\"\0".to_vec();
1069 // SAFETY: the vec lives for the duration of the `unsafe` block
1070 // and is NUL-terminated within MAX_NOTE_VALUE_LEN.
1071 let value = unsafe { extract_module_info(bytes.as_ptr()) }?;
1072 assert_eq!(
1073 value, "hello",
1074 "scan must stop at the closing quote, not walk past into the next field"
1075 );
1076 Ok(())
1077 }
1078
1079 /// A NUL byte before any quote (e.g., the section was stripped or
1080 /// the symbol resolved against zeroed memory) must surface as
1081 /// `MalformedJson`, not silently return an empty string.
1082 #[cfg(feature = "embed-module-info")]
1083 #[test]
1084 fn extract_module_info_rejects_leading_nul() {
1085 let bytes: [u8; 4] = [0, 0, 0, 0];
1086 match unsafe { extract_module_info(bytes.as_ptr()) } {
1087 Err(ModuleInfoError::MalformedJson(msg)) => assert!(
1088 msg.contains("NUL"),
1089 "error must mention the NUL trigger: {msg}"
1090 ),
1091 other => panic!("expected MalformedJson(...NUL...), got {other:?}"),
1092 }
1093 }
1094
1095 /// A buffer with an opening quote but no closing quote within the
1096 /// cap should report the cap-hit diagnostic, not a generic "missing
1097 /// quote" error. This is the path that fires when the section was
1098 /// stripped from the binary at link time.
1099 #[cfg(feature = "embed-module-info")]
1100 #[test]
1101 fn extract_module_info_reports_cap_on_runaway_scan() {
1102 // 2 KB of `'a'` bytes (well over MAX_JSON_SIZE = 1024 +
1103 // NOTE_ALIGN = 4) with one opening quote at byte 0 and no
1104 // closing quote anywhere in the cap.
1105 let mut bytes = vec![b'a'; 2048];
1106 bytes[0] = b'"';
1107 match unsafe { extract_module_info(bytes.as_ptr()) } {
1108 Err(ModuleInfoError::MalformedJson(msg)) => assert!(
1109 msg.contains("missing, stripped, or corrupted"),
1110 "cap-hit error must keep the diagnostic phrasing: {msg}"
1111 ),
1112 other => panic!("expected MalformedJson(...corrupted...), got {other:?}"),
1113 }
1114 }
1115
1116 #[test]
1117 fn test_align_len() {
1118 assert_eq!(utils::align_len(5, NOTE_ALIGN), 8);
1119 assert_eq!(utils::align_len(8, NOTE_ALIGN), 8);
1120 assert_eq!(utils::align_len(9, NOTE_ALIGN), 12);
1121 }
1122
1123 /// Locks in the saturating-overflow contract for `align_len`: when
1124 /// `len + (align - 1)` would overflow `u32`, the function must
1125 /// saturate to `u32::MAX` (so downstream size checks notice) rather
1126 /// than wrap to a value below `len` (which the older naive
1127 /// implementation did, silently corrupting the `.note.package`
1128 /// layout). Without this test the `None => u32::MAX` arm is dead
1129 /// code per llvm-cov.
1130 #[test]
1131 fn align_len_saturates_on_u32_overflow() {
1132 // u32::MAX + 3 (mask for align=4) overflows; must saturate.
1133 assert_eq!(utils::align_len(u32::MAX, 4), u32::MAX);
1134 // u32::MAX is already aligned to 1, so the add doesn't overflow
1135 // there; pick a value where the carry actually fires.
1136 assert_eq!(utils::align_len(u32::MAX - 1, 4), u32::MAX);
1137 }
1138
1139 /// `NoteSection::new` rejects any owner string whose name+NUL
1140 /// length is below 4 bytes. The check is a guard against a swapped
1141 /// or empty owner accidentally producing a malformed note in
1142 /// release-mode build scripts (where `debug_assert!` would no-op).
1143 /// Without this test the `n_namesz < 4` arm is dead code per
1144 /// llvm-cov.
1145 #[test]
1146 fn note_section_rejects_short_owner() {
1147 use crate::note_section::NoteSection;
1148 // Empty owner: namesz = 0 + 1 (NUL) = 1, < 4.
1149 // `NoteSection` doesn't impl `Debug`, so `.expect_err(...)` is
1150 // unavailable and we have to match explicitly.
1151 match NoteSection::new(N_TYPE, "", "desc", "", NOTE_ALIGN) {
1152 Err(ModuleInfoError::Other(boxed)) => assert!(
1153 boxed.to_string().contains("n_namesz"),
1154 "diagnostic must name the field: {boxed}"
1155 ),
1156 Err(other) => panic!("expected Other(...n_namesz...), got {other:?}"),
1157 Ok(_) => panic!("empty owner must be rejected"),
1158 }
1159 // Two-byte owner: namesz = 2 + 1 = 3, still < 4.
1160 match NoteSection::new(N_TYPE, "AB", "desc", "", NOTE_ALIGN) {
1161 Err(ModuleInfoError::Other(_)) => {}
1162 Err(other) => panic!("expected Other(_), got {other:?}"),
1163 Ok(_) => panic!("two-byte owner must be rejected"),
1164 }
1165 }
1166
1167 /// `validate_embedded_json` rejects payloads larger than
1168 /// `MAX_JSON_SIZE`. The cap exists because the `.note.package`
1169 /// payload limit is documented as 1 KiB; without this test the
1170 /// `MetadataTooLarge` arm of `embed_package_metadata`'s call to
1171 /// `validate_embedded_json` is dead code per llvm-cov.
1172 #[test]
1173 fn validate_embedded_json_rejects_oversized_payload() {
1174 // Build a JSON payload over MAX_JSON_SIZE by stuffing a single
1175 // string field. The shape doesn't have to be valid metadata;
1176 // the size check fires first.
1177 let big_value = "x".repeat(constants::MAX_JSON_SIZE + 16);
1178 let json = format!(r#"{{"binary":"{big_value}"}}"#);
1179 let err = validate_embedded_json(&json)
1180 .expect_err("payloads over MAX_JSON_SIZE must be rejected");
1181 match err {
1182 ModuleInfoError::MetadataTooLarge(msg) => assert!(
1183 msg.contains("exceeds limit"),
1184 "diagnostic must mention the cap: {msg}"
1185 ),
1186 other => panic!("expected MetadataTooLarge, got {other:?}"),
1187 }
1188 }
1189
1190 /// `validate_embedded_json` rejects non-object JSON shapes. The
1191 /// `is_object()` branch fires for arrays / scalars / etc.; without
1192 /// a focused test this arm is uncovered per llvm-cov even though
1193 /// the runtime risk (someone hand-crafting a bad payload through
1194 /// the builder API) is real.
1195 #[test]
1196 fn validate_embedded_json_rejects_non_object_shapes() {
1197 for bad in ["[]", "null", "42", r#""string""#] {
1198 let err = validate_embedded_json(bad).expect_err("non-object JSON must be rejected");
1199 assert!(
1200 matches!(err, ModuleInfoError::MalformedJson(_)),
1201 "expected MalformedJson for {bad:?}"
1202 );
1203 }
1204 }
1205
1206 /// `module_info::new(Info { … })` is the one-call entry point. The
1207 /// existing `info_embed_round_trip_writes_artifacts` test goes
1208 /// directly through `embed_package_metadata` instead of through
1209 /// `new`, so the `new` body itself stays uncovered. Exercise it
1210 /// here. The function reads `OUT_DIR` (because `EmbedOptions`
1211 /// defaults to `out_dir = None`), so set a temp directory before
1212 /// the call and restore the prior value after.
1213 ///
1214 /// This is the *only* test that touches the process-global env;
1215 /// keeping it self-contained avoids racing the rest of the suite,
1216 /// which uses explicit `out_dir` overrides.
1217 #[cfg(feature = "embed-module-info")]
1218 #[test]
1219 fn new_one_call_entry_point_writes_artifacts() -> TestResult {
1220 use std::sync::Mutex;
1221 // Single global lock around `OUT_DIR` mutation: every test that
1222 // touches process-global env must serialize, otherwise parallel
1223 // test execution will see stale values. We're the only mutator
1224 // today, but the lock makes that contract explicit.
1225 static ENV_LOCK: Mutex<()> = Mutex::new(());
1226 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1227
1228 let tmp = tempfile::tempdir()?;
1229 let prior = std::env::var_os("OUT_DIR");
1230 // SAFETY: `set_var`/`remove_var` are `unsafe` on Rust 1.80+ but
1231 // safe on the MSRV (1.74). Use the safe API; CI's stable cell
1232 // will warn but not fail because the deprecation is `unsafe`,
1233 // not a compile error.
1234 std::env::set_var("OUT_DIR", tmp.path());
1235 let result = new(Info {
1236 binary: "one_call_test".into(),
1237 name: "one_call_test".into(),
1238 version: "1.0.0".into(),
1239 moduleVersion: "1.0.0.0".into(),
1240 maintainer: "team@contoso.com".into(),
1241 os: "linux".into(),
1242 osVersion: "test".into(),
1243 ..Default::default()
1244 });
1245 match prior {
1246 Some(p) => std::env::set_var("OUT_DIR", p),
1247 None => std::env::remove_var("OUT_DIR"),
1248 }
1249
1250 let artifacts = result?;
1251 // The artifacts must land under the OUT_DIR we set.
1252 assert!(artifacts.linker_script_path.starts_with(tmp.path()));
1253 assert!(artifacts.json_path.exists());
1254 let parsed: serde_json::Value = serde_json::from_str(&artifacts.json)?;
1255 assert_eq!(parsed["binary"], "one_call_test");
1256 Ok(())
1257 }
1258
1259 #[test]
1260 fn test_get_distro_info() -> TestResult {
1261 use crate::utils::get_distro_info;
1262 let distro_info = get_distro_info()?;
1263 assert!(!distro_info.0.is_empty());
1264 assert!(!distro_info.1.is_empty());
1265 Ok(())
1266 }
1267
1268 /// The binary note section assembled by `NoteSection::new` must be 4-byte
1269 /// aligned in total length. The ELF spec requires it, and a misaligned
1270 /// section silently corrupts subsequent note entries. `NoteSection`
1271 /// handles this via `align_len` on the owner and desc blocks. This test
1272 /// exercises desc lengths that stress every residue class mod 4 so a
1273 /// future refactor that drops the
1274 /// alignment padding on one of the blocks is caught immediately.
1275 #[test]
1276 fn note_section_is_4byte_aligned_for_every_residue() {
1277 use crate::note_section::NoteSection;
1278 for desc_len in [0usize, 1, 2, 3, 4, 5, 7, 8, 17, 100, 1023] {
1279 let desc = "x".repeat(desc_len);
1280 let note = match NoteSection::new(N_TYPE, OWNER, &desc, "", NOTE_ALIGN) {
1281 Ok(n) => n,
1282 Err(e) => panic!("NoteSection::new failed for desc_len={desc_len}: {e}"),
1283 };
1284 assert_eq!(
1285 note.note_section.len() % NOTE_ALIGN,
1286 0,
1287 "note section must be 4-byte aligned (desc_len={desc_len}, got {})",
1288 note.note_section.len()
1289 );
1290 }
1291 }
1292
1293 #[test]
1294 fn test_project_metadata() {
1295 if !git_is_available() {
1296 println!("Skipping test_project_metadata because git cli is not available");
1297 return;
1298 }
1299
1300 use crate::metadata::project_metadata;
1301 let result = project_metadata();
1302
1303 assert!(
1304 result.is_ok(),
1305 "Project metadata should be created successfully: {:?}",
1306 result.err()
1307 );
1308
1309 if let Ok(res) = result {
1310 let metadata = res.0;
1311 assert!(
1312 metadata.contains("\"binary\":"),
1313 "JSON should contain binary field"
1314 );
1315 assert!(
1316 metadata.contains("\"moduleVersion\":"),
1317 "JSON should contain moduleVersion field"
1318 );
1319 assert!(
1320 metadata.contains("\"version\":"),
1321 "JSON should contain version field"
1322 );
1323 assert!(
1324 metadata.contains("\"maintainer\":"),
1325 "JSON should contain maintainer field"
1326 );
1327 assert!(
1328 metadata.contains("\"name\":"),
1329 "JSON should contain name field"
1330 );
1331 assert!(
1332 metadata.contains("\"type\":"),
1333 "JSON should contain type field"
1334 );
1335
1336 assert!(
1337 metadata.contains("\"repo\":") || metadata.contains("\"Unknown\""),
1338 "JSON should contain repo field or fallback"
1339 );
1340 assert!(
1341 metadata.contains("\"branch\":")
1342 || metadata.contains("\"main\"")
1343 || metadata.contains("\"unknown\""),
1344 "JSON should contain branch field or fallback"
1345 );
1346 assert!(
1347 metadata.contains("\"hash\":") || metadata.contains("\"unknown\""),
1348 "JSON should contain hash field or fallback"
1349 );
1350
1351 // Other required fields
1352 assert!(
1353 metadata.contains("\"copyright\":"),
1354 "JSON should contain copyright field"
1355 );
1356 assert!(metadata.contains("\"os\":"), "JSON should contain os field");
1357 assert!(
1358 metadata.contains("\"osVersion\":"),
1359 "JSON should contain osVersion field"
1360 );
1361 }
1362 }
1363
1364 /// Exercises the production Cargo.toml-reading path end-to-end against
1365 /// this crate's own manifest. The assertions are intentionally fork-safe:
1366 /// an external fork may change `copyright` (and must), but the contract
1367 /// that Cargo.toml values round-trip through `from_cargo_toml` and
1368 /// populate the expected fields stays fixed.
1369 #[test]
1370 fn test_package_metadata_from_cargo_toml() -> TestResult {
1371 let md = PackageMetadata::from_cargo_toml()?;
1372
1373 assert_eq!(md.name, "module-info");
1374 assert_eq!(md.binary, "module-info");
1375
1376 // Version is formatted to 3 numeric parts by `format_version_parts`.
1377 let parts: Vec<&str> = md.version.split('.').collect();
1378 assert_eq!(
1379 parts.len(),
1380 3,
1381 "version should have three dot-separated parts, got {:?}",
1382 md.version
1383 );
1384 for part in &parts {
1385 assert!(
1386 part.chars().all(|c| c.is_ascii_digit()),
1387 "version part {part:?} must be numeric"
1388 );
1389 }
1390
1391 // `copyright` comes from `[package.metadata.module_info].copyright`
1392 // in this crate's own Cargo.toml. Forks will legitimately set their
1393 // own value, so the contract we lock in is "non-empty and not the
1394 // `Unknown` fallback that triggers when the key is missing",
1395 // nothing organization-specific.
1396 assert!(
1397 !md.copyright.is_empty() && md.copyright != "Unknown",
1398 "copyright must come from Cargo.toml, not the Unknown fallback; got {:?}",
1399 md.copyright
1400 );
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn test_get_git_info() -> TestResult {
1406 if !git_is_available() {
1407 println!("Skipping test_get_git_info because git is not available");
1408 return Ok(());
1409 }
1410
1411 use crate::utils::get_git_info;
1412 let git_info = get_git_info()?;
1413
1414 // Just verify we get back something for the repo name
1415 // Don't assert exact values since they can change
1416 // Verify we get back non-empty values
1417 assert!(!git_info.0.is_empty(), "Branch name should not be empty"); // branch
1418 assert!(!git_info.1.is_empty(), "Commit hash should not be empty"); // hash
1419 assert!(
1420 !git_info.2.is_empty(),
1421 "Repository name should not be empty"
1422 ); // repo name
1423
1424 // In a git repo this returns the parsed remote name; outside one
1425 // (e.g. testing from a published tarball) it falls back to the
1426 // "unknown" sentinel. Either is valid here.
1427 assert!(git_info.2 == "unknown" || !git_info.2.is_empty());
1428
1429 println!(
1430 "Git Info - Branch: {}, Hash: {}, Repo: {}",
1431 git_info.0, git_info.1, git_info.2
1432 );
1433 Ok(())
1434 }
1435
1436 #[test]
1437 fn test_json_key_value_parse() -> TestResult {
1438 let json_input = r#"{
1439"binary": "sample_crashing_process",
1440"moduleVersion": "0.1.0.0",
1441"version": "0.1.0",
1442"maintainer": "Maintainer contact/UUID etc",
1443"name": "sample_crashing_process",
1444"type": "agent",
1445"repo": "Module_Info",
1446"branch": "main",
1447"hash": "76930c41aa16e31bb1e565b12c4285cde1939af3",
1448"copyright": "Microsoft",
1449"os": "Ubuntu",
1450"osVersion": "20.04"
1451}
1452"#;
1453
1454 let parsed: serde_json::Value = serde_json::from_str(json_input)?;
1455 assert_eq!(parsed["binary"], "sample_crashing_process");
1456 assert_eq!(parsed["moduleVersion"], "0.1.0.0");
1457 assert_eq!(parsed["version"], "0.1.0");
1458 assert_eq!(parsed["maintainer"], "Maintainer contact/UUID etc");
1459 assert_eq!(parsed["name"], "sample_crashing_process");
1460 assert_eq!(parsed["type"], "agent");
1461 assert_eq!(parsed["repo"], "Module_Info");
1462 assert_eq!(parsed["branch"], "main");
1463 assert_eq!(parsed["hash"], "76930c41aa16e31bb1e565b12c4285cde1939af3");
1464 assert_eq!(parsed["copyright"], "Microsoft");
1465 assert_eq!(parsed["os"], "Ubuntu");
1466 assert_eq!(parsed["osVersion"], "20.04");
1467 Ok(())
1468 }
1469
1470 #[test]
1471 fn test_get_project_path() {
1472 use crate::utils::get_project_path;
1473 let project_path = get_project_path();
1474 assert!(project_path.exists());
1475 }
1476
1477 #[test]
1478 fn test_get_cargo_toml_content() -> TestResult {
1479 use crate::utils::get_cargo_toml_content;
1480 let cargo_toml = get_cargo_toml_content()?;
1481 assert!(cargo_toml.get("package").is_some());
1482 Ok(())
1483 }
1484
1485 #[test]
1486 fn test_save_section() -> TestResult {
1487 // Create a temporary file
1488 let temp_file = NamedTempFile::new()?;
1489 let file_path = temp_file.path().to_path_buf();
1490
1491 // Create sample section data
1492 let desc_json = r#"{"binary":"test","version":"1.0.0"}"#;
1493 let linker_script_body = "BYTE(0x01); BYTE(0x02);";
1494
1495 // Create a note section
1496 use crate::note_section::NoteSection;
1497 let note = NoteSection::new(N_TYPE, OWNER, desc_json, linker_script_body, NOTE_ALIGN)?;
1498
1499 // Save the section to the temporary file
1500 note.save_section(&file_path)?;
1501
1502 // Read the file back
1503 let mut file = File::open(&file_path)?;
1504 let mut buffer = Vec::new();
1505 file.read_to_end(&mut buffer)?;
1506
1507 // Verify the content
1508 assert!(!buffer.is_empty());
1509 assert_eq!(buffer.len(), note.note_section.len());
1510 assert_eq!(buffer, note.note_section);
1511
1512 // Check that the file contains expected ELF note header values
1513 // The first 12 bytes should be the ELF note header (n_namesz, n_descsz, n_type)
1514 assert!(buffer.len() >= 12);
1515
1516 // Check for the owner string "FDO" followed by null terminator
1517 let owner_offset = 12; // After the header
1518 let owner_bytes = OWNER.as_bytes();
1519 let owner_slice = buffer
1520 .get(owner_offset..owner_offset + owner_bytes.len())
1521 .ok_or("owner slice is out of bounds")?;
1522 assert_eq!(owner_slice, owner_bytes);
1523
1524 // Ensure the N_TYPE value is present in the header (little endian)
1525 let n_type_bytes = N_TYPE.to_le_bytes();
1526 let n_type_slice = buffer.get(8..12).ok_or("n_type slice is out of bounds")?;
1527 assert_eq!(n_type_slice, &n_type_bytes);
1528 Ok(())
1529 }
1530
1531 /// `PackageMetadata` is public and implements `Default` so callers can
1532 /// use `..Default::default()` in struct-literal construction. This is
1533 /// the forward-compatible pattern recommended for build.rs consumers
1534 /// that supply metadata programmatically.
1535 #[test]
1536 fn test_package_metadata_default_construction() {
1537 let md = PackageMetadata {
1538 binary: "my_tool".into(),
1539 name: "my_tool".into(),
1540 version: "1.2.3".into(),
1541 module_version: "1.2.3.4".into(),
1542 maintainer: "team@contoso.com".into(),
1543 hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
1544 ..Default::default()
1545 };
1546
1547 // Fields we set round-trip.
1548 assert_eq!(md.binary, "my_tool");
1549 assert_eq!(md.version, "1.2.3");
1550 assert_eq!(md.module_version, "1.2.3.4");
1551 // Fields we didn't set come from `Default`: empty strings.
1552 assert_eq!(md.module_type, "");
1553 assert_eq!(md.repo, "");
1554 assert_eq!(md.os, "");
1555 }
1556
1557 /// `embed_package_metadata` with a caller-supplied `out_dir` and
1558 /// `emit_cargo_link_arg = false` must write all three artifacts
1559 /// (linker script, note bin, JSON) into the specified directory.
1560 /// This is the static-library flow: the outer build system handles
1561 /// the final link, so we write artifacts to a known location and
1562 /// skip the `cargo:rustc-link-arg` directive.
1563 #[cfg(feature = "embed-module-info")]
1564 #[test]
1565 fn test_embed_package_metadata_custom_out_dir_no_link_arg() -> TestResult {
1566 let tmp = tempfile::tempdir()?;
1567 let md = PackageMetadata {
1568 binary: "test_binary".into(),
1569 name: "test_binary".into(),
1570 version: "1.2.3".into(),
1571 module_version: "1.2.3.4".into(),
1572 maintainer: "team@contoso.com".into(),
1573 hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
1574 module_type: "agent".into(),
1575 repo: "test_repo".into(),
1576 branch: "main".into(),
1577 copyright: "Test".into(),
1578 os: "Ubuntu".into(),
1579 os_version: "22.04".into(),
1580 ..Default::default()
1581 };
1582
1583 let opts = EmbedOptions {
1584 out_dir: Some(tmp.path().to_path_buf()),
1585 emit_cargo_link_arg: false,
1586 ..Default::default()
1587 };
1588
1589 let artifacts = embed_package_metadata(&md, &opts)?;
1590
1591 // All three artifact paths must live under the custom out_dir.
1592 assert!(artifacts.linker_script_path.starts_with(tmp.path()));
1593 assert!(artifacts.note_bin_path.starts_with(tmp.path()));
1594 assert!(artifacts.json_path.starts_with(tmp.path()));
1595
1596 // And the files must actually exist on disk.
1597 assert!(artifacts.linker_script_path.exists());
1598 assert!(artifacts.note_bin_path.exists());
1599 assert!(artifacts.json_path.exists());
1600
1601 // And the returned JSON is parseable and contains the supplied values.
1602 let parsed: serde_json::Value = serde_json::from_str(&artifacts.json)?;
1603 assert_eq!(parsed["binary"], "test_binary");
1604 assert_eq!(parsed["version"], "1.2.3");
1605 assert_eq!(parsed["moduleVersion"], "1.2.3.4");
1606 Ok(())
1607 }
1608
1609 /// `embed_package_metadata` must reject `PackageMetadata` values whose
1610 /// serialized JSON lacks a required field. Required fields are a safety
1611 /// guardrail so consumers do not accidentally emit a note section that
1612 /// `print_module_info` / `get_module_info!` cannot parse. Since
1613 /// `PackageMetadata` always serializes every field, "missing" in practice
1614 /// means "empty string". Leave a required field as `Default::default()`
1615 /// and the validator must reject it.
1616 #[cfg(feature = "embed-module-info")]
1617 #[test]
1618 fn test_embed_package_metadata_rejects_empty_required_field() -> TestResult {
1619 let tmp = tempfile::tempdir()?;
1620 // `osVersion` is required; leaving it at `Default::default()` ("")
1621 // exercises the validator's empty-string rejection path, and its
1622 // `#[serde(rename = "osVersion")]` mapping is the same one the runtime
1623 // map consumers see.
1624 let md = PackageMetadata {
1625 binary: "b".into(),
1626 name: "n".into(),
1627 version: "1.0.0".into(),
1628 module_version: "1.0.0.0".into(),
1629 maintainer: "m".into(),
1630 os: "linux".into(),
1631 // os_version omitted on purpose; `..Default::default()` gives "".
1632 ..Default::default()
1633 };
1634 let opts = EmbedOptions {
1635 out_dir: Some(tmp.path().to_path_buf()),
1636 emit_cargo_link_arg: false,
1637 ..Default::default()
1638 };
1639 let err = embed_package_metadata(&md, &opts)
1640 .expect_err("embed must reject PackageMetadata with empty required field");
1641 match err {
1642 ModuleInfoError::MalformedJson(msg) => {
1643 assert!(
1644 msg.contains("osVersion"),
1645 "error must name the empty required field: {msg}"
1646 );
1647 }
1648 other => panic!("expected MalformedJson, got {other:?}"),
1649 }
1650 Ok(())
1651 }
1652
1653 /// Direct test for the required-field guardrail: feed JSON missing a
1654 /// required field and confirm it's rejected with a `MalformedJson` error.
1655 #[test]
1656 fn test_validate_embedded_json_rejects_missing_required_fields() {
1657 // Missing "maintainer" (one of the seven required identity-plus-
1658 // platform keys that stays required even when optional fields like
1659 // `hash`/`repo`/`branch` are deliberately left empty).
1660 let bad_json = r#"{"binary":"b","version":"1.0.0","moduleVersion":"1.0.0.0","name":"n"}"#;
1661 let err =
1662 validate_embedded_json(bad_json).expect_err("missing required field must be rejected");
1663 match err {
1664 ModuleInfoError::MalformedJson(msg) => {
1665 assert!(
1666 msg.contains("maintainer"),
1667 "error must name the missing field: {msg}"
1668 );
1669 }
1670 other => panic!("expected MalformedJson, got {other:?}"),
1671 }
1672 }
1673
1674 /// Direct test for the empty-string half of the required-field guardrail.
1675 /// `PackageMetadata::default()` fields serialize as `""`; we treat that
1676 /// as "missing" too for the required identity keys, so consumers can't
1677 /// silently ship a note section with an empty `binary` or `maintainer`.
1678 /// Non-required fields (hash/repo/branch/type/copyright) are *allowed*
1679 /// to be empty; that's the documented "disable" knob.
1680 #[test]
1681 fn test_validate_embedded_json_rejects_empty_required_fields() {
1682 // "maintainer" present but empty.
1683 let bad_json = r#"{"binary":"b","version":"1.0.0","moduleVersion":"1.0.0.0","name":"n","maintainer":""}"#;
1684 let err =
1685 validate_embedded_json(bad_json).expect_err("empty required field must be rejected");
1686 match err {
1687 ModuleInfoError::MalformedJson(msg) => {
1688 assert!(
1689 msg.contains("maintainer"),
1690 "error must name the empty field: {msg}"
1691 );
1692 }
1693 other => panic!("expected MalformedJson, got {other:?}"),
1694 }
1695 }
1696
1697 /// Complement to the rejection tests: a payload that supplies the five
1698 /// required identity keys but leaves every optional field empty must
1699 /// pass validation. This pins the "disabled field = empty string"
1700 /// contract against accidental regressions (e.g., re-adding `hash` to
1701 /// `REQUIRED_JSON_KEYS`).
1702 #[test]
1703 fn test_validate_embedded_json_accepts_empty_optional_fields() {
1704 let ok_json = r#"{"binary":"b","version":"1.0.0","moduleVersion":"1.0.0.0","name":"n","maintainer":"m","type":"","repo":"","branch":"","hash":"","copyright":"","os":"linux","osVersion":"1"}"#;
1705 if let Err(e) = validate_embedded_json(ok_json) {
1706 panic!("optional fields may be empty; only the identity keys are required. got {e:?}");
1707 }
1708 }
1709
1710 /// `EmbedOptions::default()` pins the zero-config behavior:
1711 /// `out_dir = None` (use `$OUT_DIR`) and `emit_cargo_link_arg = true` so
1712 /// plain build.rs consumers don't have to set any options.
1713 #[test]
1714 fn test_embed_options_default_preserves_bc_behavior() {
1715 let opts = EmbedOptions::default();
1716 assert!(opts.out_dir.is_none());
1717 assert!(opts.emit_cargo_link_arg);
1718 }
1719
1720 /// The linker script body must always carry at least one `BYTE(0x00);`
1721 /// NUL terminator, regardless of the JSON byte-length mod 4. Without it,
1722 /// `extract_module_info` at runtime would scan past the end of
1723 /// `.note.package` looking for the sentinel: harmless in practice
1724 /// (read-only mapped memory) but a latent SIGSEGV risk when the section
1725 /// sits at a segment boundary. This test constructs a `PackageMetadata`
1726 /// specifically shaped so the total payload byte-count is a multiple
1727 /// of 4, which is the tricky case the original `padding_needed = (... % 4)`
1728 /// formula got wrong (it computed 0 and emitted no padding).
1729 #[test]
1730 fn render_note_payloads_always_emits_nul_padding() -> TestResult {
1731 // Any well-formed PackageMetadata works; we just need the payload.
1732 // 4-aligned input isn't easy to construct deliberately since the
1733 // JSON shape mixes fixed keys with variable values, so we assert
1734 // the stronger "always emits NUL padding" invariant across every
1735 // permutation of field lengths we can reach with a 2-character probe.
1736 for suffix_len in 0..=4 {
1737 let suffix = "x".repeat(suffix_len);
1738 let md = PackageMetadata {
1739 binary: format!("b{suffix}"),
1740 name: format!("n{suffix}"),
1741 version: "1.0.0".into(),
1742 module_version: "1.0.0.0".into(),
1743 maintainer: "m".into(),
1744 os: "linux".into(),
1745 os_version: "22.04".into(),
1746 ..Default::default()
1747 };
1748 let (_json, linker_script_body) = crate::metadata::render_note_payloads(&md)?;
1749 assert!(
1750 linker_script_body.contains("BYTE(0x00);"),
1751 "linker script must contain a BYTE(0x00) even when the payload is 4-aligned (suffix_len={suffix_len})"
1752 );
1753 }
1754 Ok(())
1755 }
1756
1757 /// `link_arg_directive` is the single branch that decides whether
1758 /// `cargo:rustc-link-arg=-T<path>` is emitted. Asserting both arms here
1759 /// locks in the "emit_cargo_link_arg=false means no directive" contract
1760 /// that static-library flows depend on.
1761 #[test]
1762 fn link_arg_directive_gates_on_flag() {
1763 let p = Path::new("/tmp/linker_script.ld");
1764 match link_arg_directive(p, true) {
1765 Some(d) => assert_eq!(d, "cargo:rustc-link-arg=-T/tmp/linker_script.ld"),
1766 None => panic!("emit_cargo_link_arg=true must produce a directive"),
1767 }
1768 assert!(
1769 link_arg_directive(p, false).is_none(),
1770 "emit_cargo_link_arg=false must suppress the directive"
1771 );
1772 }
1773
1774 /// Drift guard: every key in `REQUIRED_JSON_KEYS` must appear in
1775 /// `ModuleInfoField::ALL.to_key()`. If someone adds a required field
1776 /// without extending the enum (or vice versa), this test fails before
1777 /// the divergence reaches a consumer.
1778 #[test]
1779 fn required_keys_are_subset_of_module_info_fields() {
1780 let known: std::collections::HashSet<&str> =
1781 ModuleInfoField::ALL.iter().map(|f| f.to_key()).collect();
1782 for key in constants::REQUIRED_JSON_KEYS {
1783 assert!(
1784 known.contains(key),
1785 "REQUIRED_JSON_KEYS contains {key:?} which is not in ModuleInfoField::ALL"
1786 );
1787 }
1788 }
1789
1790 /// `Info` must be constructible from a struct literal (that's the whole
1791 /// point of the type), and `From<Info> for PackageMetadata` must carry
1792 /// every field across with the JSON-key-shaped name on the `Info` side and
1793 /// the snake_case name on the `PackageMetadata` side.
1794 #[test]
1795 fn info_struct_literal_and_conversion() {
1796 let info = Info {
1797 binary: "b".into(),
1798 version: "1.2.3".into(),
1799 moduleVersion: "1.2.3.4".into(),
1800 maintainer: "m".into(),
1801 name: "n".into(),
1802 r#type: "agent".into(),
1803 repo: "r".into(),
1804 branch: "br".into(),
1805 hash: "h".into(),
1806 copyright: "c".into(),
1807 os: "o".into(),
1808 osVersion: "ov".into(),
1809 };
1810 let md: PackageMetadata = info.into();
1811 assert_eq!(md.binary, "b");
1812 assert_eq!(md.version, "1.2.3");
1813 assert_eq!(md.module_version, "1.2.3.4");
1814 assert_eq!(md.maintainer, "m");
1815 assert_eq!(md.name, "n");
1816 assert_eq!(md.module_type, "agent");
1817 assert_eq!(md.repo, "r");
1818 assert_eq!(md.branch, "br");
1819 assert_eq!(md.hash, "h");
1820 assert_eq!(md.copyright, "c");
1821 assert_eq!(md.os, "o");
1822 assert_eq!(md.os_version, "ov");
1823 }
1824
1825 /// `Info::default()` plus `..Default::default()` struct-literal syntax is
1826 /// the forward-compatible pattern consumers should use. Unlike
1827 /// `PackageMetadata`, `Info` is intentionally not `#[non_exhaustive]`, so
1828 /// both full struct literals and `..Default::default()` must compile and
1829 /// produce empty strings for unassigned fields.
1830 #[test]
1831 fn info_default_fills_missing_fields_with_empty_strings() {
1832 let info = Info {
1833 binary: "b".into(),
1834 moduleVersion: "1.2.3.4".into(),
1835 ..Default::default()
1836 };
1837 assert_eq!(info.binary, "b");
1838 assert_eq!(info.moduleVersion, "1.2.3.4");
1839 assert_eq!(info.version, "");
1840 assert_eq!(info.r#type, "");
1841 assert_eq!(info.osVersion, "");
1842 }
1843
1844 /// `Info` → `PackageMetadata` → `embed_package_metadata` is the path
1845 /// `new(Info { .. })` takes internally (`new` is just two lines: convert
1846 /// and dispatch). Exercise it end-to-end with an explicit `out_dir` so
1847 /// the test doesn't have to mutate `OUT_DIR` on the shared process
1848 /// environment: `std::env::set_var` is `unsafe fn` on Rust 1.80+ and
1849 /// racy when tests run in parallel. The actual `new` function is so
1850 /// thin that the conversion test and this embed test together cover
1851 /// everything it does.
1852 #[cfg(feature = "embed-module-info")]
1853 #[test]
1854 fn info_embed_round_trip_writes_artifacts() -> TestResult {
1855 let tmp = tempfile::tempdir()?;
1856 let md: PackageMetadata = Info {
1857 binary: "b".into(),
1858 name: "n".into(),
1859 version: "1.2.3".into(),
1860 moduleVersion: "1.2.3.4".into(),
1861 maintainer: "m".into(),
1862 r#type: "agent".into(),
1863 hash: "deadbeef".into(),
1864 os: "linux".into(),
1865 osVersion: "22.04".into(),
1866 ..Default::default()
1867 }
1868 .into();
1869
1870 let opts = EmbedOptions {
1871 out_dir: Some(tmp.path().to_path_buf()),
1872 emit_cargo_link_arg: false,
1873 ..Default::default()
1874 };
1875 let artifacts = embed_package_metadata(&md, &opts)?;
1876
1877 assert!(artifacts.linker_script_path.starts_with(tmp.path()));
1878 assert!(artifacts.json_path.exists());
1879 let parsed: serde_json::Value = serde_json::from_str(&artifacts.json)?;
1880 assert_eq!(parsed["moduleVersion"], "1.2.3.4");
1881 assert_eq!(parsed["type"], "agent");
1882 Ok(())
1883 }
1884
1885 /// `validate_module_version` accepts the full u16 range on every part.
1886 #[test]
1887 fn validate_module_version_accepts_valid_values() -> TestResult {
1888 for v in ["0.0.0.0", "1.2.3.4", "65535.65535.65535.65535", "10.0.0.1"] {
1889 validate_module_version(v)?;
1890 }
1891 Ok(())
1892 }
1893
1894 /// Wrong number of dot-separated parts must fail loudly, not silently
1895 /// pad or truncate.
1896 #[test]
1897 fn validate_module_version_rejects_wrong_part_count() {
1898 for v in ["", "1", "1.2", "1.2.3", "1.2.3.4.5"] {
1899 let err = validate_module_version(v).expect_err("wrong part count must be rejected");
1900 match err {
1901 ModuleInfoError::MalformedJson(msg) => {
1902 assert!(
1903 msg.contains("exactly 4"),
1904 "error must explain the 4-part rule: {msg}"
1905 );
1906 }
1907 other => panic!("expected MalformedJson, got {other:?}"),
1908 }
1909 }
1910 }
1911
1912 /// A u16 overflows at 65536, and consumers parsing the 4-WORD
1913 /// VS_FIXEDFILEINFO shape would truncate, so reject at embed time.
1914 #[test]
1915 fn validate_module_version_rejects_overflow() {
1916 // 65536 = u16::MAX + 1, on each of the four positions.
1917 for v in [
1918 "65536.0.0.0",
1919 "0.65536.0.0",
1920 "0.0.65536.0",
1921 "0.0.0.65536",
1922 "99999.1.2.3",
1923 ] {
1924 let err = validate_module_version(v).expect_err("u16 overflow must be rejected");
1925 match err {
1926 ModuleInfoError::MalformedJson(msg) => {
1927 assert!(
1928 msg.contains("16 bits"),
1929 "error must mention the u16 constraint: {msg}"
1930 );
1931 }
1932 other => panic!("expected MalformedJson, got {other:?}"),
1933 }
1934 }
1935 }
1936
1937 /// Negative numbers and non-numeric text never fit a u16, and would
1938 /// silently turn into `0` under lossy casts, so reject them up front.
1939 #[test]
1940 fn validate_module_version_rejects_non_numeric() {
1941 for v in ["-1.0.0.0", "a.b.c.d", "1.2.x.4", "1.2.3.4a", "v1.2.3.4"] {
1942 validate_module_version(v).expect_err("non-numeric parts must be rejected");
1943 }
1944 }
1945
1946 /// When `allow_prerelease_suffix = true` reaches the validator (the
1947 /// `format_version_parts` path re-attaches a SemVer-style tail to the
1948 /// formatted numeric core), the validator must read past the suffix and
1949 /// only enforce the u16/4-part rule on the numeric core. Inputs without
1950 /// a `-` or `+` are unaffected.
1951 #[test]
1952 fn validate_module_version_accepts_valid_core_with_suffix() -> TestResult {
1953 for v in [
1954 "1.2.3.4-PullRequest-12345",
1955 "7.5.3.0-beta.3",
1956 "5.2.100.7+ci.42",
1957 "0.0.0.0-alpha",
1958 "65535.65535.65535.65535-build-99",
1959 ] {
1960 validate_module_version(v)?;
1961 }
1962 Ok(())
1963 }
1964
1965 /// Empty component between dots is rejected explicitly (not just
1966 /// `parse::<u16>()` fallout) so the error message names the position.
1967 #[test]
1968 fn validate_module_version_rejects_empty_part() {
1969 for v in ["1.2.3.", "1..3.4", "..1.2", "1.2..4"] {
1970 let err = validate_module_version(v).expect_err("empty part must be rejected");
1971 if let ModuleInfoError::MalformedJson(msg) = err {
1972 // Either the part-count check or the empty-part check can
1973 // fire first depending on the shape; both are acceptable.
1974 assert!(
1975 msg.contains("empty") || msg.contains("exactly 4"),
1976 "unexpected error message: {msg}"
1977 );
1978 } else {
1979 panic!("expected MalformedJson");
1980 }
1981 }
1982 }
1983
1984 /// `validate_embedded_json` must enforce the u16 constraint on
1985 /// `moduleVersion`, not just the presence check, so the guardrail
1986 /// applies to every path into `embed_package_metadata`.
1987 #[test]
1988 fn validate_embedded_json_rejects_bad_module_version() {
1989 let bad_json = r#"{"binary":"b","version":"1.0.0","moduleVersion":"1.2.3.99999","name":"n","maintainer":"m","os":"linux","osVersion":"22.04"}"#;
1990 let err = validate_embedded_json(bad_json)
1991 .expect_err("out-of-range moduleVersion must be rejected");
1992 match err {
1993 ModuleInfoError::MalformedJson(msg) => {
1994 assert!(
1995 msg.contains("moduleVersion"),
1996 "error must name the field: {msg}"
1997 );
1998 }
1999 other => panic!("expected MalformedJson, got {other:?}"),
2000 }
2001 }
2002
2003 /// Drift guard: `PackageMetadata::field_value` covers every variant in
2004 /// `ModuleInfoField::ALL`, and every produced value matches the struct
2005 /// field serde serializes for the same JSON key. Catches the case where
2006 /// a new enum variant lands but `field_value` / the struct isn't
2007 /// extended.
2008 #[test]
2009 fn package_metadata_field_value_covers_all_variants() -> TestResult {
2010 let md = PackageMetadata {
2011 binary: "bv".into(),
2012 version: "vv".into(),
2013 module_version: "mv".into(),
2014 maintainer: "mn".into(),
2015 name: "nv".into(),
2016 module_type: "tv".into(),
2017 repo: "rv".into(),
2018 branch: "bn".into(),
2019 hash: "hv".into(),
2020 copyright: "cv".into(),
2021 os: "ov".into(),
2022 os_version: "ov2".into(),
2023 };
2024
2025 let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&md)?)?;
2026 for field in ModuleInfoField::ALL {
2027 let from_method = md.field_value(*field);
2028 let from_json = json
2029 .get(field.to_key())
2030 .and_then(|v| v.as_str())
2031 .unwrap_or_else(|| panic!("JSON missing key for {field:?}"));
2032 assert_eq!(
2033 from_method, from_json,
2034 "field_value and serde output disagree for {field:?}"
2035 );
2036 }
2037 Ok(())
2038 }
2039}