1use std::path::{Path, PathBuf};
4
5use crate::file_lock::FileLock;
6use crate::store_io::quarantine_label;
7use crate::BoxRecord;
8use a3s_box_core::{ExecutionId, OperationId};
9
10#[derive(Debug)]
16pub struct BoxStateStore {
17 path: PathBuf,
18 records: Vec<BoxRecord>,
19}
20
21impl BoxStateStore {
22 pub fn from_records(path: impl Into<PathBuf>, records: Vec<BoxRecord>) -> Self {
24 Self {
25 path: path.into(),
26 records,
27 }
28 }
29
30 pub fn load(path: &Path) -> std::io::Result<Self> {
35 Self::load_unlocked(path, CorruptionPolicy::ReturnError, true)
36 }
37
38 pub fn load_or_quarantine(path: &Path) -> std::io::Result<Self> {
43 Self::load_unlocked(path, CorruptionPolicy::Quarantine, true)
44 }
45
46 pub fn load_readonly(path: impl Into<PathBuf>) -> std::io::Result<Self> {
51 let path = path.into();
52 Self::load_unlocked(&path, CorruptionPolicy::ReturnError, false)
53 }
54
55 pub fn save(&self) -> std::io::Result<()> {
57 let _lock = FileLock::acquire(&self.path)?;
58 self.write_unlocked()
59 }
60
61 pub fn modify<R>(
66 path: &Path,
67 f: impl FnOnce(&mut Self) -> std::io::Result<R>,
68 ) -> std::io::Result<R> {
69 Self::modify_with_policy(path, CorruptionPolicy::ReturnError, f)
70 }
71
72 pub fn transact<R, E>(path: &Path, f: impl FnOnce(&mut Self) -> Result<R, E>) -> Result<R, E>
77 where
78 E: From<std::io::Error>,
79 {
80 Self::modify_with_policy(path, CorruptionPolicy::ReturnError, f)
81 }
82
83 pub fn modify_or_quarantine<R, E>(
89 path: &Path,
90 f: impl FnOnce(&mut Self) -> Result<R, E>,
91 ) -> Result<R, E>
92 where
93 E: From<std::io::Error>,
94 {
95 Self::modify_with_policy(path, CorruptionPolicy::Quarantine, f)
96 }
97
98 fn modify_with_policy<R, E>(
99 path: &Path,
100 policy: CorruptionPolicy,
101 f: impl FnOnce(&mut Self) -> Result<R, E>,
102 ) -> Result<R, E>
103 where
104 E: From<std::io::Error>,
105 {
106 let _lock = FileLock::acquire(path).map_err(E::from)?;
107 let mut store = Self::load_unlocked(path, policy, true).map_err(E::from)?;
108 let output = f(&mut store)?;
109 store.write_unlocked().map_err(E::from)?;
110 Ok(output)
111 }
112
113 fn load_unlocked(
114 path: &Path,
115 corruption_policy: CorruptionPolicy,
116 create_parent: bool,
117 ) -> std::io::Result<Self> {
118 if !path.exists() {
119 if create_parent {
120 if let Some(parent) = path.parent() {
121 std::fs::create_dir_all(parent)?;
122 }
123 }
124 return Ok(Self::from_records(path.to_path_buf(), Vec::new()));
125 }
126
127 let data = std::fs::read_to_string(path)?;
128 let parsed = serde_json::from_str::<Vec<BoxRecord>>(&data)
129 .map_err(|error| error.to_string())
130 .and_then(|records| {
131 validate_managed_records(&records)?;
132 Ok(records)
133 });
134 match parsed {
135 Ok(records) => Ok(Self::from_records(path.to_path_buf(), records)),
136 Err(error) if corruption_policy == CorruptionPolicy::ReturnError => {
137 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
138 }
139 Err(error) => {
140 let preserved = quarantine_label(path);
141 eprintln!(
142 "a3s-box: WARNING: state file {} is corrupt ({error}); preserved a \
143 copy at {preserved} and started from empty state. Running boxes are \
144 no longer tracked; repair and restore the preserved records, then \
145 reconcile state. Otherwise remove leaked executions manually.",
146 path.display(),
147 );
148 Ok(Self::from_records(path.to_path_buf(), Vec::new()))
149 }
150 }
151 }
152
153 fn write_unlocked(&self) -> std::io::Result<()> {
154 validate_managed_records(&self.records)
155 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
156 if let Some(parent) = self.path.parent() {
157 std::fs::create_dir_all(parent)?;
158 }
159 let data = serde_json::to_vec_pretty(&self.records).map_err(std::io::Error::other)?;
160 let temporary_path = self.path.with_extension("json.tmp");
161 a3s_box_core::fs_atomic::write_durable(&temporary_path, &self.path, &data)
162 }
163
164 pub fn path(&self) -> &Path {
166 &self.path
167 }
168
169 pub fn records(&self) -> &[BoxRecord] {
171 &self.records
172 }
173
174 pub fn records_mut(&mut self) -> &mut Vec<BoxRecord> {
176 &mut self.records
177 }
178
179 pub fn find_by_id(&self, id: &str) -> Option<&BoxRecord> {
181 self.records.iter().find(|record| record.id == id)
182 }
183
184 pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut BoxRecord> {
186 self.records.iter_mut().find(|record| record.id == id)
187 }
188
189 pub fn remove_by_id(&mut self, id: &str) -> bool {
191 let previous_len = self.records.len();
192 self.records.retain(|record| record.id != id);
193 self.records.len() < previous_len
194 }
195
196 pub fn find_by_name(&self, name: &str) -> Option<&BoxRecord> {
198 self.records.iter().find(|record| record.name == name)
199 }
200
201 pub fn find_by_operation_id(&self, operation_id: &OperationId) -> Option<&BoxRecord> {
203 self.records.iter().find(|record| {
204 record
205 .managed_execution
206 .as_ref()
207 .is_some_and(|metadata| &metadata.operation_id == operation_id)
208 })
209 }
210
211 pub fn find_by_operation_id_mut(
213 &mut self,
214 operation_id: &OperationId,
215 ) -> Option<&mut BoxRecord> {
216 self.records.iter_mut().find(|record| {
217 record
218 .managed_execution
219 .as_ref()
220 .is_some_and(|metadata| &metadata.operation_id == operation_id)
221 })
222 }
223
224 pub fn find_by_id_prefix(&self, prefix: &str) -> Vec<&BoxRecord> {
226 self.records
227 .iter()
228 .filter(|record| record.id.starts_with(prefix) || record.short_id.starts_with(prefix))
229 .collect()
230 }
231
232 pub fn list(&self, all: bool) -> Vec<&BoxRecord> {
234 self.records
235 .iter()
236 .filter(|record| all || record.status == "running")
237 .collect()
238 }
239}
240
241fn validate_managed_records(records: &[BoxRecord]) -> Result<(), String> {
242 let mut operation_ids = std::collections::HashSet::new();
243 for record in records {
244 let Some(metadata) = &record.managed_execution else {
245 continue;
246 };
247 metadata
248 .validate()
249 .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?;
250 ExecutionId::new(record.id.clone())
251 .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?;
252 record
253 .managed_state()
254 .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?;
255 if !operation_ids.insert(metadata.operation_id.clone()) {
256 return Err(format!(
257 "duplicate managed operation ID: {}",
258 metadata.operation_id
259 ));
260 }
261 }
262 Ok(())
263}
264
265#[derive(Clone, Copy, PartialEq, Eq)]
266enum CorruptionPolicy {
267 ReturnError,
268 Quarantine,
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn record(id: &str) -> BoxRecord {
276 serde_json::from_value(serde_json::json!({
277 "id": id,
278 "short_id": BoxRecord::make_short_id(id),
279 "name": format!("box-{id}"),
280 "image": "alpine:latest",
281 "status": "created",
282 "pid": null,
283 "cpus": 1,
284 "memory_mb": 128,
285 "volumes": [],
286 "env": {},
287 "cmd": ["sh"],
288 "box_dir": format!("/tmp/{id}"),
289 "console_log": format!("/tmp/{id}/console.log"),
290 "created_at": "2026-07-14T12:00:00Z",
291 "started_at": null,
292 "auto_remove": false
293 }))
294 .unwrap()
295 }
296
297 fn managed_record(id: &str, operation_id: OperationId) -> BoxRecord {
298 let mut record = record(id);
299 let config = a3s_box_core::BoxConfig {
300 image: "alpine:latest".to_string(),
301 isolation: a3s_box_core::ExecutionIsolation::Sandbox,
302 ..Default::default()
303 };
304 record.managed_execution = Some(
305 crate::ManagedExecutionMetadata::new(
306 operation_id,
307 a3s_box_core::ExecutionGeneration::INITIAL,
308 a3s_box_core::CreateExecutionRequest {
309 external_sandbox_id: format!("sandbox-{id}"),
310 config,
311 labels: Default::default(),
312 policy: Default::default(),
313 rootfs_snapshot_id: None,
314 },
315 )
316 .unwrap(),
317 );
318 record
319 }
320
321 #[test]
322 fn missing_state_is_empty_and_creates_parent() {
323 let directory = tempfile::tempdir().unwrap();
324 let path = directory.path().join("nested").join("boxes.json");
325
326 let store = BoxStateStore::load(&path).unwrap();
327
328 assert!(store.records().is_empty());
329 assert!(path.parent().unwrap().exists());
330 assert!(!path.exists());
331 }
332
333 #[test]
334 fn strict_load_reports_corruption_without_moving_file() {
335 let directory = tempfile::tempdir().unwrap();
336 let path = directory.path().join("boxes.json");
337 std::fs::write(&path, "invalid json").unwrap();
338
339 let error = BoxStateStore::load(&path).unwrap_err();
340
341 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
342 assert_eq!(std::fs::read_to_string(&path).unwrap(), "invalid json");
343 }
344
345 #[test]
346 fn compatibility_load_quarantines_corruption() {
347 let directory = tempfile::tempdir().unwrap();
348 let path = directory.path().join("boxes.json");
349 std::fs::write(&path, "invalid json").unwrap();
350
351 let store = BoxStateStore::load_or_quarantine(&path).unwrap();
352
353 assert!(store.records().is_empty());
354 assert!(!path.exists());
355 let backups: Vec<_> = std::fs::read_dir(directory.path())
356 .unwrap()
357 .filter_map(Result::ok)
358 .filter(|entry| entry.file_name().to_string_lossy().contains(".corrupt-"))
359 .collect();
360 assert_eq!(backups.len(), 1);
361 assert_eq!(
362 std::fs::read_to_string(backups[0].path()).unwrap(),
363 "invalid json"
364 );
365 }
366
367 #[test]
368 fn failed_transaction_does_not_write_mutations() {
369 let directory = tempfile::tempdir().unwrap();
370 let path = directory.path().join("boxes.json");
371 BoxStateStore::from_records(path.clone(), vec![record("original")])
372 .save()
373 .unwrap();
374
375 let result = BoxStateStore::modify(&path, |store| {
376 store.records_mut().push(record("discarded"));
377 Err::<(), _>(std::io::Error::other("abort"))
378 });
379
380 assert!(result.is_err());
381 let persisted = BoxStateStore::load(&path).unwrap();
382 assert_eq!(persisted.records().len(), 1);
383 assert_eq!(persisted.records()[0].id, "original");
384 }
385
386 #[test]
387 fn save_preserves_runtime_owned_fields() {
388 let directory = tempfile::tempdir().unwrap();
389 let path = directory.path().join("boxes.json");
390 let mut value = record("runtime-field");
391 value.virtiofs_cache = Some("always".to_string());
392
393 BoxStateStore::from_records(path.clone(), vec![value])
394 .save()
395 .unwrap();
396
397 let persisted = BoxStateStore::load(&path).unwrap();
398 assert_eq!(
399 persisted.records()[0].virtiofs_cache.as_deref(),
400 Some("always")
401 );
402 }
403
404 #[test]
405 fn operation_lookup_ignores_legacy_records_and_finds_managed_intent() {
406 let operation_id = OperationId::new("operation-1").unwrap();
407 let managed = managed_record("managed", operation_id.clone());
408 let mut store =
409 BoxStateStore::from_records("/tmp/boxes.json", vec![record("legacy"), managed]);
410
411 assert_eq!(
412 store.find_by_operation_id(&operation_id).unwrap().id,
413 "managed"
414 );
415 store
416 .find_by_operation_id_mut(&operation_id)
417 .unwrap()
418 .status = "running".to_string();
419 assert_eq!(store.find_by_id("managed").unwrap().status, "running");
420 assert!(store
421 .find_by_operation_id(&OperationId::new("missing").unwrap())
422 .is_none());
423 }
424
425 #[test]
426 fn strict_load_rejects_duplicate_managed_operation_ids() {
427 let directory = tempfile::tempdir().unwrap();
428 let path = directory.path().join("boxes.json");
429 let operation_id = OperationId::new("operation-1").unwrap();
430 let records = vec![
431 managed_record("first", operation_id.clone()),
432 managed_record("second", operation_id),
433 ];
434 std::fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap();
435
436 let error = BoxStateStore::load(&path).unwrap_err();
437
438 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
439 assert!(error.to_string().contains("duplicate managed operation ID"));
440 }
441
442 #[test]
443 fn transaction_rejects_duplicate_operation_without_changing_disk() {
444 let directory = tempfile::tempdir().unwrap();
445 let path = directory.path().join("boxes.json");
446 let operation_id = OperationId::new("operation-1").unwrap();
447 BoxStateStore::from_records(
448 path.clone(),
449 vec![managed_record("first", operation_id.clone())],
450 )
451 .save()
452 .unwrap();
453
454 let error = BoxStateStore::modify(&path, |store| {
455 store
456 .records_mut()
457 .push(managed_record("second", operation_id));
458 Ok(())
459 })
460 .unwrap_err();
461
462 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
463 let persisted = BoxStateStore::load(&path).unwrap();
464 assert_eq!(persisted.records().len(), 1);
465 assert_eq!(persisted.records()[0].id, "first");
466 }
467
468 #[test]
469 fn strict_load_rejects_managed_plan_drift() {
470 let directory = tempfile::tempdir().unwrap();
471 let path = directory.path().join("boxes.json");
472 let mut record = managed_record("managed", OperationId::new("operation-1").unwrap());
473 record.managed_execution.as_mut().unwrap().plan =
474 a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
475 std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap();
476
477 let error = BoxStateStore::load(&path).unwrap_err();
478
479 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
480 assert!(error.to_string().contains("does not match"));
481 }
482
483 #[test]
484 fn strict_load_rejects_unknown_managed_lifecycle_state() {
485 let directory = tempfile::tempdir().unwrap();
486 let path = directory.path().join("boxes.json");
487 let mut record = managed_record("managed", OperationId::new("operation-1").unwrap());
488 record.status = "future-state".to_string();
489 std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap();
490
491 let error = BoxStateStore::load(&path).unwrap_err();
492
493 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
494 assert!(error
495 .to_string()
496 .contains("unknown managed execution state"));
497 }
498
499 #[test]
500 fn strict_load_rejects_transition_without_matching_pending_operation() {
501 let directory = tempfile::tempdir().unwrap();
502 let path = directory.path().join("boxes.json");
503 let mut record = managed_record("managed", OperationId::new("operation-1").unwrap());
504 record.status = "pausing".to_string();
505 std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap();
506
507 let error = BoxStateStore::load(&path).unwrap_err();
508
509 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
510 assert!(error.to_string().contains("inconsistent pending operation"));
511 }
512
513 #[cfg(unix)]
514 #[test]
515 fn concurrent_transactions_do_not_lose_records() {
516 let directory = tempfile::tempdir().unwrap();
517 let path = directory.path().join("boxes.json");
518 let handles: Vec<_> = (0..8)
519 .map(|index| {
520 let path = path.clone();
521 std::thread::spawn(move || {
522 BoxStateStore::modify(&path, |store| {
523 store.records_mut().push(record(&format!("id-{index}")));
524 Ok::<(), std::io::Error>(())
525 })
526 .unwrap();
527 })
528 })
529 .collect();
530
531 for handle in handles {
532 handle.join().unwrap();
533 }
534
535 let store = BoxStateStore::load(&path).unwrap();
536 assert_eq!(store.records().len(), 8);
537 }
538}