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