shipper_core/state/execution_state/
mod.rs1use 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
29pub const CURRENT_RECEIPT_VERSION: &str = "shipper.receipt.v2";
31
32pub const MINIMUM_SUPPORTED_VERSION: &str = "shipper.receipt.v1";
34
35pub const CURRENT_STATE_VERSION: &str = "shipper.state.v1";
37
38pub 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
80pub 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
90pub fn has_incomplete_state(state_dir: &Path) -> bool {
92 state_path(state_dir).exists() && !receipt_path(state_dir).exists()
93}
94
95pub 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
113pub 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
129pub 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
145pub 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 if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
160 if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
162 return migrate_receipt_encrypted(&path, encrypt_config).map(Some);
165 }
166 return Ok(Some(receipt));
167 }
168
169 migrate_receipt_encrypted(&path, encrypt_config).map(Some)
171}
172
173fn 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
208pub fn validate_receipt_version(version: &str) -> Result<()> {
210 shipper_types::schema::validate_schema_version(version, MINIMUM_SUPPORTED_VERSION, "receipt")
211}
212
213pub fn migrate_receipt(path: &Path) -> Result<Receipt> {
215 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 let receipt_version = value
224 .get("receipt_version")
225 .and_then(|v| v.as_str())
226 .unwrap_or("shipper.receipt.v1") .to_string(); validate_receipt_version(&receipt_version)?;
231
232 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 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
252fn migrate_v1_to_v2(mut receipt: serde_json::Value) -> Result<Receipt> {
254 if receipt.get("git_context").is_none() {
256 receipt["git_context"] = serde_json::Value::Null;
257 }
258
259 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 receipt["receipt_version"] = serde_json::Value::String(CURRENT_RECEIPT_VERSION.to_string());
268
269 serde_json::from_value(receipt).context("failed to deserialize migrated receipt")
271}
272
273pub 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 let content = fs::read_to_string(&path)
282 .with_context(|| format!("failed to read receipt file {}", path.display()))?;
283
284 if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
286 if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
288 return migrate_receipt(&path).map(Some);
290 }
291 return Ok(Some(receipt));
292 }
293
294 migrate_receipt(&path).map(Some)
296}
297
298pub 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}