use std::{path::Path, process::Command};
use serde::Serialize;
use crate::{
error::{AppError, Result},
pack::{dependency_scope, pack_digest_sha256, Pack, ScopeDepth},
};
pub const PORTABLE_RECEIPT_REL_PATH: &str = ".ggen-v2/receipt-portable.json";
pub const PORTABLE_RECEIPT_SCHEMA: &str = "https://ggen.dev/receipt/pack/v1";
pub const PORTABLE_RECEIPT_SPEC: &str = "RFC-GPACK-001-v26.9.17";
pub const WORK_ORDER_REL_PATH: &str = "work-order.ttl";
const STANDING_ALIVE: &str = "ALIVE";
const STANDING_PARTIAL_ALIVE: &str = "PARTIAL_ALIVE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortableStanding {
Alive,
PartialAlive,
Refused(String),
}
impl std::fmt::Display for PortableStanding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Alive => f.write_str(STANDING_ALIVE),
Self::PartialAlive => f.write_str(STANDING_PARTIAL_ALIVE),
Self::Refused(code) => write!(f, "REFUSED:{code}"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableEngine {
pub name: &'static str,
pub version: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableToolchain {
pub rustc: String,
pub cargo: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableEnvironment {
pub os: &'static str,
pub arch: &'static str,
pub family: &'static str,
pub variables_count: usize,
pub variables_sha256: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableSubject {
pub pack: String,
pub version: String,
pub pack_digest: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableDependency {
pub name: String,
pub version: String,
pub digest: String,
pub scope: Vec<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortablePackIdentity {
pub name: String,
pub version: String,
pub digest: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableComposition {
pub resolved_packs: Vec<PortablePackIdentity>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableGraph {
pub canonical_digest: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableAdmission {
pub gates_attempted: Vec<String>,
pub refusals: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableConsequence {
pub target: String,
pub operation: &'static str,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableReplay {
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableWorkOrder {
pub source: String,
pub canonical_digest: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PortableReceiptEnvelope {
pub schema: &'static str,
pub spec: &'static str,
pub engine: PortableEngine,
pub toolchain: PortableToolchain,
pub environment: PortableEnvironment,
pub subject: PortableSubject,
pub dependencies: Vec<PortableDependency>,
pub composition: PortableComposition,
pub graph: PortableGraph,
pub admission: PortableAdmission,
pub consequences: Vec<PortableConsequence>,
pub work_order: PortableWorkOrder,
pub replay: PortableReplay,
pub standing: String,
}
fn operation_for_decision(decision: &str) -> Option<&'static str> {
if decision == "written" || decision.starts_with("written;") {
Some("MANAGED_WRITE")
} else if decision == "injected" || decision.starts_with("injected;") {
Some("MANAGED_INJECT")
} else {
None
}
}
fn build_consequences(
root: &Path, decisions: &std::collections::BTreeMap<String, String>,
) -> Result<Vec<PortableConsequence>> {
use sha2::Digest as _;
let mut consequences = Vec::new();
for (target, decision) in decisions {
let Some(operation) = operation_for_decision(decision) else {
continue;
};
let path = root.join(target);
let bytes = std::fs::read(&path).map_err(|e| {
AppError::fm_chain(
15,
format!(
"portable envelope: consequence `{target}` (decision \
`{decision}`) cannot be read for digest binding: {e}. \
Remediation: fix the file's permissions."
),
)
})?;
let digest = sha2::Sha256::digest(&bytes);
consequences.push(PortableConsequence {
target: target.clone(),
operation,
sha256: hex::encode(digest),
});
}
Ok(consequences)
}
fn sha256_prefixed(bytes: &[u8]) -> String {
use sha2::Digest as _;
format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes)))
}
fn command_identity(env_key: &str, fallback: &str) -> Result<String> {
let program = std::env::var_os(env_key).unwrap_or_else(|| fallback.into());
let output = Command::new(&program)
.args(["--version", "--verbose"])
.output()
.map_err(|e| {
AppError::fm_chain(
18,
format!(
"portable envelope: cannot observe {fallback} toolchain identity via {}: {e}",
program.to_string_lossy()
),
)
})?;
if !output.status.success() {
return Err(AppError::fm_chain(
18,
format!(
"portable envelope: {fallback} toolchain identity command exited {:?}",
output.status.code()
),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn observe_toolchain_identity() -> Result<PortableToolchain> {
Ok(PortableToolchain {
rustc: command_identity("RUSTC", "rustc")?,
cargo: command_identity("CARGO", "cargo")?,
})
}
fn secret_like_env_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
[
"TOKEN",
"SECRET",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"API_KEY",
"PRIVATE_KEY",
]
.iter()
.any(|needle| upper.contains(needle))
}
fn identity_bearing_env_key(key: &str) -> bool {
matches!(
key,
"CARGO"
| "CARGO_BUILD_TARGET"
| "HOME"
| "LANG"
| "LC_ALL"
| "PATH"
| "RUSTC"
| "RUSTFLAGS"
| "SOURCE_DATE_EPOCH"
| "TZ"
) || key.starts_with("GGEN_")
}
pub(crate) fn observe_environment_identity() -> Result<PortableEnvironment> {
let variables = std::env::vars_os()
.filter_map(|(key, value)| {
let key = key.into_string().ok()?;
let value = value.into_string().ok()?;
(identity_bearing_env_key(&key) && !secret_like_env_key(&key)).then_some((key, value))
})
.collect::<std::collections::BTreeMap<_, _>>();
let bytes = serde_json::to_vec(&variables)?;
Ok(PortableEnvironment {
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
family: std::env::consts::FAMILY,
variables_count: variables.len(),
variables_sha256: sha256_prefixed(&bytes),
})
}
fn build_subject(packs: &[Pack]) -> Result<PortableSubject> {
match packs.first() {
Some(pack) => {
let digest = pack_digest_sha256(pack)?;
Ok(PortableSubject {
pack: pack.name.clone(),
version: pack.version.clone(),
pack_digest: format!("sha256:{}", crate::sync::hex32(&digest)),
})
}
None => Ok(PortableSubject {
pack: "UNKNOWN".to_string(),
version: "UNKNOWN".to_string(),
pack_digest: "UNKNOWN".to_string(),
}),
}
}
fn build_composition(packs: &[Pack]) -> Result<PortableComposition> {
let mut resolved_packs = Vec::with_capacity(packs.len());
for pack in packs {
let digest = pack_digest_sha256(pack)?;
resolved_packs.push(PortablePackIdentity {
name: pack.name.clone(),
version: pack.version.clone(),
digest: format!("sha256:{}", crate::sync::hex32(&digest)),
});
}
resolved_packs.sort_by(|left, right| {
(&left.name, &left.version, &left.digest).cmp(&(&right.name, &right.version, &right.digest))
});
Ok(PortableComposition { resolved_packs })
}
fn build_dependencies(packs: &[Pack]) -> Result<Vec<PortableDependency>> {
let Some(subject) = packs.first() else {
return Ok(Vec::new());
};
let scoped = dependency_scope(packs, &subject.name, ScopeDepth::Transitive)?;
let mut dependencies = Vec::with_capacity(scoped.len().saturating_sub(1));
for pack in scoped.into_iter().skip(1) {
let digest = pack_digest_sha256(pack)?;
dependencies.push(PortableDependency {
name: pack.name.clone(),
version: pack.version.clone(),
digest: format!("sha256:{}", crate::sync::hex32(&digest)),
scope: vec!["SEMANTICS", "LAW", "PROJECTION"],
});
}
Ok(dependencies)
}
fn build_work_order(root: &Path) -> Result<PortableWorkOrder> {
let path = root.join(WORK_ORDER_REL_PATH);
if !path.is_file() {
return Ok(PortableWorkOrder {
source: "UNKNOWN".to_string(),
canonical_digest: "UNKNOWN".to_string(),
});
}
let ttl = std::fs::read_to_string(&path)?;
let graph = crate::graph::DeterministicGraph::new()?;
graph.insert_turtle(&ttl)?;
let hash = graph.state_hash()?;
Ok(PortableWorkOrder {
source: WORK_ORDER_REL_PATH.to_string(),
canonical_digest: crate::sync::hex32(&hash),
})
}
pub fn write_portable_envelope(
root: &Path, packs: &[Pack], graph_hash_hex: &str, gates_attempted: &[String],
refusals: &[String], standing: &PortableStanding,
decisions: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
let envelope = PortableReceiptEnvelope {
schema: PORTABLE_RECEIPT_SCHEMA,
spec: PORTABLE_RECEIPT_SPEC,
engine: PortableEngine {
name: "ggen",
version: env!("CARGO_PKG_VERSION"),
},
toolchain: observe_toolchain_identity()?,
environment: observe_environment_identity()?,
subject: build_subject(packs)?,
dependencies: build_dependencies(packs)?,
composition: build_composition(packs)?,
graph: PortableGraph {
canonical_digest: graph_hash_hex.to_string(),
},
admission: PortableAdmission {
gates_attempted: gates_attempted.to_vec(),
refusals: refusals.to_vec(),
},
consequences: build_consequences(root, decisions)?,
work_order: build_work_order(root)?,
replay: PortableReplay {
status: "UNKNOWN".to_string(),
},
standing: standing.to_string(),
};
let path = root.join(PORTABLE_RECEIPT_REL_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_vec(&envelope)?)?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn only_landed_writes_are_consequences() {
assert_eq!(operation_for_decision("written"), Some("MANAGED_WRITE"));
assert_eq!(
operation_for_decision("written; match=x"),
Some("MANAGED_WRITE")
);
assert_eq!(operation_for_decision("injected"), Some("MANAGED_INJECT"));
assert_eq!(operation_for_decision("skipped: when guard false"), None);
assert_eq!(operation_for_decision("planned: write (dry-run)"), None);
assert_eq!(operation_for_decision(""), None);
}
#[test]
fn standing_renders_exact_rfc82_vocabulary() {
assert_eq!(PortableStanding::Alive.to_string(), "ALIVE");
assert_eq!(PortableStanding::PartialAlive.to_string(), "PARTIAL_ALIVE");
assert_eq!(
PortableStanding::Refused("GATE_VIOLATION".to_string()).to_string(),
"REFUSED:GATE_VIOLATION"
);
}
#[test]
fn packless_subject_is_unknown_not_invented() {
let subject = build_subject(&[]).expect("subject");
assert_eq!(subject.pack, "UNKNOWN");
assert_eq!(subject.version, "UNKNOWN");
assert_eq!(subject.pack_digest, "UNKNOWN");
}
}