1use crate::storage::engine::{Page, PageType, Pager, PagerConfig, HEADER_SIZE, PAGE_SIZE};
6use reddb_engine::storage::{
7 btree::BTree,
8 pager_io::{DWB_ZONE_PAGES, DWB_ZONE_START_PAGE, FIRST_ALLOCATABLE_PAGE},
9};
10use reddb_file as f;
11use std::{
12 collections::BTreeMap,
13 fs::{File, OpenOptions},
14 io::{BufRead, BufReader, Read, Write},
15 path::{Path, PathBuf},
16 sync::Arc,
17};
18mod assets;
19pub(crate) mod boot_system_wal;
20mod catalog;
21pub(crate) mod catalog_aliases;
22mod internal_metadata;
23mod salvage;
24mod scrub;
25mod source;
26use catalog::Catalog;
27pub use salvage::{
28 salvage_paged_collections, salvage_paged_indexes, PagedCollectionLoss,
29 PagedCollectionSalvageReport, PagedIndexSalvageFinding, PagedIndexSalvageReport,
30};
31pub use scrub::{
32 scrub_paged, scrub_paged_trees, PagedScrubReport, PagedTreeFinding, PagedTreeScrubReport,
33};
34use source::Source;
35type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
36
37#[derive(Debug)]
38pub struct CompactionReport {
39 pub source_bytes: u64,
40 pub destination_bytes: u64,
41 pub source_allocated_bytes: Option<u64>,
42 pub destination_allocated_bytes: Option<u64>,
43 pub records: u64,
45 pub trees: usize,
46 pub repaired_leaf_backlinks: u64,
48 pub retired_boot_system_wal_actions: usize,
50 pub reconciled_system_collections: usize,
51 pub imported_system_records: usize,
52}
53
54impl CompactionReport {
55 pub fn machine_json(&self) -> String {
56 format!(
57 r#"{{"source_bytes":{},"destination_bytes":{},"records":{},"trees":{},"repaired_leaf_backlinks":{},"retired_boot_system_wal_actions":{},"reconciled_system_collections":{},"imported_system_records":{},"source_allocated_bytes":{},"destination_allocated_bytes":{}}}"#,
58 self.source_bytes,
59 self.destination_bytes,
60 self.records,
61 self.trees,
62 self.repaired_leaf_backlinks,
63 self.retired_boot_system_wal_actions,
64 self.reconciled_system_collections,
65 self.imported_system_records,
66 self.source_allocated_bytes
67 .map_or_else(|| "null".into(), |n| n.to_string()),
68 self.destination_allocated_bytes
69 .map_or_else(|| "null".into(), |n| n.to_string())
70 )
71 }
72}
73
74pub fn compact_paged(source: &Path, destination: &Path) -> Result<CompactionReport> {
78 compact_paged_inner(source, destination, None, None, false)
79}
80
81pub fn compact_paged_canonical_system_config(
85 source: &Path,
86 destination: &Path,
87) -> Result<CompactionReport> {
88 compact_paged_inner(source, destination, None, None, true)
89}
90
91pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
95 recover_paged_wal_inner(source, destination, false)
96}
97
98pub fn recover_paged_wal_canonical_system_config(
103 source: &Path,
104 destination: &Path,
105) -> Result<CompactionReport> {
106 recover_paged_wal_inner(source, destination, true)
107}
108
109fn recover_paged_wal_inner(
110 source: &Path,
111 destination: &Path,
112 retain_canonical_system_config: bool,
113) -> Result<CompactionReport> {
114 use crate::storage::wal::{WalReader, WalRecord};
115 let source = source.canonicalize()?;
116 let parent = destination
117 .parent()
118 .filter(|p| !p.as_os_str().is_empty())
119 .unwrap_or_else(|| Path::new("."))
120 .canonicalize()?;
121 for owned in [
122 f::support_dir_for(&source),
123 source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
124 ] {
125 if owned.exists() {
126 ensure(
127 !parent.starts_with(owned.canonicalize()?),
128 "destination is inside source storage",
129 )?;
130 }
131 }
132 let guard = Arc::new(Source::open_for_wal_recovery(&source)?);
133 guard.verify_superblocks()?;
134 salvage::verify_wal_recovery_sidecars(&source)?;
135 let wal = f::unified_wal_path(&source);
136 let engine_wal = f::engine_wal_path(&source);
137 let pending_unified = wal_has_records(&wal)?;
138 let pending_engine = wal_has_records(&engine_wal)?;
139 ensure(
140 pending_unified
141 || pending_engine
142 || guard.pending_double_write
143 || guard.header.checkpoint_in_progress,
144 "no pending recovery",
145 )?;
146 ensure(
147 !guard.header.checkpoint_in_progress || pending_engine,
148 "interrupted page checkpoint requires its engine WAL",
149 )?;
150 let mut boot_wal =
151 if retain_canonical_system_config && pending_unified && !guard.pending_double_write {
152 boot_system_wal::BootSystemWalPlan::prepare(&guard, &wal, pending_engine)?
153 } else {
154 boot_system_wal::BootSystemWalPlan::default()
155 };
156 let pending_ownership = if pending_unified {
157 verify_wal_recovery_records(
158 &wal,
159 retain_canonical_system_config && !guard.pending_double_write,
160 &boot_wal,
161 )?
162 } else {
163 false
164 };
165 if !guard.pending_double_write
166 && !guard.header.checkpoint_in_progress
167 && !pending_ownership
168 && !pending_engine
169 {
170 salvage::verify_vault(&source, &guard)?;
171 }
172 let mut recovered_engine_xid = 0;
173 if pending_engine {
174 for entry in WalReader::open(&engine_wal)?.iter() {
175 match entry?.1 {
176 WalRecord::Begin { .. }
177 | WalRecord::Commit { .. }
178 | WalRecord::Rollback { .. }
179 | WalRecord::Checkpoint { .. } => {}
180 WalRecord::PageWrite { page_id, data, .. } => {
181 ensure(
182 page_id > 0
183 && !(DWB_ZONE_START_PAGE..FIRST_ALLOCATABLE_PAGE).contains(&page_id)
184 && u64::from(page_id) < guard.bytes.div_ceil(PAGE_SIZE as u64)
185 && data.len() == PAGE_SIZE,
186 "invalid engine WAL page",
187 )?;
188 let page = Page::from_slice(&data)?;
189 ensure(
190 page.page_id() == page_id,
191 "engine WAL page identity mismatch",
192 )?;
193 page.page_type()?;
194 }
198 _ => return Err("unsupported engine WAL record".into()),
199 }
200 }
201 }
202 std::fs::create_dir(destination)?;
203 #[cfg(unix)]
204 {
205 use std::os::unix::fs::PermissionsExt;
206 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
207 }
208 let stage = destination.join(".wal-replay");
209 std::fs::create_dir(&stage)?;
210 let name = source.file_name().ok_or("source filename is missing")?;
211 let staged_source = stage.join(name);
212 guard.copy_snapshot(&staged_source)?;
213 copy_sidecars(&source, &staged_source, true)?;
214 copy_physical_metadata_files(&source, &staged_source)?;
215 let preserved_audit = detach_recovery_audit_logs(&staged_source, &stage)?;
216 let config = crate::storage::unified::UnifiedStoreConfig {
217 resident_memory_bytes_max: (guard.header.version
218 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
219 .then_some(64 * 1024 * 1024),
220 ..Default::default()
221 };
222 if pending_engine {
223 let pager = Pager::open(
224 &staged_source,
225 PagerConfig {
226 disk_residency: config.resident_memory_bytes_max.is_some(),
227 ..PagerConfig::default()
228 },
229 )?;
230 let wal = f::engine_wal_path(&staged_source);
231 recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
232 let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
233 writer.truncate()?;
234 writer.sync()?;
235 }
236 if retain_canonical_system_config && pending_unified && guard.pending_double_write {
237 {
240 let pager = Pager::open(
241 &staged_source,
242 PagerConfig {
243 disk_residency: config.resident_memory_bytes_max.is_some(),
244 ..PagerConfig::default()
245 },
246 )?;
247 pager.sync()?;
248 }
249 let checkpoint = Arc::new(Source::open_for_wal_recovery(&staged_source)?);
250 let staged_wal = f::unified_wal_path(&staged_source);
251 boot_wal =
252 boot_system_wal::BootSystemWalPlan::prepare(&checkpoint, &staged_wal, pending_engine)?;
253 verify_wal_recovery_records(&staged_wal, true, &boot_wal)?;
254 }
255 let catalog_plan = if retain_canonical_system_config {
256 catalog_aliases::CanonicalCatalogPlan::prepare(
257 &staged_source,
258 &f::unified_wal_path(&staged_source),
259 )?
260 } else {
261 catalog_aliases::CanonicalCatalogPlan::default()
262 };
263 let probabilistic_deltas;
264 let mut has_vector_cache = false;
265 {
266 let store = crate::storage::unified::UnifiedStore::open_with_config_for_recovery(
267 &staged_source,
268 config,
269 retain_canonical_system_config,
270 Some(&boot_wal),
271 Some(&catalog_plan),
272 )?;
273 boot_wal.preserve_identity_floors(&store)?;
274 catalog_plan.restore(&store)?;
275 if pending_engine || guard.pending_double_write {
276 store.reconcile_physical_recovery()?;
277 }
278 if pending_unified || pending_engine || guard.pending_double_write {
279 let mut high = store
283 .startup_xid_high_water_mark()
284 .unwrap_or(0)
285 .max(recovered_engine_xid);
286 for name in store.list_collections() {
287 let manager = store
288 .get_collection(&name)
289 .ok_or("recovered collection disappeared")?;
290 let mut max_entity = 0u64;
291 let mut max_row = 0u64;
292 let mut max_sequence = 0u64;
293 let mut has_entity = false;
294 manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
295 has_entity = true;
296 high = high.max(entity.xmin).max(entity.xmax);
297 max_entity = max_entity.max(entity.id.raw());
298 max_sequence = max_sequence.max(entity.sequence_id);
299 if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
300 max_row = max_row.max(*row_id);
301 }
302 true
303 })?;
304 if !has_entity {
305 continue;
306 }
307 let entity_next = max_entity
308 .checked_add(1)
309 .ok_or("recovered entity IDs are exhausted")?;
310 let row_next = max_row
311 .checked_add(1)
312 .ok_or("recovered row IDs are exhausted")?;
313 let sequence_next = max_sequence
314 .checked_add(1)
315 .ok_or("recovered sequence IDs are exhausted")?;
316 manager.restore_identity_counters(entity_next, row_next);
317 manager.restore_sequence_id_next(sequence_next);
318 store.register_entity_id(crate::storage::EntityId::new(max_entity));
319 }
320 ensure(
321 high < u64::MAX,
322 "recovered transaction high-water mark is exhausted",
323 )?;
324 store.remember_startup_xid_high_water_mark(high);
325 }
326 for (collection, records) in store.replayed_turbo_inserts.lock().iter() {
330 has_vector_cache = true;
331 ensure(
332 crate::runtime::vector_turbo_kind::is_turbo(&store, collection),
333 "WAL vector collection is not a turbo collection",
334 )?;
335 for (id, vector) in records {
336 let entity = store
337 .try_get(
338 collection,
339 crate::storage::EntityId::new(*id),
340 32 * 1024 * 1024,
341 )?
342 .ok_or("WAL vector has no recovered entity")?;
343 let crate::storage::EntityData::Vector(data) = entity.data else {
344 return Err("WAL vector points to a non-vector entity".into());
345 };
346 ensure(
347 data.dense.len() == vector.len()
348 && data
349 .dense
350 .iter()
351 .zip(vector)
352 .all(|(a, b)| a.to_bits() == b.to_bits()),
353 "WAL vector differs from recovered entity",
354 )?;
355 }
356 }
357 has_vector_cache |= verify_turbo_snapshots(&staged_source, &store)?;
358 probabilistic_deltas = store.take_replayed_probabilistic_deltas();
359 store.persist()?;
360 }
361 if !probabilistic_deltas.is_empty() {
362 let options = crate::RedDBOptions::persistent(&staged_source)
363 .with_memory_budget(64 * 1024 * 1024)
364 .with_bounded_disk_residency(
365 guard.header.version >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
366 );
367 let runtime = crate::RedDBRuntime::with_options(options)?;
368 runtime.persist_offline_probabilistic_recovery(probabilistic_deltas)?;
369 runtime.checkpoint()?;
370 runtime.graceful_shutdown(false)?;
371 }
372 if has_vector_cache {
373 scrub::verify_recovery_vector_pages(&staged_source)?;
374 }
375 let validated_dir = stage.join("validated");
376 let mut report = compact_paged(&staged_source, &validated_dir)?;
377 report.retired_boot_system_wal_actions = boot_wal.action_count();
378 report.reconciled_system_collections = catalog_plan.reconciled_count();
379 report.imported_system_records = catalog_plan.imported_count();
380 let verified = scrub_paged(&validated_dir.join(name))?;
381 ensure(
382 verified.assets_verified
383 && verified.trees.findings.is_empty()
384 && verified.unclassified_pages == 0,
385 "replayed store failed scrub",
386 )?;
387 if let Some(audit) = preserved_audit {
388 restore_recovery_audit_logs(&audit, &validated_dir.join(name))?;
389 }
390 let final_path = destination.join(name);
391 copy_sidecars(&validated_dir.join(name), &final_path, false)?;
392 copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
393 write_new(
394 &destination.join("compaction.json"),
395 report.machine_json().as_bytes(),
396 )?;
397 sync_directory(destination)?;
398 std::fs::hard_link(validated_dir.join(name), &final_path)?;
399 sync_directory(destination)?;
400 std::fs::remove_file(validated_dir.join(name))?;
403 sync_directory(&validated_dir)?;
404 Ok(report)
405}
406
407fn verify_wal_recovery_records(
408 wal: &Path,
409 retain_canonical_system_config: bool,
410 boot_wal: &boot_system_wal::BootSystemWalPlan,
411) -> Result<bool> {
412 use crate::storage::wal::{WalReader, WalRecord};
413 let mut pending_ownership = false;
414 for entry in WalReader::open(wal)?.iter() {
415 let (lsn, record) = entry?;
416 match record {
417 WalRecord::PageWrite { data, .. } => {
418 verify_wal_recovery_action(&data, retain_canonical_system_config)?
419 }
420 WalRecord::TxCommitBatch { actions, .. } => {
421 for (index, action) in actions.iter().enumerate() {
422 if !boot_wal.skips_action(lsn, index) {
423 verify_wal_recovery_action(action, retain_canonical_system_config)?;
424 }
425 }
426 }
427 WalRecord::VectorInsert { collection, .. } => {
428 ensure(
429 !retain_canonical_system_config || collection != "red_config",
430 "WAL references divergent legacy red_config",
431 )?;
432 }
433 WalRecord::ProbabilisticDelta { name, .. } => {
434 ensure(
435 !retain_canonical_system_config || name != "red_config",
436 "WAL references divergent legacy red_config",
437 )?;
438 }
439 WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
440 pending_ownership = true
441 }
442 _ => {}
443 }
444 }
445 Ok(pending_ownership)
446}
447
448fn verify_wal_recovery_action(bytes: &[u8], retain_canonical_system_config: bool) -> Result<()> {
449 let action = f::decode_store_wal_action_frame(bytes)?;
450 if retain_canonical_system_config {
451 let name = match &action {
452 f::StoreWalActionFrame::CreateCollection { name }
453 | f::StoreWalActionFrame::DropCollection { name } => Some(name),
454 f::StoreWalActionFrame::UpsertEntityRecord { collection, .. }
455 | f::StoreWalActionFrame::DeleteEntityRecord { collection, .. }
456 | f::StoreWalActionFrame::BulkUpsertEntityRecords { collection, .. }
457 | f::StoreWalActionFrame::RefreshCollection { collection, .. } => Some(collection),
458 f::StoreWalActionFrame::SetAuxMetadata { bytes } => {
459 if bytes.first() == Some(&b'[') {
460 let contracts: crate::serde_json::Value = crate::serde_json::from_slice(bytes)?;
461 let contracts = contracts.as_array().ok_or("invalid WAL contract catalog")?;
462 ensure(
463 contracts.iter().all(|contract| {
464 contract
465 .get("name")
466 .and_then(crate::serde_json::Value::as_str)
467 != Some("red_config")
468 }),
469 "WAL references divergent legacy red_config",
470 )?;
471 }
472 None
473 }
474 };
475 ensure(
476 name.is_none_or(|name| name != "red_config"),
477 "WAL references divergent legacy red_config",
478 )?;
479 }
480 Ok(())
481}
482
483#[cfg(test)]
484mod canonical_config_wal_tests {
485 use super::*;
486
487 #[test]
488 fn compaction_progress_is_machine_readable() {
489 let directory = tempfile::tempdir().expect("progress directory");
490 write_compaction_progress(directory.path(), "rewrite", 2, 5, 1_000)
491 .expect("write progress");
492 let progress: crate::serde_json::Value = crate::serde_json::from_slice(
493 &std::fs::read(directory.path().join("compaction.progress.json"))
494 .expect("progress file"),
495 )
496 .expect("progress JSON");
497 assert_eq!(progress["phase"].as_str(), Some("rewrite"));
498 assert_eq!(progress["completed_trees"].as_u64(), Some(2));
499 assert_eq!(progress["total_trees"].as_u64(), Some(5));
500 assert_eq!(progress["records"].as_u64(), Some(1_000));
501 }
502
503 #[test]
504 fn every_legacy_collection_action_is_rejected_before_name_normalization() {
505 use f::StoreWalActionFrame as Action;
506
507 let actions = [
508 Action::CreateCollection {
509 name: "red_config".into(),
510 },
511 Action::DropCollection {
512 name: "red_config".into(),
513 },
514 Action::UpsertEntityRecord {
515 collection: "red_config".into(),
516 record: vec![1],
517 },
518 Action::DeleteEntityRecord {
519 collection: "red_config".into(),
520 entity_id: 1,
521 },
522 Action::BulkUpsertEntityRecords {
523 collection: "red_config".into(),
524 records: vec![vec![1]],
525 },
526 Action::RefreshCollection {
527 collection: "red_config".into(),
528 records: vec![vec![1]],
529 },
530 ];
531 for action in actions {
532 let bytes = f::encode_store_wal_action_frame(&action).unwrap();
533 assert!(verify_wal_recovery_action(&bytes, false).is_ok());
534 assert!(verify_wal_recovery_action(&bytes, true)
535 .unwrap_err()
536 .to_string()
537 .contains("WAL references divergent legacy red_config"));
538 }
539 let canonical = f::encode_store_wal_action_frame(&Action::DeleteEntityRecord {
540 collection: "red.config".into(),
541 entity_id: 1,
542 })
543 .unwrap();
544 assert!(verify_wal_recovery_action(&canonical, true).is_ok());
545 let legacy_contract = f::encode_store_wal_action_frame(&Action::SetAuxMetadata {
546 bytes: br#"[{"name":"red\u005fconfig"}]"#.to_vec(),
547 })
548 .unwrap();
549 assert!(verify_wal_recovery_action(&legacy_contract, true).is_err());
550 }
551}
552
553fn verify_turbo_snapshots(
554 path: &Path,
555 store: &crate::storage::unified::UnifiedStore,
556) -> Result<bool> {
557 let snapshots = f::support_dir_for(path).join("snapshots");
558 if !snapshots.exists() {
559 return Ok(false);
560 }
561 let mut found = false;
562 for entry in std::fs::read_dir(snapshots)? {
563 let entry = entry?;
564 if entry.file_type()?.is_dir() {
565 continue;
566 }
567 found = true;
568 let path = entry.path();
569 let name = path
570 .file_stem()
571 .and_then(|name| name.to_str())
572 .ok_or("invalid turbo snapshot name")?;
573 ensure(
574 crate::runtime::vector_turbo_kind::is_turbo(store, name),
575 "snapshot has no turbo collection",
576 )?;
577 let length = entry.metadata()?.len();
578 ensure(
579 length <= 64 * 1024 * 1024,
580 "turbo snapshot exceeds recovery budget",
581 )?;
582 let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
583 File::open(&path)?.read_exact(&mut header)?;
584 let dim = u32::from_le_bytes(header[12..16].try_into()?);
585 let count = u64::from_le_bytes(header[32..40].try_into()?);
586 let record_size = u64::from(dim)
587 .checked_mul(4)
588 .and_then(|v| v.checked_add(12))
589 .ok_or("snapshot dimension overflow")?;
590 let expected = count
591 .checked_mul(record_size)
592 .and_then(|v| v.checked_add(header.len() as u64))
593 .ok_or("snapshot length overflow")?;
594 ensure(expected == length, "invalid turbo snapshot length")?;
595 let payload = f::read_turboquant_snapshot(
596 &path,
597 dim,
598 crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
599 )?;
600 for (id, vector) in payload.vectors {
601 let entity = store
602 .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
603 .ok_or("snapshot vector has no recovered entity")?;
604 let crate::storage::EntityData::Vector(data) = entity.data else {
605 return Err("snapshot points to a non-vector entity".into());
606 };
607 ensure(
608 data.dense.len() == vector.len()
609 && data
610 .dense
611 .iter()
612 .zip(&vector)
613 .all(|(a, b)| a.to_bits() == b.to_bits()),
614 "snapshot vector differs from recovered entity",
615 )?;
616 }
617 std::fs::remove_file(path)?;
620 }
621 Ok(found)
622}
623
624fn wal_has_records(path: &Path) -> Result<bool> {
625 match std::fs::metadata(path) {
626 Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
627 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
628 Err(error) => Err(error.into()),
629 }
630}
631
632fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
633 use crate::storage::wal::{WalReader, WalRecord};
634 let mut transactions = std::collections::HashMap::new();
635 let mut last_lsn = 0;
636 let mut highest_tx = 0u64;
637 let header = pager.header()?;
638 let mut target_seen = !header.checkpoint_in_progress;
639 for item in WalReader::open(path)?.iter() {
640 let (lsn, record) = item?;
641 last_lsn = lsn;
642 target_seen |= lsn == header.checkpoint_target_lsn;
643 match record {
644 WalRecord::Begin { tx_id } => {
645 highest_tx = highest_tx.max(tx_id);
646 transactions.insert(tx_id, false);
647 }
648 WalRecord::Commit { tx_id } => {
649 highest_tx = highest_tx.max(tx_id);
650 transactions.insert(tx_id, true);
651 }
652 WalRecord::Rollback { tx_id } => {
653 highest_tx = highest_tx.max(tx_id);
654 transactions.remove(&tx_id);
655 }
656 WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
657 _ => {}
658 }
659 }
660 ensure(
661 target_seen,
662 "engine WAL does not contain the interrupted checkpoint target",
663 )?;
664 let mut latest = std::collections::HashMap::new();
666 for item in WalReader::open(path)?.iter() {
667 let (lsn, record) = item?;
668 if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
669 if transactions.get(&tx_id).copied().unwrap_or(false) {
670 latest.insert(page_id, lsn);
671 }
672 }
673 }
674 ensure(
675 !header.checkpoint_in_progress || !latest.is_empty(),
676 "interrupted checkpoint has no committed page images",
677 )?;
678 if !latest.is_empty() {
679 pager.set_checkpoint_in_progress(true, last_lsn)?;
680 for item in WalReader::open(path)?.iter() {
681 let (lsn, record) = item?;
682 if let WalRecord::PageWrite { page_id, data, .. } = record {
683 if latest.get(&page_id) == Some(&lsn) {
684 pager.write_page(page_id, Page::from_slice(&data)?)?;
685 }
686 }
687 }
688 pager.sync()?;
689 pager.complete_checkpoint(last_lsn)?;
690 }
691 Ok(highest_tx)
692}
693
694fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
695 for (old, new) in [
696 (
697 f::physical_metadata_binary_path(source),
698 f::physical_metadata_binary_path(destination),
699 ),
700 (
701 f::physical_metadata_json_path(source),
702 f::physical_metadata_json_path(destination),
703 ),
704 ] {
705 match std::fs::symlink_metadata(&old) {
706 Ok(stat) => {
707 ensure(
708 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
709 "invalid physical metadata sidecar",
710 )?;
711 let mut input = File::open(&old)?;
712 let mut output = create_new(&new)?;
713 std::io::copy(&mut input, &mut output)?;
714 output.sync_all()?;
715 drop(output);
716 verify_file_copy(&old, &new)?;
717 }
718 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
719 Err(error) => return Err(error.into()),
720 }
721 }
722 Ok(())
723}
724
725fn compact_paged_inner(
726 source: &Path,
727 destination: &Path,
728 salvage: Option<&PagedIndexSalvageReport>,
729 lost_collections: Option<&PagedCollectionSalvageReport>,
730 retain_canonical_system_config: bool,
731) -> Result<CompactionReport> {
732 let source_path = source.canonicalize()?;
733 let parent = destination
734 .parent()
735 .filter(|parent| !parent.as_os_str().is_empty())
736 .unwrap_or_else(|| Path::new("."))
737 .canonicalize()?;
738 for source_directory in [
739 f::support_dir_for(&source_path),
740 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
741 ] {
742 if source_directory.exists() {
743 ensure(
744 !parent.starts_with(source_directory.canonicalize()?),
745 "destination is inside source storage",
746 )?;
747 }
748 }
749 let source = Arc::new(Source::open(&source_path)?);
750 let mut catalog = Catalog::read(&source)?;
751 if retain_canonical_system_config {
752 catalog.retain_canonical_system_config()?;
753 }
754 ensure(
755 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
756 || catalog.resume.is_some(),
757 "prepared disk-resident file has no resume catalog",
758 )?;
759 if let Some(salvage) = salvage {
760 let resume = catalog
761 .resume
762 .as_mut()
763 .ok_or("index recovery requires a resume catalog")?;
764 for finding in &salvage.indexes {
765 let state = resume
766 .collections
767 .get_mut(&finding.collection)
768 .ok_or("index collection missing from resume")?;
769 let index = state
770 .secondary_indices
771 .remove(&finding.index)
772 .ok_or("index missing from resume")?;
773 ensure(
774 index.root_page == finding.root_page,
775 "index root changed during salvage",
776 )?;
777 }
778 }
779 if let Some(losses) = lost_collections {
780 ensure(
781 catalog.resume.is_none() && !catalog.has_cross_references(),
782 "collection recovery requires a reference-free legacy catalog",
783 )?;
784 for loss in &losses.collections {
785 let root = catalog
786 .roots
787 .iter_mut()
788 .find(|root| root.collection == loss.collection)
789 .ok_or("collection missing from catalog")?;
790 ensure(
791 root.root_page == loss.root_page,
792 "collection root changed during salvage",
793 )?;
794 if loss.skipped_pages == [loss.root_page] {
795 root.root_page = 0;
796 }
797 }
798 }
799 let name = source_path
800 .file_name()
801 .ok_or("source filename is missing")?;
802 std::fs::create_dir(destination)?;
804 #[cfg(unix)]
805 {
806 use std::os::unix::fs::PermissionsExt;
807 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
808 }
809 let total_trees = catalog.roots.len()
810 + catalog.resume.as_ref().map_or(0, |resume| {
811 resume
812 .collections
813 .values()
814 .map(|state| {
815 usize::from(state.key_index_root.is_some()) + state.secondary_indices.len()
816 })
817 .sum::<usize>()
818 });
819 let mut completed_trees = 0usize;
820 write_compaction_progress(destination, "rewrite", completed_trees, total_trees, 0)?;
821 let output = destination.join(name);
822 let temporary = destination.join(".compacting.rdb");
823 ensure(output != temporary, "reserved compaction source filename")?;
824 let target = Arc::new(Pager::open(
825 &temporary,
826 PagerConfig {
827 cache_size: 1024,
828 disk_residency: source.header.version
829 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
830 ..PagerConfig::default()
831 },
832 )?);
833 let mut roots = BTreeMap::new();
834 let mut recovered_trees = BTreeMap::new();
835 let mut records = 0;
836 let mut repaired_leaf_backlinks = 0;
837 for collection in &catalog.roots {
838 let loss = lost_collections.and_then(|report| {
839 report
840 .collections
841 .iter()
842 .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
843 });
844 let count = if let Some(loss) = loss {
845 let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
846 recovered_trees.insert(loss.root_page, (count, hash));
847 count
848 } else {
849 rewrite_tree(
850 &source,
851 &target,
852 collection.root_page,
853 &mut roots,
854 &mut repaired_leaf_backlinks,
855 )?
856 };
857 if let Some(resume) = &catalog.resume {
858 ensure(
859 resume.collections[&collection.collection].entities == count,
860 "collection count disagrees with resume",
861 )?;
862 }
863 records += count;
864 completed_trees += 1;
865 write_compaction_progress(
866 destination,
867 "rewrite",
868 completed_trees,
869 total_trees,
870 records,
871 )?;
872 }
873 if let Some(resume) = &catalog.resume {
874 for state in resume.collections.values() {
875 if let Some(root) = state.key_index_root {
876 let count = rewrite_tree(
877 &source,
878 &target,
879 root,
880 &mut roots,
881 &mut repaired_leaf_backlinks,
882 )?;
883 records += count;
884 completed_trees += 1;
885 write_compaction_progress(
886 destination,
887 "rewrite",
888 completed_trees,
889 total_trees,
890 records,
891 )?;
892 }
893 for index in state.secondary_indices.values() {
894 let count = rewrite_tree(
895 &source,
896 &target,
897 index.root_page,
898 &mut roots,
899 &mut repaired_leaf_backlinks,
900 )?;
901 records += count;
902 completed_trees += 1;
903 write_compaction_progress(
904 destination,
905 "rewrite",
906 completed_trees,
907 total_trees,
908 records,
909 )?;
910 }
911 }
912 }
913 write_compaction_progress(destination, "assets", completed_trees, total_trees, records)?;
914 let mut assets = assets::Assets::new(&source, &target);
915 assets.vault()?;
916 let physical = assets.physical()?;
917 let internal = internal_metadata::read(&source, &catalog)?;
918 let rewritten_document = assets.metadata(
919 &source_path,
920 Some(&output),
921 internal.as_ref().map(|record| record.document.as_str()),
922 )?;
923 let mut rewritten_metadata = None;
924 if let (Some(record), Some(document)) = (internal, rewritten_document) {
925 let old_root = record.root;
926 let key = record.key.clone();
927 let value = record.rewrite(document)?;
928 let tree = BTree::with_root(Arc::clone(&target), roots[&old_root]);
929 tree.upsert(&key, &value)?;
930 roots.insert(old_root, tree.root_page_id());
931 rewritten_metadata = Some((old_root, key, value));
932 }
933 catalog.claim_retired(&source)?;
934 if salvage.is_some() || lost_collections.is_some() {
935 if let Some(salvage) = salvage {
936 for index in &salvage.indexes {
937 if index.root_page != 0 {
938 match source.raw_page(index.root_page, true) {
939 Ok(_) => {
940 let tree = BTree::with_root(Arc::clone(&source), index.root_page);
941 let skipped = tree.visit_recoverable_leaves(
942 |id| {
943 source.claim(id).map_err(|error| {
944 reddb_engine::storage::btree::BTreeError::Corrupted(
945 error.to_string(),
946 )
947 })
948 },
949 |_, _| Ok(()),
950 )?;
951 ensure(
952 !skipped.is_empty(),
953 "index damage is not a verified leaf checksum failure",
954 )?;
955 }
956 Err(error) => {
957 ensure(
958 matches!(
959 error.downcast_ref::<reddb_engine::storage::page::PageError>(),
960 Some(
961 reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
962 )
963 ),
964 "index root failure is not a checksum mismatch",
965 )?;
966 ensure(
967 source.raw_page(index.root_page, false)?.page_type()?
968 == PageType::BTreeLeaf,
969 "damaged interior index root cannot be recovered",
970 )?;
971 source.claim(index.root_page)?;
972 }
973 }
974 }
975 }
976 }
977 if let Some(losses) = lost_collections {
978 for collection in &losses.collections {
979 if collection.skipped_pages == [collection.root_page] {
980 source.claim(collection.root_page)?;
981 }
982 }
983 }
984 ensure(
985 source.unclaimed_pages() == 0,
986 "unclassified source pages require separate recovery verification",
987 )?;
988 }
989 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
990 target.update_physical_header(physical)?;
991 target.fence_file_format_version(source.header.version)?;
992 target.sync()?;
993 for _ in 0..2 {
995 target.commit_superblock(|slot| {
996 let mut header = f::decode_database_header(slot).map_err(|error| {
997 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
998 })?;
999 header.schema_version = source.header.schema_version;
1000 header.checkpoint_lsn = source.header.checkpoint_lsn;
1001 f::encode_database_header(slot, &header).map_err(|error| {
1002 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
1003 })
1004 })?;
1005 }
1006 if lost_collections.is_some() {
1007 for _ in 0..2 {
1010 target.commit_superblock(|slot| {
1011 f::set_database_header_version(
1012 slot,
1013 f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
1014 )
1015 .map_err(|error| {
1016 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
1017 })
1018 })?;
1019 }
1020 }
1021 let assets_verification = assets.into_verification();
1022 drop(target);
1023 write_compaction_progress(destination, "verify", completed_trees, total_trees, records)?;
1024 let reopened = Arc::new(Source::open(&temporary)?);
1025 if salvage.is_some() || lost_collections.is_some() {
1026 salvage::verify_vault(&temporary, &reopened)?;
1027 }
1028 ensure(
1029 reopened.header.physical == physical,
1030 "rewritten physical header failed parity",
1031 )?;
1032 let rewritten_catalog = Catalog::read(&reopened)?;
1033 ensure(
1034 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
1035 "rewritten catalog differs from source",
1036 )?;
1037 for (&old, &new) in &roots {
1038 if let Some((count, hash)) = recovered_trees.get(&old) {
1039 verify_recovered_tree(&reopened, new, *count, *hash)?;
1040 } else {
1041 verify_tree(
1042 &source,
1043 &reopened,
1044 old,
1045 new,
1046 rewritten_metadata.as_ref().filter(|record| record.0 == old),
1047 )?;
1048 }
1049 }
1050 for (id, expected, checksum) in assets_verification {
1051 let page = reopened.raw_page(id, checksum)?;
1052 if id != 2 {
1053 reopened.claim(id)?;
1054 }
1055 ensure(
1056 blake3::hash(page.content()) == expected,
1057 "native asset failed readback parity",
1058 )?;
1059 }
1060 let report = CompactionReport {
1061 retired_boot_system_wal_actions: 0,
1062 reconciled_system_collections: 0,
1063 imported_system_records: 0,
1064 source_bytes: source.bytes,
1065 destination_bytes: reopened.bytes,
1066 source_allocated_bytes: allocated_bytes(&source_path)?,
1067 destination_allocated_bytes: allocated_bytes(&temporary)?,
1068 records,
1069 trees: roots.len(),
1070 repaired_leaf_backlinks,
1071 };
1072 drop(reopened);
1073 copy_sidecars(&source_path, &output, false)?;
1074 if retain_canonical_system_config {
1075 let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
1076 if operational_path.exists() {
1077 crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
1078 .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
1079 }
1080 }
1081 write_new(
1082 &destination.join("compaction.json"),
1083 report.machine_json().as_bytes(),
1084 )?;
1085 if let Some(salvage) = salvage {
1086 write_new(
1087 &destination.join("salvage.json"),
1088 salvage.machine_json().as_bytes(),
1089 )?;
1090 }
1091 if let Some(losses) = lost_collections {
1092 write_new(
1093 &destination.join("salvage.json"),
1094 losses.machine_json().as_bytes(),
1095 )?;
1096 }
1097 sync_directory(destination)?;
1098 write_compaction_progress(
1099 destination,
1100 "publish",
1101 completed_trees,
1102 total_trees,
1103 records,
1104 )?;
1105 std::fs::hard_link(&temporary, &output)?;
1107 sync_directory(destination)?;
1108 std::fs::remove_file(&temporary)?;
1109 sync_directory(destination)?;
1110 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
1111 sync_directory(parent)?;
1112 }
1113 write_compaction_progress(
1114 destination,
1115 "complete",
1116 completed_trees,
1117 total_trees,
1118 records,
1119 )?;
1120 sync_directory(destination)?;
1121 std::fs::remove_file(destination.join("compaction.progress.json"))?;
1122 sync_directory(destination)?;
1123 Ok(report)
1124}
1125
1126fn write_compaction_progress(
1127 destination: &Path,
1128 phase: &str,
1129 completed_trees: usize,
1130 total_trees: usize,
1131 records: u64,
1132) -> Result<()> {
1133 let mut file = OpenOptions::new()
1134 .create(true)
1135 .write(true)
1136 .truncate(true)
1137 .open(destination.join("compaction.progress.json"))?;
1138 write!(
1139 file,
1140 "{{\"phase\":\"{phase}\",\"completed_trees\":{completed_trees},\"total_trees\":{total_trees},\"records\":{records}}}"
1141 )?;
1142 file.flush()?;
1143 Ok(())
1144}
1145fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
1146 roots.values().map(|root| (*root, *root)).collect()
1147}
1148
1149fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
1150 hasher.update(&(key.len() as u64).to_le_bytes());
1151 hasher.update(key);
1152 hasher.update(&(value.len() as u64).to_le_bytes());
1153 hasher.update(value);
1154}
1155
1156fn rewrite_recoverable_tree(
1157 source: &Arc<Source>,
1158 target: &Arc<Pager>,
1159 loss: &PagedCollectionLoss,
1160 roots: &mut BTreeMap<u32, u32>,
1161) -> Result<(u64, blake3::Hash)> {
1162 ensure(
1163 !roots.contains_key(&loss.root_page),
1164 "duplicate live tree owner",
1165 )?;
1166 let original = BTree::with_root(Arc::clone(source), loss.root_page);
1167 let destination = BTree::new(Arc::clone(target));
1168 let mut hash = blake3::Hasher::new();
1169 let mut count = 0u64;
1170 let mut batch = Vec::new();
1171 let mut size = 0usize;
1172 let skipped = original.visit_recoverable_leaves(
1173 |id| {
1174 source.claim(id).map_err(|error| {
1175 reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
1176 })
1177 },
1178 |key, value| {
1179 hash_record(&mut hash, &key, &value);
1180 size += key.len() + value.len();
1181 batch.push((key, value));
1182 count += 1;
1183 if size >= 1024 * 1024 || batch.len() >= 512 {
1184 destination.bulk_insert_sorted(&batch)?;
1185 batch.clear();
1186 size = 0;
1187 }
1188 Ok(())
1189 },
1190 )?;
1191 ensure(
1192 skipped == loss.skipped_pages,
1193 "damaged pages changed during recovery",
1194 )?;
1195 destination.bulk_insert_sorted(&batch)?;
1196 roots.insert(loss.root_page, destination.root_page_id());
1197 Ok((count, hash.finalize()))
1198}
1199
1200fn verify_recovered_tree(
1201 source: &Arc<Source>,
1202 root: u32,
1203 count: u64,
1204 expected: blake3::Hash,
1205) -> Result<()> {
1206 let tree = BTree::with_root(Arc::clone(source), root);
1207 let (owned, _) = inventory_tree(source, &tree, false)?;
1208 ensure(owned == count, "recovered tree record count differs")?;
1209 let mut cursor = tree.cursor_first()?;
1210 let mut hash = blake3::Hasher::new();
1211 let mut actual = 0u64;
1212 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1213 hash_record(&mut hash, &key, &value);
1214 actual += 1;
1215 ensure(actual <= count, "recovered tree has extra records")?;
1216 }
1217 ensure(
1218 actual == count && hash.finalize() == expected,
1219 "recovered tree failed record parity",
1220 )
1221}
1222
1223fn rewrite_tree(
1224 source: &Arc<Source>,
1225 target: &Arc<Pager>,
1226 root: u32,
1227 roots: &mut BTreeMap<u32, u32>,
1228 repaired_leaf_backlinks: &mut u64,
1229) -> Result<u64> {
1230 if root == 0 {
1231 return Ok(0);
1232 }
1233 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
1234 let tree = BTree::with_root(Arc::clone(source), root);
1235 let (expected, repairs) = inventory_tree(source, &tree, true)?;
1236 *repaired_leaf_backlinks += repairs;
1237 let destination = BTree::new(Arc::clone(target));
1238 let mut cursor = tree.cursor_first()?;
1239 let mut count = 0;
1240 let mut previous: Option<Vec<u8>> = None;
1241 let mut batch = Vec::new();
1242 let mut bytes = 0;
1243 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1244 ensure(
1245 previous.as_ref().is_none_or(|old| old < &key),
1246 "source tree keys are not strictly ordered",
1247 )?;
1248 previous = Some(key.clone());
1249 bytes += key.len() + value.len();
1250 batch.push((key, value));
1251 count += 1;
1252 ensure(
1253 count <= expected,
1254 "tree cursor exceeds structural inventory",
1255 )?;
1256 if bytes >= 1024 * 1024 || batch.len() >= 512 {
1257 destination.bulk_insert_sorted(&batch)?;
1258 batch.clear();
1259 bytes = 0;
1260 }
1261 }
1262 destination.bulk_insert_sorted(&batch)?;
1263 ensure(count == expected, "tree cursor omits owned records")?;
1264 roots.insert(root, destination.root_page_id());
1265 Ok(count)
1266}
1267fn inventory_tree(
1271 source: &Source,
1272 tree: &BTree<Source>,
1273 allow_backlinks: bool,
1274) -> Result<(u64, u64)> {
1275 let pages = tree.retired_pages()?;
1276 let mut leaves = Vec::new();
1277 let mut expected = 0u64;
1278 for id in pages {
1279 source.claim(id)?;
1280 let page = source.raw_page(id, true)?;
1281 if page.page_type()? == PageType::BTreeLeaf {
1282 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
1283 expected += u64::from(page.cell_count());
1284 }
1285 }
1286 leaves.reverse();
1289 let mut repaired = 0;
1290 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
1291 ensure(
1292 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
1293 &format!(
1294 "broken forward leaf chain at page {id}, root {}",
1295 tree.root_page_id()
1296 ),
1297 )?;
1298 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
1299 ensure(
1300 allow_backlinks,
1301 &format!("broken backward leaf chain at page {id}"),
1302 )?;
1303 repaired += 1;
1304 }
1305 }
1306 Ok((expected, repaired))
1307}
1308fn verify_tree(
1309 source: &Arc<Source>,
1310 destination: &Arc<Source>,
1311 old: u32,
1312 new: u32,
1313 rewritten: Option<&(u32, Vec<u8>, Vec<u8>)>,
1314) -> Result<()> {
1315 let old = BTree::with_root(Arc::clone(source), old);
1316 let new = BTree::with_root(Arc::clone(destination), new);
1317 inventory_tree(destination, &new, false)?;
1318 let mut left = old.cursor_first()?;
1319 let mut right = new.cursor_first()?;
1320 loop {
1321 let mut expected = left.next_with_budget(32 * 1024 * 1024)?;
1322 if let (Some((key, value)), Some((_, rewritten_key, rewritten_value))) =
1323 (&mut expected, rewritten)
1324 {
1325 if key == rewritten_key {
1326 *value = rewritten_value.clone();
1327 }
1328 }
1329 let actual = right.next_with_budget(32 * 1024 * 1024)?;
1330 ensure(
1331 expected == actual,
1332 "compacted tree failed complete key/value parity",
1333 )?;
1334 if expected.is_none() {
1335 return Ok(());
1336 }
1337 }
1338}
1339fn copy_sidecars(source: &Path, destination: &Path, recovery: bool) -> Result<()> {
1340 for (old, new) in [
1343 (
1344 f::unified_wal_path(source),
1345 f::unified_wal_path(destination),
1346 ),
1347 (f::engine_wal_path(source), f::engine_wal_path(destination)),
1348 (
1349 f::logical_wal_path(source),
1350 f::logical_wal_path(destination),
1351 ),
1352 (
1353 f::legacy_logical_slots_path(source),
1354 f::legacy_logical_slots_path(destination),
1355 ),
1356 ] {
1357 match File::open(&old) {
1358 Ok(_) => copy_file_verified(&old, &new)?,
1359 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1360 Err(error) => return Err(error.into()),
1361 }
1362 }
1363 copy_audit_log_family(source, destination)?;
1364 let support = f::support_dir_for(source);
1365 if support.exists() {
1366 if recovery {
1367 validate_recovery_support(&support, 0, false)?;
1368 } else {
1369 validate_empty_directory(&support, 0)?;
1370 }
1371 copy_directory(&support, &f::support_dir_for(destination), 0)?;
1372 }
1373 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
1374 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
1375 if old.exists() {
1376 f::OperationalManifest::for_db_path(source).verify_owned_for_copy()?;
1377 copy_directory(&old, &new, 0)?;
1378 }
1379 Ok(())
1380}
1381
1382fn detach_recovery_audit_logs(source: &Path, stage: &Path) -> Result<Option<PathBuf>> {
1383 let family = audit_log_family(source)?;
1384 if family.is_empty() {
1385 return Ok(None);
1386 }
1387 let preserved = stage.join("preserved-audit");
1388 std::fs::create_dir(&preserved)?;
1389 for audit in family {
1390 let name = audit.file_name().ok_or("audit log filename is missing")?;
1391 std::fs::rename(&audit, preserved.join(name))?;
1392 }
1393 sync_directory(&preserved)?;
1394 sync_directory(stage)?;
1395 Ok(Some(preserved))
1396}
1397
1398fn restore_recovery_audit_logs(preserved: &Path, destination: &Path) -> Result<()> {
1399 let parent = destination
1400 .parent()
1401 .ok_or("destination parent is missing")?;
1402 for entry in std::fs::read_dir(preserved)? {
1403 let entry = entry?;
1404 ensure(
1405 entry.file_type()?.is_file(),
1406 "preserved audit log must be a regular file",
1407 )?;
1408 copy_file_verified(&entry.path(), &parent.join(entry.file_name()))?;
1409 }
1410 sync_directory(parent)
1411}
1412
1413fn copy_file_verified(source: &Path, destination: &Path) -> Result<()> {
1414 let mut input = File::open(source)?;
1415 let mut output = create_new(destination)?;
1416 std::io::copy(&mut input, &mut output)?;
1417 output.sync_all()?;
1418 verify_file_copy(source, destination)
1419}
1420
1421fn audit_log_family(data_path: &Path) -> Result<Vec<PathBuf>> {
1422 let active = f::legacy_audit_log_path(data_path);
1423 let parent = active.parent().ok_or("audit log parent is missing")?;
1424 let mut rotated = Vec::new();
1425 for entry in std::fs::read_dir(parent)? {
1426 let entry = entry?;
1427 let name = entry.file_name();
1428 let Some(name) = name.to_str() else {
1429 continue;
1430 };
1431 let Some(timestamp) = f::parse_audit_log_rotated_timestamp(&active, name) else {
1432 continue;
1433 };
1434 ensure(
1435 entry.file_type()?.is_file(),
1436 "rotated audit log must be a regular file",
1437 )?;
1438 rotated.push((timestamp, entry.path()));
1439 }
1440 rotated.sort_by_key(|(timestamp, _)| *timestamp);
1441 for pair in rotated.windows(2) {
1442 ensure(
1443 pair[0].0 != pair[1].0,
1444 "duplicate rotated audit log timestamp",
1445 )?;
1446 }
1447 let mut family: Vec<PathBuf> = rotated.into_iter().map(|(_, path)| path).collect();
1448 match std::fs::symlink_metadata(&active) {
1449 Ok(metadata) => {
1450 ensure(metadata.is_file(), "audit log must be a regular file")?;
1451 family.push(active);
1452 }
1453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1454 Err(error) => return Err(error.into()),
1455 }
1456 Ok(family)
1457}
1458
1459fn copy_audit_log_family(source: &Path, destination: &Path) -> Result<()> {
1460 let destination_parent = destination
1461 .parent()
1462 .ok_or("audit log destination parent is missing")?;
1463 for old in audit_log_family(source)? {
1464 let name = old.file_name().ok_or("audit log filename is missing")?;
1465 copy_file_verified(&old, &destination_parent.join(name))?;
1466 }
1467 Ok(())
1468}
1469
1470pub(super) fn verify_audit_log_family(data_path: &Path) -> Result<()> {
1471 let mut previous = None;
1472 let mut has_events = false;
1473 for path in audit_log_family(data_path)? {
1474 let input = File::open(&path)?;
1475 if path
1476 .extension()
1477 .is_some_and(|extension| extension == f::layout::AUDIT_LOG_ROTATED_COMPRESSED_EXTENSION)
1478 {
1479 let decoder = zstd::stream::read::Decoder::new(input)?;
1480 (previous, has_events) = verify_audit_log_chain(decoder, previous, has_events)?;
1481 } else {
1482 (previous, has_events) = verify_audit_log_chain(input, previous, has_events)?;
1483 }
1484 }
1485 Ok(())
1486}
1487
1488fn verify_audit_log_chain(
1489 input: impl Read,
1490 mut previous: Option<String>,
1491 mut has_events: bool,
1492) -> Result<(Option<String>, bool)> {
1493 const MAX_EVENT_BYTES: usize = 1024 * 1024;
1494 let mut reader = BufReader::new(input);
1495 let mut line = Vec::new();
1496 loop {
1497 line.clear();
1498 loop {
1499 let buffer = reader.fill_buf()?;
1500 if buffer.is_empty() {
1501 if line.is_empty() {
1502 return Ok((previous, has_events));
1503 }
1504 break;
1505 }
1506 let count = buffer
1507 .iter()
1508 .position(|byte| *byte == b'\n')
1509 .map_or(buffer.len(), |position| position + 1);
1510 ensure(
1511 line.len().saturating_add(count) <= MAX_EVENT_BYTES,
1512 "audit log event exceeds recovery verification budget",
1513 )?;
1514 line.extend_from_slice(&buffer[..count]);
1515 let ended = buffer[count - 1] == b'\n';
1516 reader.consume(count);
1517 if ended {
1518 break;
1519 }
1520 }
1521 if line.last() == Some(&b'\n') {
1522 line.pop();
1523 }
1524 if line.last() == Some(&b'\r') {
1525 line.pop();
1526 }
1527 ensure(!line.is_empty(), "audit log contains an empty event")?;
1528 let value: crate::serde_json::Value = crate::serde_json::from_slice(&line)?;
1529 ensure(
1530 value.as_object().is_some(),
1531 "audit log event must be a JSON object",
1532 )?;
1533 let declared = match value.get("prev_hash") {
1534 Some(value) => Some(
1535 value
1536 .as_str()
1537 .ok_or("audit log prev_hash must be a string")?,
1538 ),
1539 None => None,
1540 };
1541 if has_events {
1542 ensure(
1543 declared == previous.as_deref(),
1544 "audit log hash chain verification failed",
1545 )?;
1546 } else if let Some(anchor) = declared {
1547 ensure(
1548 anchor.len() == 64 && anchor.bytes().all(|byte| byte.is_ascii_hexdigit()),
1549 "audit log initial prev_hash is invalid",
1550 )?;
1551 }
1552 previous = Some(crate::utils::to_hex(&crate::crypto::sha256(&line)));
1553 has_events = true;
1554 }
1555}
1556
1557fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
1558 ensure(depth < 32, "operational directory nesting exceeds limit")?;
1559 ensure(
1560 std::fs::symlink_metadata(source)?.is_dir(),
1561 "operational directory must not be a symlink",
1562 )?;
1563 std::fs::create_dir(destination)?;
1564 for entry in std::fs::read_dir(source)? {
1565 let entry = entry?;
1566 let kind = entry.file_type()?;
1567 let output = destination.join(entry.file_name());
1568 if kind.is_dir() {
1569 copy_directory(&entry.path(), &output, depth + 1)?;
1570 } else {
1571 ensure(
1572 kind.is_file(),
1573 "operational directory contains a symlink or special file",
1574 )?;
1575 let mut input = File::open(entry.path())?;
1576 let mut target = create_new(&output)?;
1577 std::io::copy(&mut input, &mut target)?;
1578 target.sync_all()?;
1579 verify_file_copy(&entry.path(), &output)?;
1580 }
1581 }
1582 sync_directory(destination)
1583}
1584fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
1585 ensure(depth < 32, "support directory nesting exceeds limit")?;
1586 ensure(
1587 std::fs::symlink_metadata(path)?.is_dir(),
1588 "support directory must not be a symlink",
1589 )?;
1590 for entry in std::fs::read_dir(path)? {
1591 let entry = entry?;
1592 ensure(
1593 entry.file_type()?.is_dir(),
1594 "non-empty support layout requires separate compaction qualification",
1595 )?;
1596 validate_empty_directory(&entry.path(), depth + 1)?;
1597 }
1598 Ok(())
1599}
1600fn validate_recovery_support(path: &Path, depth: usize, snapshots: bool) -> Result<()> {
1601 ensure(depth < 32, "support directory nesting exceeds limit")?;
1602 ensure(
1603 std::fs::symlink_metadata(path)?.is_dir(),
1604 "support directory must not be a symlink",
1605 )?;
1606 for entry in std::fs::read_dir(path)? {
1607 let entry = entry?;
1608 let kind = entry.file_type()?;
1609 if kind.is_dir() {
1610 validate_recovery_support(
1611 &entry.path(),
1612 depth + 1,
1613 depth == 0 && entry.file_name() == "snapshots",
1614 )?;
1615 } else {
1616 ensure(
1617 snapshots
1618 && kind.is_file()
1619 && entry.path().extension().is_some_and(|ext| ext == "tv"),
1620 "unsupported recovery support file",
1621 )?;
1622 }
1623 }
1624 Ok(())
1625}
1626
1627fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1628 ensure(
1629 hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1630 "sidecar failed complete readback parity",
1631 )
1632}
1633fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1634 let mut hash = blake3::Hasher::new();
1635 let mut buffer = [0; 64 * 1024];
1636 loop {
1637 let count = input.read(&mut buffer)?;
1638 if count == 0 {
1639 break;
1640 }
1641 hash.update(&buffer[..count]);
1642 }
1643 Ok(hash.finalize())
1644}
1645fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1646 #[cfg(unix)]
1647 {
1648 use std::os::unix::fs::MetadataExt;
1649 Ok(Some(
1650 std::fs::metadata(path)?
1651 .blocks()
1652 .checked_mul(512)
1653 .ok_or("allocated byte count overflow")?,
1654 ))
1655 }
1656 #[cfg(not(unix))]
1657 {
1658 let _ = path;
1659 Ok(None)
1660 }
1661}
1662fn create_new(path: &Path) -> std::io::Result<File> {
1663 let mut options = OpenOptions::new();
1664 options.write(true).create_new(true);
1665 #[cfg(unix)]
1666 {
1667 use std::os::unix::fs::OpenOptionsExt;
1668 options.mode(0o600);
1669 }
1670 options.open(path)
1671}
1672fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1673 let mut file = create_new(path)?;
1674 file.write_all(bytes)?;
1675 file.sync_all()?;
1676 Ok(())
1677}
1678fn sync_directory(_path: &Path) -> Result<()> {
1679 #[cfg(unix)]
1680 File::open(_path)?.sync_all()?;
1681 Ok(())
1682}
1683fn ensure(condition: bool, message: &str) -> Result<()> {
1684 if condition {
1685 Ok(())
1686 } else {
1687 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1688 }
1689}
1690fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1691 Ok(u32::from_le_bytes(
1692 bytes
1693 .get(offset..offset + 4)
1694 .ok_or("truncated native integer")?
1695 .try_into()?,
1696 ))
1697}