Skip to main content

module_info/
metadata.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4use std::env;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    utils::{bytes_to_linker_directives, get_cargo_toml_content, get_distro_info},
10    ModuleInfoError, ModuleInfoField, ModuleInfoResult, NOTE_ALIGN,
11};
12
13/// Package metadata for embedding in the ELF `.note.package` section.
14///
15/// `PackageMetadata` holds the raw (unsanitized) metadata values that will be
16/// serialized to JSON and byte-encoded into the linker script by
17/// [`embed_package_metadata`](crate::embed_package_metadata). Callers may
18/// either populate this struct manually in `build.rs` (e.g. to supply values
19/// from an outer build system without touching `Cargo.toml`) or use
20/// [`PackageMetadata::from_cargo_toml`] to read the current crate's metadata.
21///
22/// # Non-exhaustive + Default
23///
24/// This struct is marked `#[non_exhaustive]` and implements [`Default`] so new
25/// fields can be added in future minor releases without breaking downstream
26/// code. From outside the crate, `#[non_exhaustive]` forbids struct-literal
27/// construction; start from [`Default::default()`] and assign the fields you
28/// need:
29///
30/// ```rust,no_run
31/// # use module_info::PackageMetadata;
32/// let mut md = PackageMetadata::default();
33/// md.maintainer = "team@contoso.com".into();
34/// md.module_type = "agent".into();
35/// md.version = "1.2.3".into();
36/// md.module_version = "1.2.3.4".into();
37/// ```
38///
39/// # Disabling fields
40///
41/// Seven keys are *required* in the embedded JSON:
42/// `binary`, `version`, `moduleVersion`, `name`, `maintainer`, `os`, and
43/// `osVersion`. The remaining fields (`type`, `repo`, `branch`, `hash`,
44/// `copyright`) are optional. Leave them as the empty string and the
45/// corresponding JSON value is emitted as `""`, which downstream tooling
46/// can skip. `from_cargo_toml()` populates the `os`/`osVersion` fields
47/// from `/etc/os-release`, so most builders get them for free; override
48/// only when the detected values don't match the target platform.
49///
50/// The JSON shape stays stable (every key is always present) because the
51/// `.note.package` payload is a fixed-layout byte array built from the
52/// linker script; the empty-string-as-disabled convention keeps the layout
53/// constant while letting consumers opt out of leaking fields they don't
54/// want in the binary.
55///
56/// ```rust,no_run
57/// use module_info::PackageMetadata;
58///
59/// fn main() -> Result<(), Box<dyn std::error::Error>> {
60///     // Library crate that doesn't want to embed git or repo info:
61///     let mut md = PackageMetadata::from_cargo_toml()?;
62///     md.repo.clear();
63///     md.branch.clear();
64///     md.hash.clear();
65///     // `md` still carries binary/version/moduleVersion/name/maintainer
66///     // plus os/osVersion (auto-populated from /etc/os-release).
67///     Ok(())
68/// }
69/// ```
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71#[non_exhaustive]
72pub struct PackageMetadata {
73    /// Binary name (executable or library)
74    pub binary: String,
75
76    /// Full module version (may include build number)
77    #[serde(rename = "moduleVersion")]
78    pub module_version: String,
79
80    /// Crate version from Cargo.toml
81    pub version: String,
82
83    /// Maintainer contact information
84    pub maintainer: String,
85
86    /// Package name
87    pub name: String,
88
89    /// Module type (agent, library, executable, etc.)
90    #[serde(rename = "type")] // Ensure JSON uses "type" instead of "module_type"
91    pub module_type: String,
92
93    /// Git repository name
94    pub repo: String,
95
96    /// Git branch name
97    pub branch: String,
98
99    /// Git commit hash
100    pub hash: String,
101
102    /// Copyright information
103    pub copyright: String,
104
105    /// Operating system name
106    pub os: String,
107
108    /// Operating system version
109    #[serde(rename = "osVersion")]
110    pub os_version: String,
111}
112
113impl PackageMetadata {
114    /// Build a [`PackageMetadata`] by reading the current crate's `Cargo.toml`,
115    /// environment-variable overrides, git working copy, and OS release info.
116    ///
117    /// This is the zero-configuration entry point: the build script for a
118    /// normal Cargo crate can just call
119    /// [`generate_project_metadata_and_linker_script`](crate::generate_project_metadata_and_linker_script),
120    /// which uses this method under the hood. Call `from_cargo_toml` directly
121    /// only when you need to inspect or mutate the collected metadata before
122    /// passing it to [`embed_package_metadata`](crate::embed_package_metadata).
123    ///
124    /// The returned values are *unsanitized*. `embed_package_metadata` runs
125    /// the sanitize step internally so the linker-script bytes and the JSON
126    /// string agree byte-for-byte (the invariant that keeps the `.note.package`
127    /// section 4-byte aligned).
128    ///
129    /// # Errors
130    /// Returns a [`ModuleInfoError`] if `Cargo.toml` is unreadable or malformed,
131    /// if git invocation fails, or if the OS release info cannot be read.
132    pub fn from_cargo_toml() -> ModuleInfoResult<Self> {
133        collect_package_metadata()
134    }
135
136    /// Return the string value associated with a given [`ModuleInfoField`].
137    ///
138    /// This is the single source of truth mapping `ModuleInfoField` variants
139    /// to `PackageMetadata` fields. Both the linker-script emitter and the
140    /// build-time JSON dump iterate [`ModuleInfoField::ALL`] and call this.
141    #[must_use]
142    pub fn field_value(&self, field: ModuleInfoField) -> &str {
143        match field {
144            ModuleInfoField::Binary => &self.binary,
145            ModuleInfoField::Version => &self.version,
146            ModuleInfoField::ModuleVersion => &self.module_version,
147            ModuleInfoField::Maintainer => &self.maintainer,
148            ModuleInfoField::Name => &self.name,
149            ModuleInfoField::Type => &self.module_type,
150            ModuleInfoField::Repo => &self.repo,
151            ModuleInfoField::Branch => &self.branch,
152            ModuleInfoField::Hash => &self.hash,
153            ModuleInfoField::Copyright => &self.copyright,
154            ModuleInfoField::Os => &self.os,
155            ModuleInfoField::OsVersion => &self.os_version,
156        }
157    }
158}
159
160/// Look up `package.metadata.module_info.<key>` as a string slice.
161fn module_info_str<'a>(package: &'a toml::Value, key: &str) -> Option<&'a str> {
162    package
163        .get("metadata")
164        .and_then(|m| m.get("module_info"))
165        .and_then(|mi| mi.get(key))
166        .and_then(|v| v.as_str())
167}
168
169/// Look up `package.metadata.module_info.<key>` as a bool, defaulting to `false`.
170fn module_info_bool(package: &toml::Value, key: &str) -> bool {
171    package
172        .get("metadata")
173        .and_then(|m| m.get("module_info"))
174        .and_then(|mi| mi.get(key))
175        .and_then(|v| v.as_bool())
176        .unwrap_or(false)
177}
178
179/// Locate the first `-` or `+` byte position, marking where a SemVer-style
180/// pre-release or build-metadata suffix begins. Returns `None` when neither
181/// separator is present.
182pub(crate) fn suffix_start(version_str: &str) -> Option<usize> {
183    match (version_str.find('-'), version_str.find('+')) {
184        (Some(a), Some(b)) => Some(a.min(b)),
185        (Some(a), None) => Some(a),
186        (None, Some(b)) => Some(b),
187        (None, None) => None,
188    }
189}
190
191/// Normalize a dotted version string to exactly `parts` numeric components,
192/// padding missing trailing components with `0` and truncating extras.
193///
194/// By default, SemVer-style pre-release / build-metadata suffixes (everything
195/// from the first `-` or `+`) are stripped before splitting so that Azure
196/// Pipelines build numbers like `"5.2.100.0-PullRequest-123456"` normalize
197/// cleanly to `"5.2.100.0"` (or `"5.2.100"` when `parts == 3`) instead of
198/// leaving a non-numeric tail that would fail the u16 check in
199/// `validate_module_version`.
200///
201/// When `preserve_suffix` is `true`, the numeric core is still normalized to
202/// `parts` components, but the suffix (including the leading `-` / `+`) is
203/// re-attached to the result so the embedded version carries the buddy/PR
204/// identifier. The caller opts in via `allow_prerelease_suffix = true` in
205/// `[package.metadata.module_info]`.
206fn format_version_parts(version_str: &str, parts: usize, preserve_suffix: bool) -> String {
207    let cut = suffix_start(version_str);
208    let core = match cut {
209        Some(end) => version_str.get(..end).unwrap_or(version_str),
210        None => version_str,
211    };
212    let suffix = match cut {
213        Some(end) if preserve_suffix => version_str.get(end..).unwrap_or(""),
214        _ => "",
215    };
216    if core.len() != version_str.len() && !preserve_suffix {
217        warn!(
218            "version string {:?} carries pre-release/build-metadata suffix; using numeric core {:?}",
219            version_str, core
220        );
221    }
222    // Treat an empty core as "no dot-separated numeric input" rather than
223    // "one empty component": `"".split('.')` returns `[""]`, which would
224    // otherwise make the first emitted part the empty string and produce
225    // a malformed result like ".0.0" (for parts=3) or ".0.0.0" (for parts=4).
226    // The downstream u16 validator in `validate_module_version` would then
227    // reject with a confusing "part 0 is empty" error instead of the
228    // expected "0.0.0" / "0.0.0.0" fallback.
229    let fields: Vec<&str> = if core.is_empty() {
230        Vec::new()
231    } else {
232        core.split('.').collect()
233    };
234    if fields.len() > parts {
235        // Truncation is warn-not-error for backwards compat with pipelines
236        // whose build numbers incidentally carry extra dots (e.g. a
237        // `BUILD_BUILDNUMBER` of `"1.2.3.4.5"` trimmed to `"1.2.3.4"`).
238        warn!(
239            "version string {:?} has {} dot-separated parts; truncating to {} (dropped: {:?})",
240            core,
241            fields.len(),
242            parts,
243            fields.get(parts..).map(|s| s.join(".")).unwrap_or_default()
244        );
245    }
246    // Warn early when any part overflows u16: the hard check in
247    // `validate_module_version` will still reject the value later, but the
248    // error then surfaces several call-frames deep in `embed_package_metadata`
249    // as a generic "moduleVersion part N must fit in 16 bits". Warning here
250    // points at the actual offending env var / Cargo.toml value in the CI log.
251    for (i, f) in fields.iter().take(parts).enumerate() {
252        if !f.is_empty() && f.parse::<u16>().is_err() {
253            warn!(
254                "version part {} ({:?}) in {:?} does not fit u16; downstream validate_module_version will reject this build",
255                i, f, core
256            );
257        }
258    }
259    let formatted = (0..parts)
260        .map(|i| fields.get(i).copied().unwrap_or("0"))
261        .collect::<Vec<_>>()
262        .join(".");
263    if suffix.is_empty() {
264        formatted
265    } else {
266        format!("{formatted}{suffix}")
267    }
268}
269
270/// Read `$env_var_name` (if set) and return its trimmed value, or `fallback`
271/// when the env var is unset, unreadable, or whitespace-only.
272///
273/// Trimming matters because CI-supplied values (e.g. `BUILD_BUILDNUMBER`)
274/// occasionally arrive with stray leading/trailing whitespace, which would
275/// otherwise propagate into the first `.`-separated field of a version
276/// string and fail the u16 range check in `validate_module_version`.
277fn env_or_default(env_var_name: Option<&str>, fallback: &str) -> String {
278    let Some(name) = env_var_name else {
279        return fallback.to_string();
280    };
281    let value = match env::var(name) {
282        Ok(v) => v,
283        Err(env::VarError::NotPresent) => String::new(),
284        Err(env::VarError::NotUnicode(_)) => {
285            // Non-UTF8 env values silently drop to the fallback rather than
286            // poisoning the embedded JSON with replacement characters. A
287            // cargo:warning keeps the root cause visible at build time.
288            println!(
289                "cargo:warning=module_info: env var {name} contains non-UTF8 bytes; using fallback"
290            );
291            String::new()
292        }
293    };
294    let trimmed = value.trim();
295    if trimmed.is_empty() {
296        fallback.to_string()
297    } else {
298        trimmed.to_string()
299    }
300}
301
302/// Read Cargo.toml, env vars, git, and OS release info and populate a raw
303/// (unsanitized) `PackageMetadata`.
304fn collect_package_metadata() -> ModuleInfoResult<PackageMetadata> {
305    let cargo_toml = get_cargo_toml_content()?;
306    let package = cargo_toml
307        .get("package")
308        .ok_or_else(|| ModuleInfoError::MalformedJson("No package section found".to_string()))?;
309
310    let binary_name = env::var("CARGO_PKG_NAME").unwrap_or_default();
311    let default_version = env::var("CARGO_PKG_VERSION").unwrap_or_default();
312
313    let version_env_var_name = module_info_str(package, "version_env_var_name").map(str::to_string);
314    let module_version_env_var_name =
315        module_info_str(package, "module_version_env_var_name").map(str::to_string);
316
317    // Caller-named env vars: emit rerun-if-env-changed here since
318    // `embed_package_metadata`'s fixed rerun set can't know them.
319    if let Some(name) = version_env_var_name.as_deref() {
320        println!("cargo:rerun-if-env-changed={name}");
321    }
322    if let Some(name) = module_version_env_var_name.as_deref() {
323        println!("cargo:rerun-if-env-changed={name}");
324    }
325
326    let allow_prerelease_suffix = module_info_bool(package, "allow_prerelease_suffix");
327
328    let raw_version = env_or_default(version_env_var_name.as_deref(), &default_version);
329    let version = format_version_parts(&raw_version, 3, allow_prerelease_suffix);
330    let raw_module_version = env_or_default(module_version_env_var_name.as_deref(), &raw_version);
331    let module_version = format_version_parts(&raw_module_version, 4, allow_prerelease_suffix);
332
333    let (branch, hash, repo) = crate::utils::get_git_info()?;
334
335    let maintainer = module_info_str(package, "maintainer")
336        .unwrap_or("Unknown")
337        .to_string();
338    let module_type = module_info_str(package, "type")
339        .unwrap_or("Unknown")
340        .to_string();
341    let copyright = module_info_str(package, "copyright")
342        .unwrap_or("Unknown")
343        .to_string();
344
345    let (os, os_version) = get_distro_info()?;
346
347    // Return unsanitized values; `render_note_payloads` sanitizes at emit time
348    // so both manual and `from_cargo_toml` construction paths agree byte-for-byte.
349    Ok(PackageMetadata {
350        binary: binary_name.clone(),
351        module_version,
352        version,
353        maintainer,
354        name: binary_name,
355        module_type,
356        repo,
357        branch,
358        hash,
359        copyright,
360        os,
361        os_version,
362    })
363}
364
365/// Render a [`PackageMetadata`] into the two byte-identical payloads embedded
366/// in the ELF note section: the compact JSON string (`.0`) and the
367/// linker-script body that reproduces the same bytes (`.1`).
368///
369/// Sanitization happens here. The returned JSON string and the byte-encoded
370/// linker-script body are guaranteed to agree byte-for-byte, which is what
371/// keeps the `.note.package` section 4-byte aligned.
372pub(crate) fn render_note_payloads(md: &PackageMetadata) -> ModuleInfoResult<(String, String)> {
373    // Surface silent data loss: warn (don't fail) when a field carries
374    // characters that sanitization drops, naming the field and the lost
375    // characters so a value like `José` -> `Jos` is visible at build time.
376    for field in ModuleInfoField::ALL.iter().copied() {
377        let dropped = sanitize_dropped_chars(md.field_value(field));
378        if !dropped.is_empty() {
379            warn!(
380                "module_info: field {:?} drops non-embeddable characters {:?}; \
381                 embedding the ASCII-sanitized value instead",
382                field.to_key(),
383                dropped
384            );
385        }
386    }
387
388    // Sanitize before serialization so JSON bytes and linker bytes agree.
389    // Otherwise characters that expand/strip (`©` → `(c)`, non-ASCII) would
390    // drift padding and break 4-byte alignment of the note section.
391    let metadata = PackageMetadata {
392        binary: sanitize_for_linker_script(&md.binary),
393        module_version: sanitize_for_linker_script(&md.module_version),
394        version: sanitize_for_linker_script(&md.version),
395        maintainer: sanitize_for_linker_script(&md.maintainer),
396        name: sanitize_for_linker_script(&md.name),
397        module_type: sanitize_for_linker_script(&md.module_type),
398        repo: sanitize_for_linker_script(&md.repo),
399        branch: sanitize_for_linker_script(&md.branch),
400        hash: sanitize_for_linker_script(&md.hash),
401        copyright: sanitize_for_linker_script(&md.copyright),
402        os: sanitize_for_linker_script(&md.os),
403        os_version: sanitize_for_linker_script(&md.os_version),
404    };
405
406    // Emit JSON and linker directives in lock-step so byte counts agree.
407    // Manually emit newlines (not `serde_json::to_string`, which emits one line)
408    // so `strings`/`readelf -n` show one key:value pair per line.
409    let mut linker_script_body = String::new();
410    let mut compact_json = String::new();
411
412    // Iterate `ModuleInfoField::ALL` so the emitter stays in lock-step with the
413    // enum (exhaustive iteration surfaces missing/extra keys at compile time).
414    let entries: Vec<(&str, &str, &str)> = ModuleInfoField::ALL
415        .iter()
416        .map(|f| (f.to_key(), f.to_symbol_name(), metadata.field_value(*f)))
417        .collect();
418
419    // Derive padding from this running count, not `compact_json.len()`.
420    // Sanitized bytes *should* match `compact_json.len()` (the hard check
421    // below enforces that), but using the emitted byte count is the invariant
422    // that actually keeps `.note.package` 4-byte aligned. If sanitization
423    // ever drifts string length, the emitted count is still authoritative.
424    let mut note_payload_bytes: usize = 0;
425
426    linker_script_body.push('\n');
427    linker_script_body.push_str(&bytes_to_linker_script_format("{\n")); // '{', '\n'
428    compact_json.push_str("{\n");
429    note_payload_bytes += 2;
430    for (i, (key, symbol_name, value)) in entries.iter().enumerate() {
431        let key_json = format!("\"{key}\":");
432        let bytes_key_str = bytes_to_linker_script_format(&key_json);
433        linker_script_body.push_str(&format!("\n\n    /* Key: {key} */"));
434        linker_script_body.push_str(&format!("\n{bytes_key_str}"));
435        compact_json.push_str(&key_json);
436        note_payload_bytes += key_json.len();
437
438        // Symbol marks the value's start address; runtime extraction reads from
439        // here without parsing the JSON.
440        linker_script_body.push_str(&format!("\n    {symbol_name} = .;"));
441
442        // No `/* Value: ... */` comment: sanitized values could contain `*/`
443        // and interpolating user bytes into a C-style comment is fragile.
444        let value_json = format!("\"{value}\"");
445        let bytes_value_str = bytes_to_linker_script_format(&value_json);
446        linker_script_body.push_str(&format!("\n{bytes_value_str}"));
447        compact_json.push_str(&value_json);
448        note_payload_bytes += value_json.len();
449
450        if i < entries.len() - 1 {
451            linker_script_body.push('\n');
452            linker_script_body.push_str(&bytes_to_linker_script_format(",\n"));
453            compact_json.push_str(",\n");
454            note_payload_bytes += 2;
455        }
456    }
457
458    linker_script_body.push('\n');
459    linker_script_body.push_str(&bytes_to_linker_script_format("\n}")); // '\n', '}'
460    compact_json.push_str("\n}");
461    note_payload_bytes += 2;
462    debug!(" Compact JSON Len: {}", compact_json.len());
463
464    // Always emit 1–4 NUL bytes of padding (never 0). The lower bound is
465    // load-bearing: `extract_module_info` scans forward until `\0`, capped at
466    // `MAX_NOTE_VALUE_LEN`. Without a terminating NUL the scan runs to the cap
467    // and reads bytes past the section: harmless mid-segment, SIGSEGV at a
468    // segment tail. When `note_payload_bytes % NOTE_ALIGN == 0`, the formula
469    // below emits a full `NOTE_ALIGN` (4-byte) NUL pad, *not* 0; the section
470    // stays 4-aligned either way (+0 mod 4 vs. +4 mod 4), and the scan still
471    // terminates on the first NUL.
472    let padding_needed = NOTE_ALIGN - (note_payload_bytes % NOTE_ALIGN);
473    // Hard check (not debug_assert): mismatch here means the linker script and
474    // JSON disagree byte-for-byte, which corrupts alignment and breaks runtime
475    // extraction. Return an error (not panic) so build.rs sees a clean exit.
476    if note_payload_bytes != compact_json.len() {
477        return Err(crate::ModuleInfoError::Other(
478            format!(
479                "linker script payload size ({note_payload_bytes}) disagrees with compact_json ({}); \
480                 sanitizer and emitter drifted out of sync",
481                compact_json.len()
482            )
483            .into(),
484        ));
485    }
486
487    // Always runs; `padding_needed` is in [1, 4] by construction above.
488    linker_script_body.push_str("\n    /* Padding (always >=1 NUL so runtime scan terminates) */");
489    for _ in 0..padding_needed {
490        linker_script_body.push('\n');
491        linker_script_body.push_str(&bytes_to_linker_script_format("\0"));
492    }
493
494    debug!("Linker script body:\n{}", linker_script_body);
495    debug!("Compact JSON:\n{}", compact_json);
496    debug!("Linker script body size: {}", linker_script_body.len());
497    debug!("Compact JSON size: {}", compact_json.len());
498    debug!("Padding needed: {}", padding_needed);
499    debug!(
500        "Linker script body size after padding: {}",
501        linker_script_body.len()
502    );
503    debug!("Compact JSON size after padding: {}", compact_json.len());
504
505    Ok((compact_json, linker_script_body))
506}
507
508/// Thin wrapper that chains [`PackageMetadata::from_cargo_toml`] and
509/// [`render_note_payloads`], retained so the `test_project_metadata`
510/// regression test can exercise both stages through one call. Production code
511/// reaches for [`crate::embed_package_metadata`] instead.
512#[cfg(test)]
513pub(crate) fn project_metadata() -> ModuleInfoResult<(String, String)> {
514    let md = PackageMetadata::from_cargo_toml()?;
515    render_note_payloads(&md)
516}
517
518/// Sanitize a string so that it can be embedded verbatim in both the emitted
519/// linker script (as raw bytes) and the compact JSON metadata (via serde_json)
520/// without the two representations disagreeing in length.
521///
522/// The contract is: after sanitization, `serde_json::to_string` of the value
523/// produces the same bytes the linker will emit. That invariant keeps the
524/// `.note.package` section 4-byte aligned (padding is computed from the
525/// emitted-byte count) and keeps the JSON parseable at runtime.
526///
527/// To achieve that we strip every character that `serde_json` would escape:
528/// - `"` (would become `\"` in JSON: 2 bytes vs 1 raw byte)
529/// - `\` (would become `\\`)
530/// - any control character, including `\n`, `\r`, `\t` (would become `\n`/`\t`/`\uNNNN`)
531/// - any non-ASCII character (would be UTF-8 multi-byte; we keep the section ASCII-only)
532///
533/// We also map a few common trademark/copyright glyphs to ASCII equivalents
534/// before stripping, because those legitimately show up in `copyright` fields.
535/// These replacements *expand* the byte count (`©` = 2 UTF-8 bytes → `(c)` =
536/// 3 ASCII bytes), so a pathological copyright string full of glyphs can push
537/// the serialized JSON over [`crate::constants::MAX_JSON_SIZE`] (1 KiB) and
538/// fail validation in [`crate::embed_package_metadata`]. That's the intended
539/// outcome (the size cap is the forcing function), but if a build fails
540/// with `MetadataTooLarge`, glyph expansion is the likely cause.
541///
542/// The glyph map is intentionally minimal (©, ®, ™). Other non-ASCII glyphs
543/// that might appear in author or copyright fields (U+2014 em-dash (`—`), curly
544/// quotes (`“ ”`), section (`§`), accented Latin letters (`André`), or any
545/// CJK text) are dropped rather than transliterated. Expand the map if a
546/// legitimate field is losing characters, and update the
547/// `sanitize_strips_...` tests to match. Don't add per-language
548/// transliteration (`é` → `e`): that loses meaning silently. Prefer an ASCII
549/// spelling at the source.
550///
551/// Additionally, `*` and `/` are preserved (they appear in paths and versions),
552/// but the caller must not interpolate the sanitized string into a C-style
553/// `/* ... */` comment without escaping `*/` first.
554///
555/// # Example
556/// `"Contoso©"` → `"Contoso(c)"`; `"a\"b\nc"` → `"abc"`.
557pub fn sanitize_for_linker_script(input: &str) -> String {
558    map_glyphs(input)
559        .chars()
560        .filter(|&c| is_linker_safe(c))
561        .collect()
562}
563
564/// Transliterate the non-ASCII glyphs we map to ASCII (`©` → `(c)`, etc.)
565/// before the keep-filter runs. Shared by [`sanitize_for_linker_script`] and
566/// [`sanitize_dropped_chars`] so the two paths can never disagree on which
567/// characters survive as their ASCII spelling versus get reported as dropped.
568fn map_glyphs(input: &str) -> String {
569    input
570        .replace('©', "(c)")
571        .replace('®', "(r)")
572        .replace('™', "(tm)")
573}
574
575/// Whether `c` survives sanitization unchanged. Shared by
576/// [`sanitize_for_linker_script`] and [`sanitize_dropped_chars`] so the keep
577/// rule and the "what got dropped" report can never disagree.
578fn is_linker_safe(c: char) -> bool {
579    // Must be plain ASCII so the emitted bytes are one-to-one with chars.
580    if !c.is_ascii() {
581        return false;
582    }
583    // Drop anything serde_json would escape, and anything that would otherwise
584    // break the emitted JSON string literal at runtime.
585    if c.is_control() {
586        return false;
587    }
588    if c == '"' || c == '\\' {
589        return false;
590    }
591    // Keep printable ASCII: alphanumeric, space, and the standard punctuation
592    // set (minus the quote / backslash excluded above).
593    c.is_alphanumeric() || c == ' ' || c.is_ascii_punctuation()
594}
595
596/// Characters from `input` that [`sanitize_for_linker_script`] drops, after the
597/// ©/®/™ glyph mapping, de-duplicated and in first-seen order. Empty when the
598/// value embeds losslessly. Used to warn at build time so silent data loss
599/// (e.g. `José` -> `Jos`) is visible rather than mysterious.
600fn sanitize_dropped_chars(input: &str) -> String {
601    let mapped = map_glyphs(input);
602    let mut dropped = String::new();
603    for c in mapped.chars() {
604        if !is_linker_safe(c) && !dropped.contains(c) {
605            dropped.push(c);
606        }
607    }
608    dropped
609}
610
611/// `&str` convenience wrapper over [`bytes_to_linker_directives`].
612fn bytes_to_linker_script_format(s: &str) -> String {
613    bytes_to_linker_directives(s.as_bytes())
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    /// After sanitizing, the bytes `serde_json::to_string` emits for the value
621    /// must equal the raw bytes the linker script writes (the value wrapped in
622    /// `"..."`). This is the invariant that keeps the `.note.package` section
623    /// 4-byte aligned: the linker-script padding is computed from the raw
624    /// byte count, so any divergence would corrupt the ELF note alignment.
625    fn assert_sanitize_json_agreement(raw: &str) {
626        let sanitized = sanitize_for_linker_script(raw);
627        let linker_bytes = format!("\"{sanitized}\"");
628        // `serde_json::to_string` on a `String` cannot fail; match explicitly
629        // rather than using an unreachable `.unwrap_or_default()`.
630        let json_bytes = match serde_json::to_string(&sanitized) {
631            Ok(s) => s,
632            Err(e) => panic!("serde_json::to_string on a plain String failed: {e}"),
633        };
634        assert_eq!(
635            linker_bytes, json_bytes,
636            "sanitized input {raw:?} produced diverging linker vs. JSON bytes"
637        );
638        assert_eq!(
639            linker_bytes.len(),
640            json_bytes.len(),
641            "sanitized input {raw:?} produced diverging byte lengths"
642        );
643    }
644
645    #[test]
646    fn sanitize_strips_quote_and_backslash() {
647        // `"` and `\` would both be escaped by serde_json, doubling their byte
648        // count; they must be stripped so the sanitized value serializes verbatim.
649        let s = sanitize_for_linker_script("a\"b\\c");
650        assert_eq!(s, "abc");
651        assert_sanitize_json_agreement("a\"b\\c");
652    }
653
654    #[test]
655    fn sanitize_strips_control_chars() {
656        // Newlines, carriage returns, tabs, and the NUL byte would all be escaped
657        // by serde_json (\n, \r, \t, \u0000); strip them to keep byte counts aligned.
658        let s = sanitize_for_linker_script("line1\nline2\r\nx\ty\0z");
659        assert_eq!(s, "line1line2xyz");
660        assert_sanitize_json_agreement("line1\nline2\r\nx\ty\0z");
661    }
662
663    #[test]
664    fn sanitize_maps_common_glyphs_to_ascii() {
665        // Copyright/trademark glyphs are the realistic case in a `copyright`
666        // field; they must round-trip to ASCII before the non-ASCII filter runs.
667        let s = sanitize_for_linker_script("Contoso© Fabrikam® / Widgets™");
668        assert_eq!(s, "Contoso(c) Fabrikam(r) / Widgets(tm)");
669        assert_sanitize_json_agreement("Contoso© Fabrikam® / Widgets™");
670    }
671
672    #[test]
673    fn sanitize_strips_generic_non_ascii() {
674        // Generic non-ASCII (e.g. accented names in an author field) would be
675        // emitted as multi-byte UTF-8 by the linker but escaped as \uNNNN by
676        // serde_json unless it stays in the BMP; either way the byte counts
677        // diverge, so non-ASCII must be dropped.
678        let s = sanitize_for_linker_script("André naïve 日本");
679        assert_eq!(s, "Andr nave ");
680        assert_sanitize_json_agreement("André naïve 日本");
681    }
682
683    #[test]
684    fn sanitize_preserves_star_slash_for_paths_and_versions() {
685        // `*` and `/` are intentionally kept; they appear in paths and in
686        // version strings. (The linker-script emitter deliberately does NOT
687        // interpolate sanitized values into `/* ... */` comments, so `*/`
688        // in a value cannot close a comment.)
689        let s = sanitize_for_linker_script("path/to/*.rs v1.2.3+build");
690        assert_eq!(s, "path/to/*.rs v1.2.3+build");
691        assert_sanitize_json_agreement("path/to/*.rs v1.2.3+build");
692    }
693
694    #[test]
695    fn sanitize_keeps_star_slash_sequence_literally() {
696        // Regression guard: even if a value contains `*/`, sanitize keeps both
697        // characters (they're not safety-critical on their own). The safety
698        // comes from the linker-script emitter never interpolating the value
699        // into a C-style comment. The JSON and linker byte counts still match.
700        let raw = "hello*/world";
701        let s = sanitize_for_linker_script(raw);
702        assert_eq!(s, "hello*/world");
703        assert_sanitize_json_agreement(raw);
704    }
705
706    #[test]
707    fn sanitize_handles_empty_string() {
708        let s = sanitize_for_linker_script("");
709        assert_eq!(s, "");
710        assert_sanitize_json_agreement("");
711    }
712
713    #[test]
714    fn dropped_chars_reports_lost_characters_only() {
715        // Mapped glyphs are not "dropped" (they embed as ASCII).
716        assert_eq!(sanitize_dropped_chars("Contoso©"), "");
717        // Pure ASCII loses nothing.
718        assert_eq!(sanitize_dropped_chars("plain ascii v1.2.3+build"), "");
719        // Accents, CJK, quote, and backslash are reported, de-duplicated and
720        // in first-seen order.
721        assert_eq!(sanitize_dropped_chars("José"), "é");
722        assert_eq!(sanitize_dropped_chars("a\"b\\c"), "\"\\");
723        assert_eq!(sanitize_dropped_chars("日本 é é 日"), "日本é");
724        // What's reported is exactly what sanitization removes.
725        let raw = "André 日本";
726        let kept: String = raw.chars().filter(|&c| is_linker_safe(c)).collect();
727        assert_eq!(kept, sanitize_for_linker_script(raw));
728        for c in sanitize_dropped_chars(raw).chars() {
729            assert!(!sanitize_for_linker_script(raw).contains(c));
730        }
731    }
732
733    #[test]
734    fn sanitize_handles_only_stripped_chars() {
735        // All-bad input should collapse to empty, and empty must still agree
736        // across JSON and linker output (both emit `""`, 2 bytes).
737        let s = sanitize_for_linker_script("\"\\\n\t日");
738        assert_eq!(s, "");
739        assert_sanitize_json_agreement("\"\\\n\t日");
740    }
741
742    /// Azure Pipelines' `BUILD_BUILDNUMBER` can arrive shaped like
743    /// `"5.2.100.0-PullRequest-123456"`. The SemVer-style `-<suffix>` must
744    /// be stripped before splitting on `.`, otherwise the 4-part
745    /// `moduleVersion` fails the u16 check on the last component.
746    #[test]
747    fn format_version_parts_strips_semver_suffix() {
748        assert_eq!(
749            format_version_parts("5.2.100.0-PullRequest-123456", 4, false),
750            "5.2.100.0"
751        );
752        assert_eq!(
753            format_version_parts("5.2.100.0-PullRequest-123456", 3, false),
754            "5.2.100"
755        );
756        // SemVer pre-release label (`-beta.N`) is also stripped.
757        assert_eq!(format_version_parts("2.10.0-beta.3", 4, false), "2.10.0.0");
758        // `+build` (SemVer build-metadata) is also stripped.
759        assert_eq!(format_version_parts("3.1.4+ci.42", 3, false), "3.1.4");
760        // Plain numeric input is unchanged.
761        assert_eq!(format_version_parts("1.2.3.4", 4, false), "1.2.3.4");
762        // Padding behavior is preserved for short inputs.
763        assert_eq!(format_version_parts("1.2", 4, false), "1.2.0.0");
764        // Empty input must yield the all-zero fallback (not ".0.0" /
765        // ".0.0.0" from `"".split('.')` returning `[""]`). A malformed
766        // leading-dot result would fail downstream u16 validation with
767        // a confusing "part 0 is empty" error.
768        assert_eq!(format_version_parts("", 3, false), "0.0.0");
769        assert_eq!(format_version_parts("", 4, false), "0.0.0.0");
770    }
771
772    /// Opt-in `preserve_suffix` keeps the numeric-core normalization but
773    /// reattaches the SemVer-style tail. This is the path enabled by
774    /// `allow_prerelease_suffix = true` in `[package.metadata.module_info]`.
775    #[test]
776    fn format_version_parts_preserves_suffix_when_opted_in() {
777        assert_eq!(
778            format_version_parts("7.5.3.0-PullRequest-12345", 4, true),
779            "7.5.3.0-PullRequest-12345"
780        );
781        assert_eq!(
782            format_version_parts("7.5.3.0-PullRequest-12345", 3, true),
783            "7.5.3-PullRequest-12345"
784        );
785        // Numeric core is still padded before the suffix is reattached.
786        assert_eq!(
787            format_version_parts("1.2-beta.3", 4, true),
788            "1.2.0.0-beta.3"
789        );
790        // Build-metadata (`+`) is preserved alongside the leading separator.
791        assert_eq!(
792            format_version_parts("3.1.4+ci.42", 4, true),
793            "3.1.4.0+ci.42"
794        );
795        // No suffix in input means output matches the strip-path behavior.
796        assert_eq!(format_version_parts("1.2.3.4", 4, true), "1.2.3.4");
797        // Whichever separator (`-` or `+`) appears first is the cut point and
798        // becomes the leading character of the preserved suffix.
799        assert_eq!(
800            format_version_parts("1.0.0+build-1", 4, true),
801            "1.0.0.0+build-1"
802        );
803    }
804
805    #[test]
806    fn sanitize_is_idempotent() {
807        // Applying sanitize repeatedly must produce the same result as applying
808        // it once. The invariant described in the doc comment (JSON and
809        // linker-script bytes agree) could drift through code paths that
810        // re-sanitize defensively, so we iterate four passes rather than the
811        // original two; enough to catch a rewrite where a glyph expansion
812        // introduces another glyph-trigger pattern.
813        //
814        // The input deliberately includes:
815        // - Raw glyphs that get expanded (`©`→`(c)`, `®`→`(r)`, `™`→`(tm)`)
816        // - Text that matches the *output* of those expansions
817        //   (`"Copyright (c) Contoso (2024)"`), which is the scenario where
818        //   a regex-based rewrite could go wrong by re-triggering expansion
819        //   on the `(c)` literal. The current implementation uses literal
820        //   `.replace()` so it's immune; the test pins that contract.
821        let inputs = [
822            "Contoso© Fabrikam® Widgets™ / path*/here",
823            "Copyright (c) Contoso (2024), (r) (tm)",
824            "(c)(r)(tm) only",
825            "",
826        ];
827        for raw in inputs {
828            let once = sanitize_for_linker_script(raw);
829            let mut current = once.clone();
830            for pass in 2..=4 {
831                let next = sanitize_for_linker_script(&current);
832                assert_eq!(
833                    next, once,
834                    "sanitize pass {pass} for input {raw:?} diverged from pass 1 output {once:?}; got {next:?}"
835                );
836                current = next;
837            }
838        }
839    }
840}