1use 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#[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#[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#[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#[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
88pub trait DataStoreMigration {
90 fn migrate_v1_to_v2(
98 &mut self,
99 backup_destination: &Path,
100 as_of: &str,
101 ) -> Result<MigrationReport, MigrationError>;
102}
103
104#[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
139pub trait DataStoreMaintenance {
141 fn prune(
147 &mut self,
148 policy: &RetentionPolicy,
149 as_of: &str,
150 ) -> Result<MaintenanceEvidence, MaintenanceError>;
151
152 fn backup(
158 &mut self,
159 destination: &Path,
160 as_of: &str,
161 ) -> Result<MaintenanceEvidence, MaintenanceError>;
162
163 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 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 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 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 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 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 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 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 {
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 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}