use crate::error::{RailError, RailResult};
use crate::git::SystemGit;
use crate::utils::{canonicalize_existing, file_fingerprint};
use crate::workspace::WorkspaceContext;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
pub(crate) mod git_effect;
pub const MUTATION_CONTRACT_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationPlan {
pub contract_version: u32,
#[serde(default)]
pub operation: String,
pub operation_id: String,
pub inputs_fingerprint: String,
pub resolved_refs: MutationResolvedRefs,
pub actions: Vec<MutationAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declared_inputs: Vec<MutationInput>,
pub risks: Vec<MutationRisk>,
pub trace: Vec<MutationTrace>,
pub pre_apply: MutationPreApplyChecks,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationResolvedRefs {
pub git_head: String,
pub git_branch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MutationAction {
pub code: String,
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expected_mutations: Vec<ExpectedMutation>,
}
impl MutationAction {
pub fn new(code: impl Into<String>, target: impl Into<String>, detail: Option<String>) -> Self {
Self {
code: code.into(),
target: target.into(),
detail,
payload: serde_json::Value::Null,
expected_mutations: Vec::new(),
}
}
pub fn with_payload(mut self, payload: serde_json::Value) -> Self {
self.payload = payload;
self
}
pub fn with_mutations(mut self, mutations: Vec<ExpectedMutation>) -> Self {
self.expected_mutations = mutations;
self
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MutationEffect {
Write,
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExpectedMutation {
pub path: PathBuf,
pub effect: MutationEffect,
pub before_fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MutationInput {
pub path: PathBuf,
pub fingerprint: String,
}
impl MutationInput {
pub fn capture(git: &SystemGit, worktree_root: &Path, path: PathBuf) -> RailResult<Self> {
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(&path)
};
Ok(Self {
path,
fingerprint: git_path_fingerprint(git, &absolute)?,
})
}
}
impl ExpectedMutation {
pub fn capture(workspace_root: &Path, path: PathBuf, effect: MutationEffect) -> Self {
let absolute = if path.is_absolute() {
path.clone()
} else {
workspace_root.join(&path)
};
Self {
path,
effect,
before_fingerprint: file_fingerprint(&absolute),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MutationRisk {
pub code: String,
pub severity: String,
pub message: String,
}
impl MutationRisk {
pub fn new(code: impl Into<String>, severity: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
severity: severity.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationTrace {
pub code: String,
pub message: String,
}
impl MutationTrace {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MutationPreApplyChecks {
pub git_head: String,
pub config_fingerprint: String,
pub toolchain_fingerprint: String,
pub lock_fingerprint: String,
pub metadata_fingerprint: String,
#[serde(default)]
pub worktree_fingerprint: String,
#[serde(default)]
pub changed_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MutationObject {
pub kind: String,
pub name: String,
pub oid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationReceipt {
pub contract_version: u32,
pub operation_id: String,
pub operation: String,
pub phase: String,
pub status: String,
pub timestamp_utc: String,
pub plan: MutationPlan,
pub trace: Vec<MutationTrace>,
pub verified_inputs: MutationPreApplyChecks,
pub applied_actions: Vec<MutationAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resulting_objects: Vec<MutationObject>,
}
pub fn build_plan(
ctx: &WorkspaceContext,
operation: &str,
actions: Vec<MutationAction>,
risks: Vec<MutationRisk>,
trace: Vec<MutationTrace>,
) -> RailResult<MutationPlan> {
build_plan_with_inputs(ctx, operation, actions, Vec::new(), risks, trace)
}
pub fn build_plan_with_inputs(
ctx: &WorkspaceContext,
operation: &str,
actions: Vec<MutationAction>,
declared_inputs: Vec<MutationInput>,
risks: Vec<MutationRisk>,
trace: Vec<MutationTrace>,
) -> RailResult<MutationPlan> {
let resolved_refs = MutationResolvedRefs {
git_head: ctx.git()?.git().head_commit()?,
git_branch: ctx.git()?.current_branch()?,
};
let pre_apply = capture_pre_apply_checks(ctx)?;
let inputs_fingerprint = mutation_fingerprint(
ctx.git()?.git(),
operation,
&resolved_refs,
&pre_apply,
&actions,
&declared_inputs,
&risks,
)?;
let operation_id = build_operation_id(operation, &inputs_fingerprint);
Ok(MutationPlan {
contract_version: MUTATION_CONTRACT_VERSION,
operation: operation.to_string(),
operation_id,
inputs_fingerprint,
resolved_refs,
actions,
declared_inputs,
risks,
trace,
pre_apply,
})
}
pub fn validate_pre_apply(ctx: &WorkspaceContext, plan: &MutationPlan) -> RailResult<()> {
validate_pre_apply_with_allowed_paths(ctx, plan, &[])
}
pub fn validate_pre_apply_with_allowed_paths(
ctx: &WorkspaceContext,
plan: &MutationPlan,
allowed_paths: &[PathBuf],
) -> RailResult<()> {
if plan.contract_version != MUTATION_CONTRACT_VERSION {
return Err(RailError::with_help(
format!(
"unsupported mutation contract version {} (expected {})",
plan.contract_version, MUTATION_CONTRACT_VERSION
),
"regenerate the mutation plan with this cargo-rail version",
));
}
let expected_fingerprint = mutation_fingerprint(
ctx.git()?.git(),
&plan.operation,
&plan.resolved_refs,
&plan.pre_apply,
&plan.actions,
&plan.declared_inputs,
&plan.risks,
)?;
let expected_operation_id = build_operation_id(&plan.operation, &expected_fingerprint);
if plan.inputs_fingerprint != expected_fingerprint || plan.operation_id != expected_operation_id {
return Err(RailError::with_help(
"mutation plan integrity check failed",
"use the plan exactly as emitted; regenerate it if the file was edited",
));
}
let mut current = capture_pre_apply_checks(ctx)?;
if !allowed_paths.is_empty() {
let git_root = canonicalize_existing(&ctx.git()?.git().worktree_root)?;
let allowed = allowed_worktree_paths(&git_root, allowed_paths)?;
current.changed_paths.retain(|path| !allowed.contains(path));
current.worktree_fingerprint = fingerprint_changed_paths(ctx.git()?.git(), &git_root, ¤t.changed_paths)?;
current.metadata_fingerprint = format!("snapshot:{}", ctx.snapshot()?.id_excluding_paths(&allowed)?);
}
let git = ctx.git()?.git();
let mut changed_inputs = Vec::new();
for input in &plan.declared_inputs {
let current_fingerprint = git_path_fingerprint(git, &git.worktree_root.join(&input.path))?;
if current_fingerprint != input.fingerprint {
changed_inputs.push(format!(
"{} changed (planned {}, current {})",
input.path.display(),
input.fingerprint,
current_fingerprint
));
}
}
if current == plan.pre_apply && changed_inputs.is_empty() {
return Ok(());
}
let mut reasons = Vec::new();
if current.git_head != plan.pre_apply.git_head {
reasons.push(format!(
"git_head changed (planned {}, current {})",
plan.pre_apply.git_head, current.git_head
));
}
if current.config_fingerprint != plan.pre_apply.config_fingerprint {
reasons.push(format!(
"config fingerprint changed (planned {}, current {})",
plan.pre_apply.config_fingerprint, current.config_fingerprint
));
}
if current.toolchain_fingerprint != plan.pre_apply.toolchain_fingerprint {
reasons.push(format!(
"toolchain fingerprint changed (planned {}, current {})",
plan.pre_apply.toolchain_fingerprint, current.toolchain_fingerprint
));
}
if current.lock_fingerprint != plan.pre_apply.lock_fingerprint {
reasons.push(format!(
"lock fingerprint changed (planned {}, current {})",
plan.pre_apply.lock_fingerprint, current.lock_fingerprint
));
}
if current.metadata_fingerprint != plan.pre_apply.metadata_fingerprint {
reasons.push(format!(
"metadata fingerprint changed (planned {}, current {})",
plan.pre_apply.metadata_fingerprint, current.metadata_fingerprint
));
}
if current.worktree_fingerprint != plan.pre_apply.worktree_fingerprint {
reasons.push(format!(
"worktree changed (planned {}, current {}; paths: {})",
plan.pre_apply.worktree_fingerprint,
current.worktree_fingerprint,
display_paths(¤t.changed_paths)
));
}
reasons.extend(changed_inputs);
Err(RailError::with_help(
format!(
"mutation drift detected for operation '{}': {}",
plan.operation_id,
reasons.join("; ")
),
"regenerate the mutation plan and re-run apply".to_string(),
))
}
pub fn write_receipt(
workspace_root: &Path,
operation: &str,
phase: &str,
status: &str,
plan: MutationPlan,
trace: Vec<MutationTrace>,
) -> RailResult<PathBuf> {
write_receipt_with_objects(workspace_root, operation, phase, status, plan, trace, Vec::new())
}
pub fn write_receipt_with_objects(
workspace_root: &Path,
operation: &str,
phase: &str,
status: &str,
plan: MutationPlan,
trace: Vec<MutationTrace>,
resulting_objects: Vec<MutationObject>,
) -> RailResult<PathBuf> {
let verified_inputs = plan.pre_apply.clone();
let applied_actions = if phase == "apply" {
plan.actions.clone()
} else {
Vec::new()
};
let receipt = MutationReceipt {
contract_version: MUTATION_CONTRACT_VERSION,
operation_id: plan.operation_id.clone(),
operation: operation.to_string(),
phase: phase.to_string(),
status: status.to_string(),
timestamp_utc: Utc::now().to_rfc3339(),
plan,
trace,
verified_inputs,
applied_actions,
resulting_objects,
};
let dir = crate::workspace::cargo_rail_state_root(workspace_root).join("receipts");
fs::create_dir_all(&dir)?;
let nonce = Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = dir.join(format!(
"{}-{}-{}-{}.json",
sanitize_for_filename(operation),
receipt.operation_id,
sanitize_for_filename(phase),
nonce
));
let mut bytes = serde_json::to_vec_pretty(&receipt)
.map_err(|e| RailError::message(format!("failed to serialize receipt: {}", e)))?;
bytes.push(b'\n');
let mut temporary = tempfile::Builder::new()
.prefix(".cargo-rail-receipt-")
.suffix(".tmp")
.tempfile_in(&dir)
.map_err(|error| RailError::message(format!("failed to prepare receipt '{}': {error}", path.display())))?;
temporary
.write_all(&bytes)
.and_then(|()| temporary.as_file().sync_all())
.map_err(|error| RailError::message(format!("failed to write receipt '{}': {error}", path.display())))?;
temporary.persist_noclobber(&path).map_err(|error| {
RailError::message(format!(
"failed to publish immutable receipt '{}': {}",
path.display(),
error.error
))
})?;
#[cfg(unix)]
fs::File::open(&dir)
.and_then(|directory| directory.sync_all())
.map_err(|error| RailError::message(format!("failed to persist receipt '{}': {error}", path.display())))?;
Ok(path)
}
pub fn read_plan_file(path: &Path) -> RailResult<MutationPlan> {
let content = fs::read_to_string(path)
.map_err(|e| RailError::message(format!("failed to read '{}': {}", path.display(), e)))?;
let mut value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| RailError::message(format!("invalid mutation plan JSON '{}': {}", path.display(), e)))?;
if let Some(inner) = value.as_object_mut().and_then(|object| object.remove("mutation_plan")) {
return serde_json::from_value(inner)
.map_err(|e| RailError::message(format!("invalid mutation_plan in '{}': {}", path.display(), e)));
}
serde_json::from_value(value)
.map_err(|e| RailError::message(format!("invalid mutation plan in '{}': {}", path.display(), e)))
}
fn build_operation_id(operation: &str, inputs_fingerprint: &str) -> String {
let digest = inputs_fingerprint.rsplit(':').next().unwrap_or("unknown");
let short = digest.get(..12).unwrap_or(digest);
format!("{}-{}", sanitize_for_filename(operation), short)
}
fn mutation_fingerprint(
git: &SystemGit,
operation: &str,
resolved_refs: &MutationResolvedRefs,
pre_apply: &MutationPreApplyChecks,
actions: &[MutationAction],
declared_inputs: &[MutationInput],
risks: &[MutationRisk],
) -> RailResult<String> {
let bytes = serde_json::to_vec(&serde_json::json!({
"operation": operation,
"resolved_refs": resolved_refs,
"pre_apply": pre_apply,
"actions": actions,
"declared_inputs": declared_inputs,
"risks": risks,
}))
.map_err(|error| RailError::message(format!("failed to serialize mutation inputs: {}", error)))?;
Ok(format!("git-object:{}", git.hash_bytes(&bytes)?))
}
fn sanitize_for_filename(input: &str) -> String {
input
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_lowercase()
}
fn capture_pre_apply_checks(ctx: &WorkspaceContext) -> RailResult<MutationPreApplyChecks> {
ctx.validate_snapshot_unchanged()?;
let snapshot = ctx.snapshot()?;
let git = ctx.git()?.git();
let changed_paths = ctx.changed_source_paths()?;
Ok(MutationPreApplyChecks {
git_head: ctx.git()?.git().head_commit()?,
config_fingerprint: snapshot.configuration_fingerprint().to_string(),
toolchain_fingerprint: snapshot.toolchain_fingerprint().to_string(),
lock_fingerprint: snapshot.lockfile_fingerprint(),
metadata_fingerprint: format!("snapshot:{}", snapshot.id()),
worktree_fingerprint: fingerprint_changed_paths(git, &git.worktree_root, &changed_paths)?,
changed_paths,
})
}
pub fn expected_paths(plan: &MutationPlan) -> Vec<PathBuf> {
plan.actions
.iter()
.flat_map(|action| action.expected_mutations.iter().map(|mutation| mutation.path.clone()))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn declared_input_paths(plan: &MutationPlan) -> Vec<PathBuf> {
plan.declared_inputs.iter().map(|input| input.path.clone()).collect()
}
pub fn validate_requested_operation(approved: &MutationPlan, expected: &MutationPlan) -> RailResult<()> {
if approved.contract_version == expected.contract_version
&& approved.operation == expected.operation
&& approved.actions == expected.actions
&& approved.declared_inputs == expected.declared_inputs
&& approved.risks == expected.risks
{
return Ok(());
}
Err(RailError::with_help(
"provided mutation plan does not match current requested operation",
"regenerate the plan with the exact command options and retry",
))
}
pub fn validate_changed_paths(ctx: &WorkspaceContext, plan: &MutationPlan) -> RailResult<()> {
validate_changed_paths_with_allowed_paths(ctx, plan, &[])
}
pub fn validate_changed_paths_with_allowed_paths(
ctx: &WorkspaceContext,
plan: &MutationPlan,
allowed_paths: &[PathBuf],
) -> RailResult<()> {
let git = ctx.git()?.git();
let canonical_git_root = canonicalize_existing(&git.worktree_root)?;
let mut allowed = allowed_worktree_paths(&canonical_git_root, allowed_paths)?;
allowed.extend(expected_paths(plan).into_iter().chain(declared_input_paths(plan)));
let changed = ctx.changed_source_paths()?;
let unexpected: Vec<_> = changed.into_iter().filter(|path| !allowed.contains(path)).collect();
if unexpected.is_empty() {
return Ok(());
}
Err(RailError::with_help(
format!(
"mutation produced unplanned worktree changes: {}",
display_paths(&unexpected)
),
"restore the unexpected paths, then regenerate and re-run the mutation plan",
))
}
fn allowed_worktree_paths(git_root: &Path, paths: &[PathBuf]) -> RailResult<BTreeSet<PathBuf>> {
let mut allowed = BTreeSet::new();
for path in paths {
if path.is_absolute() {
let canonical = canonicalize_existing(path)?;
if let Ok(relative) = canonical.strip_prefix(git_root) {
allowed.insert(relative.to_path_buf());
}
} else {
allowed.insert(path.clone());
}
}
Ok(allowed)
}
fn fingerprint_changed_paths(git: &SystemGit, workspace_root: &Path, paths: &[PathBuf]) -> RailResult<String> {
let mut bytes = Vec::new();
for path in paths {
let absolute = workspace_root.join(path);
append_fingerprint_frame(&mut bytes, b"path", path.to_string_lossy().as_bytes());
match fs::symlink_metadata(&absolute) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = fs::read_link(&absolute)?;
append_fingerprint_frame(&mut bytes, b"symlink", target.to_string_lossy().as_bytes());
}
Ok(metadata) if metadata.is_file() => {
append_fingerprint_frame(&mut bytes, b"file", &fs::read(&absolute)?);
}
Ok(_) => append_fingerprint_frame(&mut bytes, b"non-file", b""),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
append_fingerprint_frame(&mut bytes, b"missing", b"");
}
Err(error) => return Err(error.into()),
}
}
Ok(format!("git-object:{}", git.hash_bytes(&bytes)?))
}
fn append_fingerprint_frame(output: &mut Vec<u8>, kind: &[u8], value: &[u8]) {
output.extend_from_slice(&(kind.len() as u64).to_be_bytes());
output.extend_from_slice(kind);
output.extend_from_slice(&(value.len() as u64).to_be_bytes());
output.extend_from_slice(value);
}
fn git_path_fingerprint(git: &SystemGit, path: &Path) -> RailResult<String> {
let bytes = match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = fs::read_link(path)?;
let mut bytes = b"symlink\0".to_vec();
bytes.extend_from_slice(target.to_string_lossy().as_bytes());
bytes
}
Ok(metadata) if metadata.is_file() => fs::read(path)?,
Ok(_) => b"non-file".to_vec(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => b"missing".to_vec(),
Err(error) => return Err(error.into()),
};
Ok(format!("git-object:{}", git.hash_bytes(&bytes)?))
}
fn display_paths(paths: &[PathBuf]) -> String {
if paths.is_empty() {
"none".to_string()
} else {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_for_filename_replaces_non_alnum() {
assert_eq!(sanitize_for_filename("release/run v1"), "release-run-v1");
}
#[test]
fn test_operation_id_is_stable() {
let op = build_operation_id("unify apply", "git-object:0123456789abcdef");
assert_eq!(op, "unify-apply-0123456789ab");
}
#[test]
fn requested_operation_rejects_a_different_operation_domain() {
let plan = MutationPlan {
contract_version: MUTATION_CONTRACT_VERSION,
operation: "split".to_string(),
operation_id: "same".to_string(),
inputs_fingerprint: "same".to_string(),
resolved_refs: MutationResolvedRefs {
git_head: "head".to_string(),
git_branch: "main".to_string(),
},
actions: vec![MutationAction::new("ACTION", "target", None)],
declared_inputs: Vec::new(),
risks: Vec::new(),
trace: Vec::new(),
pre_apply: MutationPreApplyChecks {
git_head: "head".to_string(),
config_fingerprint: "config".to_string(),
toolchain_fingerprint: "toolchain".to_string(),
lock_fingerprint: "lock".to_string(),
metadata_fingerprint: "metadata".to_string(),
worktree_fingerprint: "worktree".to_string(),
changed_paths: Vec::new(),
},
};
let mut other = plan.clone();
other.operation = "sync".to_string();
validate_requested_operation(&plan, &plan).unwrap();
assert!(validate_requested_operation(&plan, &other).is_err());
}
}