use std::env;
use serde::{Deserialize, Serialize};
use crate::{
utils::{bytes_to_linker_directives, get_cargo_toml_content, get_distro_info},
ModuleInfoError, ModuleInfoField, ModuleInfoResult, NOTE_ALIGN,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PackageMetadata {
pub binary: String,
#[serde(rename = "moduleVersion")]
pub module_version: String,
pub version: String,
pub maintainer: String,
pub name: String,
#[serde(rename = "type")] pub module_type: String,
pub repo: String,
pub branch: String,
pub hash: String,
pub copyright: String,
pub os: String,
#[serde(rename = "osVersion")]
pub os_version: String,
}
impl PackageMetadata {
pub fn from_cargo_toml() -> ModuleInfoResult<Self> {
collect_package_metadata()
}
#[must_use]
pub fn field_value(&self, field: ModuleInfoField) -> &str {
match field {
ModuleInfoField::Binary => &self.binary,
ModuleInfoField::Version => &self.version,
ModuleInfoField::ModuleVersion => &self.module_version,
ModuleInfoField::Maintainer => &self.maintainer,
ModuleInfoField::Name => &self.name,
ModuleInfoField::Type => &self.module_type,
ModuleInfoField::Repo => &self.repo,
ModuleInfoField::Branch => &self.branch,
ModuleInfoField::Hash => &self.hash,
ModuleInfoField::Copyright => &self.copyright,
ModuleInfoField::Os => &self.os,
ModuleInfoField::OsVersion => &self.os_version,
}
}
}
fn module_info_str<'a>(package: &'a toml::Value, key: &str) -> Option<&'a str> {
package
.get("metadata")
.and_then(|m| m.get("module_info"))
.and_then(|mi| mi.get(key))
.and_then(|v| v.as_str())
}
fn format_version_parts(version_str: &str, parts: usize) -> String {
let cut = match (version_str.find('-'), version_str.find('+')) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
let core = match cut {
Some(end) => version_str.get(..end).unwrap_or(version_str),
None => version_str,
};
if core.len() != version_str.len() {
warn!(
"version string {:?} carries pre-release/build-metadata suffix; using numeric core {:?}",
version_str, core
);
}
let fields: Vec<&str> = if core.is_empty() {
Vec::new()
} else {
core.split('.').collect()
};
if fields.len() > parts {
warn!(
"version string {:?} has {} dot-separated parts; truncating to {} (dropped: {:?})",
core,
fields.len(),
parts,
fields.get(parts..).map(|s| s.join(".")).unwrap_or_default()
);
}
for (i, f) in fields.iter().take(parts).enumerate() {
if !f.is_empty() && f.parse::<u16>().is_err() {
warn!(
"version part {} ({:?}) in {:?} does not fit u16; downstream validate_module_version will reject this build",
i, f, core
);
}
}
(0..parts)
.map(|i| fields.get(i).copied().unwrap_or("0"))
.collect::<Vec<_>>()
.join(".")
}
fn env_or_default(env_var_name: Option<&str>, fallback: &str) -> String {
let Some(name) = env_var_name else {
return fallback.to_string();
};
let value = match env::var(name) {
Ok(v) => v,
Err(env::VarError::NotPresent) => String::new(),
Err(env::VarError::NotUnicode(_)) => {
println!(
"cargo:warning=module_info: env var {name} contains non-UTF8 bytes; using fallback"
);
String::new()
}
};
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
fn collect_package_metadata() -> ModuleInfoResult<PackageMetadata> {
let cargo_toml = get_cargo_toml_content()?;
let package = cargo_toml
.get("package")
.ok_or_else(|| ModuleInfoError::MalformedJson("No package section found".to_string()))?;
let binary_name = env::var("CARGO_PKG_NAME").unwrap_or_default();
let default_version = env::var("CARGO_PKG_VERSION").unwrap_or_default();
let version_env_var_name = module_info_str(package, "version_env_var_name").map(str::to_string);
let module_version_env_var_name =
module_info_str(package, "module_version_env_var_name").map(str::to_string);
if let Some(name) = version_env_var_name.as_deref() {
println!("cargo:rerun-if-env-changed={name}");
}
if let Some(name) = module_version_env_var_name.as_deref() {
println!("cargo:rerun-if-env-changed={name}");
}
let raw_version = env_or_default(version_env_var_name.as_deref(), &default_version);
let version = format_version_parts(&raw_version, 3);
let raw_module_version = env_or_default(module_version_env_var_name.as_deref(), &raw_version);
let module_version = format_version_parts(&raw_module_version, 4);
let (branch, hash, repo) = crate::utils::get_git_info()?;
let maintainer = module_info_str(package, "maintainer")
.unwrap_or("Unknown")
.to_string();
let module_type = module_info_str(package, "type")
.unwrap_or("Unknown")
.to_string();
let copyright = module_info_str(package, "copyright")
.unwrap_or("Unknown")
.to_string();
let (os, os_version) = get_distro_info()?;
Ok(PackageMetadata {
binary: binary_name.clone(),
module_version,
version,
maintainer,
name: binary_name,
module_type,
repo,
branch,
hash,
copyright,
os,
os_version,
})
}
pub(crate) fn render_note_payloads(md: &PackageMetadata) -> ModuleInfoResult<(String, String)> {
let metadata = PackageMetadata {
binary: sanitize_for_linker_script(&md.binary),
module_version: sanitize_for_linker_script(&md.module_version),
version: sanitize_for_linker_script(&md.version),
maintainer: sanitize_for_linker_script(&md.maintainer),
name: sanitize_for_linker_script(&md.name),
module_type: sanitize_for_linker_script(&md.module_type),
repo: sanitize_for_linker_script(&md.repo),
branch: sanitize_for_linker_script(&md.branch),
hash: sanitize_for_linker_script(&md.hash),
copyright: sanitize_for_linker_script(&md.copyright),
os: sanitize_for_linker_script(&md.os),
os_version: sanitize_for_linker_script(&md.os_version),
};
let mut linker_script_body = String::new();
let mut compact_json = String::new();
let entries: Vec<(&str, &str, &str)> = ModuleInfoField::ALL
.iter()
.map(|f| (f.to_key(), f.to_symbol_name(), metadata.field_value(*f)))
.collect();
let mut note_payload_bytes: usize = 0;
linker_script_body.push('\n');
linker_script_body.push_str(&bytes_to_linker_script_format("{\n")); compact_json.push_str("{\n");
note_payload_bytes += 2;
for (i, (key, symbol_name, value)) in entries.iter().enumerate() {
let key_json = format!("\"{key}\":");
let bytes_key_str = bytes_to_linker_script_format(&key_json);
linker_script_body.push_str(&format!("\n\n /* Key: {key} */"));
linker_script_body.push_str(&format!("\n{bytes_key_str}"));
compact_json.push_str(&key_json);
note_payload_bytes += key_json.len();
linker_script_body.push_str(&format!("\n {symbol_name} = .;"));
let value_json = format!("\"{value}\"");
let bytes_value_str = bytes_to_linker_script_format(&value_json);
linker_script_body.push_str(&format!("\n{bytes_value_str}"));
compact_json.push_str(&value_json);
note_payload_bytes += value_json.len();
if i < entries.len() - 1 {
linker_script_body.push('\n');
linker_script_body.push_str(&bytes_to_linker_script_format(",\n"));
compact_json.push_str(",\n");
note_payload_bytes += 2;
}
}
linker_script_body.push('\n');
linker_script_body.push_str(&bytes_to_linker_script_format("\n}")); compact_json.push_str("\n}");
note_payload_bytes += 2;
debug!(" Compact JSON Len: {}", compact_json.len());
let padding_needed = NOTE_ALIGN - (note_payload_bytes % NOTE_ALIGN);
if note_payload_bytes != compact_json.len() {
return Err(crate::ModuleInfoError::Other(
format!(
"linker script payload size ({note_payload_bytes}) disagrees with compact_json ({}); \
sanitizer and emitter drifted out of sync",
compact_json.len()
)
.into(),
));
}
linker_script_body.push_str("\n /* Padding (always >=1 NUL so runtime scan terminates) */");
for _ in 0..padding_needed {
linker_script_body.push('\n');
linker_script_body.push_str(&bytes_to_linker_script_format("\0"));
}
debug!("Linker script body:\n{}", linker_script_body);
debug!("Compact JSON:\n{}", compact_json);
debug!("Linker script body size: {}", linker_script_body.len());
debug!("Compact JSON size: {}", compact_json.len());
debug!("Padding needed: {}", padding_needed);
debug!(
"Linker script body size after padding: {}",
linker_script_body.len()
);
debug!("Compact JSON size after padding: {}", compact_json.len());
Ok((compact_json, linker_script_body))
}
#[cfg(test)]
pub(crate) fn project_metadata() -> ModuleInfoResult<(String, String)> {
let md = PackageMetadata::from_cargo_toml()?;
render_note_payloads(&md)
}
pub fn sanitize_for_linker_script(input: &str) -> String {
input
.replace('©', "(c)")
.replace('®', "(r)")
.replace('™', "(tm)")
.chars()
.filter(|&c| {
if !c.is_ascii() {
return false;
}
if c.is_control() {
return false;
}
if c == '"' || c == '\\' {
return false;
}
c.is_alphanumeric() || c == ' ' || c.is_ascii_punctuation()
})
.collect()
}
fn bytes_to_linker_script_format(s: &str) -> String {
bytes_to_linker_directives(s.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_sanitize_json_agreement(raw: &str) {
let sanitized = sanitize_for_linker_script(raw);
let linker_bytes = format!("\"{sanitized}\"");
let json_bytes = match serde_json::to_string(&sanitized) {
Ok(s) => s,
Err(e) => panic!("serde_json::to_string on a plain String failed: {e}"),
};
assert_eq!(
linker_bytes, json_bytes,
"sanitized input {raw:?} produced diverging linker vs. JSON bytes"
);
assert_eq!(
linker_bytes.len(),
json_bytes.len(),
"sanitized input {raw:?} produced diverging byte lengths"
);
}
#[test]
fn sanitize_strips_quote_and_backslash() {
let s = sanitize_for_linker_script("a\"b\\c");
assert_eq!(s, "abc");
assert_sanitize_json_agreement("a\"b\\c");
}
#[test]
fn sanitize_strips_control_chars() {
let s = sanitize_for_linker_script("line1\nline2\r\nx\ty\0z");
assert_eq!(s, "line1line2xyz");
assert_sanitize_json_agreement("line1\nline2\r\nx\ty\0z");
}
#[test]
fn sanitize_maps_common_glyphs_to_ascii() {
let s = sanitize_for_linker_script("Contoso© Fabrikam® / Widgets™");
assert_eq!(s, "Contoso(c) Fabrikam(r) / Widgets(tm)");
assert_sanitize_json_agreement("Contoso© Fabrikam® / Widgets™");
}
#[test]
fn sanitize_strips_generic_non_ascii() {
let s = sanitize_for_linker_script("André naïve 日本");
assert_eq!(s, "Andr nave ");
assert_sanitize_json_agreement("André naïve 日本");
}
#[test]
fn sanitize_preserves_star_slash_for_paths_and_versions() {
let s = sanitize_for_linker_script("path/to/*.rs v1.2.3+build");
assert_eq!(s, "path/to/*.rs v1.2.3+build");
assert_sanitize_json_agreement("path/to/*.rs v1.2.3+build");
}
#[test]
fn sanitize_keeps_star_slash_sequence_literally() {
let raw = "hello*/world";
let s = sanitize_for_linker_script(raw);
assert_eq!(s, "hello*/world");
assert_sanitize_json_agreement(raw);
}
#[test]
fn sanitize_handles_empty_string() {
let s = sanitize_for_linker_script("");
assert_eq!(s, "");
assert_sanitize_json_agreement("");
}
#[test]
fn sanitize_handles_only_stripped_chars() {
let s = sanitize_for_linker_script("\"\\\n\t日");
assert_eq!(s, "");
assert_sanitize_json_agreement("\"\\\n\t日");
}
#[test]
fn format_version_parts_strips_semver_suffix() {
assert_eq!(
format_version_parts("5.2.100.0-PullRequest-123456", 4),
"5.2.100.0"
);
assert_eq!(
format_version_parts("5.2.100.0-PullRequest-123456", 3),
"5.2.100"
);
assert_eq!(format_version_parts("2.10.0-beta.3", 4), "2.10.0.0");
assert_eq!(format_version_parts("3.1.4+ci.42", 3), "3.1.4");
assert_eq!(format_version_parts("1.2.3.4", 4), "1.2.3.4");
assert_eq!(format_version_parts("1.2", 4), "1.2.0.0");
assert_eq!(format_version_parts("", 3), "0.0.0");
assert_eq!(format_version_parts("", 4), "0.0.0.0");
}
#[test]
fn sanitize_is_idempotent() {
let inputs = [
"Contoso© Fabrikam® Widgets™ / path*/here",
"Copyright (c) Contoso (2024), (r) (tm)",
"(c)(r)(tm) only",
"",
];
for raw in inputs {
let once = sanitize_for_linker_script(raw);
let mut current = once.clone();
for pass in 2..=4 {
let next = sanitize_for_linker_script(¤t);
assert_eq!(
next, once,
"sanitize pass {pass} for input {raw:?} diverged from pass 1 output {once:?}; got {next:?}"
);
current = next;
}
}
}
}