Skip to main content

shipper_core/state/execution_state/
mod.rs

1//! Execution state and receipt persistence (atomic write + schema-versioned migration).
2//!
3//! **Layer:** state (layer 3).
4//!
5//! Absorbed from the former `shipper-state` microcrate (Phase 2 decrating).
6//! This module provides atomic persistence for [`ExecutionState`] and
7//! [`Receipt`] with schema-versioned migration.
8//!
9//! # Invariants
10//!
11//! - Writes are atomic: write to `.tmp` sibling, `sync_all`, then rename.
12//! - Forward-compatible schema: unknown receipt versions are best-effort
13//!   deserialized.
14//! - v1 → v2 receipt migration fills missing `git_context` (null) and
15//!   `environment` fields and rewrites `receipt_version`.
16
17use std::fs;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result};
22
23use crate::runtime::environment::collect_environment_fingerprint;
24use shipper_types::{ExecutionState, Receipt, ReconciliationReport};
25
26#[cfg(test)]
27mod tests;
28
29/// Current receipt schema version
30pub const CURRENT_RECEIPT_VERSION: &str = "shipper.receipt.v2";
31
32/// Minimum supported receipt schema version
33pub const MINIMUM_SUPPORTED_VERSION: &str = "shipper.receipt.v1";
34
35/// Current state schema version
36pub const CURRENT_STATE_VERSION: &str = "shipper.state.v1";
37
38/// Current plan schema version
39pub const CURRENT_PLAN_VERSION: &str = "shipper.plan.v1";
40
41pub const STATE_FILE: &str = "state.json";
42pub const RECEIPT_FILE: &str = "receipt.json";
43pub const RECONCILIATION_FILE: &str = "reconciliation.json";
44pub const REMEDIATION_PLAN_FILE: &str = "remediation-plan.json";
45
46pub fn state_path(state_dir: &Path) -> PathBuf {
47    state_dir.join(STATE_FILE)
48}
49
50pub fn receipt_path(state_dir: &Path) -> PathBuf {
51    state_dir.join(RECEIPT_FILE)
52}
53
54pub fn reconciliation_path(state_dir: &Path) -> PathBuf {
55    state_dir.join(RECONCILIATION_FILE)
56}
57
58pub fn remediation_plan_path(state_dir: &Path) -> PathBuf {
59    state_dir.join(REMEDIATION_PLAN_FILE)
60}
61
62pub fn load_state(state_dir: &Path) -> Result<Option<ExecutionState>> {
63    let path = state_path(state_dir);
64    if !path.exists() {
65        return Ok(None);
66    }
67    let content = fs::read_to_string(&path)
68        .with_context(|| format!("failed to read state file {}", path.display()))?;
69    let st: ExecutionState = serde_json::from_str(&content)
70        .with_context(|| format!("failed to parse state JSON {}", path.display()))?;
71    Ok(Some(st))
72}
73
74pub fn save_state(state_dir: &Path, state: &ExecutionState) -> Result<()> {
75    fs::create_dir_all(state_dir)
76        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
77
78    let path = state_path(state_dir);
79    atomic_write_json(&path, state)
80}
81
82pub fn write_receipt(state_dir: &Path, receipt: &Receipt) -> Result<()> {
83    fs::create_dir_all(state_dir)
84        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
85
86    let path = receipt_path(state_dir);
87    atomic_write_json(&path, receipt)
88}
89
90pub fn write_reconciliation_report(state_dir: &Path, report: &ReconciliationReport) -> Result<()> {
91    fs::create_dir_all(state_dir)
92        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
93
94    let path = reconciliation_path(state_dir);
95    atomic_write_json(&path, report)
96}
97
98/// Clear state file (state.json) from state directory
99pub fn clear_state(state_dir: &Path) -> Result<()> {
100    let path = state_path(state_dir);
101    if path.exists() {
102        fs::remove_file(&path)
103            .with_context(|| format!("failed to remove state file {}", path.display()))?;
104    }
105    Ok(())
106}
107
108/// Check if there's incomplete state (state.json exists but receipt.json doesn't)
109pub fn has_incomplete_state(state_dir: &Path) -> bool {
110    state_path(state_dir).exists() && !receipt_path(state_dir).exists()
111}
112
113/// Load state with encryption support
114pub fn load_state_encrypted(
115    state_dir: &Path,
116    encrypt_config: &shipper_encrypt::EncryptionConfig,
117) -> Result<Option<ExecutionState>> {
118    let path = state_path(state_dir);
119    if !path.exists() {
120        return Ok(None);
121    }
122
123    let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
124    let content = encryption.read_file(&path)?;
125
126    let st: ExecutionState = serde_json::from_str(&content)
127        .with_context(|| format!("failed to parse state JSON {}", path.display()))?;
128    Ok(Some(st))
129}
130
131/// Save state with encryption support
132pub fn save_state_encrypted(
133    state_dir: &Path,
134    state: &ExecutionState,
135    encrypt_config: &shipper_encrypt::EncryptionConfig,
136) -> Result<()> {
137    fs::create_dir_all(state_dir)
138        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
139
140    let path = state_path(state_dir);
141
142    let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
143    let data = serde_json::to_vec_pretty(state).context("failed to serialize state JSON")?;
144    encryption.write_file(&path, &data)
145}
146
147/// Write receipt with encryption support
148pub fn write_receipt_encrypted(
149    state_dir: &Path,
150    receipt: &Receipt,
151    encrypt_config: &shipper_encrypt::EncryptionConfig,
152) -> Result<()> {
153    fs::create_dir_all(state_dir)
154        .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
155
156    let path = receipt_path(state_dir);
157
158    let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
159    let data = serde_json::to_vec_pretty(receipt).context("failed to serialize receipt JSON")?;
160    encryption.write_file(&path, &data)
161}
162
163/// Load receipt with encryption support
164pub fn load_receipt_encrypted(
165    state_dir: &Path,
166    encrypt_config: &shipper_encrypt::EncryptionConfig,
167) -> Result<Option<Receipt>> {
168    let path = receipt_path(state_dir);
169    if !path.exists() {
170        return Ok(None);
171    }
172
173    let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
174    let content = encryption.read_file(&path)?;
175
176    // Try to parse as Receipt directly
177    if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
178        // Validate the version
179        if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
180            // If version is too old, attempt migration
181            // Note: migration requires raw file access, so we'll handle this case separately
182            return migrate_receipt_encrypted(&path, encrypt_config).map(Some);
183        }
184        return Ok(Some(receipt));
185    }
186
187    // If direct parsing failed, attempt migration
188    migrate_receipt_encrypted(&path, encrypt_config).map(Some)
189}
190
191/// Migrate receipt with encryption support
192fn migrate_receipt_encrypted(
193    path: &Path,
194    encrypt_config: &shipper_encrypt::EncryptionConfig,
195) -> Result<Receipt> {
196    let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
197    let content = encryption.read_file(path)?;
198
199    let value: serde_json::Value = serde_json::from_str(&content)
200        .with_context(|| format!("failed to parse receipt JSON {}", path.display()))?;
201
202    let receipt_version = value
203        .get("receipt_version")
204        .and_then(|v| v.as_str())
205        .unwrap_or("shipper.receipt.v1")
206        .to_string();
207
208    validate_receipt_version(&receipt_version)?;
209
210    let receipt = match receipt_version.as_str() {
211        "shipper.receipt.v1" => migrate_v1_to_v2(value)?,
212        "shipper.receipt.v2" => serde_json::from_value(value)
213            .with_context(|| format!("failed to deserialize receipt v2 from {}", path.display()))?,
214        _ => serde_json::from_value(value).with_context(|| {
215            format!(
216                "failed to deserialize receipt with unknown version {} from {}",
217                receipt_version,
218                path.display()
219            )
220        })?,
221    };
222
223    Ok(receipt)
224}
225
226/// Validate receipt schema version
227pub fn validate_receipt_version(version: &str) -> Result<()> {
228    shipper_types::schema::validate_schema_version(version, MINIMUM_SUPPORTED_VERSION, "receipt")
229}
230
231/// Migrate a receipt from an older schema version to the current version
232pub fn migrate_receipt(path: &Path) -> Result<Receipt> {
233    // Load the receipt JSON
234    let content = fs::read_to_string(path)
235        .with_context(|| format!("failed to read receipt file {}", path.display()))?;
236
237    let value: serde_json::Value = serde_json::from_str(&content)
238        .with_context(|| format!("failed to parse receipt JSON {}", path.display()))?;
239
240    // Check the receipt_version field
241    let receipt_version = value
242        .get("receipt_version")
243        .and_then(|v| v.as_str())
244        .unwrap_or("shipper.receipt.v1") // Default to v1 if missing
245        .to_string(); // Clone to avoid borrow issues
246
247    // Validate the version
248    validate_receipt_version(&receipt_version)?;
249
250    // Apply migrations based on version
251    let receipt = match receipt_version.as_str() {
252        "shipper.receipt.v1" => migrate_v1_to_v2(value)?,
253        "shipper.receipt.v2" => serde_json::from_value(value)
254            .with_context(|| format!("failed to deserialize receipt v2 from {}", path.display()))?,
255        _ => {
256            // Unknown version - try to deserialize anyway (may fail on unknown fields)
257            serde_json::from_value(value).with_context(|| {
258                format!(
259                    "failed to deserialize receipt with unknown version {} from {}",
260                    receipt_version,
261                    path.display()
262                )
263            })?
264        }
265    };
266
267    Ok(receipt)
268}
269
270/// Migrate v1 receipt to v2
271fn migrate_v1_to_v2(mut receipt: serde_json::Value) -> Result<Receipt> {
272    // Add git_context: None if not present
273    if receipt.get("git_context").is_none() {
274        receipt["git_context"] = serde_json::Value::Null;
275    }
276
277    // Add environment: default EnvironmentFingerprint if not present
278    if receipt.get("environment").is_none() {
279        let environment = collect_environment_fingerprint();
280        receipt["environment"] = serde_json::to_value(environment)
281            .context("failed to serialize environment fingerprint")?;
282    }
283
284    // Update receipt_version to v2
285    receipt["receipt_version"] = serde_json::Value::String(CURRENT_RECEIPT_VERSION.to_string());
286
287    // Deserialize as Receipt
288    serde_json::from_value(receipt).context("failed to deserialize migrated receipt")
289}
290
291/// Load receipt from state directory with migration support
292pub fn load_receipt(state_dir: &Path) -> Result<Option<Receipt>> {
293    let path = receipt_path(state_dir);
294    if !path.exists() {
295        return Ok(None);
296    }
297
298    // Try to load directly first
299    let content = fs::read_to_string(&path)
300        .with_context(|| format!("failed to read receipt file {}", path.display()))?;
301
302    // Try to parse as Receipt directly
303    if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
304        // Validate the version
305        if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
306            // If version is too old, attempt migration
307            return migrate_receipt(&path).map(Some);
308        }
309        return Ok(Some(receipt));
310    }
311
312    // If direct parsing failed, attempt migration
313    migrate_receipt(&path).map(Some)
314}
315
316/// Best-effort fsync of the parent directory after a rename, ensuring the
317/// directory entry update is durable on crash.  Errors are silently ignored
318/// because not all platforms support opening a directory for sync (e.g. Windows).
319pub fn fsync_parent_dir(path: &Path) {
320    if let Some(parent) = path.parent()
321        && let Ok(dir) = fs::File::open(parent)
322    {
323        let _ = dir.sync_all();
324    }
325}
326
327pub(crate) fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
328    let tmp = path.with_extension("tmp");
329    let data = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
330
331    {
332        let mut f = fs::File::create(&tmp)
333            .with_context(|| format!("failed to create tmp file {}", tmp.display()))?;
334        f.write_all(&data)
335            .with_context(|| format!("failed to write tmp file {}", tmp.display()))?;
336        f.sync_all().ok();
337    }
338
339    fs::rename(&tmp, path).with_context(|| {
340        format!(
341            "failed to rename tmp file {} to {}",
342            tmp.display(),
343            path.display()
344        )
345    })?;
346
347    fsync_parent_dir(path);
348
349    Ok(())
350}