use crate::marketplace::trust::TrustTier;
use crate::pipeline_engine::{Epoch, PassExecution};
use crate::utils::error::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildReceipt {
pub id: String,
pub epoch_id: String,
pub ontology_hash: String,
#[serde(default)]
pub closure_hashes: Vec<String>,
pub timestamp: String,
pub toolchain_version: String,
pub passes: Vec<PassExecution>,
pub outputs: Vec<OutputFile>,
pub outputs_hash: String,
pub is_valid: bool,
pub total_duration_ms: u64,
pub policies: ReceiptPolicies,
#[serde(default)]
pub packs: Vec<PackProvenance>,
#[serde(default)]
pub bundle_expansions: Vec<BundleExpansionRef>,
#[serde(default)]
pub profile: Option<ProfileRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackProvenance {
pub pack_id: String,
pub version: String,
pub signature: String,
pub digest: String,
#[serde(default)]
pub registry_type: Option<String>,
#[serde(default)]
pub origin_url: Option<String>,
#[serde(default)]
pub templates_contributed: Vec<String>,
#[serde(default)]
pub queries_contributed: Vec<String>,
#[serde(default)]
pub files_generated: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleExpansionRef {
pub bundle_id: String,
pub expanded_to: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRef {
pub profile_id: String,
#[serde(default)]
pub runtime_constraints: Vec<String>,
pub trust_requirement: TrustTier,
}
pub type FileInfo = OutputFile;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputFile {
pub path: PathBuf,
pub hash: String,
pub size_bytes: usize,
pub produced_by: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReceiptPolicies {
pub blank_node_policy: String,
pub ordering_policy: String,
pub formatting_policy: String,
pub active_guards: Vec<String>,
}
impl BuildReceipt {
pub fn new(
epoch: &Epoch, passes: Vec<PassExecution>, outputs: Vec<OutputFile>,
toolchain_version: &str,
) -> Self {
let timestamp = chrono::Utc::now().to_rfc3339();
let outputs_hash = Self::compute_outputs_hash(&outputs);
let total_duration_ms: u64 = passes.iter().map(|p| p.duration_ms).sum();
let is_valid = passes.iter().all(|p| p.success);
let ontology_hash = epoch.id.clone();
let mut receipt = Self {
id: String::new(), epoch_id: epoch.id.clone(),
ontology_hash,
closure_hashes: vec![format!("actuator:ggen-core@{}", toolchain_version)],
timestamp,
toolchain_version: toolchain_version.to_string(),
passes,
outputs,
outputs_hash,
is_valid,
total_duration_ms,
policies: ReceiptPolicies::default(),
packs: Vec::new(),
bundle_expansions: Vec::new(),
profile: None,
};
receipt.id = receipt.compute_id();
receipt
}
pub fn add_closure_input(&mut self, key: &str, content: &[u8]) {
let hash = format!("{:x}", Sha256::digest(content));
self.closure_hashes.push(format!("{}:{}", key, hash));
}
pub fn recompute_id(&mut self) {
self.id = self.compute_id();
}
pub fn add_pack(&mut self, pack: PackProvenance) {
self.packs.push(pack);
}
pub fn add_bundle_expansion(&mut self, expansion: BundleExpansionRef) {
self.bundle_expansions.push(expansion);
}
pub fn set_profile(&mut self, profile: ProfileRef) {
self.profile = Some(profile);
}
pub fn get_packs(&self) -> &[PackProvenance] {
&self.packs
}
pub fn get_bundle_expansions(&self) -> &[BundleExpansionRef] {
&self.bundle_expansions
}
pub fn get_profile(&self) -> Option<&ProfileRef> {
self.profile.as_ref()
}
pub fn generate(epoch: &Epoch, output_dir: &Path, toolchain_version: &str) -> Result<Self> {
let outputs = Self::scan_output_files(output_dir)?;
Ok(Self::new(epoch, Vec::new(), outputs, toolchain_version))
}
fn scan_output_files(output_dir: &Path) -> Result<Vec<OutputFile>> {
let mut outputs = Vec::new();
if !output_dir.exists() {
return Ok(outputs);
}
let entries = fs::read_dir(output_dir).map_err(|e| {
Error::new(&format!(
"Failed to read output directory '{}': {}",
output_dir.display(),
e
))
})?;
for entry in entries {
let entry =
entry.map_err(|e| Error::new(&format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_dir() {
continue;
}
let rel_path = path
.strip_prefix(output_dir)
.map_err(|e| Error::new(&format!("Failed to get relative path: {}", e)))?
.to_path_buf();
let content = fs::read(&path).map_err(|e| {
Error::new(&format!(
"Failed to read output file '{}': {}",
path.display(),
e
))
})?;
let hash = format!("{:x}", Sha256::digest(&content));
outputs.push(OutputFile {
path: rel_path,
hash,
size_bytes: content.len(),
produced_by: "μ₅:receipt".to_string(),
});
}
Ok(outputs)
}
fn compute_id(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.epoch_id.as_bytes());
hasher.update(self.toolchain_version.as_bytes());
hasher.update(self.outputs_hash.as_bytes());
for entry in &self.closure_hashes {
hasher.update(b"\n");
hasher.update(entry.as_bytes());
}
format!("{:x}", hasher.finalize())
}
fn compute_outputs_hash(outputs: &[OutputFile]) -> String {
let mut hasher = Sha256::new();
for output in outputs {
hasher.update(output.path.to_string_lossy().as_bytes());
hasher.update(b":");
hasher.update(output.hash.as_bytes());
hasher.update(b"\n");
}
format!("{:x}", hasher.finalize())
}
pub fn verify_outputs(&self, output_root: &Path) -> Result<bool> {
for output in &self.outputs {
let full_path = output_root.join(&output.path);
let current_hash = Self::hash_file(&full_path)?;
if current_hash != output.hash {
return Ok(false);
}
}
Ok(true)
}
pub fn verify(&self, output_root: &Path, epoch: &Epoch) -> Result<bool> {
if self.ontology_hash != epoch.id {
return Ok(false);
}
self.verify_outputs(output_root)
}
pub fn get_changed_outputs(&self, output_root: &Path) -> Result<Vec<PathBuf>> {
let mut changed = Vec::new();
for output in &self.outputs {
let full_path = output_root.join(&output.path);
if !full_path.exists() {
changed.push(output.path.clone());
continue;
}
let current_hash = Self::hash_file(&full_path)?;
if current_hash != output.hash {
changed.push(output.path.clone());
}
}
Ok(changed)
}
fn hash_file(path: &Path) -> Result<String> {
let content = std::fs::read(path)
.map_err(|e| Error::new(&format!("Failed to read file '{}': {}", path.display(), e)))?;
let hash = Sha256::digest(&content);
Ok(format!("{:x}", hash))
}
pub fn write_to_file(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::new(&format!(
"Failed to create receipt directory '{}': {}",
parent.display(),
e
))
})?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| Error::new(&format!("Failed to serialize receipt: {}", e)))?;
fs::write(path, json).map_err(|e| {
Error::new(&format!(
"Failed to write receipt to '{}': {}",
path.display(),
e
))
})
}
pub fn save(&self, project_root: &Path) -> Result<PathBuf> {
let receipts_dir = project_root.join(".ggen").join("receipts");
fs::create_dir_all(&receipts_dir).map_err(|e| {
Error::new(&format!(
"Failed to create receipts directory '{}': {}",
receipts_dir.display(),
e
))
})?;
let filename = format!("receipt-{}.json", self.timestamp.replace([':', '.'], "-"));
let receipt_path = receipts_dir.join(&filename);
self.write_to_file(&receipt_path)?;
let latest_path = project_root.join(".ggen").join("latest.json");
self.write_to_file(&latest_path)?;
Ok(receipt_path)
}
pub fn read_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::new(&format!(
"Failed to read receipt from '{}': {}",
path.display(),
e
))
})?;
serde_json::from_str(&content)
.map_err(|e| Error::new(&format!("Failed to parse receipt: {}", e)))
}
pub fn create_output(path: PathBuf, content: &[u8], produced_by: &str) -> OutputFile {
let hash = format!("{:x}", Sha256::digest(content));
OutputFile {
path,
hash,
size_bytes: content.len(),
produced_by: produced_by.to_string(),
}
}
pub fn with_policies(mut self, policies: ReceiptPolicies) -> Self {
self.policies = policies;
self
}
}
impl OutputFile {
pub fn from_path(full_path: &Path, rel_path: PathBuf, produced_by: &str) -> Result<Self> {
let content = std::fs::read(full_path).map_err(|e| {
Error::new(&format!(
"Failed to read output file '{}': {}",
full_path.display(),
e
))
})?;
Ok(Self {
path: rel_path,
hash: format!("{:x}", Sha256::digest(&content)),
size_bytes: content.len(),
produced_by: produced_by.to_string(),
})
}
}
pub fn generate_receipt(
epoch: &Epoch, output_dir: &Path, ggen_version: &str,
) -> Result<BuildReceipt> {
BuildReceipt::generate(epoch, output_dir, ggen_version)
}
pub fn save_receipt(receipt: &BuildReceipt, project_root: &Path) -> Result<PathBuf> {
receipt.save(project_root)
}
pub fn verify_receipt(receipt: &BuildReceipt, output_root: &Path, epoch: &Epoch) -> Result<bool> {
receipt.verify(output_root, epoch)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_epoch() -> Epoch {
Epoch {
id: "0123456789abcdef".repeat(4), timestamp: chrono::Utc::now().to_rfc3339(),
inputs: Default::default(),
total_triples: 0,
}
}
#[test]
fn test_receipt_creation() {
let epoch = create_test_epoch();
let outputs = vec![OutputFile {
path: PathBuf::from("domain.ttl"),
hash: "fedcba9876543210".repeat(4), size_bytes: 1024,
produced_by: "μ₃:emission".to_string(),
}];
let receipt = BuildReceipt::new(&epoch, vec![], outputs, "6.0.0");
assert_eq!(receipt.id.len(), 64);
assert_eq!(receipt.toolchain_version, "6.0.0");
assert_eq!(receipt.outputs.len(), 1);
assert!(receipt.is_valid);
}
#[test]
fn test_closure_binding_changes_receipt_id() {
let epoch = create_test_epoch();
let baseline = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
assert_eq!(
baseline.closure_hashes.len(),
1,
"actuator identity must always be bound"
);
assert!(baseline.closure_hashes[0].starts_with("actuator:ggen-core@6.0.0"));
let mut with_template = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
with_template.add_closure_input("template:foo.tera", b"Hello {{ name }}");
with_template.recompute_id();
assert_ne!(
baseline.id, with_template.id,
"binding a template must change the receipt id"
);
let mut changed_template = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
changed_template.add_closure_input("template:foo.tera", b"Goodbye {{ name }}");
changed_template.recompute_id();
assert_ne!(
with_template.id, changed_template.id,
"changing template content must change the receipt id even though epoch is identical"
);
assert_eq!(changed_template.id.len(), 64);
}
#[test]
fn test_receipt_verify_outputs() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("domain.ttl");
let content = b"@prefix ex: <http://example.org/> .";
std::fs::write(&output_path, content).unwrap();
let epoch = create_test_epoch();
let outputs = vec![OutputFile {
path: PathBuf::from("domain.ttl"),
hash: format!("{:x}", Sha256::digest(content)),
size_bytes: content.len(),
produced_by: "μ₃:emission".to_string(),
}];
let receipt = BuildReceipt::new(&epoch, vec![], outputs, "6.0.0");
assert!(receipt.verify_outputs(temp_dir.path()).unwrap());
std::fs::write(&output_path, b"fn main() { println!(\"hello\"); }").unwrap();
assert!(!receipt.verify_outputs(temp_dir.path()).unwrap());
}
#[test]
fn test_receipt_serialization() {
let temp_dir = TempDir::new().unwrap();
let receipt_path = temp_dir.path().join("receipt.json");
let epoch = create_test_epoch();
let receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
receipt.write_to_file(&receipt_path).unwrap();
let loaded = BuildReceipt::read_from_file(&receipt_path).unwrap();
assert_eq!(receipt.id, loaded.id);
assert_eq!(receipt.epoch_id, loaded.epoch_id);
}
#[test]
fn test_generate_receipt() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
fs::write(output_dir.join("agent.rs"), "pub struct Agent {}").unwrap();
fs::write(output_dir.join("message.rs"), "pub struct Message {}").unwrap();
let epoch = create_test_epoch();
let receipt = generate_receipt(&epoch, &output_dir, "6.0.0").unwrap();
assert!(!receipt.id.is_empty());
assert_eq!(receipt.toolchain_version, "6.0.0");
assert_eq!(receipt.outputs.len(), 2);
assert_eq!(receipt.ontology_hash, epoch.id);
}
#[test]
fn test_save_receipt() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
fs::write(output_dir.join("agent.rs"), "pub struct Agent {}").unwrap();
let epoch = create_test_epoch();
let receipt = BuildReceipt::generate(&epoch, &output_dir, "6.0.0").unwrap();
let saved_path = save_receipt(&receipt, temp_dir.path()).unwrap();
assert!(saved_path.starts_with(temp_dir.path().join(".ggen/receipts")));
assert!(saved_path.exists());
let latest_path = temp_dir.path().join(".ggen/latest.json");
assert!(latest_path.exists());
}
#[test]
fn test_verify_receipt_valid() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
let content = b"pub struct Agent {}";
fs::write(output_dir.join("agent.rs"), content).unwrap();
let epoch = create_test_epoch();
let receipt = BuildReceipt::generate(&epoch, &output_dir, "6.0.0").unwrap();
let is_valid = verify_receipt(&receipt, &output_dir, &epoch).unwrap();
assert!(is_valid);
}
#[test]
fn test_verify_receipt_invalid_file() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
let content = b"pub struct Agent {}";
fs::write(output_dir.join("agent.rs"), content).unwrap();
let epoch = create_test_epoch();
let receipt = BuildReceipt::generate(&epoch, &output_dir, "6.0.0").unwrap();
fs::write(output_dir.join("agent.rs"), b"modified content").unwrap();
let is_valid = verify_receipt(&receipt, &output_dir, &epoch).unwrap();
assert!(!is_valid);
}
#[test]
fn test_verify_receipt_invalid_epoch() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
fs::write(output_dir.join("agent.rs"), b"pub struct Agent {}").unwrap();
let epoch1 = create_test_epoch();
let epoch2 = Epoch {
id: "differenthash".repeat(4),
timestamp: chrono::Utc::now().to_rfc3339(),
inputs: Default::default(),
total_triples: 0,
};
let receipt = BuildReceipt::generate(&epoch1, &output_dir, "6.0.0").unwrap();
let is_valid = verify_receipt(&receipt, &output_dir, &epoch2).unwrap();
assert!(!is_valid);
}
#[test]
fn test_receipt_with_ontology_hash() {
let epoch = create_test_epoch();
let receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
assert_eq!(receipt.ontology_hash, epoch.id);
assert_eq!(receipt.ontology_hash, receipt.epoch_id);
}
#[test]
fn test_scan_output_files_empty() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&output_dir).unwrap();
let outputs = BuildReceipt::scan_output_files(&output_dir).unwrap();
assert_eq!(outputs.len(), 0);
}
#[test]
fn test_scan_output_files_with_subdirectories() {
let temp_dir = TempDir::new().unwrap();
let output_dir = temp_dir.path().join("output");
let subdir = output_dir.join("subdir");
fs::create_dir_all(&subdir).unwrap();
fs::write(output_dir.join("main.rs"), "fn main() {}").unwrap();
fs::write(subdir.join("lib.rs"), "pub fn lib() {}").unwrap();
fs::write(subdir.join("nested.rs"), "pub fn nested() {}").unwrap();
let outputs = BuildReceipt::scan_output_files(&output_dir).unwrap();
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].path, PathBuf::from("main.rs"));
}
#[test]
fn test_pack_provenance() {
let epoch = create_test_epoch();
let mut receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
let pack = PackProvenance {
pack_id: "surface-mcp".to_string(),
version: "1.0.0".to_string(),
signature: "ed25519:abc123".to_string(),
digest: "sha256:def456".to_string(),
registry_type: Some("npm".to_string()),
origin_url: Some("https://registry.npmjs.org/surface-mcp".to_string()),
templates_contributed: vec!["mcp/server.tera".to_string()],
queries_contributed: vec!["mcp/tools.sparql".to_string()],
files_generated: vec!["src/mcp/server.rs".to_string()],
};
receipt.add_pack(pack);
assert_eq!(receipt.packs.len(), 1);
assert_eq!(receipt.packs[0].pack_id, "surface-mcp");
assert_eq!(receipt.packs[0].templates_contributed.len(), 1);
assert_eq!(receipt.packs[0].files_generated.len(), 1);
}
#[test]
fn test_bundle_expansion() {
let epoch = create_test_epoch();
let mut receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
let expansion = BundleExpansionRef {
bundle_id: "mcp-rust".to_string(),
expanded_to: vec![
"surface-mcp".to_string(),
"projection-rust".to_string(),
"runtime-axum".to_string(),
],
};
receipt.add_bundle_expansion(expansion);
assert_eq!(receipt.bundle_expansions.len(), 1);
assert_eq!(receipt.bundle_expansions[0].bundle_id, "mcp-rust");
assert_eq!(receipt.bundle_expansions[0].expanded_to.len(), 3);
}
#[test]
fn test_profile_ref() {
let epoch = create_test_epoch();
let mut receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
let profile = ProfileRef {
profile_id: "enterprise-strict".to_string(),
runtime_constraints: vec!["require-explicit-runtime".to_string()],
trust_requirement: TrustTier::EnterpriseCertified,
};
receipt.set_profile(profile);
assert!(receipt.profile.is_some());
assert_eq!(
receipt.profile.as_ref().unwrap().profile_id,
"enterprise-strict"
);
assert_eq!(
receipt.profile.as_ref().unwrap().trust_requirement,
TrustTier::EnterpriseCertified
);
}
#[test]
fn test_receipt_with_full_provenance() {
let epoch = create_test_epoch();
let mut receipt = BuildReceipt::new(&epoch, vec![], vec![], "6.0.0");
receipt.add_pack(PackProvenance {
pack_id: "surface-mcp".to_string(),
version: "1.0.0".to_string(),
signature: "sig1".to_string(),
digest: "digest1".to_string(),
registry_type: Some("local".to_string()),
origin_url: None,
templates_contributed: vec!["template1.tera".to_string()],
queries_contributed: vec!["query1.sparql".to_string()],
files_generated: vec!["file1.rs".to_string()],
});
receipt.add_bundle_expansion(BundleExpansionRef {
bundle_id: "mcp-rust".to_string(),
expanded_to: vec!["surface-mcp".to_string(), "projection-rust".to_string()],
});
receipt.set_profile(ProfileRef {
profile_id: "regulated-finance".to_string(),
runtime_constraints: vec![],
trust_requirement: TrustTier::EnterpriseCertified,
});
assert_eq!(receipt.packs.len(), 1);
assert_eq!(receipt.bundle_expansions.len(), 1);
assert!(receipt.profile.is_some());
}
}