Skip to main content

calybris_core/
persistence.rs

1//! Snapshot persistence and point-in-time recovery.
2//!
3//! Save [`crate::budget::BudgetSnapshot`] to disk, load it back, and restore engine state.
4//! Combined with WAL replay, this gives crash recovery and backup/restore.
5//!
6//! Snapshot writes fsync file contents before atomic replacement. On Unix the
7//! parent-directory fsync is also required and errors are propagated. Rust's
8//! standard library does not expose portable Windows directory fsync, so the
9//! directory-entry durability guarantee is platform dependent there.
10
11use 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
17/// Maximum accepted size for one JSON persistence artifact.
18pub const MAX_PERSISTENCE_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
19
20/// Schema for the atomically committed snapshot/WAL generation manifest.
21#[cfg(feature = "wal")]
22pub const CHECKPOINT_MANIFEST_SCHEMA: &str = "calybris.checkpoint-manifest.v1";
23
24/// The final commit record for one durable checkpoint generation.
25#[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/// A manifest-consistent snapshot/WAL-anchor generation.
40///
41/// Use [`load_and_verify_coordinated_checkpoint`] to additionally verify the
42/// actual WAL bytes against the committed anchor.
43#[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/// Persistence error types.
52#[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
62/// Save a budget snapshot with file-data fsync and atomic replacement.
63///
64/// On Unix, parent-directory fsync is mandatory and failures are returned. On
65/// Windows, the standard library does not provide portable directory fsync;
66/// callers requiring a power-loss guarantee for the directory entry must add
67/// a platform-specific storage layer.
68pub 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    // Windows does not guarantee that concurrent replace-existing operations
82    // against the same destination all succeed. A durable sibling lock file
83    // serializes writers across both threads and processes while preserving
84    // atomic visibility of the final rename/persist operation. The lock is held
85    // for the rest of this function and released when the handle drops, so error
86    // paths release it too.
87    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/// Save a trusted WAL head anchor with fsync-backed atomic replacement.
156#[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/// Load a trusted WAL head anchor.
165#[cfg(feature = "wal")]
166pub fn load_wal_anchor(path: &Path) -> Result<crate::wal::WalAnchor, PersistenceError> {
167    load_json_bounded(path)
168}
169
170/// Load a budget snapshot from a JSON file.
171pub fn load_snapshot(path: &Path) -> Result<BudgetSnapshot, PersistenceError> {
172    load_json_bounded(path)
173}
174
175/// Migrate an untagged 0.5.x snapshot into a distinct recovery-aware file.
176///
177/// The caller supplies a trusted reservation allocator fence that is greater
178/// than every ID issued before the legacy snapshot. The source file is never
179/// modified; the migrated snapshot is written atomically to `destination`.
180pub 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
231/// Checkpoint engine state without WAL binding.
232///
233/// Use [`checkpoint_with_wal`] when you have a WAL writer — it records the
234/// WAL sequence so recovery knows where to start replaying.
235pub 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
244/// Checkpoint engine state alongside a WAL sequence.
245///
246/// Records the current WAL sequence as [`BudgetSnapshot::wal_high_watermark`]
247/// so that [`recovery_plan`] can determine exactly which WAL entries need
248/// replay after a crash.
249pub 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/// Commit a generation in WAL -> snapshot -> manifest order.
264///
265/// The WAL is flushed and synced first. Snapshot and anchor files are immutable,
266/// generation-specific files; the manifest is atomically replaced last and is
267/// therefore the only recovery commit point. Parent-directory power-loss
268/// durability follows the platform contract documented by [`save_snapshot`].
269/// Callers must route each logical
270/// ledger mutation and its WAL append through the same application-level
271/// admission boundary used for this checkpoint.
272#[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/// Load the last committed generation and fail closed on any cross-file mismatch.
318#[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/// Load a manifest-consistent checkpoint and verify the actual WAL against its anchor.
360#[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
405/// Restore engine state from a snapshot file.
406///
407/// Loads the snapshot, validates it (no active reservations, conservation
408/// balanced), and restores the engine. This is an **exclusive recovery**
409/// operation — no concurrent hot-path ops should be running.
410pub 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/// Recovery strategy: load snapshot + count WAL entries that need replay.
417///
418/// Uses [`BudgetSnapshot::wal_high_watermark`] (set by [`checkpoint_with_wal`])
419/// to determine which WAL entries are newer than the checkpoint. If no watermark
420/// is set, all WAL entries are counted as needing replay.
421#[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/// Recovery plan with chain-verified WAL read (unkeyed).
473///
474/// Verifies the WAL hash chain before counting entries. Use
475/// [`recovery_plan_keyed`] for HMAC-keyed WAL files.
476#[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/// Recovery plan with chain-verified WAL read (HMAC-keyed).
485#[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/// Recovery plan with an unkeyed WAL pinned to a trusted external head.
495#[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/// Recovery plan with a keyed WAL pinned to a trusted external head.
505#[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/// A recovery plan describing what needs to happen to restore state.
516#[cfg(feature = "wal")]
517#[derive(Debug, Clone)]
518pub struct RecoveryPlan {
519    /// The snapshot to restore from.
520    pub snapshot: BudgetSnapshot,
521    /// Total entries in the WAL file.
522    pub total_wal_entries: usize,
523    /// Entries newer than the checkpoint that need domain-specific replay.
524    pub entries_to_replay: usize,
525    /// The WAL sequence at checkpoint time (0 if not set).
526    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        // Without Developer Mode or elevation Windows reports
706        // ERROR_PRIVILEGE_NOT_HELD, which std does not map to `PermissionDenied`,
707        // so the raw code is matched too and the alias check is skipped.
708        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}