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 every_legacy_collection_action_is_rejected_before_name_normalization() {
489 use f::StoreWalActionFrame as Action;
490
491 let actions = [
492 Action::CreateCollection {
493 name: "red_config".into(),
494 },
495 Action::DropCollection {
496 name: "red_config".into(),
497 },
498 Action::UpsertEntityRecord {
499 collection: "red_config".into(),
500 record: vec![1],
501 },
502 Action::DeleteEntityRecord {
503 collection: "red_config".into(),
504 entity_id: 1,
505 },
506 Action::BulkUpsertEntityRecords {
507 collection: "red_config".into(),
508 records: vec![vec![1]],
509 },
510 Action::RefreshCollection {
511 collection: "red_config".into(),
512 records: vec![vec![1]],
513 },
514 ];
515 for action in actions {
516 let bytes = f::encode_store_wal_action_frame(&action).unwrap();
517 assert!(verify_wal_recovery_action(&bytes, false).is_ok());
518 assert!(verify_wal_recovery_action(&bytes, true)
519 .unwrap_err()
520 .to_string()
521 .contains("WAL references divergent legacy red_config"));
522 }
523 let canonical = f::encode_store_wal_action_frame(&Action::DeleteEntityRecord {
524 collection: "red.config".into(),
525 entity_id: 1,
526 })
527 .unwrap();
528 assert!(verify_wal_recovery_action(&canonical, true).is_ok());
529 let legacy_contract = f::encode_store_wal_action_frame(&Action::SetAuxMetadata {
530 bytes: br#"[{"name":"red\u005fconfig"}]"#.to_vec(),
531 })
532 .unwrap();
533 assert!(verify_wal_recovery_action(&legacy_contract, true).is_err());
534 }
535}
536
537fn verify_turbo_snapshots(
538 path: &Path,
539 store: &crate::storage::unified::UnifiedStore,
540) -> Result<bool> {
541 let snapshots = f::support_dir_for(path).join("snapshots");
542 if !snapshots.exists() {
543 return Ok(false);
544 }
545 let mut found = false;
546 for entry in std::fs::read_dir(snapshots)? {
547 let entry = entry?;
548 if entry.file_type()?.is_dir() {
549 continue;
550 }
551 found = true;
552 let path = entry.path();
553 let name = path
554 .file_stem()
555 .and_then(|name| name.to_str())
556 .ok_or("invalid turbo snapshot name")?;
557 ensure(
558 crate::runtime::vector_turbo_kind::is_turbo(store, name),
559 "snapshot has no turbo collection",
560 )?;
561 let length = entry.metadata()?.len();
562 ensure(
563 length <= 64 * 1024 * 1024,
564 "turbo snapshot exceeds recovery budget",
565 )?;
566 let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
567 File::open(&path)?.read_exact(&mut header)?;
568 let dim = u32::from_le_bytes(header[12..16].try_into()?);
569 let count = u64::from_le_bytes(header[32..40].try_into()?);
570 let record_size = u64::from(dim)
571 .checked_mul(4)
572 .and_then(|v| v.checked_add(12))
573 .ok_or("snapshot dimension overflow")?;
574 let expected = count
575 .checked_mul(record_size)
576 .and_then(|v| v.checked_add(header.len() as u64))
577 .ok_or("snapshot length overflow")?;
578 ensure(expected == length, "invalid turbo snapshot length")?;
579 let payload = f::read_turboquant_snapshot(
580 &path,
581 dim,
582 crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
583 )?;
584 for (id, vector) in payload.vectors {
585 let entity = store
586 .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
587 .ok_or("snapshot vector has no recovered entity")?;
588 let crate::storage::EntityData::Vector(data) = entity.data else {
589 return Err("snapshot points to a non-vector entity".into());
590 };
591 ensure(
592 data.dense.len() == vector.len()
593 && data
594 .dense
595 .iter()
596 .zip(&vector)
597 .all(|(a, b)| a.to_bits() == b.to_bits()),
598 "snapshot vector differs from recovered entity",
599 )?;
600 }
601 std::fs::remove_file(path)?;
604 }
605 Ok(found)
606}
607
608fn wal_has_records(path: &Path) -> Result<bool> {
609 match std::fs::metadata(path) {
610 Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
611 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
612 Err(error) => Err(error.into()),
613 }
614}
615
616fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
617 use crate::storage::wal::{WalReader, WalRecord};
618 let mut transactions = std::collections::HashMap::new();
619 let mut last_lsn = 0;
620 let mut highest_tx = 0u64;
621 let header = pager.header()?;
622 let mut target_seen = !header.checkpoint_in_progress;
623 for item in WalReader::open(path)?.iter() {
624 let (lsn, record) = item?;
625 last_lsn = lsn;
626 target_seen |= lsn == header.checkpoint_target_lsn;
627 match record {
628 WalRecord::Begin { tx_id } => {
629 highest_tx = highest_tx.max(tx_id);
630 transactions.insert(tx_id, false);
631 }
632 WalRecord::Commit { tx_id } => {
633 highest_tx = highest_tx.max(tx_id);
634 transactions.insert(tx_id, true);
635 }
636 WalRecord::Rollback { tx_id } => {
637 highest_tx = highest_tx.max(tx_id);
638 transactions.remove(&tx_id);
639 }
640 WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
641 _ => {}
642 }
643 }
644 ensure(
645 target_seen,
646 "engine WAL does not contain the interrupted checkpoint target",
647 )?;
648 let mut latest = std::collections::HashMap::new();
650 for item in WalReader::open(path)?.iter() {
651 let (lsn, record) = item?;
652 if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
653 if transactions.get(&tx_id).copied().unwrap_or(false) {
654 latest.insert(page_id, lsn);
655 }
656 }
657 }
658 ensure(
659 !header.checkpoint_in_progress || !latest.is_empty(),
660 "interrupted checkpoint has no committed page images",
661 )?;
662 if !latest.is_empty() {
663 pager.set_checkpoint_in_progress(true, last_lsn)?;
664 for item in WalReader::open(path)?.iter() {
665 let (lsn, record) = item?;
666 if let WalRecord::PageWrite { page_id, data, .. } = record {
667 if latest.get(&page_id) == Some(&lsn) {
668 pager.write_page(page_id, Page::from_slice(&data)?)?;
669 }
670 }
671 }
672 pager.sync()?;
673 pager.complete_checkpoint(last_lsn)?;
674 }
675 Ok(highest_tx)
676}
677
678fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
679 for (old, new) in [
680 (
681 f::physical_metadata_binary_path(source),
682 f::physical_metadata_binary_path(destination),
683 ),
684 (
685 f::physical_metadata_json_path(source),
686 f::physical_metadata_json_path(destination),
687 ),
688 ] {
689 match std::fs::symlink_metadata(&old) {
690 Ok(stat) => {
691 ensure(
692 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
693 "invalid physical metadata sidecar",
694 )?;
695 let mut input = File::open(&old)?;
696 let mut output = create_new(&new)?;
697 std::io::copy(&mut input, &mut output)?;
698 output.sync_all()?;
699 drop(output);
700 verify_file_copy(&old, &new)?;
701 }
702 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
703 Err(error) => return Err(error.into()),
704 }
705 }
706 Ok(())
707}
708
709fn compact_paged_inner(
710 source: &Path,
711 destination: &Path,
712 salvage: Option<&PagedIndexSalvageReport>,
713 lost_collections: Option<&PagedCollectionSalvageReport>,
714 retain_canonical_system_config: bool,
715) -> Result<CompactionReport> {
716 let source_path = source.canonicalize()?;
717 let parent = destination
718 .parent()
719 .filter(|parent| !parent.as_os_str().is_empty())
720 .unwrap_or_else(|| Path::new("."))
721 .canonicalize()?;
722 for source_directory in [
723 f::support_dir_for(&source_path),
724 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
725 ] {
726 if source_directory.exists() {
727 ensure(
728 !parent.starts_with(source_directory.canonicalize()?),
729 "destination is inside source storage",
730 )?;
731 }
732 }
733 let source = Arc::new(Source::open(&source_path)?);
734 let mut catalog = Catalog::read(&source)?;
735 if retain_canonical_system_config {
736 catalog.retain_canonical_system_config()?;
737 }
738 ensure(
739 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
740 || catalog.resume.is_some(),
741 "prepared disk-resident file has no resume catalog",
742 )?;
743 if let Some(salvage) = salvage {
744 let resume = catalog
745 .resume
746 .as_mut()
747 .ok_or("index recovery requires a resume catalog")?;
748 for finding in &salvage.indexes {
749 let state = resume
750 .collections
751 .get_mut(&finding.collection)
752 .ok_or("index collection missing from resume")?;
753 let index = state
754 .secondary_indices
755 .remove(&finding.index)
756 .ok_or("index missing from resume")?;
757 ensure(
758 index.root_page == finding.root_page,
759 "index root changed during salvage",
760 )?;
761 }
762 }
763 if let Some(losses) = lost_collections {
764 ensure(
765 catalog.resume.is_none() && !catalog.has_cross_references(),
766 "collection recovery requires a reference-free legacy catalog",
767 )?;
768 for loss in &losses.collections {
769 let root = catalog
770 .roots
771 .iter_mut()
772 .find(|root| root.collection == loss.collection)
773 .ok_or("collection missing from catalog")?;
774 ensure(
775 root.root_page == loss.root_page,
776 "collection root changed during salvage",
777 )?;
778 if loss.skipped_pages == [loss.root_page] {
779 root.root_page = 0;
780 }
781 }
782 }
783 let name = source_path
784 .file_name()
785 .ok_or("source filename is missing")?;
786 std::fs::create_dir(destination)?;
788 #[cfg(unix)]
789 {
790 use std::os::unix::fs::PermissionsExt;
791 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
792 }
793 let output = destination.join(name);
794 let temporary = destination.join(".compacting.rdb");
795 ensure(output != temporary, "reserved compaction source filename")?;
796 let target = Arc::new(Pager::open(
797 &temporary,
798 PagerConfig {
799 cache_size: 1024,
800 disk_residency: source.header.version
801 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
802 ..PagerConfig::default()
803 },
804 )?);
805 let mut roots = BTreeMap::new();
806 let mut recovered_trees = BTreeMap::new();
807 let mut records = 0;
808 let mut repaired_leaf_backlinks = 0;
809 for collection in &catalog.roots {
810 let loss = lost_collections.and_then(|report| {
811 report
812 .collections
813 .iter()
814 .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
815 });
816 let count = if let Some(loss) = loss {
817 let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
818 recovered_trees.insert(loss.root_page, (count, hash));
819 count
820 } else {
821 rewrite_tree(
822 &source,
823 &target,
824 collection.root_page,
825 &mut roots,
826 &mut repaired_leaf_backlinks,
827 )?
828 };
829 if let Some(resume) = &catalog.resume {
830 ensure(
831 resume.collections[&collection.collection].entities == count,
832 "collection count disagrees with resume",
833 )?;
834 }
835 records += count;
836 }
837 if let Some(resume) = &catalog.resume {
838 for state in resume.collections.values() {
839 if let Some(root) = state.key_index_root {
840 records += rewrite_tree(
841 &source,
842 &target,
843 root,
844 &mut roots,
845 &mut repaired_leaf_backlinks,
846 )?;
847 }
848 for index in state.secondary_indices.values() {
849 records += rewrite_tree(
850 &source,
851 &target,
852 index.root_page,
853 &mut roots,
854 &mut repaired_leaf_backlinks,
855 )?;
856 }
857 }
858 }
859 let mut assets = assets::Assets::new(&source, &target);
860 assets.vault()?;
861 let physical = assets.physical()?;
862 let internal = internal_metadata::read(&source, &catalog)?;
863 let rewritten_document = assets.metadata(
864 &source_path,
865 Some(&output),
866 internal.as_ref().map(|record| record.document.as_str()),
867 )?;
868 let mut rewritten_metadata = None;
869 if let (Some(record), Some(document)) = (internal, rewritten_document) {
870 let old_root = record.root;
871 let key = record.key.clone();
872 let value = record.rewrite(document)?;
873 let tree = BTree::with_root(Arc::clone(&target), roots[&old_root]);
874 tree.upsert(&key, &value)?;
875 roots.insert(old_root, tree.root_page_id());
876 rewritten_metadata = Some((old_root, key, value));
877 }
878 catalog.claim_retired(&source)?;
879 if salvage.is_some() || lost_collections.is_some() {
880 if let Some(salvage) = salvage {
881 for index in &salvage.indexes {
882 if index.root_page != 0 {
883 match source.raw_page(index.root_page, true) {
884 Ok(_) => {
885 let tree = BTree::with_root(Arc::clone(&source), index.root_page);
886 let skipped = tree.visit_recoverable_leaves(
887 |id| {
888 source.claim(id).map_err(|error| {
889 reddb_engine::storage::btree::BTreeError::Corrupted(
890 error.to_string(),
891 )
892 })
893 },
894 |_, _| Ok(()),
895 )?;
896 ensure(
897 !skipped.is_empty(),
898 "index damage is not a verified leaf checksum failure",
899 )?;
900 }
901 Err(error) => {
902 ensure(
903 matches!(
904 error.downcast_ref::<reddb_engine::storage::page::PageError>(),
905 Some(
906 reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
907 )
908 ),
909 "index root failure is not a checksum mismatch",
910 )?;
911 ensure(
912 source.raw_page(index.root_page, false)?.page_type()?
913 == PageType::BTreeLeaf,
914 "damaged interior index root cannot be recovered",
915 )?;
916 source.claim(index.root_page)?;
917 }
918 }
919 }
920 }
921 }
922 if let Some(losses) = lost_collections {
923 for collection in &losses.collections {
924 if collection.skipped_pages == [collection.root_page] {
925 source.claim(collection.root_page)?;
926 }
927 }
928 }
929 ensure(
930 source.unclaimed_pages() == 0,
931 "unclassified source pages require separate recovery verification",
932 )?;
933 }
934 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
935 target.update_physical_header(physical)?;
936 target.fence_file_format_version(source.header.version)?;
937 target.sync()?;
938 for _ in 0..2 {
940 target.commit_superblock(|slot| {
941 let mut header = f::decode_database_header(slot).map_err(|error| {
942 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
943 })?;
944 header.schema_version = source.header.schema_version;
945 header.checkpoint_lsn = source.header.checkpoint_lsn;
946 f::encode_database_header(slot, &header).map_err(|error| {
947 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
948 })
949 })?;
950 }
951 if lost_collections.is_some() {
952 for _ in 0..2 {
955 target.commit_superblock(|slot| {
956 f::set_database_header_version(
957 slot,
958 f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
959 )
960 .map_err(|error| {
961 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
962 })
963 })?;
964 }
965 }
966 let assets_verification = assets.into_verification();
967 drop(target);
968 let reopened = Arc::new(Source::open(&temporary)?);
969 if salvage.is_some() || lost_collections.is_some() {
970 salvage::verify_vault(&temporary, &reopened)?;
971 }
972 ensure(
973 reopened.header.physical == physical,
974 "rewritten physical header failed parity",
975 )?;
976 let rewritten_catalog = Catalog::read(&reopened)?;
977 ensure(
978 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
979 "rewritten catalog differs from source",
980 )?;
981 for (&old, &new) in &roots {
982 if let Some((count, hash)) = recovered_trees.get(&old) {
983 verify_recovered_tree(&reopened, new, *count, *hash)?;
984 } else {
985 verify_tree(
986 &source,
987 &reopened,
988 old,
989 new,
990 rewritten_metadata.as_ref().filter(|record| record.0 == old),
991 )?;
992 }
993 }
994 for (id, expected, checksum) in assets_verification {
995 let page = reopened.raw_page(id, checksum)?;
996 if id != 2 {
997 reopened.claim(id)?;
998 }
999 ensure(
1000 blake3::hash(page.content()) == expected,
1001 "native asset failed readback parity",
1002 )?;
1003 }
1004 let report = CompactionReport {
1005 retired_boot_system_wal_actions: 0,
1006 reconciled_system_collections: 0,
1007 imported_system_records: 0,
1008 source_bytes: source.bytes,
1009 destination_bytes: reopened.bytes,
1010 source_allocated_bytes: allocated_bytes(&source_path)?,
1011 destination_allocated_bytes: allocated_bytes(&temporary)?,
1012 records,
1013 trees: roots.len(),
1014 repaired_leaf_backlinks,
1015 };
1016 drop(reopened);
1017 copy_sidecars(&source_path, &output, false)?;
1018 if retain_canonical_system_config {
1019 let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
1020 if operational_path.exists() {
1021 crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
1022 .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
1023 }
1024 }
1025 write_new(
1026 &destination.join("compaction.json"),
1027 report.machine_json().as_bytes(),
1028 )?;
1029 if let Some(salvage) = salvage {
1030 write_new(
1031 &destination.join("salvage.json"),
1032 salvage.machine_json().as_bytes(),
1033 )?;
1034 }
1035 if let Some(losses) = lost_collections {
1036 write_new(
1037 &destination.join("salvage.json"),
1038 losses.machine_json().as_bytes(),
1039 )?;
1040 }
1041 sync_directory(destination)?;
1042 std::fs::hard_link(&temporary, &output)?;
1044 sync_directory(destination)?;
1045 std::fs::remove_file(&temporary)?;
1046 sync_directory(destination)?;
1047 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
1048 sync_directory(parent)?;
1049 }
1050 Ok(report)
1051}
1052fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
1053 roots.values().map(|root| (*root, *root)).collect()
1054}
1055
1056fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
1057 hasher.update(&(key.len() as u64).to_le_bytes());
1058 hasher.update(key);
1059 hasher.update(&(value.len() as u64).to_le_bytes());
1060 hasher.update(value);
1061}
1062
1063fn rewrite_recoverable_tree(
1064 source: &Arc<Source>,
1065 target: &Arc<Pager>,
1066 loss: &PagedCollectionLoss,
1067 roots: &mut BTreeMap<u32, u32>,
1068) -> Result<(u64, blake3::Hash)> {
1069 ensure(
1070 !roots.contains_key(&loss.root_page),
1071 "duplicate live tree owner",
1072 )?;
1073 let original = BTree::with_root(Arc::clone(source), loss.root_page);
1074 let destination = BTree::new(Arc::clone(target));
1075 let mut hash = blake3::Hasher::new();
1076 let mut count = 0u64;
1077 let mut batch = Vec::new();
1078 let mut size = 0usize;
1079 let skipped = original.visit_recoverable_leaves(
1080 |id| {
1081 source.claim(id).map_err(|error| {
1082 reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
1083 })
1084 },
1085 |key, value| {
1086 hash_record(&mut hash, &key, &value);
1087 size += key.len() + value.len();
1088 batch.push((key, value));
1089 count += 1;
1090 if size >= 1024 * 1024 || batch.len() >= 512 {
1091 destination.bulk_insert_sorted(&batch)?;
1092 batch.clear();
1093 size = 0;
1094 }
1095 Ok(())
1096 },
1097 )?;
1098 ensure(
1099 skipped == loss.skipped_pages,
1100 "damaged pages changed during recovery",
1101 )?;
1102 destination.bulk_insert_sorted(&batch)?;
1103 roots.insert(loss.root_page, destination.root_page_id());
1104 Ok((count, hash.finalize()))
1105}
1106
1107fn verify_recovered_tree(
1108 source: &Arc<Source>,
1109 root: u32,
1110 count: u64,
1111 expected: blake3::Hash,
1112) -> Result<()> {
1113 let tree = BTree::with_root(Arc::clone(source), root);
1114 let (owned, _) = inventory_tree(source, &tree, false)?;
1115 ensure(owned == count, "recovered tree record count differs")?;
1116 let mut cursor = tree.cursor_first()?;
1117 let mut hash = blake3::Hasher::new();
1118 let mut actual = 0u64;
1119 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1120 hash_record(&mut hash, &key, &value);
1121 actual += 1;
1122 ensure(actual <= count, "recovered tree has extra records")?;
1123 }
1124 ensure(
1125 actual == count && hash.finalize() == expected,
1126 "recovered tree failed record parity",
1127 )
1128}
1129
1130fn rewrite_tree(
1131 source: &Arc<Source>,
1132 target: &Arc<Pager>,
1133 root: u32,
1134 roots: &mut BTreeMap<u32, u32>,
1135 repaired_leaf_backlinks: &mut u64,
1136) -> Result<u64> {
1137 if root == 0 {
1138 return Ok(0);
1139 }
1140 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
1141 let tree = BTree::with_root(Arc::clone(source), root);
1142 let (expected, repairs) = inventory_tree(source, &tree, true)?;
1143 *repaired_leaf_backlinks += repairs;
1144 let destination = BTree::new(Arc::clone(target));
1145 let mut cursor = tree.cursor_first()?;
1146 let mut count = 0;
1147 let mut previous: Option<Vec<u8>> = None;
1148 let mut batch = Vec::new();
1149 let mut bytes = 0;
1150 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1151 ensure(
1152 previous.as_ref().is_none_or(|old| old < &key),
1153 "source tree keys are not strictly ordered",
1154 )?;
1155 previous = Some(key.clone());
1156 bytes += key.len() + value.len();
1157 batch.push((key, value));
1158 count += 1;
1159 ensure(
1160 count <= expected,
1161 "tree cursor exceeds structural inventory",
1162 )?;
1163 if bytes >= 1024 * 1024 || batch.len() >= 512 {
1164 destination.bulk_insert_sorted(&batch)?;
1165 batch.clear();
1166 bytes = 0;
1167 }
1168 }
1169 destination.bulk_insert_sorted(&batch)?;
1170 ensure(count == expected, "tree cursor omits owned records")?;
1171 roots.insert(root, destination.root_page_id());
1172 Ok(count)
1173}
1174fn inventory_tree(
1178 source: &Source,
1179 tree: &BTree<Source>,
1180 allow_backlinks: bool,
1181) -> Result<(u64, u64)> {
1182 let pages = tree.retired_pages()?;
1183 let mut leaves = Vec::new();
1184 let mut expected = 0u64;
1185 for id in pages {
1186 source.claim(id)?;
1187 let page = source.raw_page(id, true)?;
1188 if page.page_type()? == PageType::BTreeLeaf {
1189 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
1190 expected += u64::from(page.cell_count());
1191 }
1192 }
1193 leaves.reverse();
1196 let mut repaired = 0;
1197 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
1198 ensure(
1199 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
1200 &format!(
1201 "broken forward leaf chain at page {id}, root {}",
1202 tree.root_page_id()
1203 ),
1204 )?;
1205 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
1206 ensure(
1207 allow_backlinks,
1208 &format!("broken backward leaf chain at page {id}"),
1209 )?;
1210 repaired += 1;
1211 }
1212 }
1213 Ok((expected, repaired))
1214}
1215fn verify_tree(
1216 source: &Arc<Source>,
1217 destination: &Arc<Source>,
1218 old: u32,
1219 new: u32,
1220 rewritten: Option<&(u32, Vec<u8>, Vec<u8>)>,
1221) -> Result<()> {
1222 let old = BTree::with_root(Arc::clone(source), old);
1223 let new = BTree::with_root(Arc::clone(destination), new);
1224 inventory_tree(destination, &new, false)?;
1225 let mut left = old.cursor_first()?;
1226 let mut right = new.cursor_first()?;
1227 loop {
1228 let mut expected = left.next_with_budget(32 * 1024 * 1024)?;
1229 if let (Some((key, value)), Some((_, rewritten_key, rewritten_value))) =
1230 (&mut expected, rewritten)
1231 {
1232 if key == rewritten_key {
1233 *value = rewritten_value.clone();
1234 }
1235 }
1236 let actual = right.next_with_budget(32 * 1024 * 1024)?;
1237 ensure(
1238 expected == actual,
1239 "compacted tree failed complete key/value parity",
1240 )?;
1241 if expected.is_none() {
1242 return Ok(());
1243 }
1244 }
1245}
1246fn copy_sidecars(source: &Path, destination: &Path, recovery: bool) -> Result<()> {
1247 for (old, new) in [
1250 (
1251 f::unified_wal_path(source),
1252 f::unified_wal_path(destination),
1253 ),
1254 (f::engine_wal_path(source), f::engine_wal_path(destination)),
1255 (
1256 f::logical_wal_path(source),
1257 f::logical_wal_path(destination),
1258 ),
1259 (
1260 f::legacy_logical_slots_path(source),
1261 f::legacy_logical_slots_path(destination),
1262 ),
1263 ] {
1264 match File::open(&old) {
1265 Ok(_) => copy_file_verified(&old, &new)?,
1266 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1267 Err(error) => return Err(error.into()),
1268 }
1269 }
1270 copy_audit_log_family(source, destination)?;
1271 let support = f::support_dir_for(source);
1272 if support.exists() {
1273 if recovery {
1274 validate_recovery_support(&support, 0, false)?;
1275 } else {
1276 validate_empty_directory(&support, 0)?;
1277 }
1278 copy_directory(&support, &f::support_dir_for(destination), 0)?;
1279 }
1280 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
1281 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
1282 if old.exists() {
1283 f::OperationalManifest::for_db_path(source).verify_owned_for_copy()?;
1284 copy_directory(&old, &new, 0)?;
1285 }
1286 Ok(())
1287}
1288
1289fn detach_recovery_audit_logs(source: &Path, stage: &Path) -> Result<Option<PathBuf>> {
1290 let family = audit_log_family(source)?;
1291 if family.is_empty() {
1292 return Ok(None);
1293 }
1294 let preserved = stage.join("preserved-audit");
1295 std::fs::create_dir(&preserved)?;
1296 for audit in family {
1297 let name = audit.file_name().ok_or("audit log filename is missing")?;
1298 std::fs::rename(&audit, preserved.join(name))?;
1299 }
1300 sync_directory(&preserved)?;
1301 sync_directory(stage)?;
1302 Ok(Some(preserved))
1303}
1304
1305fn restore_recovery_audit_logs(preserved: &Path, destination: &Path) -> Result<()> {
1306 let parent = destination
1307 .parent()
1308 .ok_or("destination parent is missing")?;
1309 for entry in std::fs::read_dir(preserved)? {
1310 let entry = entry?;
1311 ensure(
1312 entry.file_type()?.is_file(),
1313 "preserved audit log must be a regular file",
1314 )?;
1315 copy_file_verified(&entry.path(), &parent.join(entry.file_name()))?;
1316 }
1317 sync_directory(parent)
1318}
1319
1320fn copy_file_verified(source: &Path, destination: &Path) -> Result<()> {
1321 let mut input = File::open(source)?;
1322 let mut output = create_new(destination)?;
1323 std::io::copy(&mut input, &mut output)?;
1324 output.sync_all()?;
1325 verify_file_copy(source, destination)
1326}
1327
1328fn audit_log_family(data_path: &Path) -> Result<Vec<PathBuf>> {
1329 let active = f::legacy_audit_log_path(data_path);
1330 let parent = active.parent().ok_or("audit log parent is missing")?;
1331 let mut rotated = Vec::new();
1332 for entry in std::fs::read_dir(parent)? {
1333 let entry = entry?;
1334 let name = entry.file_name();
1335 let Some(name) = name.to_str() else {
1336 continue;
1337 };
1338 let Some(timestamp) = f::parse_audit_log_rotated_timestamp(&active, name) else {
1339 continue;
1340 };
1341 ensure(
1342 entry.file_type()?.is_file(),
1343 "rotated audit log must be a regular file",
1344 )?;
1345 rotated.push((timestamp, entry.path()));
1346 }
1347 rotated.sort_by_key(|(timestamp, _)| *timestamp);
1348 for pair in rotated.windows(2) {
1349 ensure(
1350 pair[0].0 != pair[1].0,
1351 "duplicate rotated audit log timestamp",
1352 )?;
1353 }
1354 let mut family: Vec<PathBuf> = rotated.into_iter().map(|(_, path)| path).collect();
1355 match std::fs::symlink_metadata(&active) {
1356 Ok(metadata) => {
1357 ensure(metadata.is_file(), "audit log must be a regular file")?;
1358 family.push(active);
1359 }
1360 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1361 Err(error) => return Err(error.into()),
1362 }
1363 Ok(family)
1364}
1365
1366fn copy_audit_log_family(source: &Path, destination: &Path) -> Result<()> {
1367 let destination_parent = destination
1368 .parent()
1369 .ok_or("audit log destination parent is missing")?;
1370 for old in audit_log_family(source)? {
1371 let name = old.file_name().ok_or("audit log filename is missing")?;
1372 copy_file_verified(&old, &destination_parent.join(name))?;
1373 }
1374 Ok(())
1375}
1376
1377pub(super) fn verify_audit_log_family(data_path: &Path) -> Result<()> {
1378 let mut previous = None;
1379 let mut has_events = false;
1380 for path in audit_log_family(data_path)? {
1381 let input = File::open(&path)?;
1382 if path
1383 .extension()
1384 .is_some_and(|extension| extension == f::layout::AUDIT_LOG_ROTATED_COMPRESSED_EXTENSION)
1385 {
1386 let decoder = zstd::stream::read::Decoder::new(input)?;
1387 (previous, has_events) = verify_audit_log_chain(decoder, previous, has_events)?;
1388 } else {
1389 (previous, has_events) = verify_audit_log_chain(input, previous, has_events)?;
1390 }
1391 }
1392 Ok(())
1393}
1394
1395fn verify_audit_log_chain(
1396 input: impl Read,
1397 mut previous: Option<String>,
1398 mut has_events: bool,
1399) -> Result<(Option<String>, bool)> {
1400 const MAX_EVENT_BYTES: usize = 1024 * 1024;
1401 let mut reader = BufReader::new(input);
1402 let mut line = Vec::new();
1403 loop {
1404 line.clear();
1405 loop {
1406 let buffer = reader.fill_buf()?;
1407 if buffer.is_empty() {
1408 if line.is_empty() {
1409 return Ok((previous, has_events));
1410 }
1411 break;
1412 }
1413 let count = buffer
1414 .iter()
1415 .position(|byte| *byte == b'\n')
1416 .map_or(buffer.len(), |position| position + 1);
1417 ensure(
1418 line.len().saturating_add(count) <= MAX_EVENT_BYTES,
1419 "audit log event exceeds recovery verification budget",
1420 )?;
1421 line.extend_from_slice(&buffer[..count]);
1422 let ended = buffer[count - 1] == b'\n';
1423 reader.consume(count);
1424 if ended {
1425 break;
1426 }
1427 }
1428 if line.last() == Some(&b'\n') {
1429 line.pop();
1430 }
1431 if line.last() == Some(&b'\r') {
1432 line.pop();
1433 }
1434 ensure(!line.is_empty(), "audit log contains an empty event")?;
1435 let value: crate::serde_json::Value = crate::serde_json::from_slice(&line)?;
1436 ensure(
1437 value.as_object().is_some(),
1438 "audit log event must be a JSON object",
1439 )?;
1440 let declared = match value.get("prev_hash") {
1441 Some(value) => Some(
1442 value
1443 .as_str()
1444 .ok_or("audit log prev_hash must be a string")?,
1445 ),
1446 None => None,
1447 };
1448 if has_events {
1449 ensure(
1450 declared == previous.as_deref(),
1451 "audit log hash chain verification failed",
1452 )?;
1453 } else if let Some(anchor) = declared {
1454 ensure(
1455 anchor.len() == 64 && anchor.bytes().all(|byte| byte.is_ascii_hexdigit()),
1456 "audit log initial prev_hash is invalid",
1457 )?;
1458 }
1459 previous = Some(crate::utils::to_hex(&crate::crypto::sha256(&line)));
1460 has_events = true;
1461 }
1462}
1463
1464fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
1465 ensure(depth < 32, "operational directory nesting exceeds limit")?;
1466 ensure(
1467 std::fs::symlink_metadata(source)?.is_dir(),
1468 "operational directory must not be a symlink",
1469 )?;
1470 std::fs::create_dir(destination)?;
1471 for entry in std::fs::read_dir(source)? {
1472 let entry = entry?;
1473 let kind = entry.file_type()?;
1474 let output = destination.join(entry.file_name());
1475 if kind.is_dir() {
1476 copy_directory(&entry.path(), &output, depth + 1)?;
1477 } else {
1478 ensure(
1479 kind.is_file(),
1480 "operational directory contains a symlink or special file",
1481 )?;
1482 let mut input = File::open(entry.path())?;
1483 let mut target = create_new(&output)?;
1484 std::io::copy(&mut input, &mut target)?;
1485 target.sync_all()?;
1486 verify_file_copy(&entry.path(), &output)?;
1487 }
1488 }
1489 sync_directory(destination)
1490}
1491fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
1492 ensure(depth < 32, "support directory nesting exceeds limit")?;
1493 ensure(
1494 std::fs::symlink_metadata(path)?.is_dir(),
1495 "support directory must not be a symlink",
1496 )?;
1497 for entry in std::fs::read_dir(path)? {
1498 let entry = entry?;
1499 ensure(
1500 entry.file_type()?.is_dir(),
1501 "non-empty support layout requires separate compaction qualification",
1502 )?;
1503 validate_empty_directory(&entry.path(), depth + 1)?;
1504 }
1505 Ok(())
1506}
1507fn validate_recovery_support(path: &Path, depth: usize, snapshots: bool) -> Result<()> {
1508 ensure(depth < 32, "support directory nesting exceeds limit")?;
1509 ensure(
1510 std::fs::symlink_metadata(path)?.is_dir(),
1511 "support directory must not be a symlink",
1512 )?;
1513 for entry in std::fs::read_dir(path)? {
1514 let entry = entry?;
1515 let kind = entry.file_type()?;
1516 if kind.is_dir() {
1517 validate_recovery_support(
1518 &entry.path(),
1519 depth + 1,
1520 depth == 0 && entry.file_name() == "snapshots",
1521 )?;
1522 } else {
1523 ensure(
1524 snapshots
1525 && kind.is_file()
1526 && entry.path().extension().is_some_and(|ext| ext == "tv"),
1527 "unsupported recovery support file",
1528 )?;
1529 }
1530 }
1531 Ok(())
1532}
1533
1534fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1535 ensure(
1536 hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1537 "sidecar failed complete readback parity",
1538 )
1539}
1540fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1541 let mut hash = blake3::Hasher::new();
1542 let mut buffer = [0; 64 * 1024];
1543 loop {
1544 let count = input.read(&mut buffer)?;
1545 if count == 0 {
1546 break;
1547 }
1548 hash.update(&buffer[..count]);
1549 }
1550 Ok(hash.finalize())
1551}
1552fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1553 #[cfg(unix)]
1554 {
1555 use std::os::unix::fs::MetadataExt;
1556 Ok(Some(
1557 std::fs::metadata(path)?
1558 .blocks()
1559 .checked_mul(512)
1560 .ok_or("allocated byte count overflow")?,
1561 ))
1562 }
1563 #[cfg(not(unix))]
1564 {
1565 let _ = path;
1566 Ok(None)
1567 }
1568}
1569fn create_new(path: &Path) -> std::io::Result<File> {
1570 let mut options = OpenOptions::new();
1571 options.write(true).create_new(true);
1572 #[cfg(unix)]
1573 {
1574 use std::os::unix::fs::OpenOptionsExt;
1575 options.mode(0o600);
1576 }
1577 options.open(path)
1578}
1579fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1580 let mut file = create_new(path)?;
1581 file.write_all(bytes)?;
1582 file.sync_all()?;
1583 Ok(())
1584}
1585fn sync_directory(_path: &Path) -> Result<()> {
1586 #[cfg(unix)]
1587 File::open(_path)?.sync_all()?;
1588 Ok(())
1589}
1590fn ensure(condition: bool, message: &str) -> Result<()> {
1591 if condition {
1592 Ok(())
1593 } else {
1594 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1595 }
1596}
1597fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1598 Ok(u32::from_le_bytes(
1599 bytes
1600 .get(offset..offset + 4)
1601 .ok_or("truncated native integer")?
1602 .try_into()?,
1603 ))
1604}