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: (f::file_format::page_format_is_disk_resident(
218 guard.header.version,
219 ))
220 .then_some(64 * 1024 * 1024),
221 ..Default::default()
222 };
223 if pending_engine {
224 let pager = Pager::open(
225 &staged_source,
226 PagerConfig {
227 disk_residency: config.resident_memory_bytes_max.is_some(),
228 ..PagerConfig::default()
229 },
230 )?;
231 let wal = f::engine_wal_path(&staged_source);
232 recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
233 let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
234 writer.truncate()?;
235 writer.sync()?;
236 }
237 if retain_canonical_system_config && pending_unified && guard.pending_double_write {
238 {
241 let pager = Pager::open(
242 &staged_source,
243 PagerConfig {
244 disk_residency: config.resident_memory_bytes_max.is_some(),
245 ..PagerConfig::default()
246 },
247 )?;
248 pager.sync()?;
249 }
250 let checkpoint = Arc::new(Source::open_for_wal_recovery(&staged_source)?);
251 let staged_wal = f::unified_wal_path(&staged_source);
252 boot_wal =
253 boot_system_wal::BootSystemWalPlan::prepare(&checkpoint, &staged_wal, pending_engine)?;
254 verify_wal_recovery_records(&staged_wal, true, &boot_wal)?;
255 }
256 let catalog_plan = if retain_canonical_system_config {
257 catalog_aliases::CanonicalCatalogPlan::prepare(
258 &staged_source,
259 &f::unified_wal_path(&staged_source),
260 )?
261 } else {
262 catalog_aliases::CanonicalCatalogPlan::default()
263 };
264 let probabilistic_deltas;
265 let mut has_vector_cache = false;
266 {
267 let store = crate::storage::unified::UnifiedStore::open_with_config_for_recovery(
268 &staged_source,
269 config,
270 retain_canonical_system_config,
271 Some(&boot_wal),
272 Some(&catalog_plan),
273 )?;
274 boot_wal.preserve_identity_floors(&store)?;
275 catalog_plan.restore(&store)?;
276 if pending_engine || guard.pending_double_write {
277 store.reconcile_physical_recovery()?;
278 }
279 if pending_unified || pending_engine || guard.pending_double_write {
280 let mut high = store
284 .startup_xid_high_water_mark()
285 .unwrap_or(0)
286 .max(recovered_engine_xid);
287 for name in store.list_collections() {
288 let manager = store
289 .get_collection(&name)
290 .ok_or("recovered collection disappeared")?;
291 let mut max_entity = 0u64;
292 let mut max_row = 0u64;
293 let mut max_sequence = 0u64;
294 let mut has_entity = false;
295 manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
296 has_entity = true;
297 high = high.max(entity.xmin).max(entity.xmax);
298 max_entity = max_entity.max(entity.id.raw());
299 max_sequence = max_sequence.max(entity.sequence_id);
300 if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
301 max_row = max_row.max(*row_id);
302 }
303 true
304 })?;
305 if !has_entity {
306 continue;
307 }
308 let entity_next = max_entity
309 .checked_add(1)
310 .ok_or("recovered entity IDs are exhausted")?;
311 let row_next = max_row
312 .checked_add(1)
313 .ok_or("recovered row IDs are exhausted")?;
314 let sequence_next = max_sequence
315 .checked_add(1)
316 .ok_or("recovered sequence IDs are exhausted")?;
317 manager.restore_identity_counters(entity_next, row_next);
318 manager.restore_sequence_id_next(sequence_next);
319 store.register_entity_id(crate::storage::EntityId::new(max_entity));
320 }
321 ensure(
322 high < u64::MAX,
323 "recovered transaction high-water mark is exhausted",
324 )?;
325 store.remember_startup_xid_high_water_mark(high);
326 }
327 for (collection, records) in store.replayed_turbo_inserts.lock().iter() {
331 has_vector_cache = true;
332 ensure(
333 crate::runtime::vector_turbo_kind::is_turbo(&store, collection),
334 "WAL vector collection is not a turbo collection",
335 )?;
336 for (id, vector) in records {
337 let entity = store
338 .try_get(
339 collection,
340 crate::storage::EntityId::new(*id),
341 32 * 1024 * 1024,
342 )?
343 .ok_or("WAL vector has no recovered entity")?;
344 let crate::storage::EntityData::Vector(data) = entity.data else {
345 return Err("WAL vector points to a non-vector entity".into());
346 };
347 ensure(
348 data.dense.len() == vector.len()
349 && data
350 .dense
351 .iter()
352 .zip(vector)
353 .all(|(a, b)| a.to_bits() == b.to_bits()),
354 "WAL vector differs from recovered entity",
355 )?;
356 }
357 }
358 has_vector_cache |= verify_turbo_snapshots(&staged_source, &store)?;
359 probabilistic_deltas = store.take_replayed_probabilistic_deltas();
360 store.persist()?;
361 }
362 if !probabilistic_deltas.is_empty() {
363 let options = crate::RedDBOptions::persistent(&staged_source)
364 .with_memory_budget(64 * 1024 * 1024)
365 .with_bounded_disk_residency(f::file_format::page_format_is_disk_resident(
366 guard.header.version,
367 ));
368 let runtime = crate::RedDBRuntime::with_options(options)?;
369 runtime.persist_offline_probabilistic_recovery(probabilistic_deltas)?;
370 runtime.checkpoint()?;
371 runtime.graceful_shutdown(false)?;
372 }
373 if has_vector_cache {
374 scrub::verify_recovery_vector_pages(&staged_source)?;
375 }
376 let validated_dir = stage.join("validated");
377 let mut report = compact_paged(&staged_source, &validated_dir)?;
378 report.retired_boot_system_wal_actions = boot_wal.action_count();
379 report.reconciled_system_collections = catalog_plan.reconciled_count();
380 report.imported_system_records = catalog_plan.imported_count();
381 let verified = scrub_paged(&validated_dir.join(name))?;
382 ensure(
383 verified.assets_verified
384 && verified.trees.findings.is_empty()
385 && verified.unclassified_pages == 0,
386 "replayed store failed scrub",
387 )?;
388 if let Some(audit) = preserved_audit {
389 restore_recovery_audit_logs(&audit, &validated_dir.join(name))?;
390 }
391 let final_path = destination.join(name);
392 copy_sidecars(&validated_dir.join(name), &final_path, false)?;
393 copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
394 write_new(
395 &destination.join("compaction.json"),
396 report.machine_json().as_bytes(),
397 )?;
398 sync_directory(destination)?;
399 std::fs::hard_link(validated_dir.join(name), &final_path)?;
400 sync_directory(destination)?;
401 std::fs::remove_file(validated_dir.join(name))?;
404 sync_directory(&validated_dir)?;
405 Ok(report)
406}
407
408fn verify_wal_recovery_records(
409 wal: &Path,
410 retain_canonical_system_config: bool,
411 boot_wal: &boot_system_wal::BootSystemWalPlan,
412) -> Result<bool> {
413 use crate::storage::wal::{WalReader, WalRecord};
414 let mut pending_ownership = false;
415 for entry in WalReader::open(wal)?.iter() {
416 let (lsn, record) = entry?;
417 match record {
418 WalRecord::PageWrite { data, .. } => {
419 verify_wal_recovery_action(&data, retain_canonical_system_config)?
420 }
421 WalRecord::TxCommitBatch { actions, .. } => {
422 for (index, action) in actions.iter().enumerate() {
423 if !boot_wal.skips_action(lsn, index) {
424 verify_wal_recovery_action(action, retain_canonical_system_config)?;
425 }
426 }
427 }
428 WalRecord::VectorInsert { collection, .. } => {
429 ensure(
430 !retain_canonical_system_config || collection != "red_config",
431 "WAL references divergent legacy red_config",
432 )?;
433 }
434 WalRecord::ProbabilisticDelta { name, .. } => {
435 ensure(
436 !retain_canonical_system_config || name != "red_config",
437 "WAL references divergent legacy red_config",
438 )?;
439 }
440 WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
441 pending_ownership = true
442 }
443 _ => {}
444 }
445 }
446 Ok(pending_ownership)
447}
448
449fn verify_wal_recovery_action(bytes: &[u8], retain_canonical_system_config: bool) -> Result<()> {
450 let action = f::decode_store_wal_action_frame(bytes)?;
451 if retain_canonical_system_config {
452 let name = match &action {
453 f::StoreWalActionFrame::CreateCollection { name }
454 | f::StoreWalActionFrame::DropCollection { name } => Some(name),
455 f::StoreWalActionFrame::UpsertEntityRecord { collection, .. }
456 | f::StoreWalActionFrame::DeleteEntityRecord { collection, .. }
457 | f::StoreWalActionFrame::BulkUpsertEntityRecords { collection, .. }
458 | f::StoreWalActionFrame::RefreshCollection { collection, .. } => Some(collection),
459 f::StoreWalActionFrame::SetAuxMetadata { bytes } => {
460 if bytes.first() == Some(&b'[') {
461 let contracts: crate::serde_json::Value = crate::serde_json::from_slice(bytes)?;
462 let contracts = contracts.as_array().ok_or("invalid WAL contract catalog")?;
463 ensure(
464 contracts.iter().all(|contract| {
465 contract
466 .get("name")
467 .and_then(crate::serde_json::Value::as_str)
468 != Some("red_config")
469 }),
470 "WAL references divergent legacy red_config",
471 )?;
472 }
473 None
474 }
475 };
476 ensure(
477 name.is_none_or(|name| name != "red_config"),
478 "WAL references divergent legacy red_config",
479 )?;
480 }
481 Ok(())
482}
483
484#[cfg(test)]
485mod canonical_config_wal_tests {
486 use super::*;
487
488 #[test]
489 fn compaction_progress_is_machine_readable() {
490 let directory = tempfile::tempdir().expect("progress directory");
491 write_compaction_progress(directory.path(), "rewrite", 2, 5, 1_000)
492 .expect("write progress");
493 let progress: crate::serde_json::Value = crate::serde_json::from_slice(
494 &std::fs::read(directory.path().join("compaction.progress.json"))
495 .expect("progress file"),
496 )
497 .expect("progress JSON");
498 assert_eq!(progress["phase"].as_str(), Some("rewrite"));
499 assert_eq!(progress["completed_trees"].as_u64(), Some(2));
500 assert_eq!(progress["total_trees"].as_u64(), Some(5));
501 assert_eq!(progress["records"].as_u64(), Some(1_000));
502 }
503
504 #[test]
505 fn every_legacy_collection_action_is_rejected_before_name_normalization() {
506 use f::StoreWalActionFrame as Action;
507
508 let actions = [
509 Action::CreateCollection {
510 name: "red_config".into(),
511 },
512 Action::DropCollection {
513 name: "red_config".into(),
514 },
515 Action::UpsertEntityRecord {
516 collection: "red_config".into(),
517 record: vec![1],
518 },
519 Action::DeleteEntityRecord {
520 collection: "red_config".into(),
521 entity_id: 1,
522 },
523 Action::BulkUpsertEntityRecords {
524 collection: "red_config".into(),
525 records: vec![vec![1]],
526 },
527 Action::RefreshCollection {
528 collection: "red_config".into(),
529 records: vec![vec![1]],
530 },
531 ];
532 for action in actions {
533 let bytes = f::encode_store_wal_action_frame(&action).unwrap();
534 assert!(verify_wal_recovery_action(&bytes, false).is_ok());
535 assert!(verify_wal_recovery_action(&bytes, true)
536 .unwrap_err()
537 .to_string()
538 .contains("WAL references divergent legacy red_config"));
539 }
540 let canonical = f::encode_store_wal_action_frame(&Action::DeleteEntityRecord {
541 collection: "red.config".into(),
542 entity_id: 1,
543 })
544 .unwrap();
545 assert!(verify_wal_recovery_action(&canonical, true).is_ok());
546 let legacy_contract = f::encode_store_wal_action_frame(&Action::SetAuxMetadata {
547 bytes: br#"[{"name":"red\u005fconfig"}]"#.to_vec(),
548 })
549 .unwrap();
550 assert!(verify_wal_recovery_action(&legacy_contract, true).is_err());
551 }
552}
553
554fn verify_turbo_snapshots(
555 path: &Path,
556 store: &crate::storage::unified::UnifiedStore,
557) -> Result<bool> {
558 let snapshots = f::support_dir_for(path).join("snapshots");
559 if !snapshots.exists() {
560 return Ok(false);
561 }
562 let mut found = false;
563 for entry in std::fs::read_dir(snapshots)? {
564 let entry = entry?;
565 if entry.file_type()?.is_dir() {
566 continue;
567 }
568 found = true;
569 let path = entry.path();
570 let name = path
571 .file_stem()
572 .and_then(|name| name.to_str())
573 .ok_or("invalid turbo snapshot name")?;
574 ensure(
575 crate::runtime::vector_turbo_kind::is_turbo(store, name),
576 "snapshot has no turbo collection",
577 )?;
578 let length = entry.metadata()?.len();
579 ensure(
580 length <= 64 * 1024 * 1024,
581 "turbo snapshot exceeds recovery budget",
582 )?;
583 let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
584 File::open(&path)?.read_exact(&mut header)?;
585 let dim = u32::from_le_bytes(header[12..16].try_into()?);
586 let count = u64::from_le_bytes(header[32..40].try_into()?);
587 let record_size = u64::from(dim)
588 .checked_mul(4)
589 .and_then(|v| v.checked_add(12))
590 .ok_or("snapshot dimension overflow")?;
591 let expected = count
592 .checked_mul(record_size)
593 .and_then(|v| v.checked_add(header.len() as u64))
594 .ok_or("snapshot length overflow")?;
595 ensure(expected == length, "invalid turbo snapshot length")?;
596 let payload = f::read_turboquant_snapshot(
597 &path,
598 dim,
599 crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
600 )?;
601 for (id, vector) in payload.vectors {
602 let entity = store
603 .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
604 .ok_or("snapshot vector has no recovered entity")?;
605 let crate::storage::EntityData::Vector(data) = entity.data else {
606 return Err("snapshot points to a non-vector entity".into());
607 };
608 ensure(
609 data.dense.len() == vector.len()
610 && data
611 .dense
612 .iter()
613 .zip(&vector)
614 .all(|(a, b)| a.to_bits() == b.to_bits()),
615 "snapshot vector differs from recovered entity",
616 )?;
617 }
618 std::fs::remove_file(path)?;
621 }
622 Ok(found)
623}
624
625fn wal_has_records(path: &Path) -> Result<bool> {
626 match std::fs::metadata(path) {
627 Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
628 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
629 Err(error) => Err(error.into()),
630 }
631}
632
633fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
634 use crate::storage::wal::{WalReader, WalRecord};
635 let mut transactions = std::collections::HashMap::new();
636 let mut last_lsn = 0;
637 let mut highest_tx = 0u64;
638 let header = pager.header()?;
639 let mut target_seen = !header.checkpoint_in_progress;
640 for item in WalReader::open(path)?.iter() {
641 let (lsn, record) = item?;
642 last_lsn = lsn;
643 target_seen |= lsn == header.checkpoint_target_lsn;
644 match record {
645 WalRecord::Begin { tx_id } => {
646 highest_tx = highest_tx.max(tx_id);
647 transactions.insert(tx_id, false);
648 }
649 WalRecord::Commit { tx_id } => {
650 highest_tx = highest_tx.max(tx_id);
651 transactions.insert(tx_id, true);
652 }
653 WalRecord::Rollback { tx_id } => {
654 highest_tx = highest_tx.max(tx_id);
655 transactions.remove(&tx_id);
656 }
657 WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
658 _ => {}
659 }
660 }
661 ensure(
662 target_seen,
663 "engine WAL does not contain the interrupted checkpoint target",
664 )?;
665 let mut latest = std::collections::HashMap::new();
667 for item in WalReader::open(path)?.iter() {
668 let (lsn, record) = item?;
669 if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
670 if transactions.get(&tx_id).copied().unwrap_or(false) {
671 latest.insert(page_id, lsn);
672 }
673 }
674 }
675 ensure(
676 !header.checkpoint_in_progress || !latest.is_empty(),
677 "interrupted checkpoint has no committed page images",
678 )?;
679 if !latest.is_empty() {
680 pager.set_checkpoint_in_progress(true, last_lsn)?;
681 for item in WalReader::open(path)?.iter() {
682 let (lsn, record) = item?;
683 if let WalRecord::PageWrite { page_id, data, .. } = record {
684 if latest.get(&page_id) == Some(&lsn) {
685 pager.write_page(page_id, Page::from_slice(&data)?)?;
686 }
687 }
688 }
689 pager.sync()?;
690 pager.complete_checkpoint(last_lsn)?;
691 }
692 Ok(highest_tx)
693}
694
695fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
696 for (old, new) in [
697 (
698 f::physical_metadata_binary_path(source),
699 f::physical_metadata_binary_path(destination),
700 ),
701 (
702 f::physical_metadata_json_path(source),
703 f::physical_metadata_json_path(destination),
704 ),
705 ] {
706 match std::fs::symlink_metadata(&old) {
707 Ok(stat) => {
708 ensure(
709 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
710 "invalid physical metadata sidecar",
711 )?;
712 let mut input = File::open(&old)?;
713 let mut output = create_new(&new)?;
714 std::io::copy(&mut input, &mut output)?;
715 output.sync_all()?;
716 drop(output);
717 verify_file_copy(&old, &new)?;
718 }
719 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
720 Err(error) => return Err(error.into()),
721 }
722 }
723 Ok(())
724}
725
726fn compact_paged_inner(
727 source: &Path,
728 destination: &Path,
729 salvage: Option<&PagedIndexSalvageReport>,
730 lost_collections: Option<&PagedCollectionSalvageReport>,
731 retain_canonical_system_config: bool,
732) -> Result<CompactionReport> {
733 let source_path = source.canonicalize()?;
734 let parent = destination
735 .parent()
736 .filter(|parent| !parent.as_os_str().is_empty())
737 .unwrap_or_else(|| Path::new("."))
738 .canonicalize()?;
739 for source_directory in [
740 f::support_dir_for(&source_path),
741 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
742 ] {
743 if source_directory.exists() {
744 ensure(
745 !parent.starts_with(source_directory.canonicalize()?),
746 "destination is inside source storage",
747 )?;
748 }
749 }
750 let source = Arc::new(Source::open(&source_path)?);
751 let mut catalog = Catalog::read(&source)?;
752 if retain_canonical_system_config {
753 catalog.retain_canonical_system_config()?;
754 }
755 ensure(
756 !f::file_format::page_format_is_disk_resident(source.header.version)
757 || catalog.resume.is_some(),
758 "prepared disk-resident file has no resume catalog",
759 )?;
760 if let Some(salvage) = salvage {
761 let resume = catalog
762 .resume
763 .as_mut()
764 .ok_or("index recovery requires a resume catalog")?;
765 for finding in &salvage.indexes {
766 let state = resume
767 .collections
768 .get_mut(&finding.collection)
769 .ok_or("index collection missing from resume")?;
770 let index = state
771 .secondary_indices
772 .remove(&finding.index)
773 .ok_or("index missing from resume")?;
774 ensure(
775 index.root_page == finding.root_page,
776 "index root changed during salvage",
777 )?;
778 }
779 }
780 if let Some(losses) = lost_collections {
781 ensure(
782 catalog.resume.is_none() && !catalog.has_cross_references(),
783 "collection recovery requires a reference-free legacy catalog",
784 )?;
785 for loss in &losses.collections {
786 let root = catalog
787 .roots
788 .iter_mut()
789 .find(|root| root.collection == loss.collection)
790 .ok_or("collection missing from catalog")?;
791 ensure(
792 root.root_page == loss.root_page,
793 "collection root changed during salvage",
794 )?;
795 if loss.skipped_pages == [loss.root_page] {
796 root.root_page = 0;
797 }
798 }
799 }
800 let name = source_path
801 .file_name()
802 .ok_or("source filename is missing")?;
803 std::fs::create_dir(destination)?;
805 #[cfg(unix)]
806 {
807 use std::os::unix::fs::PermissionsExt;
808 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
809 }
810 let total_trees = catalog.roots.len()
811 + catalog.resume.as_ref().map_or(0, |resume| {
812 resume
813 .collections
814 .values()
815 .map(|state| {
816 usize::from(state.key_index_root.is_some()) + state.secondary_indices.len()
817 })
818 .sum::<usize>()
819 });
820 let mut completed_trees = 0usize;
821 write_compaction_progress(destination, "rewrite", completed_trees, total_trees, 0)?;
822 let output = destination.join(name);
823 let temporary = destination.join(".compacting.rdb");
824 ensure(output != temporary, "reserved compaction source filename")?;
825 let target = Arc::new(Pager::open(
826 &temporary,
827 PagerConfig {
828 cache_size: 1024,
829 disk_residency: f::file_format::page_format_is_disk_resident(source.header.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}