Skip to main content

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(&note_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}