use std::{collections::BTreeMap, env, fs, path::Path, process::Command};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
#[derive(Debug)]
pub enum BuildTimeFingerprintError {
CargoMetadataFailed(String),
CargoMetadataNotUtf8,
CargoMetadataInvalidJson(String),
CargoLockNotFound(String),
IoError(std::io::Error),
SerializationFailed(String),
EnvVarMissing(&'static str),
}
impl std::fmt::Display for BuildTimeFingerprintError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CargoMetadataFailed(e) => write!(f, "cargo metadata failed: {e}"),
Self::CargoMetadataNotUtf8 => write!(f, "cargo metadata output is not valid UTF-8"),
Self::CargoMetadataInvalidJson(e) => write!(f, "cargo metadata output is invalid JSON: {e}"),
Self::CargoLockNotFound(p) => write!(f, "Cargo.lock not found at: {p}"),
Self::IoError(e) => write!(f, "I/O error: {e}"),
Self::SerializationFailed(e) => write!(f, "serialisation failed: {e}"),
Self::EnvVarMissing(v) => write!(f, "required env var not set: {v} (is this running inside a Cargo build script?)"),
}
}
}
impl std::error::Error for BuildTimeFingerprintError {}
impl From<std::io::Error> for BuildTimeFingerprintError {
fn from(e: std::io::Error) -> Self { Self::IoError(e) }
}
pub fn generate_fingerprint(export: bool) -> Result<(), BuildTimeFingerprintError> {
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=Cargo.lock");
let out_dir = env::var("OUT_DIR")
.map_err(|_| BuildTimeFingerprintError::EnvVarMissing("OUT_DIR"))?;
let manifest_dir = env::var("CARGO_MANIFEST_DIR")
.map_err(|_| BuildTimeFingerprintError::EnvVarMissing("CARGO_MANIFEST_DIR"))?;
let fingerprint = build_fingerprint()?;
let compact = serde_json::to_string(&fingerprint)
.map_err(|e| BuildTimeFingerprintError::SerializationFailed(e.to_string()))?;
write_atomic(&format!("{out_dir}/fingerprint.json"), compact.as_bytes())?;
if export {
let pretty = serde_json::to_string_pretty(&fingerprint)
.map_err(|e| BuildTimeFingerprintError::SerializationFailed(e.to_string()))?;
write_atomic(&format!("{manifest_dir}/fingerprint.json"), pretty.as_bytes())?;
}
Ok(())
}
pub fn export(enabled: bool) -> Result<(), BuildTimeFingerprintError> {
if !enabled { return Ok(()); }
let manifest_dir = env::var("CARGO_MANIFEST_DIR")
.map_err(|_| BuildTimeFingerprintError::EnvVarMissing("CARGO_MANIFEST_DIR"))?;
let fingerprint = build_fingerprint()?;
let pretty = serde_json::to_string_pretty(&fingerprint)
.map_err(|e| BuildTimeFingerprintError::SerializationFailed(e.to_string()))?;
write_atomic(&format!("{manifest_dir}/fingerprint.json"), pretty.as_bytes())?;
Ok(())
}
fn write_atomic(path: &str, data: &[u8]) -> Result<(), BuildTimeFingerprintError> {
let tmp = format!("{path}.tmp");
fs::write(&tmp, data)?;
fs::rename(&tmp, path)?;
Ok(())
}
fn build_fingerprint() -> Result<Value, BuildTimeFingerprintError> {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let pkg_name = env::var("CARGO_PKG_NAME").unwrap_or_default();
let pkg_version = env::var("CARGO_PKG_VERSION").unwrap_or_default();
let profile = env::var("PROFILE").unwrap_or_default();
let opt_level = env::var("OPT_LEVEL").unwrap_or_default();
let target = env::var("TARGET").unwrap_or_default();
let mut features: Vec<String> = env::vars()
.filter_map(|(k, _)| {
k.strip_prefix("CARGO_FEATURE_")
.map(|feat| feat.to_lowercase().replace('_', "-"))
})
.collect();
features.sort_unstable();
let rustc_version = Command::new("rustc")
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_owned())
.unwrap_or_else(|| "unknown".to_owned());
let cargo_bin = env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
let meta_out = Command::new(&cargo_bin)
.args([
"metadata",
"--format-version=1",
"--manifest-path",
&format!("{manifest_dir}/Cargo.toml"),
])
.output()
.map_err(|e| BuildTimeFingerprintError::CargoMetadataFailed(e.to_string()))?;
if !meta_out.status.success() {
let err = String::from_utf8_lossy(&meta_out.stderr).to_string();
return Err(BuildTimeFingerprintError::CargoMetadataFailed(err));
}
let meta_str = String::from_utf8(meta_out.stdout)
.map_err(|_| BuildTimeFingerprintError::CargoMetadataNotUtf8)?;
let meta_raw: Value = serde_json::from_str(&meta_str)
.map_err(|e| BuildTimeFingerprintError::CargoMetadataInvalidJson(e.to_string()))?;
let meta_clean = strip_absolute_paths(meta_raw);
let meta_normalised = normalise_json(meta_clean);
let lock_path = format!("{manifest_dir}/Cargo.lock");
if !Path::new(&lock_path).exists() {
return Err(BuildTimeFingerprintError::CargoLockNotFound(lock_path));
}
let lock_raw = fs::read(&lock_path)?;
let lock_stripped: Vec<u8> = lock_raw
.split(|&b| b == b'\n')
.filter(|line| !line.starts_with(b"#"))
.flat_map(|line| line.iter().chain(std::iter::once(&b'\n')))
.copied()
.collect();
let lock_sha256 = hex_sha256(&lock_stripped);
let src_dir = format!("{manifest_dir}/src");
let source_hashes = hash_source_tree(&src_dir, &manifest_dir)?;
let fingerprint = json!({
"package": {
"name": pkg_name,
"version": pkg_version,
},
"build": {
"features": features,
"opt_level": opt_level,
"profile": profile,
"rustc_version": rustc_version,
"target": target,
},
"cargo_lock_sha256": lock_sha256,
"deps": meta_normalised,
"source": source_hashes,
});
Ok(normalise_json(fingerprint))
}
fn normalise_json(value: Value) -> Value {
match value {
Value::Object(map) => {
let sorted: Map<String, Value> = map
.into_iter()
.map(|(k, v)| (k, normalise_json(v)))
.collect();
Value::Object(sorted)
}
Value::Array(arr) => {
let mut items: Vec<Value> = arr.into_iter().map(normalise_json).collect();
items.sort_by(|a, b| array_sort_key(a).cmp(&array_sort_key(b)));
Value::Array(items)
}
other => other,
}
}
fn array_sort_key(v: &Value) -> String {
if let Some(obj) = v.as_object() {
if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {
return id.to_owned();
}
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let ver = obj.get("version").and_then(|v| v.as_str()).unwrap_or("");
if !name.is_empty() {
return format!("{name}@{ver}");
}
}
serde_json::to_string(v).unwrap_or_default()
}
fn strip_absolute_paths(value: Value) -> Value {
match value {
Value::Object(mut map) => {
for key in &[
"workspace_root",
"workspace_members",
"workspace_default_members",
"target_directory",
"build_directory",
"manifest_path",
"src_path",
"path",
] {
map.remove(*key);
}
Value::Object(
map.into_iter()
.map(|(k, v)| (k, strip_absolute_paths(v)))
.collect(),
)
}
Value::Array(arr) => {
Value::Array(arr.into_iter().map(strip_absolute_paths).collect())
}
other => other,
}
}
fn hex_sha256(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
fn hash_source_tree(
src_dir: &str,
manifest_dir: &str,
) -> Result<BTreeMap<String, String>, BuildTimeFingerprintError> {
let mut map = BTreeMap::new();
visit_rs_files(Path::new(src_dir), Path::new(manifest_dir), &mut map)?;
Ok(map)
}
fn visit_rs_files(
dir: &Path,
base: &Path,
map: &mut BTreeMap<String, String>,
) -> Result<(), BuildTimeFingerprintError> {
if !dir.exists() {
return Ok(());
}
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
if path.is_dir() {
visit_rs_files(&path, base, map)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let contents = fs::read(&path)?;
map.insert(rel, format!("sha256:{}", hex_sha256(&contents)));
}
}
Ok(())
}