Skip to main content

traverse_runtime/
data_store_maintenance.rs

1//! Host-explicit `DataStore` retention prune and verified zip backup/restore.
2//!
3//! Governed by spec `083-datastore-retention-backup` / ADR-0021.
4
5use super::{
6    DataStoreError, LOCAL_DATA_STORE_FORMAT, LOCAL_DATA_STORE_LOCK_FILE,
7    LOCAL_DATA_STORE_V2_FORMAT, LocalDataClassification, LocalDataStoreEnvelope,
8    decode_v2_envelope, digest_for_private_envelope, digest_for_record, hex_decode, lock_error,
9    v2_envelope, validate_key,
10};
11use chrono::{DateTime, Duration, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use sha2::{Digest, Sha256};
15use std::fs::{self, File, OpenOptions};
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18use zip::CompressionMethod;
19use zip::ZipWriter;
20use zip::read::ZipArchive;
21use zip::write::SimpleFileOptions;
22
23const MAINTENANCE_SPEC: &str = "083-datastore-retention-backup";
24const MIGRATION_SPEC: &str = "092-datastore-v2-migration-ownership";
25const BACKUP_MANIFEST_VERSION: &str = "1";
26const BACKUP_MANIFEST_MEMBER: &str = "manifest.json";
27const BACKUP_RECORDS_PREFIX: &str = "records/";
28const HEXADECIMAL_DIGITS: &[u8; 16] = b"0123456789abcdef";
29
30/// Host retention knobs for prune (FR-002).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct RetentionPolicy {
33    pub max_count: Option<usize>,
34    pub max_age_secs: Option<u64>,
35}
36
37/// Stable maintenance failure codes (FR-010).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum MaintenanceErrorCode {
41    StoreLocked,
42    InvalidRetentionPolicy,
43    BackupVerifyFailed,
44    RestoreVerifyFailed,
45    UnsupportedStoreFormat,
46    MaintenanceIoFailed,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct MaintenanceError {
51    pub code: MaintenanceErrorCode,
52    pub message: String,
53    pub details: Value,
54}
55
56/// Stable, secret-free failure codes for an explicit host-owned v1-to-v2 migration.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum MigrationErrorCode {
60    TransitionUnsupported,
61    SourceInvalid,
62    BackupFailed,
63    WriteFailed,
64    VerificationFailed,
65    CommitFailed,
66    RestoreFailed,
67    OwnerLocked,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct MigrationError {
72    pub code: MigrationErrorCode,
73    pub message: String,
74    pub details: Value,
75}
76
77/// Safe evidence returned to the owning host; it never contains paths, keys, or values.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct MigrationReport {
80    pub governing_spec: String,
81    pub source_format: String,
82    pub target_format: String,
83    pub record_count: u64,
84    pub backup_content_digest: String,
85    pub outcome: String,
86}
87
88/// Host-explicit format-migration port. Generic runtime and CLI paths never call it.
89pub trait DataStoreMigration {
90    /// Migrates a verified v1 root to v2 only after a host-directed backup and
91    /// complete candidate verification have succeeded.
92    ///
93    /// # Errors
94    ///
95    /// Returns a stable, secret-free [`MigrationError`] when the source,
96    /// backup, candidate, ownership lock, or atomic commit cannot be verified.
97    fn migrate_v1_to_v2(
98        &mut self,
99        backup_destination: &Path,
100        as_of: &str,
101    ) -> Result<MigrationReport, MigrationError>;
102}
103
104/// Secret-free maintenance evidence (FR-009).
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct MaintenanceEvidence {
107    pub governing_spec: String,
108    pub outcome: String,
109    pub as_of: String,
110    pub attempted_count: u64,
111    pub removed_count: u64,
112    pub retained_count: u64,
113    pub record_count: u64,
114    pub archive_content_digest: Option<String>,
115    pub failure_code: Option<MaintenanceErrorCode>,
116    pub failure_reason: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct BackupRecordIndexEntry {
121    pub key: String,
122    pub classification: LocalDataClassification,
123    pub envelope_digest: String,
124    pub member_path: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct BackupManifest {
129    pub manifest_format_version: String,
130    pub created_as_of: String,
131    pub record_count: u64,
132    pub archive_content_digest: String,
133    pub records: Vec<BackupRecordIndexEntry>,
134    pub store_format: String,
135    pub writer_tool: String,
136    pub writer_semver: String,
137}
138
139/// Separate maintenance port for the same root and exclusive lock (FR-001).
140pub trait DataStoreMaintenance {
141    /// Prunes records under host policy with required `as_of` (FR-002/FR-003).
142    ///
143    /// # Errors
144    ///
145    /// Returns [`MaintenanceError`] when the policy is invalid or prune fails.
146    fn prune(
147        &mut self,
148        policy: &RetentionPolicy,
149        as_of: &str,
150    ) -> Result<MaintenanceEvidence, MaintenanceError>;
151
152    /// Writes a verified zip backup to `destination` (FR-004/FR-006/FR-007).
153    ///
154    /// # Errors
155    ///
156    /// Returns [`MaintenanceError`] when backup or verification fails.
157    fn backup(
158        &mut self,
159        destination: &Path,
160        as_of: &str,
161    ) -> Result<MaintenanceEvidence, MaintenanceError>;
162
163    /// Verifies an archive and atomically replaces the store root (FR-005/FR-008).
164    ///
165    /// # Errors
166    ///
167    /// Returns [`MaintenanceError`] when verification or replace fails.
168    fn restore(
169        &mut self,
170        archive: &Path,
171        as_of: &str,
172    ) -> Result<MaintenanceEvidence, MaintenanceError>;
173}
174
175#[derive(Debug)]
176pub struct LocalFileDataStoreMaintenance {
177    root: PathBuf,
178    lock_file: File,
179}
180
181impl Drop for LocalFileDataStoreMaintenance {
182    fn drop(&mut self) {
183        let _ = self.lock_file.unlock();
184    }
185}
186
187impl LocalFileDataStoreMaintenance {
188    /// Opens maintenance for an existing host-owned store root.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`MaintenanceError`] when the root cannot be created or locked.
193    pub fn open(root: impl Into<PathBuf>) -> Result<Self, MaintenanceError> {
194        let root = root.into();
195        fs::create_dir_all(&root)
196            .map_err(|error| maintenance_io("create data store root for maintenance", &error))?;
197        let lock_path = root.join(LOCAL_DATA_STORE_LOCK_FILE);
198        let lock_file = OpenOptions::new()
199            .read(true)
200            .write(true)
201            .create(true)
202            .truncate(false)
203            .open(&lock_path)
204            .map_err(|error| maintenance_io("open data store lock for maintenance", &error))?;
205        lock_file.try_lock().map_err(|error| {
206            let mapped = lock_error(error);
207            map_lock_to_maintenance(mapped)
208        })?;
209        Ok(Self { root, lock_file })
210    }
211
212    fn list_record_keys(&self) -> Result<Vec<String>, MaintenanceError> {
213        let mut keys = Vec::new();
214        for entry in
215            fs::read_dir(&self.root).map_err(|error| maintenance_io("list keys", &error))?
216        {
217            let entry = entry.map_err(|error| maintenance_io("read key entry", &error))?;
218            let path = entry.path();
219            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
220                continue;
221            }
222            if let Some(key) = path.file_stem().and_then(|stem| stem.to_str()) {
223                keys.push(key.to_string());
224            }
225        }
226        keys.sort();
227        Ok(keys)
228    }
229
230    fn path_for_key(&self, key: &str) -> Result<PathBuf, MaintenanceError> {
231        validate_key(key).map_err(map_data_store_error)?;
232        Ok(self.root.join(format!("{key}.json")))
233    }
234
235    fn read_envelope_bytes(&self, key: &str) -> Result<Vec<u8>, MaintenanceError> {
236        let path = self.path_for_key(key)?;
237        fs::read(&path).map_err(|error| maintenance_io("read envelope bytes", &error))
238    }
239
240    fn read_envelope(&self, key: &str) -> Result<LocalDataStoreEnvelope, MaintenanceError> {
241        let bytes = self.read_envelope_bytes(key)?;
242        parse_envelope_bytes(&bytes)
243    }
244
245    fn delete_key(&self, key: &str) -> Result<(), MaintenanceError> {
246        let path = self.path_for_key(key)?;
247        match fs::remove_file(path) {
248            Ok(()) => Ok(()),
249            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
250            Err(error) => Err(maintenance_io("delete pruned record", &error)),
251        }
252    }
253
254    fn reacquire_lock(&mut self) -> Result<(), MaintenanceError> {
255        let lock_path = self.root.join(LOCAL_DATA_STORE_LOCK_FILE);
256        let lock_file = OpenOptions::new()
257            .read(true)
258            .write(true)
259            .create(true)
260            .truncate(false)
261            .open(&lock_path)
262            .map_err(|error| maintenance_io("reopen data store lock", &error))?;
263        lock_file
264            .try_lock()
265            .map_err(|error| map_lock_to_maintenance(lock_error(error)))?;
266        let _ = self.lock_file.unlock();
267        self.lock_file = lock_file;
268        Ok(())
269    }
270}
271
272impl DataStoreMaintenance for LocalFileDataStoreMaintenance {
273    fn prune(
274        &mut self,
275        policy: &RetentionPolicy,
276        as_of: &str,
277    ) -> Result<MaintenanceEvidence, MaintenanceError> {
278        validate_policy(policy)?;
279        let as_of_instant = parse_as_of(as_of)?;
280        let keys = self.list_record_keys()?;
281        let total = keys.len() as u64;
282        let victims = select_prune_victims(self, &keys, policy, as_of_instant)?;
283        let attempted = victims.len() as u64;
284        let mut removed = 0_u64;
285        for key in &victims {
286            if let Err(error) = self.delete_key(key) {
287                return Err(MaintenanceError {
288                    code: error.code,
289                    message: error.message.clone(),
290                    details: json!({
291                        "attempted_count": attempted,
292                        "removed_count": removed,
293                        "retained_count": total.saturating_sub(removed),
294                        "failure_reason": error.message,
295                    }),
296                });
297            }
298            removed += 1;
299        }
300        Ok(prune_evidence(
301            "prune_completed",
302            as_of,
303            attempted,
304            removed,
305            total.saturating_sub(removed),
306            None,
307            None,
308        ))
309    }
310
311    fn backup(
312        &mut self,
313        destination: &Path,
314        as_of: &str,
315    ) -> Result<MaintenanceEvidence, MaintenanceError> {
316        let _ = parse_as_of(as_of)?;
317        let keys = self.list_record_keys()?;
318        let mut members: Vec<(String, Vec<u8>, BackupRecordIndexEntry)> = Vec::new();
319        for key in &keys {
320            let bytes = self.read_envelope_bytes(key)?;
321            let envelope = parse_envelope_bytes(&bytes)?;
322            let member_path = format!("{BACKUP_RECORDS_PREFIX}{key}.json");
323            let envelope_digest = sha256_hex(&bytes);
324            members.push((
325                member_path.clone(),
326                bytes,
327                BackupRecordIndexEntry {
328                    key: key.clone(),
329                    classification: envelope.classification,
330                    envelope_digest,
331                    member_path,
332                },
333            ));
334        }
335        members.sort_by(|left, right| left.0.cmp(&right.0));
336        let index: Vec<BackupRecordIndexEntry> =
337            members.iter().map(|(_, _, entry)| entry.clone()).collect();
338        let content_digest = archive_content_digest_from_members(
339            &members
340                .iter()
341                .map(|(path, bytes, _)| (path.as_str(), bytes.as_slice()))
342                .collect::<Vec<_>>(),
343        );
344        let manifest = BackupManifest {
345            manifest_format_version: BACKUP_MANIFEST_VERSION.to_string(),
346            created_as_of: as_of.to_string(),
347            record_count: index.len() as u64,
348            archive_content_digest: content_digest.clone(),
349            records: index,
350            store_format: LOCAL_DATA_STORE_FORMAT.to_string(),
351            writer_tool: env!("CARGO_PKG_NAME").to_string(),
352            writer_semver: env!("CARGO_PKG_VERSION").to_string(),
353        };
354        let manifest_bytes = serialize_backup_manifest(&manifest)?;
355
356        if let Some(parent) = destination.parent() {
357            fs::create_dir_all(parent)
358                .map_err(|error| maintenance_io("create backup destination directory", &error))?;
359        }
360        let temporary = destination.with_extension("zip.tmp");
361        let _ = fs::remove_file(&temporary);
362        {
363            let file = File::create(&temporary)
364                .map_err(|error| maintenance_io("create backup zip", &error))?;
365            let mut zip = ZipWriter::new(file);
366            let options =
367                SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
368            zip.start_file(BACKUP_MANIFEST_MEMBER, options)
369                .map_err(|error| maintenance_zip("start manifest zip member", error))?;
370            zip.write_all(&manifest_bytes)
371                .map_err(|error| maintenance_io("write manifest zip member", &error))?;
372            for (path, bytes, _) in &members {
373                zip.start_file(path, options)
374                    .map_err(|error| maintenance_zip("start record zip member", error))?;
375                zip.write_all(bytes)
376                    .map_err(|error| maintenance_io("write record zip member", &error))?;
377            }
378            zip.finish()
379                .map_err(|error| maintenance_zip("finish backup zip", error))?;
380        }
381
382        verify_backup_archive(&temporary)
383            .inspect_err(|_| cleanup_failed_backup_temp(&temporary))?;
384        commit_backup_zip(&temporary, destination)?;
385
386        Ok(MaintenanceEvidence {
387            governing_spec: MAINTENANCE_SPEC.to_string(),
388            outcome: "backup_created".to_string(),
389            as_of: as_of.to_string(),
390            attempted_count: members.len() as u64,
391            removed_count: 0,
392            retained_count: members.len() as u64,
393            record_count: members.len() as u64,
394            archive_content_digest: Some(content_digest),
395            failure_code: None,
396            failure_reason: None,
397        })
398    }
399
400    fn restore(
401        &mut self,
402        archive: &Path,
403        as_of: &str,
404    ) -> Result<MaintenanceEvidence, MaintenanceError> {
405        let _ = parse_as_of(as_of)?;
406        let verified = verify_backup_archive(archive)?;
407        let parent = restore_parent(&self.root)?;
408        let work_suffix = restore_work_suffix(&self.root, as_of);
409        let temp_root = parent.join(format!(".traverse-datastore-restore-{work_suffix}"));
410        let backup_root = parent.join(format!(".traverse-datastore-replaced-{work_suffix}"));
411        let _ = fs::remove_dir_all(&temp_root);
412        let _ = fs::remove_dir_all(&backup_root);
413        fs::create_dir_all(&temp_root)
414            .map_err(|error| maintenance_io("create restore temp root", &error))?;
415
416        materialize_archive_to_root(archive, &temp_root)?;
417        verify_store_root_envelopes(&temp_root)?;
418
419        // Release lock on the live root before swapping directories.
420        let _ = self.lock_file.unlock();
421        move_live_store_aside(self, &backup_root)?;
422        replace_store_root(self, &temp_root, &backup_root)?;
423        self.reacquire_lock()?;
424        let _ = fs::remove_dir_all(&backup_root);
425
426        Ok(MaintenanceEvidence {
427            governing_spec: MAINTENANCE_SPEC.to_string(),
428            outcome: "restore_committed".to_string(),
429            as_of: as_of.to_string(),
430            attempted_count: verified.record_count,
431            removed_count: 0,
432            retained_count: verified.record_count,
433            record_count: verified.record_count,
434            archive_content_digest: Some(verified.archive_content_digest),
435            failure_code: None,
436            failure_reason: None,
437        })
438    }
439}
440
441impl DataStoreMigration for LocalFileDataStoreMaintenance {
442    fn migrate_v1_to_v2(
443        &mut self,
444        backup_destination: &Path,
445        as_of: &str,
446    ) -> Result<MigrationReport, MigrationError> {
447        parse_as_of(as_of).map_err(map_migration_source_error)?;
448        let keys = self
449            .list_record_keys()
450            .map_err(map_migration_source_error)?;
451
452        // Validate every committed source envelope before any backup artifact or
453        // candidate representation is written.
454        for key in &keys {
455            self.read_envelope(key)
456                .map_err(map_migration_source_error)?;
457        }
458
459        let backup = self
460            .backup(backup_destination, as_of)
461            .map_err(map_migration_backup_error)?;
462        let backup_digest = verified_backup_digest(backup.archive_content_digest)?;
463
464        let parent = restore_parent(&self.root).map_err(map_migration_commit_error)?;
465        let suffix = restore_work_suffix(&self.root, as_of);
466        let candidate_root = parent.join(format!(".traverse-datastore-migration-{suffix}"));
467        let previous_root = parent.join(format!(".traverse-datastore-pre-v2-{suffix}"));
468        let _ = fs::remove_dir_all(&candidate_root);
469        let _ = fs::remove_dir_all(&previous_root);
470        fs::create_dir_all(&candidate_root).map_err(map_migration_write_io)?;
471        File::create(candidate_root.join(LOCAL_DATA_STORE_LOCK_FILE))
472            .map_err(map_migration_write_io)?;
473
474        (|| -> Result<(), MigrationError> {
475            for key in &keys {
476                let source = self
477                    .read_envelope(key)
478                    .map_err(map_migration_source_error)?;
479                let v2 = v2_envelope(source).map_err(map_migration_write_data_store)?;
480                let bytes = serialize_v2_candidate(&v2)?;
481                fs::write(candidate_root.join(format!("{key}.json")), bytes)
482                    .map_err(map_migration_write_io)?;
483            }
484            verify_v2_store_root(&candidate_root).map_err(map_migration_verification_error)
485        })()
486        .map_err(|error| abandon_failed_candidate(&candidate_root, error))?;
487
488        commit_migrated_store_root(self, &candidate_root, &previous_root)?;
489
490        Ok(MigrationReport {
491            governing_spec: MIGRATION_SPEC.to_string(),
492            source_format: LOCAL_DATA_STORE_FORMAT.to_string(),
493            target_format: LOCAL_DATA_STORE_V2_FORMAT.to_string(),
494            record_count: keys.len() as u64,
495            backup_content_digest: backup_digest,
496            outcome: "datastore_migration_committed".to_string(),
497        })
498    }
499}
500
501fn verify_v2_store_root(root: &Path) -> Result<(), MaintenanceError> {
502    for entry in
503        fs::read_dir(root).map_err(|error| maintenance_io("list migration candidate", &error))?
504    {
505        let entry =
506            entry.map_err(|error| maintenance_io("read migration candidate entry", &error))?;
507        let path = entry.path();
508        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
509            continue;
510        }
511        let bytes =
512            fs::read(path).map_err(|error| maintenance_io("read migration candidate", &error))?;
513        let value: Value =
514            serde_json::from_slice(&bytes).map_err(|_| malformed_envelope_error())?;
515        let envelope = decode_v2_envelope(value).map_err(map_data_store_error)?;
516        parse_envelope_bytes(&serialize_envelope_for_verify(&envelope)?)?;
517    }
518    Ok(())
519}
520
521fn serialize_envelope_for_verify(
522    envelope: &LocalDataStoreEnvelope,
523) -> Result<Vec<u8>, MaintenanceError> {
524    json_to_vec(envelope).map_err(map_envelope_serialize_error)
525}
526
527fn map_envelope_serialize_error(_: serde_json::Error) -> MaintenanceError {
528    malformed_envelope_error()
529}
530
531fn json_to_vec<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
532    serde_json::to_vec(value)
533}
534
535fn verified_backup_digest(digest: Option<String>) -> Result<String, MigrationError> {
536    digest.ok_or_else(|| {
537        migration_error(
538            MigrationErrorCode::BackupFailed,
539            "datastore_backup_failed",
540            "missing_verified_backup_digest",
541        )
542    })
543}
544
545fn serialize_v2_candidate(
546    envelope: &super::LocalDataStoreV2Envelope,
547) -> Result<Vec<u8>, MigrationError> {
548    json_to_vec(envelope).map_err(map_serialize_candidate_error)
549}
550
551fn map_serialize_candidate_error(_: serde_json::Error) -> MigrationError {
552    migration_error(
553        MigrationErrorCode::WriteFailed,
554        "datastore_write_failed",
555        "serialize_candidate",
556    )
557}
558
559fn abandon_failed_candidate(candidate_root: &Path, error: MigrationError) -> MigrationError {
560    let _ = fs::remove_dir_all(candidate_root);
561    error
562}
563
564fn commit_migrated_store_root(
565    maintenance: &mut LocalFileDataStoreMaintenance,
566    candidate_root: &Path,
567    previous_root: &Path,
568) -> Result<(), MigrationError> {
569    let _ = maintenance.lock_file.unlock();
570    move_live_aside_for_migration(maintenance, previous_root, candidate_root)?;
571    install_migrated_candidate(maintenance, candidate_root, previous_root)?;
572    reacquire_migrated_lock(maintenance, candidate_root, previous_root)?;
573    let _ = fs::remove_dir_all(previous_root);
574    Ok(())
575}
576
577fn move_live_aside_for_migration(
578    maintenance: &mut LocalFileDataStoreMaintenance,
579    previous_root: &Path,
580    candidate_root: &Path,
581) -> Result<(), MigrationError> {
582    fs::rename(&maintenance.root, previous_root)
583        .map_err(|error| abort_migration_before_aside(maintenance, candidate_root, error))
584}
585
586fn install_migrated_candidate(
587    maintenance: &mut LocalFileDataStoreMaintenance,
588    candidate_root: &Path,
589    previous_root: &Path,
590) -> Result<(), MigrationError> {
591    fs::rename(candidate_root, &maintenance.root)
592        .map_err(|error| abort_migration_after_aside(maintenance, previous_root, error))
593}
594
595fn abort_migration_before_aside(
596    maintenance: &mut LocalFileDataStoreMaintenance,
597    candidate_root: &Path,
598    error: std::io::Error,
599) -> MigrationError {
600    let _ = maintenance.reacquire_lock();
601    let _ = fs::remove_dir_all(candidate_root);
602    map_migration_commit_io(error)
603}
604
605fn abort_migration_after_aside(
606    maintenance: &mut LocalFileDataStoreMaintenance,
607    previous_root: &Path,
608    error: std::io::Error,
609) -> MigrationError {
610    let _ = fs::rename(previous_root, &maintenance.root);
611    let _ = maintenance.reacquire_lock();
612    map_migration_commit_io(error)
613}
614
615fn reacquire_migrated_lock(
616    maintenance: &mut LocalFileDataStoreMaintenance,
617    candidate_root: &Path,
618    previous_root: &Path,
619) -> Result<(), MigrationError> {
620    if let Err(error) = maintenance.reacquire_lock() {
621        let _ = fs::rename(&maintenance.root, candidate_root);
622        let _ = fs::rename(previous_root, &maintenance.root);
623        let _ = maintenance.reacquire_lock();
624        return Err(map_migration_commit_error(error));
625    }
626    Ok(())
627}
628
629fn migration_error(code: MigrationErrorCode, message: &str, reason: &str) -> MigrationError {
630    MigrationError {
631        code,
632        message: message.to_string(),
633        details: json!({ "reason": reason }),
634    }
635}
636
637fn map_migration_source_error(_: MaintenanceError) -> MigrationError {
638    migration_error(
639        MigrationErrorCode::SourceInvalid,
640        "datastore_source_invalid",
641        "source_validation_failed",
642    )
643}
644
645fn map_migration_backup_error(_: MaintenanceError) -> MigrationError {
646    migration_error(
647        MigrationErrorCode::BackupFailed,
648        "datastore_backup_failed",
649        "backup_verification_failed",
650    )
651}
652
653fn map_migration_write_io(_: std::io::Error) -> MigrationError {
654    migration_error(
655        MigrationErrorCode::WriteFailed,
656        "datastore_write_failed",
657        "candidate_write_failed",
658    )
659}
660
661fn map_migration_write_data_store(_: DataStoreError) -> MigrationError {
662    migration_error(
663        MigrationErrorCode::WriteFailed,
664        "datastore_write_failed",
665        "candidate_serialization_failed",
666    )
667}
668
669fn map_migration_verification_error(_: MaintenanceError) -> MigrationError {
670    migration_error(
671        MigrationErrorCode::VerificationFailed,
672        "datastore_verification_failed",
673        "candidate_verification_failed",
674    )
675}
676
677fn map_migration_commit_io(_: std::io::Error) -> MigrationError {
678    migration_error(
679        MigrationErrorCode::CommitFailed,
680        "datastore_commit_failed",
681        "atomic_commit_failed",
682    )
683}
684
685fn map_migration_commit_error(_: MaintenanceError) -> MigrationError {
686    migration_error(
687        MigrationErrorCode::CommitFailed,
688        "datastore_commit_failed",
689        "atomic_commit_failed",
690    )
691}
692
693fn select_prune_victims(
694    store: &LocalFileDataStoreMaintenance,
695    keys: &[String],
696    policy: &RetentionPolicy,
697    as_of: DateTime<Utc>,
698) -> Result<Vec<String>, MaintenanceError> {
699    // Start retained; clear flags for records that fail any active bound.
700    // Count: deterministic sorted-key order; newest = suffix of length max_count.
701    // Age: remove when retained_at <= as_of - max_age (unstamped envelopes stay).
702    let mut retain = vec![true; keys.len()];
703
704    if let Some(max_count) = policy.max_count.filter(|count| keys.len() > *count) {
705        for flag in retain.iter_mut().take(keys.len() - max_count) {
706            *flag = false;
707        }
708    }
709
710    if let Some(max_age_secs) = policy.max_age_secs {
711        let cutoff = age_cutoff(as_of, max_age_secs)?;
712        for (index, key) in keys.iter().enumerate() {
713            let envelope = store.read_envelope(key)?;
714            if let Some(stamp) = envelope.retained_at.as_deref() {
715                let retained = parse_as_of(stamp)?;
716                if retained <= cutoff {
717                    retain[index] = false;
718                }
719            }
720        }
721    }
722
723    Ok(keys
724        .iter()
725        .zip(retain)
726        .filter_map(|(key, keep)| if keep { None } else { Some(key.clone()) })
727        .collect())
728}
729
730fn validate_policy(policy: &RetentionPolicy) -> Result<(), MaintenanceError> {
731    if policy.max_count.is_none() && policy.max_age_secs.is_none() {
732        return Err(MaintenanceError {
733            code: MaintenanceErrorCode::InvalidRetentionPolicy,
734            message: "invalid_retention_policy".to_string(),
735            details: json!({ "reason": "at_least_one_bound_required" }),
736        });
737    }
738    Ok(())
739}
740
741fn parse_as_of(as_of: &str) -> Result<DateTime<Utc>, MaintenanceError> {
742    DateTime::parse_from_rfc3339(as_of)
743        .map(|value| value.with_timezone(&Utc))
744        .map_err(|error| MaintenanceError {
745            code: MaintenanceErrorCode::InvalidRetentionPolicy,
746            message: "invalid_retention_policy".to_string(),
747            details: json!({ "reason": "invalid_as_of", "cause": error.to_string() }),
748        })
749}
750
751fn parse_envelope_bytes(bytes: &[u8]) -> Result<LocalDataStoreEnvelope, MaintenanceError> {
752    let value: Value = serde_json::from_slice(bytes).map_err(|_| MaintenanceError {
753        code: MaintenanceErrorCode::RestoreVerifyFailed,
754        message: "restore_verify_failed".to_string(),
755        details: json!({ "reason": "malformed_envelope" }),
756    })?;
757    if value.get("format").is_none() {
758        return Err(MaintenanceError {
759            code: MaintenanceErrorCode::UnsupportedStoreFormat,
760            message: "unsupported_store_format".to_string(),
761            details: json!({ "reason": "legacy_unverified" }),
762        });
763    }
764    let envelope: LocalDataStoreEnvelope =
765        serde_json::from_value(value).map_err(|_| MaintenanceError {
766            code: MaintenanceErrorCode::RestoreVerifyFailed,
767            message: "restore_verify_failed".to_string(),
768            details: json!({ "reason": "malformed_envelope" }),
769        })?;
770    if envelope.format != LOCAL_DATA_STORE_FORMAT {
771        return Err(MaintenanceError {
772            code: MaintenanceErrorCode::UnsupportedStoreFormat,
773            message: "unsupported_store_format".to_string(),
774            details: json!({ "reason": "unknown_format_version", "format": envelope.format }),
775        });
776    }
777    let key = envelope_record_key(&envelope)?;
778    let expected = match envelope.classification {
779        LocalDataClassification::Public => {
780            if envelope.key_id.is_some()
781                || envelope.record_key.is_some()
782                || envelope.nonce.is_some()
783                || envelope.ciphertext.is_some()
784            {
785                return Err(malformed_envelope_error());
786            }
787            digest_for_record(
788                envelope
789                    .record
790                    .as_ref()
791                    .ok_or_else(malformed_envelope_error)?,
792            )
793            .map_err(map_data_store_error)?
794        }
795        LocalDataClassification::Private => {
796            if envelope.record.is_some() {
797                return Err(malformed_envelope_error());
798            }
799            let key_id = envelope
800                .key_id
801                .as_deref()
802                .ok_or_else(malformed_envelope_error)?;
803            let nonce = envelope
804                .nonce
805                .as_deref()
806                .ok_or_else(malformed_envelope_error)?;
807            let ciphertext = envelope
808                .ciphertext
809                .as_deref()
810                .ok_or_else(malformed_envelope_error)?;
811            let nonce_bytes = hex_decode(nonce).map_err(|_| malformed_envelope_error())?;
812            let ciphertext_bytes =
813                hex_decode(ciphertext).map_err(|_| malformed_envelope_error())?;
814            if nonce_bytes.len() != 12 || ciphertext_bytes.len() < 16 {
815                return Err(malformed_envelope_error());
816            }
817            digest_for_private_envelope(key_id, &key, nonce, ciphertext)
818        }
819    };
820    if envelope.digest != expected {
821        return Err(MaintenanceError {
822            code: MaintenanceErrorCode::RestoreVerifyFailed,
823            message: "restore_verify_failed".to_string(),
824            details: json!({ "reason": "digest_mismatch", "key": key }),
825        });
826    }
827    Ok(envelope)
828}
829
830fn envelope_record_key(envelope: &LocalDataStoreEnvelope) -> Result<String, MaintenanceError> {
831    match envelope.classification {
832        LocalDataClassification::Public => envelope
833            .record
834            .as_ref()
835            .map(|record| record.key.clone())
836            .ok_or_else(malformed_envelope_error),
837        LocalDataClassification::Private => envelope
838            .record_key
839            .clone()
840            .ok_or_else(malformed_envelope_error),
841    }
842}
843
844fn malformed_envelope_error() -> MaintenanceError {
845    MaintenanceError {
846        code: MaintenanceErrorCode::RestoreVerifyFailed,
847        message: "restore_verify_failed".to_string(),
848        details: json!({ "reason": "malformed_envelope" }),
849    }
850}
851
852fn archive_content_digest_from_members(members: &[(&str, &[u8])]) -> String {
853    let mut hasher = Sha256::new();
854    for (path, bytes) in members {
855        hasher.update(path.as_bytes());
856        hasher.update([0]);
857        hasher.update(bytes);
858        hasher.update([0]);
859    }
860    format!("sha256:{}", hex_encode(&hasher.finalize()))
861}
862
863fn sha256_hex(bytes: &[u8]) -> String {
864    format!("sha256:{}", hex_encode(&Sha256::digest(bytes)))
865}
866
867fn restore_work_suffix(root: &Path, as_of: &str) -> String {
868    let root_digest = hex_encode(&Sha256::digest(root.as_os_str().as_encoded_bytes()));
869    format!(
870        "{}-{}-{}",
871        std::process::id(),
872        &root_digest[..16],
873        as_of.replace(':', "")
874    )
875}
876
877fn hex_encode(digest: &[u8]) -> String {
878    let mut hexadecimal = String::with_capacity(digest.len() * 2);
879    for byte in digest {
880        hexadecimal.push(char::from(HEXADECIMAL_DIGITS[usize::from(byte >> 4)]));
881        hexadecimal.push(char::from(HEXADECIMAL_DIGITS[usize::from(byte & 0x0f)]));
882    }
883    hexadecimal
884}
885
886fn verify_backup_archive(path: &Path) -> Result<BackupManifest, MaintenanceError> {
887    let mut archive = open_backup_zip(path)?;
888    let manifest = read_and_validate_manifest_header(&mut archive)?;
889    verify_manifest_member_payloads(&mut archive, &manifest)?;
890    Ok(manifest)
891}
892
893fn open_backup_zip(path: &Path) -> Result<ZipArchive<File>, MaintenanceError> {
894    let file = File::open(path).map_err(|error| MaintenanceError {
895        code: MaintenanceErrorCode::BackupVerifyFailed,
896        message: "backup_verify_failed".to_string(),
897        details: json!({ "reason": "open_archive", "cause": error.to_string() }),
898    })?;
899    ZipArchive::new(file).map_err(|error| MaintenanceError {
900        code: MaintenanceErrorCode::BackupVerifyFailed,
901        message: "backup_verify_failed".to_string(),
902        details: json!({ "reason": "invalid_zip", "cause": error.to_string() }),
903    })
904}
905
906fn read_and_validate_manifest_header(
907    archive: &mut ZipArchive<File>,
908) -> Result<BackupManifest, MaintenanceError> {
909    let mut manifest_bytes = Vec::new();
910    {
911        let mut manifest_file =
912            archive
913                .by_name(BACKUP_MANIFEST_MEMBER)
914                .map_err(|_| MaintenanceError {
915                    code: MaintenanceErrorCode::BackupVerifyFailed,
916                    message: "backup_verify_failed".to_string(),
917                    details: json!({ "reason": "missing_manifest" }),
918                })?;
919        manifest_file
920            .read_to_end(&mut manifest_bytes)
921            .map_err(|error| read_manifest_bytes_error(&error))?;
922    }
923    let manifest: BackupManifest =
924        serde_json::from_slice(&manifest_bytes).map_err(|_| MaintenanceError {
925            code: MaintenanceErrorCode::BackupVerifyFailed,
926            message: "backup_verify_failed".to_string(),
927            details: json!({ "reason": "malformed_manifest" }),
928        })?;
929    if manifest.manifest_format_version != BACKUP_MANIFEST_VERSION {
930        return Err(MaintenanceError {
931            code: MaintenanceErrorCode::UnsupportedStoreFormat,
932            message: "unsupported_store_format".to_string(),
933            details: json!({
934                "reason": "unsupported_manifest_version",
935                "version": manifest.manifest_format_version
936            }),
937        });
938    }
939    if manifest.store_format != LOCAL_DATA_STORE_FORMAT {
940        return Err(MaintenanceError {
941            code: MaintenanceErrorCode::UnsupportedStoreFormat,
942            message: "unsupported_store_format".to_string(),
943            details: json!({ "reason": "unsupported_store_format", "format": manifest.store_format }),
944        });
945    }
946    Ok(manifest)
947}
948
949fn verify_manifest_member_payloads(
950    archive: &mut ZipArchive<File>,
951    manifest: &BackupManifest,
952) -> Result<(), MaintenanceError> {
953    let mut members = Vec::new();
954    for entry in &manifest.records {
955        if !entry.member_path.starts_with(BACKUP_RECORDS_PREFIX) || entry.member_path.contains("..")
956        {
957            return Err(MaintenanceError {
958                code: MaintenanceErrorCode::BackupVerifyFailed,
959                message: "backup_verify_failed".to_string(),
960                details: json!({ "reason": "invalid_member_path", "path": entry.member_path }),
961            });
962        }
963        let mut bytes = Vec::new();
964        {
965            let mut file = archive
966                .by_name(&entry.member_path)
967                .map_err(|_| MaintenanceError {
968                    code: MaintenanceErrorCode::BackupVerifyFailed,
969                    message: "backup_verify_failed".to_string(),
970                    details: json!({ "reason": "missing_member", "path": entry.member_path }),
971                })?;
972            file.read_to_end(&mut bytes)
973                .map_err(|error| read_member_bytes_error(&error))?;
974        }
975        if sha256_hex(&bytes) != entry.envelope_digest {
976            return Err(MaintenanceError {
977                code: MaintenanceErrorCode::BackupVerifyFailed,
978                message: "backup_verify_failed".to_string(),
979                details: json!({ "reason": "member_digest_mismatch", "key": entry.key }),
980            });
981        }
982        let envelope = parse_envelope_bytes(&bytes)?;
983        if envelope_record_key(&envelope)? != entry.key {
984            return Err(MaintenanceError {
985                code: MaintenanceErrorCode::BackupVerifyFailed,
986                message: "backup_verify_failed".to_string(),
987                details: json!({ "reason": "key_mismatch", "key": entry.key }),
988            });
989        }
990        members.push((entry.member_path.clone(), bytes));
991    }
992    members.sort_by(|left, right| left.0.cmp(&right.0));
993    let computed = archive_content_digest_from_members(
994        &members
995            .iter()
996            .map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
997            .collect::<Vec<_>>(),
998    );
999    if computed != manifest.archive_content_digest {
1000        return Err(MaintenanceError {
1001            code: MaintenanceErrorCode::BackupVerifyFailed,
1002            message: "backup_verify_failed".to_string(),
1003            details: json!({ "reason": "archive_content_digest_mismatch" }),
1004        });
1005    }
1006    if manifest.record_count != manifest.records.len() as u64 {
1007        return Err(MaintenanceError {
1008            code: MaintenanceErrorCode::BackupVerifyFailed,
1009            message: "backup_verify_failed".to_string(),
1010            details: json!({ "reason": "record_count_mismatch" }),
1011        });
1012    }
1013    Ok(())
1014}
1015
1016fn materialize_archive_to_root(archive: &Path, root: &Path) -> Result<(), MaintenanceError> {
1017    let manifest = verify_backup_archive(archive)?;
1018    let mut zip = open_restore_zip(archive)?;
1019    // Ensure lock file exists in the new root so reopen can acquire it.
1020    let lock_path = root.join(LOCAL_DATA_STORE_LOCK_FILE);
1021    File::create(&lock_path)
1022        .map_err(|error| maintenance_io("create restored lock file", &error))?;
1023
1024    for entry in &manifest.records {
1025        let key = &entry.key;
1026        validate_key(key).map_err(map_data_store_error)?;
1027        let mut bytes = Vec::new();
1028        {
1029            let mut member = zip
1030                .by_name(&entry.member_path)
1031                .map_err(|_| restore_missing_member_error(&entry.member_path))?;
1032            member
1033                .read_to_end(&mut bytes)
1034                .map_err(|error| maintenance_io("extract member", &error))?;
1035        }
1036        let dest = root.join(format!("{key}.json"));
1037        fs::write(&dest, bytes)
1038            .map_err(|error| maintenance_io("write restored envelope", &error))?;
1039    }
1040    Ok(())
1041}
1042
1043fn verify_store_root_envelopes(root: &Path) -> Result<(), MaintenanceError> {
1044    for entry in fs::read_dir(root).map_err(|error| maintenance_io("list restored root", &error))? {
1045        let entry = entry.map_err(|error| maintenance_io("read restored entry", &error))?;
1046        let path = entry.path();
1047        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
1048            continue;
1049        }
1050        let bytes =
1051            fs::read(&path).map_err(|error| maintenance_io("read restored envelope", &error))?;
1052        parse_envelope_bytes(&bytes)?;
1053    }
1054    Ok(())
1055}
1056
1057fn prune_evidence(
1058    outcome: &str,
1059    as_of: &str,
1060    attempted: u64,
1061    removed: u64,
1062    retained: u64,
1063    failure_code: Option<MaintenanceErrorCode>,
1064    failure_reason: Option<String>,
1065) -> MaintenanceEvidence {
1066    MaintenanceEvidence {
1067        governing_spec: MAINTENANCE_SPEC.to_string(),
1068        outcome: outcome.to_string(),
1069        as_of: as_of.to_string(),
1070        attempted_count: attempted,
1071        removed_count: removed,
1072        retained_count: retained,
1073        record_count: retained,
1074        archive_content_digest: None,
1075        failure_code,
1076        failure_reason,
1077    }
1078}
1079
1080fn age_cutoff(as_of: DateTime<Utc>, max_age_secs: u64) -> Result<DateTime<Utc>, MaintenanceError> {
1081    let age = Duration::seconds(
1082        i64::try_from(max_age_secs).map_err(|_| max_age_secs_out_of_range_error())?,
1083    );
1084    as_of
1085        .checked_sub_signed(age)
1086        .ok_or_else(as_of_minus_max_age_underflow_error)
1087}
1088
1089fn max_age_secs_out_of_range_error() -> MaintenanceError {
1090    MaintenanceError {
1091        code: MaintenanceErrorCode::InvalidRetentionPolicy,
1092        message: "invalid_retention_policy".to_string(),
1093        details: json!({ "reason": "max_age_secs_out_of_range" }),
1094    }
1095}
1096
1097fn as_of_minus_max_age_underflow_error() -> MaintenanceError {
1098    MaintenanceError {
1099        code: MaintenanceErrorCode::InvalidRetentionPolicy,
1100        message: "invalid_retention_policy".to_string(),
1101        details: json!({ "reason": "as_of_minus_max_age_underflow" }),
1102    }
1103}
1104
1105fn move_live_store_aside(
1106    maintenance: &mut LocalFileDataStoreMaintenance,
1107    backup_root: &Path,
1108) -> Result<(), MaintenanceError> {
1109    fs::rename(&maintenance.root, backup_root)
1110        .map_err(|error| handle_move_live_store_aside_failure(maintenance, &error))
1111}
1112
1113fn replace_store_root(
1114    maintenance: &mut LocalFileDataStoreMaintenance,
1115    temp_root: &Path,
1116    backup_root: &Path,
1117) -> Result<(), MaintenanceError> {
1118    if let Err(error) = fs::rename(temp_root, &maintenance.root) {
1119        return Err(handle_replace_store_root_failure(
1120            maintenance,
1121            backup_root,
1122            &error,
1123        ));
1124    }
1125    Ok(())
1126}
1127
1128fn read_manifest_bytes_error(error: &std::io::Error) -> MaintenanceError {
1129    MaintenanceError {
1130        code: MaintenanceErrorCode::BackupVerifyFailed,
1131        message: "backup_verify_failed".to_string(),
1132        details: json!({ "reason": "read_manifest", "cause": error.to_string() }),
1133    }
1134}
1135
1136fn read_member_bytes_error(error: &std::io::Error) -> MaintenanceError {
1137    MaintenanceError {
1138        code: MaintenanceErrorCode::BackupVerifyFailed,
1139        message: "backup_verify_failed".to_string(),
1140        details: json!({ "reason": "read_member", "cause": error.to_string() }),
1141    }
1142}
1143
1144fn open_restore_zip(archive: &Path) -> Result<ZipArchive<File>, MaintenanceError> {
1145    let file = File::open(archive).map_err(|error| maintenance_io("reopen archive", &error))?;
1146    ZipArchive::new(file).map_err(|error| restore_invalid_zip_error(&error))
1147}
1148
1149fn restore_invalid_zip_error(error: &zip::result::ZipError) -> MaintenanceError {
1150    MaintenanceError {
1151        code: MaintenanceErrorCode::RestoreVerifyFailed,
1152        message: "restore_verify_failed".to_string(),
1153        details: json!({ "reason": "invalid_zip", "cause": error.to_string() }),
1154    }
1155}
1156
1157fn restore_missing_member_error(path: &str) -> MaintenanceError {
1158    MaintenanceError {
1159        code: MaintenanceErrorCode::RestoreVerifyFailed,
1160        message: "restore_verify_failed".to_string(),
1161        details: json!({ "reason": "missing_member", "path": path }),
1162    }
1163}
1164
1165fn serialize_backup_manifest(manifest: &BackupManifest) -> Result<Vec<u8>, MaintenanceError> {
1166    serde_json::to_vec_pretty(manifest).map_err(|error| serialize_manifest_error(&error))
1167}
1168
1169fn serialize_manifest_error(error: &serde_json::Error) -> MaintenanceError {
1170    MaintenanceError {
1171        code: MaintenanceErrorCode::MaintenanceIoFailed,
1172        message: "maintenance_io_failed".to_string(),
1173        details: json!({ "operation": "serialize_manifest", "reason": error.to_string() }),
1174    }
1175}
1176
1177fn cleanup_failed_backup_temp(temporary: &Path) {
1178    let _ = fs::remove_file(temporary);
1179}
1180
1181fn commit_backup_zip(temporary: &Path, destination: &Path) -> Result<(), MaintenanceError> {
1182    fs::rename(temporary, destination).map_err(|error| {
1183        cleanup_failed_backup_temp(temporary);
1184        maintenance_io("commit backup zip", &error)
1185    })
1186}
1187
1188fn restore_parent(root: &Path) -> Result<&Path, MaintenanceError> {
1189    root.parent().ok_or_else(store_root_has_no_parent_error)
1190}
1191
1192fn store_root_has_no_parent_error() -> MaintenanceError {
1193    MaintenanceError {
1194        code: MaintenanceErrorCode::MaintenanceIoFailed,
1195        message: "maintenance_io_failed".to_string(),
1196        details: json!({ "operation": "restore", "reason": "store_root_has_no_parent" }),
1197    }
1198}
1199
1200fn handle_move_live_store_aside_failure(
1201    maintenance: &mut LocalFileDataStoreMaintenance,
1202    error: &std::io::Error,
1203) -> MaintenanceError {
1204    let _ = maintenance.reacquire_lock();
1205    maintenance_io("move live store aside for restore", error)
1206}
1207
1208fn handle_replace_store_root_failure(
1209    maintenance: &mut LocalFileDataStoreMaintenance,
1210    backup_root: &Path,
1211    error: &std::io::Error,
1212) -> MaintenanceError {
1213    let _ = fs::rename(backup_root, &maintenance.root);
1214    let _ = maintenance.reacquire_lock();
1215    maintenance_io("atomically replace store root", error)
1216}
1217
1218fn maintenance_io(operation: &str, error: &std::io::Error) -> MaintenanceError {
1219    MaintenanceError {
1220        code: MaintenanceErrorCode::MaintenanceIoFailed,
1221        message: "maintenance_io_failed".to_string(),
1222        details: json!({ "operation": operation, "reason": error.to_string() }),
1223    }
1224}
1225
1226fn maintenance_zip(operation: &str, error: impl std::fmt::Display) -> MaintenanceError {
1227    MaintenanceError {
1228        code: MaintenanceErrorCode::MaintenanceIoFailed,
1229        message: "maintenance_io_failed".to_string(),
1230        details: json!({ "operation": operation, "reason": error.to_string() }),
1231    }
1232}
1233
1234fn map_lock_to_maintenance(error: DataStoreError) -> MaintenanceError {
1235    match error.code {
1236        super::DataStoreErrorCode::StoreLocked => MaintenanceError {
1237            code: MaintenanceErrorCode::StoreLocked,
1238            message: "store_locked".to_string(),
1239            details: error.details,
1240        },
1241        _ => MaintenanceError {
1242            code: MaintenanceErrorCode::MaintenanceIoFailed,
1243            message: "maintenance_io_failed".to_string(),
1244            details: error.details,
1245        },
1246    }
1247}
1248
1249#[allow(clippy::needless_pass_by_value)]
1250fn map_data_store_error(error: DataStoreError) -> MaintenanceError {
1251    MaintenanceError {
1252        code: MaintenanceErrorCode::MaintenanceIoFailed,
1253        message: "maintenance_io_failed".to_string(),
1254        details: json!({ "cause": error.message, "details": error.details }),
1255    }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    #![allow(clippy::expect_used, clippy::unwrap_used)]
1261
1262    use super::*;
1263    use crate::data_store::{
1264        DataStore, DataStoreError, DataStoreErrorCode, InMemoryKeyProvider, KeyProvider,
1265        LocalFileDataStore, StateRecord,
1266    };
1267    use serde_json::json;
1268    use std::fs::{self, File, OpenOptions, TryLockError};
1269    use std::path::Path;
1270    use std::sync::Arc;
1271    use uuid::Uuid;
1272
1273    fn temp_root(label: &str) -> PathBuf {
1274        let root = std::env::temp_dir().join(format!(
1275            "traverse-maintenance-{label}-{}-{}",
1276            std::process::id(),
1277            Uuid::new_v4()
1278        ));
1279        fs::create_dir_all(&root).expect("temp root");
1280        root
1281    }
1282
1283    fn seed_store(root: &Path, count: usize, stamp: Option<&str>) -> LocalFileDataStore {
1284        let mut store =
1285            LocalFileDataStore::with_classification(root, LocalDataClassification::Public)
1286                .expect("open");
1287        if let Some(stamp) = stamp {
1288            store.set_write_retained_at(Some(stamp.to_string()));
1289        }
1290        for index in 0..count {
1291            store
1292                .write(StateRecord {
1293                    key: format!("k{index:02}"),
1294                    value: json!({ "n": index }),
1295                    lamport_clock: (index + 1) as u64,
1296                    writer_id: "host".to_string(),
1297                })
1298                .expect("write");
1299        }
1300        store
1301    }
1302
1303    #[test]
1304    fn prune_max_count_removes_oldest_prefix() {
1305        let root = temp_root("count");
1306        let store = seed_store(&root, 10, None);
1307        drop(store);
1308        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1309        let evidence = maintenance
1310            .prune(
1311                &RetentionPolicy {
1312                    max_count: Some(3),
1313                    max_age_secs: None,
1314                },
1315                "2026-07-29T00:00:00Z",
1316            )
1317            .expect("prune");
1318        assert_eq!(evidence.outcome, "prune_completed");
1319        assert_eq!(evidence.removed_count, 7);
1320        assert_eq!(evidence.retained_count, 3);
1321        drop(maintenance);
1322        let store = LocalFileDataStore::new(&root).expect("reopen");
1323        let keys = store.list_keys().expect("keys");
1324        assert_eq!(
1325            keys,
1326            vec!["k07".to_string(), "k08".to_string(), "k09".to_string()]
1327        );
1328    }
1329
1330    #[test]
1331    fn prune_rejects_empty_policy_and_reports_age_bounds() {
1332        let root = temp_root("policy");
1333        drop(seed_store(&root, 3, Some("2026-07-01T00:00:00Z")));
1334        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1335        let invalid = maintenance
1336            .prune(
1337                &RetentionPolicy {
1338                    max_count: None,
1339                    max_age_secs: None,
1340                },
1341                "2026-07-29T00:00:00Z",
1342            )
1343            .expect_err("policy");
1344        assert_eq!(invalid.code, MaintenanceErrorCode::InvalidRetentionPolicy);
1345
1346        let evidence = maintenance
1347            .prune(
1348                &RetentionPolicy {
1349                    max_count: None,
1350                    max_age_secs: Some(7 * 24 * 60 * 60),
1351                },
1352                "2026-07-29T00:00:00Z",
1353            )
1354            .expect("age prune");
1355        assert_eq!(evidence.removed_count, 3);
1356    }
1357
1358    #[test]
1359    fn prune_partial_failure_preserves_remaining_victims() {
1360        let root = temp_root("partial");
1361        drop(seed_store(&root, 5, None));
1362        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1363        // Turn the second victim path into a directory so delete fails mid-prune.
1364        let victim = root.join("k01.json");
1365        fs::remove_file(&victim).expect("remove");
1366        fs::create_dir_all(&victim).expect("dir");
1367        fs::write(victim.join("nested"), b"x").expect("nested");
1368        let failure = maintenance
1369            .prune(
1370                &RetentionPolicy {
1371                    max_count: Some(2),
1372                    max_age_secs: None,
1373                },
1374                "2026-07-29T00:00:00Z",
1375            )
1376            .expect_err("partial");
1377        assert_eq!(failure.code, MaintenanceErrorCode::MaintenanceIoFailed);
1378        assert_eq!(failure.details["removed_count"], 1);
1379        // First victim k00 removed; k01 blocked; k02 still present among remaining.
1380        assert!(!root.join("k00.json").exists());
1381        assert!(root.join("k02.json").exists());
1382    }
1383
1384    #[test]
1385    fn backup_restore_round_trip_including_empty_store() {
1386        let root = temp_root("backup");
1387        drop(seed_store(&root, 2, Some("2026-07-28T00:00:00Z")));
1388        let archive = root
1389            .parent()
1390            .expect("parent")
1391            .join(format!("backup-{}.zip", Uuid::new_v4()));
1392        {
1393            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1394            let evidence = maintenance
1395                .backup(&archive, "2026-07-29T00:00:00Z")
1396                .expect("backup");
1397            assert_eq!(evidence.outcome, "backup_created");
1398            assert!(evidence.archive_content_digest.is_some());
1399        }
1400        // Mutate store then restore.
1401        {
1402            let mut store =
1403                LocalFileDataStore::with_classification(&root, LocalDataClassification::Public)
1404                    .expect("open");
1405            store
1406                .write(StateRecord {
1407                    key: "extra".to_string(),
1408                    value: json!({ "x": 1 }),
1409                    lamport_clock: 99,
1410                    writer_id: "host".to_string(),
1411                })
1412                .expect("extra");
1413        }
1414        {
1415            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1416            let evidence = maintenance
1417                .restore(&archive, "2026-07-29T01:00:00Z")
1418                .expect("restore");
1419            assert_eq!(evidence.outcome, "restore_committed");
1420        }
1421        let store = LocalFileDataStore::new(&root).expect("reopen");
1422        let keys = store.list_keys().expect("keys");
1423        assert_eq!(keys, vec!["k00".to_string(), "k01".to_string()]);
1424
1425        // Empty store backup/restore.
1426        let empty_root = temp_root("empty");
1427        drop(LocalFileDataStore::new(&empty_root).expect("empty"));
1428        let empty_archive = empty_root
1429            .parent()
1430            .expect("parent")
1431            .join(format!("empty-{}.zip", Uuid::new_v4()));
1432        {
1433            let mut maintenance =
1434                LocalFileDataStoreMaintenance::open(&empty_root).expect("maintenance");
1435            maintenance
1436                .backup(&empty_archive, "2026-07-29T00:00:00Z")
1437                .expect("empty backup");
1438            maintenance
1439                .restore(&empty_archive, "2026-07-29T00:00:00Z")
1440                .expect("empty restore");
1441        }
1442    }
1443
1444    #[test]
1445    fn backup_restore_verifies_private_envelopes_without_plaintext() {
1446        let root = temp_root("private-backup");
1447        let provider: Arc<dyn KeyProvider> =
1448            Arc::new(InMemoryKeyProvider::new("backup-key", [11; 32]));
1449        let expected = StateRecord {
1450            key: "secret".to_string(),
1451            value: json!({ "private": true }),
1452            lamport_clock: 1,
1453            writer_id: "host".to_string(),
1454        };
1455        let mut store = LocalFileDataStore::new(&root)
1456            .expect("open")
1457            .with_key_provider(Arc::clone(&provider));
1458        store.write(expected.clone()).expect("private write");
1459        drop(store);
1460
1461        let archive = root
1462            .parent()
1463            .expect("parent")
1464            .join(format!("private-{}.zip", Uuid::new_v4()));
1465        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1466        maintenance
1467            .backup(&archive, "2026-07-29T00:00:00Z")
1468            .expect("private backup");
1469        maintenance
1470            .restore(&archive, "2026-07-29T05:00:00Z")
1471            .expect("private restore");
1472        drop(maintenance);
1473
1474        let reopened = LocalFileDataStore::new(&root)
1475            .expect("reopen")
1476            .with_key_provider(provider);
1477        assert_eq!(reopened.read("secret").expect("decrypt"), Some(expected));
1478    }
1479
1480    #[test]
1481    fn second_owner_receives_store_locked() {
1482        let root = temp_root("locked");
1483        let _owner = LocalFileDataStore::new(&root).expect("owner");
1484        let locked = LocalFileDataStoreMaintenance::open(&root).expect_err("locked");
1485        assert_eq!(locked.code, MaintenanceErrorCode::StoreLocked);
1486    }
1487
1488    #[test]
1489    fn backup_verify_rejects_tampered_archive() {
1490        let root = temp_root("tamper");
1491        drop(seed_store(&root, 1, None));
1492        let archive = root
1493            .parent()
1494            .expect("parent")
1495            .join(format!("tamper-{}.zip", Uuid::new_v4()));
1496        {
1497            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1498            maintenance
1499                .backup(&archive, "2026-07-29T00:00:00Z")
1500                .expect("backup");
1501        }
1502        let mut bytes = fs::read(&archive).expect("read");
1503        if let Some(byte) = bytes.last_mut() {
1504            *byte ^= 0xff;
1505        }
1506        fs::write(&archive, bytes).expect("write");
1507        let failure = verify_backup_archive(&archive).expect_err("tampered");
1508        assert_eq!(failure.code, MaintenanceErrorCode::BackupVerifyFailed);
1509    }
1510
1511    #[test]
1512    fn local_file_store_root_accessor_returns_open_path() {
1513        let root = temp_root("root-accessor");
1514        let store = LocalFileDataStore::new(&root).expect("open");
1515        assert_eq!(store.root(), &root);
1516    }
1517
1518    #[test]
1519    fn maintenance_error_helpers_report_stable_codes() {
1520        let io = std::io::Error::other("disk full");
1521        let maintenance = maintenance_io("unit op", &io);
1522        assert_eq!(maintenance.code, MaintenanceErrorCode::MaintenanceIoFailed);
1523        assert_eq!(maintenance.details["operation"], "unit op");
1524
1525        let zip = maintenance_zip("zip op", "zip failed");
1526        assert_eq!(zip.code, MaintenanceErrorCode::MaintenanceIoFailed);
1527        assert_eq!(zip.details["operation"], "zip op");
1528
1529        let locked = map_lock_to_maintenance(lock_error(TryLockError::WouldBlock));
1530        assert_eq!(locked.code, MaintenanceErrorCode::StoreLocked);
1531
1532        let lock_io = map_lock_to_maintenance(lock_error(TryLockError::Error(
1533            std::io::Error::other("lock device"),
1534        )));
1535        assert_eq!(lock_io.code, MaintenanceErrorCode::MaintenanceIoFailed);
1536
1537        let mapped = map_data_store_error(DataStoreError {
1538            code: DataStoreErrorCode::InvalidKey,
1539            message: "invalid".to_string(),
1540            details: json!({ "key": "bad.key" }),
1541        });
1542        assert_eq!(mapped.code, MaintenanceErrorCode::MaintenanceIoFailed);
1543
1544        let parent = restore_parent(Path::new("/")).expect_err("root has no parent");
1545        assert_eq!(parent.code, MaintenanceErrorCode::MaintenanceIoFailed);
1546        assert_eq!(parent.details["reason"], "store_root_has_no_parent");
1547
1548        let parse_error = serde_json::from_str::<Value>("{").expect_err("invalid json");
1549        let manifest = serialize_manifest_error(&parse_error);
1550        assert_eq!(manifest.details["operation"], "serialize_manifest");
1551
1552        let io = std::io::Error::other("read failed");
1553        assert_eq!(
1554            read_manifest_bytes_error(&io).details["reason"],
1555            "read_manifest"
1556        );
1557        assert_eq!(
1558            read_member_bytes_error(&io).details["reason"],
1559            "read_member"
1560        );
1561
1562        let bad = temp_root("restore-zip").join("bad.zip");
1563        fs::write(&bad, b"not a zip").expect("write");
1564        let invalid = open_restore_zip(&bad).expect_err("invalid");
1565        assert_eq!(invalid.details["reason"], "invalid_zip");
1566
1567        let missing = restore_missing_member_error("records/missing.json");
1568        assert_eq!(missing.details["reason"], "missing_member");
1569    }
1570
1571    #[test]
1572    fn age_prune_retains_unstamped_envelopes() {
1573        let root = temp_root("unstamped");
1574        drop(seed_store(&root, 2, None));
1575        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1576        let evidence = maintenance
1577            .prune(
1578                &RetentionPolicy {
1579                    max_count: None,
1580                    max_age_secs: Some(86_400),
1581                },
1582                "2026-07-29T00:00:00Z",
1583            )
1584            .expect("prune");
1585        assert_eq!(evidence.removed_count, 0);
1586        assert_eq!(evidence.retained_count, 2);
1587    }
1588
1589    #[test]
1590    fn backup_without_destination_parent_skips_parent_creation() {
1591        let root = temp_root("skip-parent");
1592        drop(seed_store(&root, 1, None));
1593        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1594        let destination = PathBuf::new();
1595        assert!(destination.parent().is_none());
1596        let _ = maintenance.backup(&destination, "2026-07-29T00:00:00Z");
1597    }
1598
1599    #[test]
1600    fn restore_replace_failure_rolls_back_live_store() {
1601        let root = temp_root("replace-live");
1602        drop(seed_store(&root, 1, None));
1603        let parent = root.parent().expect("parent");
1604        let temp_root_path = parent.join(format!(
1605            ".traverse-datastore-restore-{}-manual",
1606            std::process::id()
1607        ));
1608        let backup_root = parent.join(format!(
1609            ".traverse-datastore-replaced-{}-manual",
1610            std::process::id()
1611        ));
1612        let _ = fs::remove_dir_all(&temp_root_path);
1613        let _ = fs::remove_dir_all(&backup_root);
1614        fs::create_dir_all(&temp_root_path).expect("temp");
1615        fs::write(temp_root_path.join(LOCAL_DATA_STORE_LOCK_FILE), b"").expect("lock");
1616        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1617        move_live_store_aside(&mut maintenance, &backup_root).expect("move aside");
1618        fs::write(&root, "block replace").expect("occupy root path");
1619        let failure = replace_store_root(&mut maintenance, &temp_root_path, &backup_root)
1620            .expect_err("replace");
1621        assert_eq!(
1622            failure.details["operation"],
1623            "atomically replace store root"
1624        );
1625        let _ = fs::remove_file(&root);
1626        let _ = fs::rename(&backup_root, &root);
1627        let _ = fs::remove_dir_all(&temp_root_path);
1628    }
1629
1630    #[test]
1631    fn verify_io_error_helpers_and_truncated_manifest_reads_fail() {
1632        let root = temp_root("truncated");
1633        drop(seed_store(&root, 1, None));
1634        let archive = root
1635            .parent()
1636            .expect("parent")
1637            .join(format!("trunc-{}.zip", Uuid::new_v4()));
1638        {
1639            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1640            maintenance
1641                .backup(&archive, "2026-07-29T00:00:00Z")
1642                .expect("backup");
1643        }
1644        let file = OpenOptions::new().write(true).open(&archive).expect("open");
1645        file.set_len(32).expect("truncate");
1646        drop(file);
1647        let failure = verify_backup_archive(&archive).expect_err("truncated");
1648        assert_eq!(failure.code, MaintenanceErrorCode::BackupVerifyFailed);
1649    }
1650
1651    #[test]
1652    fn parse_as_of_and_policy_bounds_reject_invalid_inputs() {
1653        let invalid = parse_as_of("not-a-timestamp").expect_err("invalid as_of");
1654        assert_eq!(invalid.code, MaintenanceErrorCode::InvalidRetentionPolicy);
1655        assert_eq!(invalid.details["reason"], "invalid_as_of");
1656
1657        let root = temp_root("age-bounds");
1658        drop(seed_store(&root, 1, Some("2026-07-01T00:00:00Z")));
1659        let _maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1660        let overflow = age_cutoff(
1661            parse_as_of("2026-07-29T00:00:00Z").expect("as_of"),
1662            u64::MAX,
1663        )
1664        .expect_err("max age overflow");
1665        assert_eq!(overflow.details["reason"], "max_age_secs_out_of_range");
1666
1667        let underflow = age_cutoff(
1668            parse_as_of("2026-07-29T00:00:00Z").expect("as_of"),
1669            9_000_000_000_000,
1670        )
1671        .expect_err("as_of underflow");
1672        assert_eq!(underflow.details["reason"], "as_of_minus_max_age_underflow");
1673        assert_eq!(
1674            max_age_secs_out_of_range_error().details["reason"],
1675            "max_age_secs_out_of_range"
1676        );
1677        assert_eq!(
1678            as_of_minus_max_age_underflow_error().details["reason"],
1679            "as_of_minus_max_age_underflow"
1680        );
1681    }
1682
1683    #[test]
1684    fn parse_envelope_bytes_rejects_malformed_legacy_and_tampered_records() {
1685        let malformed = parse_envelope_bytes(b"{").expect_err("malformed");
1686        assert_eq!(malformed.code, MaintenanceErrorCode::RestoreVerifyFailed);
1687
1688        let legacy = json!({
1689            "record": {
1690                "key": "k",
1691                "value": {},
1692                "lamport_clock": 1,
1693                "writer_id": "host"
1694            },
1695            "digest": "sha256:00"
1696        });
1697        let legacy_err = parse_envelope_bytes(&serde_json::to_vec(&legacy).expect("serialize"))
1698            .expect_err("legacy");
1699        assert_eq!(
1700            legacy_err.code,
1701            MaintenanceErrorCode::UnsupportedStoreFormat
1702        );
1703        assert_eq!(legacy_err.details["reason"], "legacy_unverified");
1704
1705        let record = StateRecord {
1706            key: "k".to_string(),
1707            value: json!({ "n": 1 }),
1708            lamport_clock: 1,
1709            writer_id: "host".to_string(),
1710        };
1711        let digest = digest_for_record(&record).expect("digest");
1712        let wrong_shape = json!({
1713            "format": LOCAL_DATA_STORE_FORMAT,
1714            "classification": "public",
1715            "record": { "key": "k" },
1716            "digest": digest
1717        });
1718        let shape_err = parse_envelope_bytes(&serde_json::to_vec(&wrong_shape).expect("serialize"))
1719            .expect_err("shape");
1720        assert_eq!(shape_err.code, MaintenanceErrorCode::RestoreVerifyFailed);
1721
1722        let unknown = json!({
1723            "format": "local-datastore/99",
1724            "classification": "public",
1725            "record": record,
1726            "digest": digest
1727        });
1728        let version_err = parse_envelope_bytes(&serde_json::to_vec(&unknown).expect("serialize"))
1729            .expect_err("version");
1730        assert_eq!(
1731            version_err.code,
1732            MaintenanceErrorCode::UnsupportedStoreFormat
1733        );
1734
1735        let tampered = json!({
1736            "format": LOCAL_DATA_STORE_FORMAT,
1737            "classification": "public",
1738            "record": record,
1739            "digest": "sha256:deadbeef"
1740        });
1741        let digest_err = parse_envelope_bytes(&serde_json::to_vec(&tampered).expect("serialize"))
1742            .expect_err("digest");
1743        assert_eq!(digest_err.code, MaintenanceErrorCode::RestoreVerifyFailed);
1744        assert_eq!(digest_err.details["reason"], "digest_mismatch");
1745
1746        let public_with_private_metadata = json!({
1747            "format": LOCAL_DATA_STORE_FORMAT,
1748            "classification": "public",
1749            "record": record,
1750            "digest": digest,
1751            "key_id": "unexpected"
1752        });
1753        let public_shape = parse_envelope_bytes(
1754            &serde_json::to_vec(&public_with_private_metadata).expect("serialize"),
1755        )
1756        .expect_err("public private metadata");
1757        assert_eq!(public_shape.details["reason"], "malformed_envelope");
1758
1759        let private_with_plaintext = json!({
1760            "format": LOCAL_DATA_STORE_FORMAT,
1761            "classification": "private",
1762            "record": record,
1763            "record_key": "k",
1764            "key_id": "key-1",
1765            "nonce": "000000000000000000000000",
1766            "ciphertext": "00000000000000000000000000000000",
1767            "digest": "sha256:00"
1768        });
1769        let private_shape =
1770            parse_envelope_bytes(&serde_json::to_vec(&private_with_plaintext).expect("serialize"))
1771                .expect_err("private plaintext");
1772        assert_eq!(private_shape.details["reason"], "malformed_envelope");
1773
1774        let private_short_nonce = json!({
1775            "format": LOCAL_DATA_STORE_FORMAT,
1776            "classification": "private",
1777            "record_key": "k",
1778            "key_id": "key-1",
1779            "nonce": "00",
1780            "ciphertext": "00000000000000000000000000000000",
1781            "digest": "sha256:00"
1782        });
1783        let private_lengths =
1784            parse_envelope_bytes(&serde_json::to_vec(&private_short_nonce).expect("serialize"))
1785                .expect_err("private lengths");
1786        assert_eq!(private_lengths.details["reason"], "malformed_envelope");
1787    }
1788
1789    #[test]
1790    #[allow(clippy::too_many_lines)]
1791    fn verify_backup_archive_rejects_invalid_archives() {
1792        let missing = temp_root("missing-archive").join("missing.zip");
1793        let open = open_backup_zip(&missing).expect_err("missing");
1794        assert_eq!(open.code, MaintenanceErrorCode::BackupVerifyFailed);
1795        assert_eq!(open.details["reason"], "open_archive");
1796
1797        let bad_zip = temp_root("bad-zip").join("bad.zip");
1798        fs::write(&bad_zip, b"not a zip").expect("write");
1799        let invalid = open_backup_zip(&bad_zip).expect_err("invalid zip");
1800        assert_eq!(invalid.details["reason"], "invalid_zip");
1801
1802        let no_manifest = write_zip(bad_zip.parent().expect("parent"), &[]);
1803        let missing_manifest = verify_backup_archive(&no_manifest).expect_err("manifest");
1804        assert_eq!(missing_manifest.details["reason"], "missing_manifest");
1805
1806        let malformed_manifest = write_zip(
1807            bad_zip.parent().expect("parent"),
1808            &[(BACKUP_MANIFEST_MEMBER, b"{".as_slice())],
1809        );
1810        let malformed = verify_backup_archive(&malformed_manifest).expect_err("malformed");
1811        assert_eq!(malformed.details["reason"], "malformed_manifest");
1812
1813        let unsupported_manifest = backup_zip_with_manifest(&json!({
1814            "manifest_format_version": "99",
1815            "created_as_of": "2026-07-29T00:00:00Z",
1816            "record_count": 0,
1817            "archive_content_digest": "sha256:00",
1818            "records": [],
1819            "store_format": LOCAL_DATA_STORE_FORMAT,
1820            "writer_tool": "test",
1821            "writer_semver": "0.0.0"
1822        }));
1823        let version = verify_backup_archive(&unsupported_manifest).expect_err("version");
1824        assert_eq!(version.code, MaintenanceErrorCode::UnsupportedStoreFormat);
1825
1826        let unsupported_store = backup_zip_with_manifest(&json!({
1827            "manifest_format_version": BACKUP_MANIFEST_VERSION,
1828            "created_as_of": "2026-07-29T00:00:00Z",
1829            "record_count": 0,
1830            "archive_content_digest": "sha256:00",
1831            "records": [],
1832            "store_format": "legacy/0",
1833            "writer_tool": "test",
1834            "writer_semver": "0.0.0"
1835        }));
1836        let format = verify_backup_archive(&unsupported_store).expect_err("format");
1837        assert_eq!(format.details["reason"], "unsupported_store_format");
1838
1839        let invalid_member = backup_zip_with_manifest(&json!({
1840            "manifest_format_version": BACKUP_MANIFEST_VERSION,
1841            "created_as_of": "2026-07-29T00:00:00Z",
1842            "record_count": 1,
1843            "archive_content_digest": "sha256:00",
1844            "records": [{
1845                "key": "k00",
1846                "classification": "public",
1847                "envelope_digest": "sha256:00",
1848                "member_path": "../escape.json"
1849            }],
1850            "store_format": LOCAL_DATA_STORE_FORMAT,
1851            "writer_tool": "test",
1852            "writer_semver": "0.0.0"
1853        }));
1854        let path = verify_backup_archive(&invalid_member).expect_err("path");
1855        assert_eq!(path.details["reason"], "invalid_member_path");
1856
1857        let root = temp_root("member-errors");
1858        drop(seed_store(&root, 1, None));
1859        let good = root
1860            .parent()
1861            .expect("parent")
1862            .join(format!("good-{}.zip", Uuid::new_v4()));
1863        {
1864            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
1865            maintenance
1866                .backup(&good, "2026-07-29T00:00:00Z")
1867                .expect("backup");
1868        }
1869        let envelope_bytes = fs::read(root.join("k00.json")).expect("envelope");
1870        let missing_member = write_zip_with_manifest(
1871            good.parent().expect("parent"),
1872            &json!({
1873                "manifest_format_version": BACKUP_MANIFEST_VERSION,
1874                "created_as_of": "2026-07-29T00:00:00Z",
1875                "record_count": 1,
1876                "archive_content_digest": "sha256:00",
1877                "records": [{
1878                    "key": "k00",
1879                    "classification": "public",
1880                    "envelope_digest": "sha256:00",
1881                    "member_path": "records/missing.json"
1882                }],
1883                "store_format": LOCAL_DATA_STORE_FORMAT,
1884                "writer_tool": "test",
1885                "writer_semver": "0.0.0"
1886            }),
1887            &[],
1888        );
1889        let missing = verify_backup_archive(&missing_member).expect_err("missing member");
1890        assert_eq!(missing.details["reason"], "missing_member");
1891
1892        let digest_mismatch = write_zip_with_manifest(
1893            good.parent().expect("parent"),
1894            &json!({
1895                "manifest_format_version": BACKUP_MANIFEST_VERSION,
1896                "created_as_of": "2026-07-29T00:00:00Z",
1897                "record_count": 1,
1898                "archive_content_digest": "sha256:00",
1899                "records": [{
1900                    "key": "k00",
1901                    "classification": "public",
1902                    "envelope_digest": "sha256:deadbeef",
1903                    "member_path": "records/k00.json"
1904                }],
1905                "store_format": LOCAL_DATA_STORE_FORMAT,
1906                "writer_tool": "test",
1907                "writer_semver": "0.0.0"
1908            }),
1909            &[("records/k00.json", envelope_bytes.as_slice())],
1910        );
1911        let digest = verify_backup_archive(&digest_mismatch).expect_err("digest");
1912        assert_eq!(digest.details["reason"], "member_digest_mismatch");
1913
1914        let key_mismatch = write_zip_with_manifest(
1915            good.parent().expect("parent"),
1916            &json!({
1917                "manifest_format_version": BACKUP_MANIFEST_VERSION,
1918                "created_as_of": "2026-07-29T00:00:00Z",
1919                "record_count": 1,
1920                "archive_content_digest": archive_content_digest_from_members(&[(
1921                    "records/k00.json",
1922                    envelope_bytes.as_slice(),
1923                )]),
1924                "records": [{
1925                    "key": "other",
1926                    "classification": "public",
1927                    "envelope_digest": sha256_hex(&envelope_bytes),
1928                    "member_path": "records/k00.json"
1929                }],
1930                "store_format": LOCAL_DATA_STORE_FORMAT,
1931                "writer_tool": "test",
1932                "writer_semver": "0.0.0"
1933            }),
1934            &[("records/k00.json", envelope_bytes.as_slice())],
1935        );
1936        let key = verify_backup_archive(&key_mismatch).expect_err("key");
1937        assert_eq!(key.details["reason"], "key_mismatch");
1938
1939        let count_mismatch = write_zip_with_manifest(
1940            good.parent().expect("parent"),
1941            &json!({
1942                "manifest_format_version": BACKUP_MANIFEST_VERSION,
1943                "created_as_of": "2026-07-29T00:00:00Z",
1944                "record_count": 2,
1945                "archive_content_digest": archive_content_digest_from_members(&[(
1946                    "records/k00.json",
1947                    envelope_bytes.as_slice(),
1948                )]),
1949                "records": [{
1950                    "key": "k00",
1951                    "classification": "public",
1952                    "envelope_digest": sha256_hex(&envelope_bytes),
1953                    "member_path": "records/k00.json"
1954                }],
1955                "store_format": LOCAL_DATA_STORE_FORMAT,
1956                "writer_tool": "test",
1957                "writer_semver": "0.0.0"
1958            }),
1959            &[("records/k00.json", envelope_bytes.as_slice())],
1960        );
1961        let count = verify_backup_archive(&count_mismatch).expect_err("count");
1962        assert_eq!(count.details["reason"], "record_count_mismatch");
1963
1964        let digest_mismatch_archive = write_zip_with_manifest(
1965            good.parent().expect("parent"),
1966            &json!({
1967                "manifest_format_version": BACKUP_MANIFEST_VERSION,
1968                "created_as_of": "2026-07-29T00:00:00Z",
1969                "record_count": 1,
1970                "archive_content_digest": "sha256:deadbeef",
1971                "records": [{
1972                    "key": "k00",
1973                    "classification": "public",
1974                    "envelope_digest": sha256_hex(&envelope_bytes),
1975                    "member_path": "records/k00.json"
1976                }],
1977                "store_format": LOCAL_DATA_STORE_FORMAT,
1978                "writer_tool": "test",
1979                "writer_semver": "0.0.0"
1980            }),
1981            &[("records/k00.json", envelope_bytes.as_slice())],
1982        );
1983        let archive_digest =
1984            verify_backup_archive(&digest_mismatch_archive).expect_err("archive digest");
1985        assert_eq!(
1986            archive_digest.details["reason"],
1987            "archive_content_digest_mismatch"
1988        );
1989    }
1990
1991    #[test]
1992    fn open_and_backup_surface_io_failures_without_weakening_fail_closed() {
1993        let file_root = temp_root("file-root");
1994        fs::remove_dir(&file_root).expect("remove dir");
1995        fs::write(&file_root, "not a directory").expect("write");
1996        let open = LocalFileDataStoreMaintenance::open(&file_root).expect_err("open");
1997        assert_eq!(open.code, MaintenanceErrorCode::MaintenanceIoFailed);
1998
1999        let root = temp_root("backup-io");
2000        drop(seed_store(&root, 1, None));
2001        let parent = root.parent().expect("parent");
2002        let file_parent = parent.join(format!("file-parent-{}", Uuid::new_v4()));
2003        fs::write(&file_parent, "blocker").expect("write");
2004        let blocked = file_parent.join("backup.zip");
2005        {
2006            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2007            let failure = maintenance
2008                .backup(&blocked, "2026-07-29T00:00:00Z")
2009                .expect_err("destination parent blocked");
2010            assert_eq!(failure.code, MaintenanceErrorCode::MaintenanceIoFailed);
2011        }
2012
2013        let commit_blocked = parent.join(format!("commit-dir-{}", Uuid::new_v4()));
2014        fs::create_dir_all(&commit_blocked).expect("dir");
2015        let destination = commit_blocked.join("backup.zip");
2016        fs::create_dir_all(&destination).expect("block destination");
2017        let temporary = parent.join(format!("backup-{}.zip.tmp", Uuid::new_v4()));
2018        fs::write(&temporary, b"partial").expect("temp");
2019        let commit = commit_backup_zip(&temporary, &destination).expect_err("commit");
2020        assert_eq!(commit.details["operation"], "commit backup zip");
2021        assert!(!temporary.exists());
2022
2023        cleanup_failed_backup_temp(&parent.join("missing-temp.zip"));
2024    }
2025
2026    #[test]
2027    fn backup_rejects_invalid_as_of_and_verify_failure_cleans_temp() {
2028        let root = temp_root("backup-as-of");
2029        drop(seed_store(&root, 1, None));
2030        let archive = root
2031            .parent()
2032            .expect("parent")
2033            .join(format!("asof-{}.zip", Uuid::new_v4()));
2034        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2035        let invalid = maintenance
2036            .backup(&archive, "not-a-timestamp")
2037            .expect_err("as_of");
2038        assert_eq!(invalid.code, MaintenanceErrorCode::InvalidRetentionPolicy);
2039
2040        let temporary = archive.with_extension("zip.tmp");
2041        fs::write(&temporary, b"not a zip").expect("temp");
2042        let failure = verify_backup_archive(&temporary)
2043            .inspect_err(|_| cleanup_failed_backup_temp(&temporary));
2044        assert!(failure.is_err());
2045        assert!(!temporary.exists());
2046    }
2047
2048    #[test]
2049    fn restore_reports_invalid_as_of_and_rename_failures() {
2050        let root = temp_root("restore-as-of");
2051        drop(seed_store(&root, 1, None));
2052        let archive = root
2053            .parent()
2054            .expect("parent")
2055            .join(format!("restore-{}.zip", Uuid::new_v4()));
2056        {
2057            let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2058            maintenance
2059                .backup(&archive, "2026-07-29T00:00:00Z")
2060                .expect("backup");
2061        }
2062        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2063        let invalid = maintenance
2064            .restore(&archive, "not-a-timestamp")
2065            .expect_err("as_of");
2066        assert_eq!(invalid.code, MaintenanceErrorCode::InvalidRetentionPolicy);
2067        drop(maintenance);
2068
2069        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2070        let parent = root.parent().expect("parent");
2071        let backup_root = parent.join(format!(
2072            ".traverse-datastore-replaced-{}",
2073            restore_work_suffix(&root, "2026-07-29T01:00:00Z")
2074        ));
2075        let _ = fs::remove_dir_all(&backup_root);
2076        let _ = fs::remove_file(&backup_root);
2077        fs::write(&backup_root, "blocker").expect("blocker");
2078        let blocked = maintenance
2079            .restore(&archive, "2026-07-29T01:00:00Z")
2080            .expect_err("move aside");
2081        assert_eq!(blocked.code, MaintenanceErrorCode::MaintenanceIoFailed);
2082        let _ = fs::remove_file(&backup_root);
2083        drop(maintenance);
2084
2085        let mut rollback_maintenance =
2086            LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2087        let simulated = std::io::Error::other("simulated");
2088        let rollback_error =
2089            handle_move_live_store_aside_failure(&mut rollback_maintenance, &simulated);
2090        assert_eq!(
2091            rollback_error.details["operation"],
2092            "move live store aside for restore"
2093        );
2094
2095        let replace_root = temp_root("replace-failure");
2096        drop(seed_store(&replace_root, 1, None));
2097        let replace_archive = replace_root
2098            .parent()
2099            .expect("parent")
2100            .join(format!("replace-{}.zip", Uuid::new_v4()));
2101        {
2102            let mut writer =
2103                LocalFileDataStoreMaintenance::open(&replace_root).expect("maintenance");
2104            writer
2105                .backup(&replace_archive, "2026-07-29T00:00:00Z")
2106                .expect("backup");
2107        }
2108        let mut replacer = LocalFileDataStoreMaintenance::open(&replace_root).expect("maintenance");
2109        let simulated = std::io::Error::other("simulated");
2110        let replace = handle_replace_store_root_failure(
2111            &mut replacer,
2112            &replace_root.join("backup-aside"),
2113            &simulated,
2114        );
2115        assert_eq!(
2116            replace.details["operation"],
2117            "atomically replace store root"
2118        );
2119    }
2120
2121    #[test]
2122    fn materialize_archive_reports_missing_members_and_invalid_zip() {
2123        let root = temp_root("materialize");
2124        fs::create_dir_all(&root).expect("dir");
2125        let bad_zip = temp_root("materialize-bad").join("bad.zip");
2126        fs::write(&bad_zip, b"not a zip").expect("write");
2127        let invalid = materialize_archive_to_root(&bad_zip, &root).expect_err("zip");
2128        assert_eq!(invalid.code, MaintenanceErrorCode::BackupVerifyFailed);
2129
2130        let missing_member = write_zip_with_manifest(
2131            bad_zip.parent().expect("parent"),
2132            &json!({
2133                "manifest_format_version": BACKUP_MANIFEST_VERSION,
2134                "created_as_of": "2026-07-29T00:00:00Z",
2135                "record_count": 1,
2136                "archive_content_digest": "sha256:00",
2137                "records": [{
2138                    "key": "k00",
2139                    "classification": "public",
2140                    "envelope_digest": "sha256:00",
2141                    "member_path": "records/k00.json"
2142                }],
2143                "store_format": LOCAL_DATA_STORE_FORMAT,
2144                "writer_tool": "test",
2145                "writer_semver": "0.0.0"
2146            }),
2147            &[],
2148        );
2149        let missing = materialize_archive_to_root(&missing_member, &root).expect_err("missing");
2150        assert_eq!(missing.code, MaintenanceErrorCode::BackupVerifyFailed);
2151        assert_eq!(missing.details["reason"], "missing_member");
2152    }
2153
2154    #[test]
2155    fn explicit_v1_to_v2_migration_is_verified_and_restore_remains_explicit() {
2156        let root = temp_root("v2-migration-success");
2157        drop(seed_store(&root, 2, None));
2158        let archive = root
2159            .parent()
2160            .expect("parent")
2161            .join(format!("migration-{}.zip", Uuid::new_v4()));
2162
2163        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2164        let report = maintenance
2165            .migrate_v1_to_v2(&archive, "2026-08-05T00:00:00Z")
2166            .expect("migration");
2167        assert_eq!(report.governing_spec, MIGRATION_SPEC);
2168        assert_eq!(report.source_format, LOCAL_DATA_STORE_FORMAT);
2169        assert_eq!(report.target_format, LOCAL_DATA_STORE_V2_FORMAT);
2170        assert_eq!(report.record_count, 2);
2171        assert!(report.backup_content_digest.starts_with("sha256:"));
2172        assert_eq!(
2173            serde_json::from_slice::<Value>(&fs::read(root.join("k00.json")).expect("v2 file"))
2174                .expect("v2 json")["format"],
2175            LOCAL_DATA_STORE_V2_FORMAT
2176        );
2177        drop(maintenance);
2178
2179        let mut reader =
2180            LocalFileDataStore::with_classification(&root, LocalDataClassification::Public)
2181                .expect("v2 reader");
2182        assert_eq!(
2183            reader.read("k00").expect("read").expect("record").key,
2184            "k00"
2185        );
2186        reader
2187            .write(StateRecord {
2188                key: "k00".to_string(),
2189                value: json!({ "n": 99 }),
2190                lamport_clock: 99,
2191                writer_id: "host".to_string(),
2192            })
2193            .expect("v2-preserving write");
2194        assert_eq!(
2195            serde_json::from_slice::<Value>(&fs::read(root.join("k00.json")).expect("v2 rewrite"))
2196                .expect("v2 json")["format"],
2197            LOCAL_DATA_STORE_V2_FORMAT
2198        );
2199        drop(reader);
2200
2201        let mut restore = LocalFileDataStoreMaintenance::open(&root).expect("restore maintenance");
2202        restore
2203            .restore(&archive, "2026-08-05T00:01:00Z")
2204            .expect("explicit restore");
2205        drop(restore);
2206        let restored =
2207            serde_json::from_slice::<Value>(&fs::read(root.join("k00.json")).expect("v1 file"))
2208                .expect("v1 json");
2209        assert_eq!(restored["format"], LOCAL_DATA_STORE_FORMAT);
2210    }
2211
2212    #[test]
2213    fn migration_rejects_unknown_source_without_backup_or_rewrite() {
2214        let root = temp_root("v2-migration-unknown");
2215        fs::write(root.join("k00.json"), br#"{"format":"unknown/9"}"#).expect("source");
2216        let original = fs::read(root.join("k00.json")).expect("original");
2217        let archive = root
2218            .parent()
2219            .expect("parent")
2220            .join(format!("migration-unknown-{}.zip", Uuid::new_v4()));
2221        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2222        let error = maintenance
2223            .migrate_v1_to_v2(&archive, "2026-08-05T00:00:00Z")
2224            .expect_err("unknown source must fail closed");
2225        assert_eq!(error.code, MigrationErrorCode::SourceInvalid);
2226        assert_eq!(error.message, "datastore_source_invalid");
2227        assert_eq!(
2228            fs::read(root.join("k00.json")).expect("source preserved"),
2229            original
2230        );
2231        assert!(!archive.exists());
2232    }
2233
2234    #[test]
2235    fn corrupt_restore_backup_preserves_the_committed_store() {
2236        let root = temp_root("v2-migration-corrupt-backup");
2237        drop(seed_store(&root, 1, None));
2238        let before = fs::read(root.join("k00.json")).expect("source");
2239        let corrupt = root
2240            .parent()
2241            .expect("parent")
2242            .join(format!("migration-corrupt-{}.zip", Uuid::new_v4()));
2243        fs::write(&corrupt, b"not a verified backup").expect("corrupt archive");
2244        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2245        let error = maintenance
2246            .restore(&corrupt, "2026-08-05T00:00:00Z")
2247            .expect_err("corrupt archive");
2248        assert_eq!(error.code, MaintenanceErrorCode::BackupVerifyFailed);
2249        assert_eq!(
2250            fs::read(root.join("k00.json")).expect("source preserved"),
2251            before
2252        );
2253    }
2254
2255    #[test]
2256    fn migration_error_mapping_is_stable_and_secret_free() {
2257        let maintenance = MaintenanceError {
2258            code: MaintenanceErrorCode::MaintenanceIoFailed,
2259            message: "internal path must not escape".to_string(),
2260            details: json!({ "path": "/secret" }),
2261        };
2262        let io = std::io::Error::other("internal path must not escape");
2263        let data_store = DataStoreError {
2264            code: DataStoreErrorCode::IoFailure,
2265            message: "internal path must not escape".to_string(),
2266            details: json!({ "path": "/secret" }),
2267        };
2268        let mapped = [
2269            map_migration_source_error(maintenance.clone()),
2270            map_migration_backup_error(maintenance.clone()),
2271            map_migration_write_io(io),
2272            map_migration_write_data_store(data_store),
2273            map_migration_verification_error(maintenance.clone()),
2274            map_migration_commit_io(std::io::Error::other("internal")),
2275            map_migration_commit_error(maintenance),
2276        ];
2277        let expected = [
2278            MigrationErrorCode::SourceInvalid,
2279            MigrationErrorCode::BackupFailed,
2280            MigrationErrorCode::WriteFailed,
2281            MigrationErrorCode::WriteFailed,
2282            MigrationErrorCode::VerificationFailed,
2283            MigrationErrorCode::CommitFailed,
2284            MigrationErrorCode::CommitFailed,
2285        ];
2286        for (error, code) in mapped.into_iter().zip(expected) {
2287            assert_eq!(error.code, code);
2288            assert!(!error.details.to_string().contains("secret"));
2289        }
2290    }
2291
2292    #[test]
2293    fn migration_requires_verified_backup_digest() {
2294        let error = verified_backup_digest(None).expect_err("digest is required");
2295        assert_eq!(error.code, MigrationErrorCode::BackupFailed);
2296        assert_eq!(error.message, "datastore_backup_failed");
2297        assert_eq!(error.details["reason"], "missing_verified_backup_digest");
2298    }
2299
2300    #[derive(Debug)]
2301    struct Boom;
2302    impl Serialize for Boom {
2303        fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
2304            Err(serde::ser::Error::custom("boom"))
2305        }
2306    }
2307
2308    #[test]
2309    fn migration_serialize_and_abandon_helpers_are_covered() {
2310        let parse_error = serde_json::from_str::<Value>("{").expect_err("invalid json");
2311        let write_failed = map_serialize_candidate_error(parse_error);
2312        assert_eq!(write_failed.code, MigrationErrorCode::WriteFailed);
2313        assert_eq!(write_failed.details["reason"], "serialize_candidate");
2314
2315        let boom = json_to_vec(&Boom).expect_err("custom serialize failure");
2316        assert_eq!(
2317            map_envelope_serialize_error(boom).code,
2318            MaintenanceErrorCode::RestoreVerifyFailed
2319        );
2320        assert!(
2321            json_to_vec(&Boom)
2322                .map_err(map_serialize_candidate_error)
2323                .is_err()
2324        );
2325
2326        let candidate = temp_root("abandon-candidate").join("work");
2327        fs::create_dir_all(&candidate).expect("candidate");
2328        fs::write(candidate.join("marker.json"), b"{}").expect("marker");
2329        let abandoned = abandon_failed_candidate(
2330            &candidate,
2331            migration_error(
2332                MigrationErrorCode::VerificationFailed,
2333                "datastore_verification_failed",
2334                "candidate_verification_failed",
2335            ),
2336        );
2337        assert_eq!(abandoned.code, MigrationErrorCode::VerificationFailed);
2338        assert!(!candidate.exists());
2339    }
2340
2341    #[test]
2342    fn migration_commit_rename_failures_preserve_live_store() {
2343        let as_of = "2026-08-05T00:00:00Z";
2344        let root = temp_root("mig-commit-aside");
2345        drop(seed_store(&root, 1, None));
2346        let before = fs::read(root.join("k00.json")).expect("source");
2347        let parent = root.parent().expect("parent");
2348        let suffix = restore_work_suffix(&root, as_of);
2349        let candidate_root = parent.join(format!(".traverse-datastore-migration-{suffix}"));
2350        let previous_root = parent.join(format!(".traverse-datastore-pre-v2-{suffix}"));
2351        let _ = fs::remove_dir_all(&candidate_root);
2352        let _ = fs::remove_file(&previous_root);
2353        let _ = fs::remove_dir_all(&previous_root);
2354        fs::create_dir_all(&candidate_root).expect("candidate");
2355        fs::write(candidate_root.join(LOCAL_DATA_STORE_LOCK_FILE), b"").expect("lock");
2356        fs::write(&previous_root, b"block aside").expect("occupy previous");
2357
2358        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2359        let failed = commit_migrated_store_root(&mut maintenance, &candidate_root, &previous_root)
2360            .expect_err("aside must fail");
2361        assert_eq!(failed.code, MigrationErrorCode::CommitFailed);
2362        assert_eq!(
2363            fs::read(root.join("k00.json")).expect("live preserved"),
2364            before
2365        );
2366        assert!(!candidate_root.exists());
2367        let _ = fs::remove_file(&previous_root);
2368    }
2369
2370    #[test]
2371    fn migration_commit_install_failure_restores_previous_root() {
2372        let as_of = "2026-08-05T00:00:00Z";
2373        let root = temp_root("mig-commit-install");
2374        drop(seed_store(&root, 1, None));
2375        let before = fs::read(root.join("k00.json")).expect("source");
2376        let parent = root.parent().expect("parent");
2377        let suffix = restore_work_suffix(&root, as_of);
2378        let candidate_root = parent.join(format!(".traverse-datastore-migration-{suffix}"));
2379        let previous_root = parent.join(format!(".traverse-datastore-pre-v2-{suffix}"));
2380        let _ = fs::remove_dir_all(&candidate_root);
2381        let _ = fs::remove_dir_all(&previous_root);
2382        fs::create_dir_all(&candidate_root).expect("candidate");
2383        fs::write(candidate_root.join(LOCAL_DATA_STORE_LOCK_FILE), b"").expect("lock");
2384
2385        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2386        let _ = maintenance.lock_file.unlock();
2387        fs::rename(&root, &previous_root).expect("aside");
2388        fs::write(&root, b"block install").expect("occupy root");
2389
2390        let failed = install_migrated_candidate(&mut maintenance, &candidate_root, &previous_root)
2391            .expect_err("install must fail");
2392        assert_eq!(failed.code, MigrationErrorCode::CommitFailed);
2393        assert_eq!(
2394            fs::read(previous_root.join("k00.json")).expect("aside preserved"),
2395            before
2396        );
2397        assert!(candidate_root.exists());
2398        let _ = fs::remove_file(&root);
2399        let _ = fs::rename(&previous_root, &root);
2400        let _ = fs::remove_dir_all(&candidate_root);
2401    }
2402
2403    #[test]
2404    fn migration_reacquire_failure_rolls_back_committed_swap() {
2405        let as_of = "2026-08-05T00:00:00Z";
2406        let root = temp_root("mig-reacquire");
2407        drop(seed_store(&root, 1, None));
2408        let before = fs::read(root.join("k00.json")).expect("source");
2409        let parent = root.parent().expect("parent");
2410        let suffix = restore_work_suffix(&root, as_of);
2411        let candidate_root = parent.join(format!(".traverse-datastore-migration-{suffix}"));
2412        let previous_root = parent.join(format!(".traverse-datastore-pre-v2-{suffix}"));
2413        let _ = fs::remove_dir_all(&candidate_root);
2414        let _ = fs::remove_dir_all(&previous_root);
2415        fs::create_dir_all(&candidate_root).expect("candidate");
2416        fs::write(candidate_root.join(LOCAL_DATA_STORE_LOCK_FILE), b"").expect("candidate lock");
2417        fs::write(
2418            candidate_root.join("k00.json"),
2419            br#"{"format":"candidate"}"#,
2420        )
2421        .expect("cand");
2422
2423        let mut maintenance = LocalFileDataStoreMaintenance::open(&root).expect("maintenance");
2424        let _ = maintenance.lock_file.unlock();
2425        fs::rename(&root, &previous_root).expect("aside");
2426        fs::rename(&candidate_root, &root).expect("install");
2427
2428        let blocker = OpenOptions::new()
2429            .read(true)
2430            .write(true)
2431            .open(root.join(LOCAL_DATA_STORE_LOCK_FILE))
2432            .expect("blocker");
2433        blocker.try_lock().expect("hold lock");
2434
2435        let failed = reacquire_migrated_lock(&mut maintenance, &candidate_root, &previous_root)
2436            .expect_err("reacquire must fail while blocked");
2437        assert_eq!(failed.code, MigrationErrorCode::CommitFailed);
2438        drop(blocker);
2439        assert_eq!(
2440            fs::read(root.join("k00.json")).expect("rolled back"),
2441            before
2442        );
2443        let _ = fs::remove_dir_all(&candidate_root);
2444        let _ = fs::remove_dir_all(&previous_root);
2445    }
2446
2447    fn write_zip(parent: &Path, members: &[(&str, &[u8])]) -> PathBuf {
2448        let path = parent.join(format!("archive-{}.zip", Uuid::new_v4()));
2449        let file = File::create(&path).expect("create");
2450        let mut zip = ZipWriter::new(file);
2451        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2452        for (name, bytes) in members {
2453            zip.start_file(*name, options).expect("start");
2454            zip.write_all(bytes).expect("write");
2455        }
2456        zip.finish().expect("finish");
2457        path
2458    }
2459
2460    fn backup_zip_with_manifest(manifest: &Value) -> PathBuf {
2461        write_zip_with_manifest(&temp_root("manifest-only"), manifest, &[])
2462    }
2463
2464    fn write_zip_with_manifest(
2465        parent: &Path,
2466        manifest: &Value,
2467        members: &[(&str, &[u8])],
2468    ) -> PathBuf {
2469        let manifest_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest");
2470        let path = parent.join(format!("archive-{}.zip", Uuid::new_v4()));
2471        let file = File::create(&path).expect("create");
2472        let mut zip = ZipWriter::new(file);
2473        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2474        zip.start_file(BACKUP_MANIFEST_MEMBER, options)
2475            .expect("manifest");
2476        zip.write_all(&manifest_bytes).expect("write manifest");
2477        for (name, bytes) in members {
2478            zip.start_file(*name, options).expect("start");
2479            zip.write_all(bytes).expect("write");
2480        }
2481        zip.finish().expect("finish");
2482        path
2483    }
2484}