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