1use crate::bounded_io::LimitedWriter;
12use crate::budget::{validate_snapshot_for_restore, BudgetEngine, BudgetSnapshot, RestoreError};
13use fs2::FileExt;
14use std::io::Read;
15use std::path::{Component, Path, PathBuf};
16
17pub const MAX_PERSISTENCE_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
19
20#[cfg(feature = "wal")]
22pub const CHECKPOINT_MANIFEST_SCHEMA: &str = "calybris.checkpoint-manifest.v1";
23
24#[cfg(feature = "wal")]
26#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct CheckpointManifest {
29 pub schema_version: String,
30 pub snapshot_file: String,
31 pub wal_anchor_file: String,
32 pub snapshot_version: u64,
33 pub ledger_digest_hex: String,
34 pub wal_sequence: u64,
35 pub wal_hash: String,
36 pub wal_keyed: bool,
37}
38
39#[cfg(feature = "wal")]
44#[derive(Debug, Clone)]
45pub struct CoordinatedCheckpoint {
46 pub manifest: CheckpointManifest,
47 pub snapshot: BudgetSnapshot,
48 pub anchor: crate::wal::WalAnchor,
49}
50
51#[derive(Debug, thiserror::Error)]
53pub enum PersistenceError {
54 #[error("I/O error: {0}")]
55 Io(#[from] std::io::Error),
56 #[error("JSON error: {0}")]
57 Json(#[from] serde_json::Error),
58 #[error("restore error: {0}")]
59 Restore(#[from] RestoreError),
60}
61
62pub fn save_snapshot(snapshot: &BudgetSnapshot, path: &Path) -> Result<(), PersistenceError> {
69 save_json_atomic(snapshot, path)
70}
71
72fn save_json_atomic<T: serde::Serialize>(value: &T, path: &Path) -> Result<(), PersistenceError> {
73 let parent = path
74 .parent()
75 .filter(|parent| !parent.as_os_str().is_empty())
76 .unwrap_or_else(|| Path::new("."));
77 let filename = path
78 .file_name()
79 .and_then(|name| name.to_str())
80 .unwrap_or("snapshot");
81 let lock_path: PathBuf = parent.join(format!(".{filename}.calybris.lock"));
88 let lock_file = std::fs::OpenOptions::new()
89 .create(true)
90 .truncate(false)
91 .read(true)
92 .write(true)
93 .open(lock_path)?;
94 lock_file.lock_exclusive()?;
95 let mut temporary = tempfile::Builder::new()
96 .prefix(&format!(".{filename}.tmp."))
97 .tempfile_in(parent)?;
98 {
99 let mut writer = LimitedWriter::new(&mut temporary, MAX_PERSISTENCE_ARTIFACT_BYTES);
100 if let Err(error) = serde_json::to_writer_pretty(&mut writer, value) {
101 if writer.limit_exceeded() {
102 return Err(PersistenceError::Io(std::io::Error::new(
103 std::io::ErrorKind::InvalidInput,
104 format!("persistence artifact exceeds {MAX_PERSISTENCE_ARTIFACT_BYTES} bytes"),
105 )));
106 }
107 return Err(PersistenceError::Json(error));
108 }
109 }
110 temporary.as_file().sync_all()?;
111 temporary
112 .persist(path)
113 .map_err(|error| PersistenceError::Io(error.error))?;
114
115 sync_parent_directory(path)?;
116 Ok(())
117}
118
119#[cfg(unix)]
120fn sync_parent_directory(path: &Path) -> Result<(), PersistenceError> {
121 let parent = path
122 .parent()
123 .filter(|parent| !parent.as_os_str().is_empty())
124 .unwrap_or_else(|| Path::new("."));
125 let directory = std::fs::File::open(parent)?;
126 directory.sync_all()?;
127 Ok(())
128}
129
130#[cfg(not(unix))]
131fn sync_parent_directory(_path: &Path) -> Result<(), PersistenceError> {
132 Ok(())
133}
134
135fn load_json_bounded<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, PersistenceError> {
136 let file = std::fs::File::open(path)?;
137 if file.metadata()?.len() > MAX_PERSISTENCE_ARTIFACT_BYTES as u64 {
138 return Err(PersistenceError::Io(std::io::Error::new(
139 std::io::ErrorKind::InvalidData,
140 format!("persistence artifact exceeds {MAX_PERSISTENCE_ARTIFACT_BYTES} bytes"),
141 )));
142 }
143 let mut bytes = Vec::new();
144 file.take((MAX_PERSISTENCE_ARTIFACT_BYTES + 1) as u64)
145 .read_to_end(&mut bytes)?;
146 if bytes.len() > MAX_PERSISTENCE_ARTIFACT_BYTES {
147 return Err(PersistenceError::Io(std::io::Error::new(
148 std::io::ErrorKind::InvalidData,
149 format!("persistence artifact exceeds {MAX_PERSISTENCE_ARTIFACT_BYTES} bytes"),
150 )));
151 }
152 Ok(serde_json::from_slice(&bytes)?)
153}
154
155#[cfg(feature = "wal")]
157pub fn save_wal_anchor(
158 anchor: &crate::wal::WalAnchor,
159 path: &Path,
160) -> Result<(), PersistenceError> {
161 save_json_atomic(anchor, path)
162}
163
164#[cfg(feature = "wal")]
166pub fn load_wal_anchor(path: &Path) -> Result<crate::wal::WalAnchor, PersistenceError> {
167 load_json_bounded(path)
168}
169
170pub fn load_snapshot(path: &Path) -> Result<BudgetSnapshot, PersistenceError> {
172 load_json_bounded(path)
173}
174
175pub fn migrate_legacy_snapshot_file(
181 source: &Path,
182 destination: &Path,
183 trusted_next_reservation_id: u64,
184) -> Result<BudgetSnapshot, PersistenceError> {
185 ensure_distinct_migration_files(source, destination)?;
186 let legacy = load_snapshot(source)?;
187 let migrated = crate::budget::migrate_legacy_snapshot(legacy, trusted_next_reservation_id)
188 .map_err(|error| invalid_recovery_data(error.to_string()))?;
189 save_snapshot(&migrated, destination)?;
190 Ok(migrated)
191}
192
193fn ensure_distinct_migration_files(
194 source: &Path,
195 destination: &Path,
196) -> Result<(), PersistenceError> {
197 let canonical_source = std::fs::canonicalize(source)?;
198 let source_identity = file_id::get_file_id(&canonical_source)?;
199
200 match std::fs::metadata(destination) {
201 Ok(_) => {
202 let canonical_destination = std::fs::canonicalize(destination)?;
203 let destination_identity = file_id::get_file_id(&canonical_destination)?;
204 if canonical_source == canonical_destination || source_identity == destination_identity
205 {
206 return Err(invalid_recovery_data(
207 "legacy snapshot migration requires a distinct destination file",
208 ));
209 }
210 }
211 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
212 let parent = destination
213 .parent()
214 .filter(|parent| !parent.as_os_str().is_empty())
215 .unwrap_or_else(|| Path::new("."));
216 let filename = destination.file_name().ok_or_else(|| {
217 invalid_recovery_data("legacy snapshot destination must name a file")
218 })?;
219 let normalized_destination = std::fs::canonicalize(parent)?.join(filename);
220 if canonical_source == normalized_destination {
221 return Err(invalid_recovery_data(
222 "legacy snapshot migration requires a distinct destination file",
223 ));
224 }
225 }
226 Err(error) => return Err(PersistenceError::Io(error)),
227 }
228 Ok(())
229}
230
231pub fn checkpoint(engine: &BudgetEngine, path: &Path) -> Result<BudgetSnapshot, PersistenceError> {
236 let snapshot = engine
237 .try_snapshot()
238 .map_err(|error| invalid_recovery_data(error.to_string()))?;
239 validate_snapshot_for_restore(&snapshot)?;
240 save_snapshot(&snapshot, path)?;
241 Ok(snapshot)
242}
243
244pub fn checkpoint_with_wal(
250 engine: &BudgetEngine,
251 path: &Path,
252 wal_sequence: u64,
253) -> Result<BudgetSnapshot, PersistenceError> {
254 let mut snapshot = engine
255 .try_snapshot()
256 .map_err(|error| invalid_recovery_data(error.to_string()))?;
257 snapshot.wal_high_watermark = Some(wal_sequence);
258 validate_snapshot_for_restore(&snapshot)?;
259 save_snapshot(&snapshot, path)?;
260 Ok(snapshot)
261}
262
263#[cfg(feature = "wal")]
273pub fn checkpoint_coordinated<T: serde::Serialize>(
274 engine: &BudgetEngine,
275 wal: &mut crate::wal::WalWriter<T>,
276 directory: &Path,
277) -> Result<CoordinatedCheckpoint, PersistenceError> {
278 std::fs::create_dir_all(directory)?;
279 wal.flush_and_sync()
280 .map_err(|error| invalid_recovery_data(error.to_string()))?;
281 let anchor = wal.anchor();
282 let mut snapshot = engine
283 .try_snapshot()
284 .map_err(|error| invalid_recovery_data(error.to_string()))?;
285 snapshot.wal_high_watermark = Some(anchor.sequence);
286 validate_snapshot_for_restore(&snapshot)?;
287
288 let snapshot_file = format!(
289 "snapshot-v{}-wal-{}.json",
290 snapshot.version, anchor.sequence
291 );
292 let wal_anchor_file = format!(
293 "wal-anchor-v{}-wal-{}.json",
294 snapshot.version, anchor.sequence
295 );
296 save_snapshot(&snapshot, &directory.join(&snapshot_file))?;
297 save_wal_anchor(&anchor, &directory.join(&wal_anchor_file))?;
298
299 let manifest = CheckpointManifest {
300 schema_version: CHECKPOINT_MANIFEST_SCHEMA.to_string(),
301 snapshot_file,
302 wal_anchor_file,
303 snapshot_version: snapshot.version,
304 ledger_digest_hex: crate::digest::digest_to_hex(&crate::finance::ledger_digest(&snapshot)),
305 wal_sequence: anchor.sequence,
306 wal_hash: anchor.last_hash.clone(),
307 wal_keyed: anchor.keyed,
308 };
309 save_json_atomic(&manifest, &directory.join("checkpoint-manifest.json"))?;
310 Ok(CoordinatedCheckpoint {
311 manifest,
312 snapshot,
313 anchor,
314 })
315}
316
317#[cfg(feature = "wal")]
319pub fn load_coordinated_checkpoint(
320 directory: &Path,
321) -> Result<CoordinatedCheckpoint, PersistenceError> {
322 let manifest: CheckpointManifest =
323 load_json_bounded(&directory.join("checkpoint-manifest.json"))?;
324 if manifest.schema_version != CHECKPOINT_MANIFEST_SCHEMA {
325 return Err(invalid_recovery_data(format!(
326 "unknown checkpoint manifest schema: {}",
327 manifest.schema_version
328 )));
329 }
330 validate_generation_filename(&manifest.snapshot_file)?;
331 validate_generation_filename(&manifest.wal_anchor_file)?;
332
333 let snapshot = load_snapshot(&directory.join(&manifest.snapshot_file))?;
334 let anchor = load_wal_anchor(&directory.join(&manifest.wal_anchor_file))?;
335 let digest = crate::digest::digest_to_hex(&crate::finance::ledger_digest(&snapshot));
336 if snapshot.version != manifest.snapshot_version
337 || snapshot.wal_high_watermark != Some(manifest.wal_sequence)
338 || digest != manifest.ledger_digest_hex
339 {
340 return Err(invalid_recovery_data(
341 "checkpoint snapshot does not match committed manifest",
342 ));
343 }
344 validate_snapshot_for_restore(&snapshot)?;
345 anchor
346 .verify_head(
347 manifest.wal_sequence,
348 manifest.wal_hash.clone(),
349 manifest.wal_keyed,
350 )
351 .map_err(|error| invalid_recovery_data(error.to_string()))?;
352 Ok(CoordinatedCheckpoint {
353 manifest,
354 snapshot,
355 anchor,
356 })
357}
358
359#[cfg(feature = "wal")]
361pub fn load_and_verify_coordinated_checkpoint(
362 directory: &Path,
363 wal_path: &Path,
364 hmac_key: Option<&[u8]>,
365) -> Result<CoordinatedCheckpoint, PersistenceError> {
366 let checkpoint = load_coordinated_checkpoint(directory)?;
367 match (checkpoint.anchor.keyed, hmac_key) {
368 (true, Some(key)) => {
369 crate::wal::verify_wal_keyed_contains_anchor(wal_path, key, &checkpoint.anchor)
370 }
371 (false, None) => crate::wal::verify_wal_contains_anchor(wal_path, &checkpoint.anchor),
372 (true, None) => {
373 return Err(invalid_recovery_data(
374 "checkpoint WAL is keyed but no HMAC key was supplied",
375 ));
376 }
377 (false, Some(_)) => {
378 return Err(invalid_recovery_data(
379 "checkpoint WAL is unkeyed but an HMAC key was supplied",
380 ));
381 }
382 }
383 .map_err(|error| invalid_recovery_data(error.to_string()))?;
384 Ok(checkpoint)
385}
386
387#[cfg(feature = "wal")]
388fn validate_generation_filename(filename: &str) -> Result<(), PersistenceError> {
389 let mut components = Path::new(filename).components();
390 match (components.next(), components.next()) {
391 (Some(Component::Normal(_)), None) => Ok(()),
392 _ => Err(invalid_recovery_data(
393 "checkpoint manifest contains an unsafe generation filename",
394 )),
395 }
396}
397
398fn invalid_recovery_data(message: impl Into<String>) -> PersistenceError {
399 PersistenceError::Io(std::io::Error::new(
400 std::io::ErrorKind::InvalidData,
401 message.into(),
402 ))
403}
404
405pub fn restore(engine: &BudgetEngine, path: &Path) -> Result<BudgetSnapshot, PersistenceError> {
411 let snapshot = load_snapshot(path)?;
412 engine.restore_from_snapshot(snapshot.clone())?;
413 Ok(snapshot)
414}
415
416#[cfg(feature = "wal")]
422fn recovery_plan_inner(
423 snapshot_path: &Path,
424 wal_path: &Path,
425 key: Option<&[u8]>,
426 anchor: Option<&crate::wal::WalAnchor>,
427) -> Result<RecoveryPlan, PersistenceError> {
428 let snapshot = load_snapshot(snapshot_path)?;
429 validate_snapshot_for_restore(&snapshot)?;
430 let high = snapshot.wal_high_watermark.unwrap_or(0);
431 let mut total_wal_entries = 0_usize;
432 let mut entries_to_replay = 0_usize;
433
434 let head = if let Some(k) = key {
435 crate::wal::visit_verified_wal_keyed::<serde_json::Value, _>(wal_path, k, |entry| {
436 total_wal_entries += 1;
437 if entry.sequence > high {
438 entries_to_replay += 1;
439 }
440 })
441 } else {
442 crate::wal::visit_verified_wal::<serde_json::Value, _>(wal_path, |entry| {
443 total_wal_entries += 1;
444 if entry.sequence > high {
445 entries_to_replay += 1;
446 }
447 })
448 }
449 .map_err(|e| PersistenceError::Io(std::io::Error::other(e.to_string())))?;
450
451 if high > head.0 {
452 return Err(invalid_recovery_data(format!(
453 "snapshot WAL watermark {high} is ahead of verified WAL head {}",
454 head.0
455 )));
456 }
457
458 if let Some(anchor) = anchor {
459 anchor
460 .verify_head(head.0, head.1, key.is_some())
461 .map_err(|e| PersistenceError::Io(std::io::Error::other(e.to_string())))?;
462 }
463
464 Ok(RecoveryPlan {
465 snapshot,
466 total_wal_entries,
467 entries_to_replay,
468 wal_high_watermark: high,
469 })
470}
471
472#[cfg(feature = "wal")]
477pub fn recovery_plan(
478 snapshot_path: &Path,
479 wal_path: &Path,
480) -> Result<RecoveryPlan, PersistenceError> {
481 recovery_plan_inner(snapshot_path, wal_path, None, None)
482}
483
484#[cfg(feature = "wal")]
486pub fn recovery_plan_keyed(
487 snapshot_path: &Path,
488 wal_path: &Path,
489 key: &[u8],
490) -> Result<RecoveryPlan, PersistenceError> {
491 recovery_plan_inner(snapshot_path, wal_path, Some(key), None)
492}
493
494#[cfg(feature = "wal")]
496pub fn recovery_plan_against_anchor(
497 snapshot_path: &Path,
498 wal_path: &Path,
499 anchor: &crate::wal::WalAnchor,
500) -> Result<RecoveryPlan, PersistenceError> {
501 recovery_plan_inner(snapshot_path, wal_path, None, Some(anchor))
502}
503
504#[cfg(feature = "wal")]
506pub fn recovery_plan_keyed_against_anchor(
507 snapshot_path: &Path,
508 wal_path: &Path,
509 key: &[u8],
510 anchor: &crate::wal::WalAnchor,
511) -> Result<RecoveryPlan, PersistenceError> {
512 recovery_plan_inner(snapshot_path, wal_path, Some(key), Some(anchor))
513}
514
515#[cfg(feature = "wal")]
517#[derive(Debug, Clone)]
518pub struct RecoveryPlan {
519 pub snapshot: BudgetSnapshot,
521 pub total_wal_entries: usize,
523 pub entries_to_replay: usize,
525 pub wal_high_watermark: u64,
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::budget::BudgetEngine;
533
534 #[test]
535 #[cfg_attr(miri, ignore = "miri: 16 MiB artifact is impractical to interpret")]
536 fn atomic_writer_and_loader_share_the_same_size_limit() {
537 let dir = tempfile::TempDir::new().unwrap();
538 let path = dir.path().join("limit.json");
539 let value = "x".repeat(MAX_PERSISTENCE_ARTIFACT_BYTES - 2);
540 save_json_atomic(&value, &path).unwrap();
541 assert_eq!(
542 std::fs::metadata(&path).unwrap().len(),
543 MAX_PERSISTENCE_ARTIFACT_BYTES as u64
544 );
545 assert_eq!(load_json_bounded::<String>(&path).unwrap(), value);
546 }
547
548 #[test]
549 #[cfg_attr(miri, ignore = "miri: 16 MiB artifact is impractical to interpret")]
550 fn oversized_atomic_save_preserves_previous_checkpoint() {
551 let dir = tempfile::TempDir::new().unwrap();
552 let path = dir.path().join("preserve.json");
553 save_json_atomic(&"previous", &path).unwrap();
554 let previous = std::fs::read(&path).unwrap();
555 let oversized = "x".repeat(MAX_PERSISTENCE_ARTIFACT_BYTES);
556 assert!(save_json_atomic(&oversized, &path).is_err());
557 assert_eq!(std::fs::read(&path).unwrap(), previous);
558 assert_eq!(load_json_bounded::<String>(&path).unwrap(), "previous");
559 }
560
561 #[test]
562 #[cfg(feature = "wal")]
563 #[cfg_attr(miri, ignore = "miri: 16 MiB artifact is impractical to interpret")]
564 fn oversized_coordinated_snapshot_does_not_commit_manifest() {
565 let dir = tempfile::TempDir::new().unwrap();
566 let wal_path = dir.path().join("events.wal");
567 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
568 let engine = BudgetEngine::new();
569 engine.ensure_tenant(&"x".repeat(MAX_PERSISTENCE_ARTIFACT_BYTES), 1);
570 assert!(checkpoint_coordinated(&engine, &mut wal, dir.path()).is_err());
571 assert!(!dir.path().join("checkpoint-manifest.json").exists());
572 }
573
574 #[test]
575 #[cfg_attr(
576 all(miri, windows),
577 ignore = "miri/windows: tempfile directory creation is unsupported"
578 )]
579 fn save_load_roundtrip() {
580 let dir = tempfile::TempDir::new().unwrap();
581 let path = dir.path().join("snapshot.json");
582
583 let engine = BudgetEngine::new();
584 engine.ensure_tenant("desk", 1_000_000);
585 let (_, id) = engine.try_reserve("desk", 100_000);
586 engine.commit(id.unwrap(), 90_000);
587
588 let saved = checkpoint(&engine, &path).unwrap();
589 let loaded = load_snapshot(&path).unwrap();
590
591 assert_eq!(saved.tenants.len(), loaded.tenants.len());
592 assert_eq!(saved.tenants[0].tenant_id, loaded.tenants[0].tenant_id);
593 assert_eq!(
594 saved.tenants[0].remaining_microcents,
595 loaded.tenants[0].remaining_microcents
596 );
597 }
598
599 #[test]
600 #[cfg_attr(
601 all(miri, windows),
602 ignore = "miri/windows: tempfile directory creation is unsupported"
603 )]
604 fn restore_from_file() {
605 let dir = tempfile::TempDir::new().unwrap();
606 let path = dir.path().join("snapshot.json");
607
608 let engine = BudgetEngine::new();
609 engine.ensure_tenant("desk", 1_000_000);
610 let (_, id) = engine.try_reserve("desk", 100_000);
611 engine.commit(id.unwrap(), 90_000);
612 checkpoint(&engine, &path).unwrap();
613
614 let fresh = BudgetEngine::new();
615 let snap = restore(&fresh, &path).unwrap();
616 assert_eq!(fresh.remaining_microcents("desk"), Some(910_000));
617 assert_eq!(fresh.committed_microcents("desk"), Some(90_000));
618 assert_eq!(snap.tenants.len(), 1);
619 }
620
621 #[test]
622 fn legacy_snapshot_file_migration_is_atomic_and_never_in_place() {
623 let dir = tempfile::TempDir::new().unwrap();
624 let source_path = dir.path().join("legacy.json");
625 let migrated_path = dir.path().join("migrated.json");
626 let engine = BudgetEngine::new();
627 engine.ensure_tenant("desk", 1_000_000);
628 let mut legacy = engine.snapshot();
629 legacy.version = 7;
630 save_snapshot(&legacy, &source_path).unwrap();
631
632 assert!(migrate_legacy_snapshot_file(&source_path, &source_path, 100).is_err());
633 let migrated = migrate_legacy_snapshot_file(&source_path, &migrated_path, 100).unwrap();
634 assert_eq!(load_snapshot(&source_path).unwrap().version, 7);
635 assert_eq!(load_snapshot(&migrated_path).unwrap(), migrated);
636 BudgetEngine::new().restore_from_snapshot(migrated).unwrap();
637 }
638
639 #[test]
640 fn legacy_snapshot_file_migration_rejects_normalized_source_alias() {
641 let dir = tempfile::TempDir::new().unwrap();
642 let source_path = dir.path().join("legacy.json");
643 let alias_path = dir.path().join(".").join("legacy.json");
644 let engine = BudgetEngine::new();
645 engine.ensure_tenant("desk", 1_000_000);
646 let mut legacy = engine.snapshot();
647 legacy.version = 7;
648 save_snapshot(&legacy, &source_path).unwrap();
649 let source_bytes = std::fs::read(&source_path).unwrap();
650
651 assert!(migrate_legacy_snapshot_file(&source_path, &alias_path, 100).is_err());
652 assert_eq!(std::fs::read(&source_path).unwrap(), source_bytes);
653 }
654
655 #[test]
656 fn legacy_snapshot_file_migration_rejects_hard_link_source_alias() {
657 let dir = tempfile::TempDir::new().unwrap();
658 let source_path = dir.path().join("legacy.json");
659 let alias_path = dir.path().join("legacy-hard-link.json");
660 let engine = BudgetEngine::new();
661 engine.ensure_tenant("desk", 1_000_000);
662 let mut legacy = engine.snapshot();
663 legacy.version = 7;
664 save_snapshot(&legacy, &source_path).unwrap();
665 std::fs::hard_link(&source_path, &alias_path).unwrap();
666 let source_bytes = std::fs::read(&source_path).unwrap();
667
668 assert!(migrate_legacy_snapshot_file(&source_path, &alias_path, 100).is_err());
669 assert_eq!(std::fs::read(&source_path).unwrap(), source_bytes);
670 }
671
672 #[cfg(unix)]
673 #[test]
674 fn legacy_snapshot_file_migration_rejects_symlink_source_alias() {
675 use std::os::unix::fs::symlink;
676
677 let dir = tempfile::TempDir::new().unwrap();
678 let destination_path = dir.path().join("legacy.json");
679 let source_path = dir.path().join("legacy-link.json");
680 let engine = BudgetEngine::new();
681 engine.ensure_tenant("desk", 1_000_000);
682 let mut legacy = engine.snapshot();
683 legacy.version = 7;
684 save_snapshot(&legacy, &destination_path).unwrap();
685 symlink(&destination_path, &source_path).unwrap();
686 let source_bytes = std::fs::read(&source_path).unwrap();
687
688 assert!(migrate_legacy_snapshot_file(&source_path, &destination_path, 100).is_err());
689 assert_eq!(std::fs::read(&source_path).unwrap(), source_bytes);
690 }
691
692 #[cfg(windows)]
693 #[test]
694 fn legacy_snapshot_file_migration_rejects_symlink_source_alias() {
695 use std::os::windows::fs::symlink_file;
696
697 let dir = tempfile::TempDir::new().unwrap();
698 let destination_path = dir.path().join("legacy.json");
699 let source_path = dir.path().join("legacy-link.json");
700 let engine = BudgetEngine::new();
701 engine.ensure_tenant("desk", 1_000_000);
702 let mut legacy = engine.snapshot();
703 legacy.version = 7;
704 save_snapshot(&legacy, &destination_path).unwrap();
705 const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
709 if let Err(error) = symlink_file(&destination_path, &source_path) {
710 if error.kind() == std::io::ErrorKind::PermissionDenied
711 || error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD)
712 {
713 return;
714 }
715 panic!("could not create migration alias symlink: {error}");
716 }
717 let source_bytes = std::fs::read(&source_path).unwrap();
718
719 assert!(migrate_legacy_snapshot_file(&source_path, &destination_path, 100).is_err());
720 assert_eq!(std::fs::read(&source_path).unwrap(), source_bytes);
721 }
722
723 #[cfg(windows)]
724 #[test]
725 fn legacy_snapshot_file_migration_rejects_case_alias() {
726 let dir = tempfile::TempDir::new().unwrap();
727 let source_path = dir.path().join("Legacy.JSON");
728 let alias_path = dir.path().join("legacy.json");
729 let engine = BudgetEngine::new();
730 engine.ensure_tenant("desk", 1_000_000);
731 let mut legacy = engine.snapshot();
732 legacy.version = 7;
733 save_snapshot(&legacy, &source_path).unwrap();
734 let source_bytes = std::fs::read(&source_path).unwrap();
735
736 assert!(migrate_legacy_snapshot_file(&source_path, &alias_path, 100).is_err());
737 assert_eq!(std::fs::read(&source_path).unwrap(), source_bytes);
738 }
739
740 #[test]
741 #[cfg_attr(
742 all(miri, windows),
743 ignore = "miri/windows: tempfile directory creation is unsupported"
744 )]
745 fn atomic_write_no_partial() {
746 let dir = tempfile::TempDir::new().unwrap();
747 let path = dir.path().join("atomic.json");
748
749 let engine = BudgetEngine::new();
750 engine.ensure_tenant("desk", 500_000);
751 checkpoint(&engine, &path).unwrap();
752
753 assert!(path.exists());
754 assert!(!path.with_extension("tmp").exists());
755 }
756
757 #[test]
758 #[cfg_attr(
759 all(miri, windows),
760 ignore = "miri/windows: tempfile directory creation is unsupported"
761 )]
762 fn checkpoint_with_wal_records_watermark() {
763 let dir = tempfile::TempDir::new().unwrap();
764 let path = dir.path().join("snap-wal.json");
765
766 let engine = BudgetEngine::new();
767 engine.ensure_tenant("desk", 1_000_000);
768 let snap = checkpoint_with_wal(&engine, &path, 42).unwrap();
769 assert_eq!(snap.wal_high_watermark, Some(42));
770
771 let loaded = load_snapshot(&path).unwrap();
772 assert_eq!(loaded.wal_high_watermark, Some(42));
773 }
774
775 #[test]
776 fn checkpoint_rejects_active_reservations_without_writing() {
777 let dir = tempfile::TempDir::new().unwrap();
778 let path = dir.path().join("unrecoverable.json");
779 let engine = BudgetEngine::new();
780 engine.ensure_tenant("desk", 1_000_000);
781 let (_, reservation_id) = engine.try_reserve("desk", 100_000);
782 assert!(reservation_id.is_some());
783
784 assert!(checkpoint(&engine, &path).is_err());
785 assert!(!path.exists());
786 }
787
788 #[test]
789 fn checkpoint_with_wal_rejects_active_reservations_without_writing() {
790 let dir = tempfile::TempDir::new().unwrap();
791 let path = dir.path().join("unrecoverable-wal.json");
792 let engine = BudgetEngine::new();
793 engine.ensure_tenant("desk", 1_000_000);
794 let (_, reservation_id) = engine.try_reserve("desk", 100_000);
795 assert!(reservation_id.is_some());
796
797 assert!(checkpoint_with_wal(&engine, &path, 7).is_err());
798 assert!(!path.exists());
799 }
800
801 #[test]
802 #[cfg(feature = "wal")]
803 fn coordinated_checkpoint_commits_a_verified_generation() {
804 let dir = tempfile::TempDir::new().unwrap();
805 let wal_path = dir.path().join("events.wal");
806 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
807 wal.append(serde_json::json!({"event": "reserve"})).unwrap();
808
809 let engine = BudgetEngine::new();
810 engine.ensure_tenant("desk", 1_000_000);
811 let committed = checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
812 let recovered = load_coordinated_checkpoint(dir.path()).unwrap();
813 let fully_verified =
814 load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, None).unwrap();
815
816 assert_eq!(recovered.manifest, committed.manifest);
817 assert_eq!(fully_verified.manifest, committed.manifest);
818 assert_eq!(recovered.snapshot, committed.snapshot);
819 assert_eq!(recovered.snapshot.wal_high_watermark, Some(1));
820 assert_eq!(recovered.anchor.sequence, 1);
821 }
822
823 #[test]
824 #[cfg(feature = "wal")]
825 fn coordinated_checkpoint_accepts_a_valid_wal_suffix_for_replay() {
826 let dir = tempfile::TempDir::new().unwrap();
827 let wal_path = dir.path().join("events.wal");
828 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
829 wal.append(serde_json::json!({"event": 1})).unwrap();
830 let engine = BudgetEngine::new();
831 engine.ensure_tenant("desk", 1_000_000);
832 let committed = checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
833 wal.append(serde_json::json!({"event": 2})).unwrap();
834 wal.flush_and_sync().unwrap();
835
836 let verified = load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, None).unwrap();
837 let plan = recovery_plan(
838 &dir.path().join(&committed.manifest.snapshot_file),
839 &wal_path,
840 )
841 .unwrap();
842
843 assert_eq!(verified.manifest, committed.manifest);
844 assert_eq!(plan.entries_to_replay, 1);
845 }
846
847 #[test]
848 #[cfg(feature = "wal")]
849 fn keyed_coordinated_checkpoint_accepts_a_valid_wal_suffix_for_replay() {
850 const KEY: &[u8; 32] = b"calybris-test-hmac-key-000000001";
851
852 let dir = tempfile::TempDir::new().unwrap();
853 let wal_path = dir.path().join("events.wal");
854 let mut wal =
855 crate::wal::WalWriter::<serde_json::Value>::open_keyed(&wal_path, KEY).unwrap();
856 wal.append(serde_json::json!({"event": 1})).unwrap();
857 let engine = BudgetEngine::new();
858 engine.ensure_tenant("desk", 1_000_000);
859 let committed = checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
860 wal.append(serde_json::json!({"event": 2})).unwrap();
861 wal.flush_and_sync().unwrap();
862
863 let verified =
864 load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, Some(KEY)).unwrap();
865 let plan = recovery_plan_keyed(
866 &dir.path().join(&committed.manifest.snapshot_file),
867 &wal_path,
868 KEY,
869 )
870 .unwrap();
871
872 assert_eq!(verified.manifest, committed.manifest);
873 assert_eq!(plan.entries_to_replay, 1);
874 }
875
876 #[test]
877 #[cfg(feature = "wal")]
878 fn coordinated_checkpoint_rejects_a_valid_chain_with_a_different_prefix() {
879 let dir = tempfile::TempDir::new().unwrap();
880 let wal_path = dir.path().join("events.wal");
881 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
882 wal.append(serde_json::json!({"event": 1})).unwrap();
883 let engine = BudgetEngine::new();
884 engine.ensure_tenant("desk", 1_000_000);
885 checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
886 drop(wal);
887
888 std::fs::remove_file(&wal_path).unwrap();
889 let mut replacement = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
890 replacement
891 .append(serde_json::json!({"event": "different-prefix"}))
892 .unwrap();
893 replacement
894 .append(serde_json::json!({"event": "valid-suffix"}))
895 .unwrap();
896 replacement.flush_and_sync().unwrap();
897
898 assert!(load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, None).is_err());
899 }
900
901 #[test]
902 #[cfg(feature = "wal")]
903 fn coordinated_checkpoint_rejects_a_tampered_wal_suffix() {
904 let dir = tempfile::TempDir::new().unwrap();
905 let wal_path = dir.path().join("events.wal");
906 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
907 wal.append(serde_json::json!({"event": 1})).unwrap();
908 let engine = BudgetEngine::new();
909 engine.ensure_tenant("desk", 1_000_000);
910 checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
911 wal.append(serde_json::json!({"event": 2})).unwrap();
912 wal.flush_and_sync().unwrap();
913 drop(wal);
914
915 let contents = std::fs::read_to_string(&wal_path).unwrap();
916 let tampered = contents.replacen("\"event\":2", "\"event\":9", 1);
917 assert_ne!(tampered, contents);
918 std::fs::write(&wal_path, tampered).unwrap();
919
920 assert!(load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, None).is_err());
921 }
922
923 #[test]
924 #[cfg(feature = "wal")]
925 fn coordinated_checkpoint_never_commits_unrestorable_snapshot() {
926 let dir = tempfile::TempDir::new().unwrap();
927 let wal_path = dir.path().join("events.wal");
928 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
929 let engine = BudgetEngine::new();
930 engine.ensure_tenant("desk", 1_000_000);
931 let (_, reservation_id) = engine.try_reserve("desk", 100_000);
932 assert!(reservation_id.is_some());
933
934 assert!(checkpoint_coordinated(&engine, &mut wal, dir.path()).is_err());
935 assert!(!dir.path().join("checkpoint-manifest.json").exists());
936 }
937
938 #[test]
939 #[cfg(feature = "wal")]
940 fn coordinated_checkpoint_full_verification_rejects_a_truncated_wal() {
941 let dir = tempfile::TempDir::new().unwrap();
942 let wal_path = dir.path().join("events.wal");
943 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
944 wal.append(serde_json::json!({"event": 1})).unwrap();
945 wal.append(serde_json::json!({"event": 2})).unwrap();
946 let engine = BudgetEngine::new();
947 engine.ensure_tenant("desk", 1_000_000);
948 checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
949 drop(wal);
950
951 let contents = std::fs::read_to_string(&wal_path).unwrap();
952 let prefix = contents.lines().take(1).collect::<Vec<_>>().join("\n") + "\n";
953 std::fs::write(&wal_path, prefix).unwrap();
954
955 assert!(load_coordinated_checkpoint(dir.path()).is_ok());
956 assert!(load_and_verify_coordinated_checkpoint(dir.path(), &wal_path, None).is_err());
957 }
958
959 #[test]
960 fn oversized_persistence_artifact_is_rejected_before_json_parsing() {
961 let dir = tempfile::TempDir::new().unwrap();
962 let path = dir.path().join("oversized.json");
963 let file = std::fs::File::create(&path).unwrap();
964 file.set_len((MAX_PERSISTENCE_ARTIFACT_BYTES + 1) as u64)
965 .unwrap();
966 let error = load_snapshot(&path).unwrap_err();
967 assert!(matches!(
968 error,
969 PersistenceError::Io(ref io) if io.kind() == std::io::ErrorKind::InvalidData
970 ));
971 }
972
973 #[test]
974 #[cfg(feature = "wal")]
975 fn coordinated_checkpoint_rejects_a_torn_committed_generation() {
976 let dir = tempfile::TempDir::new().unwrap();
977 let wal_path = dir.path().join("events.wal");
978 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
979 wal.append(serde_json::json!({"event": "reserve"})).unwrap();
980 let engine = BudgetEngine::new();
981 engine.ensure_tenant("desk", 1_000_000);
982 let committed = checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
983
984 std::fs::write(
985 dir.path().join(&committed.manifest.snapshot_file),
986 b"{\"torn\":",
987 )
988 .unwrap();
989 assert!(load_coordinated_checkpoint(dir.path()).is_err());
990 }
991
992 #[test]
993 #[cfg(feature = "wal")]
994 fn coordinated_checkpoint_load_rejects_unrestorable_snapshot() {
995 let dir = tempfile::TempDir::new().unwrap();
996 let wal_path = dir.path().join("events.wal");
997 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
998 let engine = BudgetEngine::new();
999 engine.ensure_tenant("desk", 1_000_000);
1000 let committed = checkpoint_coordinated(&engine, &mut wal, dir.path()).unwrap();
1001
1002 let mut snapshot = committed.snapshot;
1003 snapshot.active_reservations = 1;
1004 save_snapshot(
1005 &snapshot,
1006 &dir.path().join(&committed.manifest.snapshot_file),
1007 )
1008 .unwrap();
1009 let mut manifest = committed.manifest;
1010 manifest.ledger_digest_hex =
1011 crate::digest::digest_to_hex(&crate::finance::ledger_digest(&snapshot));
1012 save_json_atomic(&manifest, &dir.path().join("checkpoint-manifest.json")).unwrap();
1013
1014 assert!(load_coordinated_checkpoint(dir.path()).is_err());
1015 }
1016
1017 #[test]
1018 #[cfg_attr(
1019 all(miri, windows),
1020 ignore = "miri/windows: tempfile directory creation is unsupported"
1021 )]
1022 fn checkpoint_without_wal_has_no_watermark() {
1023 let dir = tempfile::TempDir::new().unwrap();
1024 let path = dir.path().join("snap-no-wal.json");
1025
1026 let engine = BudgetEngine::new();
1027 engine.ensure_tenant("desk", 1_000_000);
1028 let snap = checkpoint(&engine, &path).unwrap();
1029 assert_eq!(snap.wal_high_watermark, None);
1030 }
1031
1032 #[test]
1033 #[cfg_attr(
1034 all(miri, windows),
1035 ignore = "miri/windows: tempfile directory creation is unsupported"
1036 )]
1037 #[cfg(feature = "wal")]
1038 fn recovery_plan_uses_watermark() {
1039 let dir = tempfile::TempDir::new().unwrap();
1040 let snap_path = dir.path().join("snap.json");
1041 let wal_path = dir.path().join("wal.jsonl");
1042
1043 let engine = BudgetEngine::new();
1044 engine.ensure_tenant("desk", 1_000_000);
1045
1046 {
1047 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
1048 wal.append(serde_json::json!({"action": "reserve"}))
1049 .unwrap();
1050 wal.append(serde_json::json!({"action": "commit"})).unwrap();
1051
1052 checkpoint_with_wal(&engine, &snap_path, wal.sequence()).unwrap();
1053
1054 wal.append(serde_json::json!({"action": "release"}))
1055 .unwrap();
1056 }
1057
1058 let plan = recovery_plan(&snap_path, &wal_path).unwrap();
1059 assert_eq!(plan.total_wal_entries, 3);
1060 assert_eq!(plan.wal_high_watermark, 2);
1061 assert_eq!(plan.entries_to_replay, 1);
1062 }
1063
1064 #[test]
1065 #[cfg(feature = "wal")]
1066 fn recovery_plan_rejects_watermark_beyond_verified_head() {
1067 let dir = tempfile::TempDir::new().unwrap();
1068 let snap_path = dir.path().join("snap.json");
1069 let wal_path = dir.path().join("wal.jsonl");
1070 let engine = BudgetEngine::new();
1071 engine.ensure_tenant("desk", 1_000_000);
1072 checkpoint_with_wal(&engine, &snap_path, 2).unwrap();
1073 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
1074 wal.append(serde_json::json!({"event": 1})).unwrap();
1075 drop(wal);
1076
1077 assert!(recovery_plan(&snap_path, &wal_path).is_err());
1078 }
1079
1080 #[test]
1081 #[cfg_attr(
1082 all(miri, windows),
1083 ignore = "miri/windows: tempfile directory creation is unsupported"
1084 )]
1085 #[cfg(feature = "wal")]
1086 fn anchored_recovery_rejects_clean_suffix_truncation() {
1087 let dir = tempfile::TempDir::new().unwrap();
1088 let snap_path = dir.path().join("snap.json");
1089 let wal_path = dir.path().join("wal.jsonl");
1090 let engine = BudgetEngine::new();
1091 engine.ensure_tenant("desk", 1_000_000);
1092 checkpoint(&engine, &snap_path).unwrap();
1093
1094 let anchor = {
1095 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
1096 wal.append(serde_json::json!({"a": 1})).unwrap();
1097 wal.append(serde_json::json!({"b": 2})).unwrap();
1098 wal.flush_and_sync().unwrap();
1099 wal.anchor()
1100 };
1101 let contents = std::fs::read_to_string(&wal_path).unwrap();
1102 let prefix = contents.lines().take(1).collect::<Vec<_>>().join("\n") + "\n";
1103 std::fs::write(&wal_path, prefix).unwrap();
1104
1105 assert!(recovery_plan(&snap_path, &wal_path).is_ok());
1106 assert!(recovery_plan_against_anchor(&snap_path, &wal_path, &anchor).is_err());
1107 }
1108
1109 #[test]
1110 #[cfg_attr(
1111 all(miri, windows),
1112 ignore = "miri/windows: tempfile directory creation is unsupported"
1113 )]
1114 #[cfg(feature = "wal")]
1115 fn wal_anchor_atomic_save_roundtrip_and_replace() {
1116 let dir = tempfile::TempDir::new().unwrap();
1117 let path = dir.path().join("anchor.json");
1118 let mut anchor = crate::wal::WalAnchor {
1119 schema_version: crate::wal::WAL_ANCHOR_SCHEMA.to_string(),
1120 sequence: 1,
1121 last_hash: "11".repeat(32),
1122 keyed: true,
1123 };
1124 save_wal_anchor(&anchor, &path).unwrap();
1125 assert_eq!(load_wal_anchor(&path).unwrap(), anchor);
1126
1127 anchor.sequence = 2;
1128 anchor.last_hash = "22".repeat(32);
1129 save_wal_anchor(&anchor, &path).unwrap();
1130 assert_eq!(load_wal_anchor(&path).unwrap(), anchor);
1131 assert!(!path.with_extension("tmp").exists());
1132 }
1133
1134 #[test]
1135 #[cfg_attr(
1136 all(miri, windows),
1137 ignore = "miri/windows: tempfile directory creation is unsupported"
1138 )]
1139 #[cfg(feature = "wal")]
1140 fn recovery_plan_no_watermark_replays_all() {
1141 let dir = tempfile::TempDir::new().unwrap();
1142 let snap_path = dir.path().join("snap.json");
1143 let wal_path = dir.path().join("wal.jsonl");
1144
1145 let engine = BudgetEngine::new();
1146 engine.ensure_tenant("desk", 1_000_000);
1147 checkpoint(&engine, &snap_path).unwrap();
1148
1149 {
1150 let mut wal = crate::wal::WalWriter::<serde_json::Value>::open(&wal_path).unwrap();
1151 wal.append(serde_json::json!({"a": 1})).unwrap();
1152 wal.append(serde_json::json!({"b": 2})).unwrap();
1153 }
1154
1155 let plan = recovery_plan(&snap_path, &wal_path).unwrap();
1156 assert_eq!(plan.entries_to_replay, 2);
1157 assert_eq!(plan.wal_high_watermark, 0);
1158 }
1159
1160 #[test]
1161 fn snapshot_can_replace_an_existing_checkpoint() {
1162 let dir = tempfile::TempDir::new().unwrap();
1163 let path = dir.path().join("replace.json");
1164 let engine = BudgetEngine::new();
1165 engine.ensure_tenant("desk", 1_000_000);
1166
1167 checkpoint(&engine, &path).unwrap();
1168 assert!(matches!(
1169 engine.top_up_tenant("desk", 500_000),
1170 crate::budget::TopUpResult::ToppedUp { .. }
1171 ));
1172 let replaced = checkpoint(&engine, &path).unwrap();
1173 let loaded = load_snapshot(&path).unwrap();
1174
1175 assert_eq!(loaded.version, replaced.version);
1176 assert_eq!(loaded.tenants[0].initial_microcents, 1_500_000);
1177 }
1178
1179 #[test]
1180 #[cfg_attr(miri, ignore = "miri: contended flock blocks, which miri cannot run")]
1181 fn concurrent_atomic_saves_do_not_share_a_temp_file() {
1182 let dir = tempfile::TempDir::new().unwrap();
1183 let path = dir.path().join("snapshot.json");
1184 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1185 let handles: Vec<_> = (0..8)
1186 .map(|index| {
1187 let path = path.clone();
1188 let barrier = std::sync::Arc::clone(&barrier);
1189 std::thread::spawn(move || {
1190 let engine = BudgetEngine::new();
1191 engine.ensure_tenant(&format!("desk-{index}"), 1_000_000);
1192 let snapshot = engine.snapshot();
1193 barrier.wait();
1194 save_snapshot(&snapshot, &path)
1195 })
1196 })
1197 .collect();
1198
1199 for handle in handles {
1200 handle.join().unwrap().unwrap();
1201 }
1202 assert_eq!(load_snapshot(&path).unwrap().tenants.len(), 1);
1203 }
1204}