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(¤t);
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}