use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildEnvironment {
#[serde(skip_serializing_if = "Option::is_none")]
pub rustc_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cargo_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bazel_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nix_flake_lock_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nix_build: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wasm_tools_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_platform: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub additional_tools: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captured_at: Option<String>,
}
fn resolve_command_path(cmd: &str) -> Option<std::path::PathBuf> {
let env_key = format!("WSC_{}_PATH", cmd.to_uppercase().replace('-', "_"));
if let Ok(explicit_path) = std::env::var(&env_key) {
let path = std::path::PathBuf::from(explicit_path);
if path.exists() {
return Some(path);
}
log::warn!(
"{} set to {:?} but file does not exist — falling back to PATH",
env_key,
path
);
}
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let full = dir.join(cmd);
if full.is_file() {
Some(full)
} else {
None
}
})
})
}
fn capture_command_output(cmd: &str, args: &[&str]) -> Option<String> {
let resolved = resolve_command_path(cmd);
if let Some(ref path) = resolved {
log::debug!("Build env capture: {} resolved to {:?}", cmd, path);
} else {
log::debug!("Build env capture: {} not found in PATH", cmd);
}
let binary = resolved
.as_ref()
.map(|p| p.as_os_str())
.unwrap_or_else(|| std::ffi::OsStr::new(cmd));
Command::new(binary)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn read_bazel_version_file() -> Option<String> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(".bazelversion");
if candidate.is_file() {
return std::fs::read_to_string(candidate)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
if !dir.pop() {
return None;
}
}
}
fn hash_flake_lock() -> Option<String> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join("flake.lock");
if candidate.is_file() {
let bytes = std::fs::read(candidate).ok()?;
let hash = Sha256::digest(&bytes);
return Some(hex::encode(hash));
}
if !dir.pop() {
return None;
}
}
}
impl BuildEnvironment {
pub fn capture() -> Self {
let rustc_version = capture_command_output("rustc", &["--version"]);
let cargo_version = capture_command_output("cargo", &["--version"]);
let bazel_version = read_bazel_version_file().or_else(|| {
capture_command_output("bazel", &["--version"])
.and_then(|s| s.strip_prefix("bazel ").map(|v| v.to_string()))
});
let nix_flake_lock_hash = hash_flake_lock();
let nix_build = if std::env::var("IN_NIX_SHELL").is_ok()
|| std::env::var("NIX_BUILD_TOP").is_ok()
{
Some(true)
} else {
None
};
let wasm_tools_version = capture_command_output("wasm-tools", &["--version"]);
let host_platform = Some(format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS));
let os_version = capture_command_output("uname", &["-sr"])
.or_else(|| std::env::var("OS").ok());
let captured_at = Some(chrono::Utc::now().to_rfc3339());
Self {
rustc_version,
cargo_version,
bazel_version,
nix_flake_lock_hash,
nix_build,
wasm_tools_version,
host_platform,
os_version,
additional_tools: HashMap::new(),
captured_at,
}
}
pub fn from_env_vars() -> Self {
let mut env = Self::capture();
if let Ok(v) = std::env::var("WSC_RUSTC_VERSION") {
env.rustc_version = Some(v);
}
if let Ok(v) = std::env::var("WSC_CARGO_VERSION") {
env.cargo_version = Some(v);
}
if let Ok(v) = std::env::var("WSC_BAZEL_VERSION") {
env.bazel_version = Some(v);
}
if let Ok(v) = std::env::var("WSC_NIX_FLAKE_LOCK_HASH") {
env.nix_flake_lock_hash = Some(v);
}
if let Ok(v) = std::env::var("WSC_WASM_TOOLS_VERSION") {
env.wasm_tools_version = Some(v);
}
env
}
pub fn to_slsa_internal_params(&self) -> serde_json::Value {
serde_json::json!({
"buildEnvironment": self
})
}
pub fn with_tool(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
self.additional_tools.insert(name.into(), version.into());
self
}
pub fn is_reproducible(&self) -> bool {
self.nix_flake_lock_hash.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_capture() {
let env = BuildEnvironment::capture();
assert!(env.rustc_version.is_some());
assert!(env.cargo_version.is_some());
assert!(env.host_platform.is_some());
assert!(env.captured_at.is_some());
}
#[test]
fn test_from_env_vars_structure() {
let env = BuildEnvironment::from_env_vars();
assert!(env.rustc_version.is_some());
assert!(env.host_platform.is_some());
}
#[test]
fn test_to_slsa_internal_params() {
let env = BuildEnvironment {
rustc_version: Some("rustc 1.90.0".to_string()),
cargo_version: Some("cargo 1.90.0".to_string()),
bazel_version: Some("8.5.1".to_string()),
nix_flake_lock_hash: Some("abc123".to_string()),
nix_build: Some(true),
wasm_tools_version: None,
host_platform: Some("aarch64-macos".to_string()),
os_version: None,
additional_tools: HashMap::new(),
captured_at: Some("2026-03-18T00:00:00Z".to_string()),
};
let params = env.to_slsa_internal_params();
let be = ¶ms["buildEnvironment"];
assert_eq!(be["rustcVersion"], "rustc 1.90.0");
assert_eq!(be["bazelVersion"], "8.5.1");
assert_eq!(be["nixFlakeLockHash"], "abc123");
assert_eq!(be["nixBuild"], true);
assert_eq!(be["hostPlatform"], "aarch64-macos");
assert!(be.get("wasmToolsVersion").is_none());
assert!(be.get("osVersion").is_none());
}
#[test]
fn test_serialization_roundtrip() {
let env = BuildEnvironment::capture();
let json = serde_json::to_string_pretty(&env).unwrap();
let parsed: BuildEnvironment = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.rustc_version, env.rustc_version);
assert_eq!(parsed.cargo_version, env.cargo_version);
assert_eq!(parsed.host_platform, env.host_platform);
}
#[test]
fn test_with_tool() {
let env = BuildEnvironment::capture()
.with_tool("protoc", "3.21.0")
.with_tool("z3", "4.12.0");
assert_eq!(
env.additional_tools.get("protoc"),
Some(&"3.21.0".to_string())
);
assert_eq!(
env.additional_tools.get("z3"),
Some(&"4.12.0".to_string())
);
}
#[test]
fn test_is_reproducible() {
let mut env = BuildEnvironment::capture();
env.nix_flake_lock_hash = None;
assert!(!env.is_reproducible());
env.nix_flake_lock_hash = Some("abc123".to_string());
assert!(env.is_reproducible());
}
#[test]
fn test_skip_none_fields() {
let env = BuildEnvironment {
rustc_version: Some("rustc 1.90.0".to_string()),
cargo_version: None,
bazel_version: None,
nix_flake_lock_hash: None,
nix_build: None,
wasm_tools_version: None,
host_platform: None,
os_version: None,
additional_tools: HashMap::new(),
captured_at: None,
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("rustcVersion"));
assert!(!json.contains("cargoVersion"));
assert!(!json.contains("bazelVersion"));
assert!(!json.contains("nixFlakeLockHash"));
assert!(!json.contains("additionalTools"));
assert!(!json.contains("capturedAt"));
}
#[test]
fn test_capture_command_output_missing_tool() {
let result = capture_command_output("this-tool-definitely-does-not-exist-xyz", &["--version"]);
assert!(result.is_none());
}
#[test]
fn test_bazel_version_from_file() {
let _ = read_bazel_version_file();
}
}