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