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::{Read, Write},
15 path::Path,
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)
69}
70
71pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
75 use crate::storage::wal::{WalReader, WalRecord};
76 let source = source.canonicalize()?;
77 let parent = destination
78 .parent()
79 .filter(|p| !p.as_os_str().is_empty())
80 .unwrap_or_else(|| Path::new("."))
81 .canonicalize()?;
82 for owned in [
83 f::support_dir_for(&source),
84 source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
85 ] {
86 if owned.exists() {
87 ensure(
88 !parent.starts_with(owned.canonicalize()?),
89 "destination is inside source storage",
90 )?;
91 }
92 }
93 let guard = Source::open_for_wal_recovery(&source)?;
94 salvage::verify_supported_source(&source, &guard)?;
95 let mut assets = assets::Assets::for_verification(&guard);
96 assets.vault()?;
97 assets.physical()?;
98 assets.metadata(&source, None)?;
99 let wal = f::unified_wal_path(&source);
100 let length = std::fs::metadata(&wal)?.len();
101 ensure(
102 length > f::WAL_FILE_HEADER_BYTES as u64,
103 "no pending unified WAL",
104 )?;
105 ensure(
106 length <= 256 * 1024 * 1024,
107 "WAL exceeds recovery verification budget",
108 )?;
109 for entry in WalReader::open(&wal)?.iter() {
110 match entry?.1 {
111 WalRecord::PageWrite { data, .. } => verify_wal_recovery_action(&data)?,
112 WalRecord::TxCommitBatch { actions, .. } => {
113 for action in actions {
114 verify_wal_recovery_action(&action)?;
115 }
116 }
117 WalRecord::VectorInsert { .. } | WalRecord::ProbabilisticDelta { .. } => {
118 return Err("WAL contains runtime-owned data requiring separate recovery".into());
119 }
120 _ => {}
121 }
122 }
123 std::fs::create_dir(destination)?;
124 #[cfg(unix)]
125 {
126 use std::os::unix::fs::PermissionsExt;
127 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
128 }
129 let stage = destination.join(".wal-replay");
130 std::fs::create_dir(&stage)?;
131 let name = source.file_name().ok_or("source filename is missing")?;
132 let staged_source = stage.join(name);
133 let mut input = File::open(&source)?;
134 let mut output = create_new(&staged_source)?;
135 std::io::copy(&mut input, &mut output)?;
136 output.sync_all()?;
137 drop(output);
138 verify_file_copy(&source, &staged_source)?;
139 copy_sidecars(&source, &staged_source)?;
140 copy_physical_metadata_files(&source, &staged_source)?;
141 let config = crate::storage::unified::UnifiedStoreConfig {
142 resident_memory_bytes_max: (guard.header.version
143 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
144 .then_some(64 * 1024 * 1024),
145 ..Default::default()
146 };
147 {
148 let store =
149 crate::storage::unified::UnifiedStore::open_with_config(&staged_source, config)?;
150 store.persist()?;
151 }
152 let validated_dir = stage.join("validated");
153 let report = compact_paged(&staged_source, &validated_dir)?;
154 let verified = scrub_paged(&validated_dir.join(name))?;
155 ensure(
156 verified.assets_verified
157 && verified.trees.findings.is_empty()
158 && verified.unclassified_pages == 0,
159 "replayed store failed scrub",
160 )?;
161 let final_path = destination.join(name);
162 copy_sidecars(&validated_dir.join(name), &final_path)?;
163 copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
164 write_new(
165 &destination.join("compaction.json"),
166 report.machine_json().as_bytes(),
167 )?;
168 sync_directory(destination)?;
169 std::fs::hard_link(validated_dir.join(name), &final_path)?;
170 sync_directory(destination)?;
171 std::fs::remove_file(validated_dir.join(name))?;
174 sync_directory(&validated_dir)?;
175 Ok(report)
176}
177
178fn verify_wal_recovery_action(bytes: &[u8]) -> Result<()> {
179 match f::decode_store_wal_action_frame(bytes)? {
180 f::StoreWalActionFrame::UpsertEntityRecord { .. }
181 | f::StoreWalActionFrame::DeleteEntityRecord { .. }
182 | f::StoreWalActionFrame::BulkUpsertEntityRecords { .. } => Ok(()),
183 _ => Err("WAL schema or metadata changes require separate recovery qualification".into()),
184 }
185}
186
187fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
188 for (old, new) in [
189 (
190 f::physical_metadata_binary_path(source),
191 f::physical_metadata_binary_path(destination),
192 ),
193 (
194 f::physical_metadata_json_path(source),
195 f::physical_metadata_json_path(destination),
196 ),
197 ] {
198 match std::fs::symlink_metadata(&old) {
199 Ok(stat) => {
200 ensure(
201 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
202 "invalid physical metadata sidecar",
203 )?;
204 let mut input = File::open(&old)?;
205 let mut output = create_new(&new)?;
206 std::io::copy(&mut input, &mut output)?;
207 output.sync_all()?;
208 drop(output);
209 verify_file_copy(&old, &new)?;
210 }
211 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
212 Err(error) => return Err(error.into()),
213 }
214 }
215 Ok(())
216}
217
218fn compact_paged_inner(
219 source: &Path,
220 destination: &Path,
221 salvage: Option<&PagedIndexSalvageReport>,
222 lost_collections: Option<&PagedCollectionSalvageReport>,
223) -> Result<CompactionReport> {
224 let source_path = source.canonicalize()?;
225 let parent = destination
226 .parent()
227 .filter(|parent| !parent.as_os_str().is_empty())
228 .unwrap_or_else(|| Path::new("."))
229 .canonicalize()?;
230 for source_directory in [
231 f::support_dir_for(&source_path),
232 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
233 ] {
234 if source_directory.exists() {
235 ensure(
236 !parent.starts_with(source_directory.canonicalize()?),
237 "destination is inside source storage",
238 )?;
239 }
240 }
241 let source = Arc::new(Source::open(&source_path)?);
242 let mut catalog = Catalog::read(&source)?;
243 ensure(
244 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
245 || catalog.resume.is_some(),
246 "prepared disk-resident file has no resume catalog",
247 )?;
248 if let Some(salvage) = salvage {
249 let resume = catalog
250 .resume
251 .as_mut()
252 .ok_or("index recovery requires a resume catalog")?;
253 for finding in &salvage.indexes {
254 let state = resume
255 .collections
256 .get_mut(&finding.collection)
257 .ok_or("index collection missing from resume")?;
258 let index = state
259 .secondary_indices
260 .remove(&finding.index)
261 .ok_or("index missing from resume")?;
262 ensure(
263 index.root_page == finding.root_page,
264 "index root changed during salvage",
265 )?;
266 }
267 }
268 if let Some(losses) = lost_collections {
269 ensure(
270 catalog.resume.is_none() && !catalog.has_cross_references(),
271 "collection recovery requires a reference-free legacy catalog",
272 )?;
273 for loss in &losses.collections {
274 let root = catalog
275 .roots
276 .iter_mut()
277 .find(|root| root.collection == loss.collection)
278 .ok_or("collection missing from catalog")?;
279 ensure(
280 root.root_page == loss.root_page,
281 "collection root changed during salvage",
282 )?;
283 if loss.skipped_pages == [loss.root_page] {
284 root.root_page = 0;
285 }
286 }
287 }
288 let name = source_path
289 .file_name()
290 .ok_or("source filename is missing")?;
291 std::fs::create_dir(destination)?;
293 #[cfg(unix)]
294 {
295 use std::os::unix::fs::PermissionsExt;
296 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
297 }
298 let output = destination.join(name);
299 let temporary = destination.join(".compacting.rdb");
300 ensure(output != temporary, "reserved compaction source filename")?;
301 let target = Arc::new(Pager::open(
302 &temporary,
303 PagerConfig {
304 cache_size: 1024,
305 disk_residency: source.header.version
306 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
307 ..PagerConfig::default()
308 },
309 )?);
310 let mut roots = BTreeMap::new();
311 let mut recovered_trees = BTreeMap::new();
312 let mut records = 0;
313 let mut repaired_leaf_backlinks = 0;
314 for collection in &catalog.roots {
315 let loss = lost_collections.and_then(|report| {
316 report
317 .collections
318 .iter()
319 .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
320 });
321 let count = if let Some(loss) = loss {
322 let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
323 recovered_trees.insert(loss.root_page, (count, hash));
324 count
325 } else {
326 rewrite_tree(
327 &source,
328 &target,
329 collection.root_page,
330 &mut roots,
331 &mut repaired_leaf_backlinks,
332 )?
333 };
334 if let Some(resume) = &catalog.resume {
335 ensure(
336 resume.collections[&collection.collection].entities == count,
337 "collection count disagrees with resume",
338 )?;
339 }
340 records += count;
341 }
342 if let Some(resume) = &catalog.resume {
343 for state in resume.collections.values() {
344 if let Some(root) = state.key_index_root {
345 records += rewrite_tree(
346 &source,
347 &target,
348 root,
349 &mut roots,
350 &mut repaired_leaf_backlinks,
351 )?;
352 }
353 for index in state.secondary_indices.values() {
354 records += rewrite_tree(
355 &source,
356 &target,
357 index.root_page,
358 &mut roots,
359 &mut repaired_leaf_backlinks,
360 )?;
361 }
362 }
363 }
364 let mut assets = assets::Assets::new(&source, &target);
365 assets.vault()?;
366 let physical = assets.physical()?;
367 assets.metadata(&source_path, Some(&output))?;
368 if salvage.is_some() || lost_collections.is_some() {
369 if let Some(salvage) = salvage {
370 for index in &salvage.indexes {
371 if index.root_page != 0 {
372 match source.raw_page(index.root_page, true) {
373 Ok(_) => {
374 let tree = BTree::with_root(Arc::clone(&source), index.root_page);
375 let skipped = tree.visit_recoverable_leaves(
376 |id| {
377 source.claim(id).map_err(|error| {
378 reddb_engine::storage::btree::BTreeError::Corrupted(
379 error.to_string(),
380 )
381 })
382 },
383 |_, _| Ok(()),
384 )?;
385 ensure(
386 !skipped.is_empty(),
387 "index damage is not a verified leaf checksum failure",
388 )?;
389 }
390 Err(error) => {
391 ensure(
392 matches!(
393 error.downcast_ref::<reddb_engine::storage::page::PageError>(),
394 Some(
395 reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
396 )
397 ),
398 "index root failure is not a checksum mismatch",
399 )?;
400 ensure(
401 source.raw_page(index.root_page, false)?.page_type()?
402 == PageType::BTreeLeaf,
403 "damaged interior index root cannot be recovered",
404 )?;
405 source.claim(index.root_page)?;
406 }
407 }
408 }
409 }
410 }
411 if let Some(losses) = lost_collections {
412 for collection in &losses.collections {
413 if collection.skipped_pages == [collection.root_page] {
414 source.claim(collection.root_page)?;
415 }
416 }
417 }
418 ensure(
419 source.unclaimed_pages() == 0,
420 "unclassified source pages require separate recovery verification",
421 )?;
422 }
423 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
424 target.update_physical_header(physical)?;
425 target.fence_file_format_version(source.header.version)?;
426 target.sync()?;
427 for _ in 0..2 {
429 target.commit_superblock(|slot| {
430 let mut header = f::decode_database_header(slot).map_err(|error| {
431 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
432 })?;
433 header.schema_version = source.header.schema_version;
434 header.checkpoint_lsn = source.header.checkpoint_lsn;
435 f::encode_database_header(slot, &header).map_err(|error| {
436 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
437 })
438 })?;
439 }
440 if lost_collections.is_some() {
441 for _ in 0..2 {
444 target.commit_superblock(|slot| {
445 f::set_database_header_version(
446 slot,
447 f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
448 )
449 .map_err(|error| {
450 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
451 })
452 })?;
453 }
454 }
455 let assets_verification = assets.into_verification();
456 drop(target);
457 let reopened = Arc::new(Source::open(&temporary)?);
458 if salvage.is_some() || lost_collections.is_some() {
459 salvage::verify_vault(&temporary, &reopened)?;
460 }
461 ensure(
462 reopened.header.physical == physical,
463 "rewritten physical header failed parity",
464 )?;
465 let rewritten_catalog = Catalog::read(&reopened)?;
466 ensure(
467 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
468 "rewritten catalog differs from source",
469 )?;
470 for (&old, &new) in &roots {
471 if let Some((count, hash)) = recovered_trees.get(&old) {
472 verify_recovered_tree(&reopened, new, *count, *hash)?;
473 } else {
474 verify_tree(&source, &reopened, old, new)?;
475 }
476 }
477 for (id, expected, checksum) in assets_verification {
478 let page = reopened.raw_page(id, checksum)?;
479 if id != 2 {
480 reopened.claim(id)?;
481 }
482 ensure(
483 blake3::hash(page.content()) == expected,
484 "native asset failed readback parity",
485 )?;
486 }
487 let report = CompactionReport {
488 source_bytes: source.bytes,
489 destination_bytes: reopened.bytes,
490 source_allocated_bytes: allocated_bytes(&source_path)?,
491 destination_allocated_bytes: allocated_bytes(&temporary)?,
492 records,
493 trees: roots.len(),
494 repaired_leaf_backlinks,
495 };
496 drop(reopened);
497 copy_sidecars(&source_path, &output)?;
498 write_new(
499 &destination.join("compaction.json"),
500 report.machine_json().as_bytes(),
501 )?;
502 if let Some(salvage) = salvage {
503 write_new(
504 &destination.join("salvage.json"),
505 salvage.machine_json().as_bytes(),
506 )?;
507 }
508 if let Some(losses) = lost_collections {
509 write_new(
510 &destination.join("salvage.json"),
511 losses.machine_json().as_bytes(),
512 )?;
513 }
514 sync_directory(destination)?;
515 std::fs::hard_link(&temporary, &output)?;
517 sync_directory(destination)?;
518 std::fs::remove_file(&temporary)?;
519 sync_directory(destination)?;
520 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
521 sync_directory(parent)?;
522 }
523 Ok(report)
524}
525fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
526 roots.values().map(|root| (*root, *root)).collect()
527}
528
529fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
530 hasher.update(&(key.len() as u64).to_le_bytes());
531 hasher.update(key);
532 hasher.update(&(value.len() as u64).to_le_bytes());
533 hasher.update(value);
534}
535
536fn rewrite_recoverable_tree(
537 source: &Arc<Source>,
538 target: &Arc<Pager>,
539 loss: &PagedCollectionLoss,
540 roots: &mut BTreeMap<u32, u32>,
541) -> Result<(u64, blake3::Hash)> {
542 ensure(
543 !roots.contains_key(&loss.root_page),
544 "duplicate live tree owner",
545 )?;
546 let original = BTree::with_root(Arc::clone(source), loss.root_page);
547 let destination = BTree::new(Arc::clone(target));
548 let mut hash = blake3::Hasher::new();
549 let mut count = 0u64;
550 let mut batch = Vec::new();
551 let mut size = 0usize;
552 let skipped = original.visit_recoverable_leaves(
553 |id| {
554 source.claim(id).map_err(|error| {
555 reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
556 })
557 },
558 |key, value| {
559 hash_record(&mut hash, &key, &value);
560 size += key.len() + value.len();
561 batch.push((key, value));
562 count += 1;
563 if size >= 1024 * 1024 || batch.len() >= 512 {
564 destination.bulk_insert_sorted(&batch)?;
565 batch.clear();
566 size = 0;
567 }
568 Ok(())
569 },
570 )?;
571 ensure(
572 skipped == loss.skipped_pages,
573 "damaged pages changed during recovery",
574 )?;
575 destination.bulk_insert_sorted(&batch)?;
576 roots.insert(loss.root_page, destination.root_page_id());
577 Ok((count, hash.finalize()))
578}
579
580fn verify_recovered_tree(
581 source: &Arc<Source>,
582 root: u32,
583 count: u64,
584 expected: blake3::Hash,
585) -> Result<()> {
586 let tree = BTree::with_root(Arc::clone(source), root);
587 let (owned, _) = inventory_tree(source, &tree, false)?;
588 ensure(owned == count, "recovered tree record count differs")?;
589 let mut cursor = tree.cursor_first()?;
590 let mut hash = blake3::Hasher::new();
591 let mut actual = 0u64;
592 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
593 hash_record(&mut hash, &key, &value);
594 actual += 1;
595 ensure(actual <= count, "recovered tree has extra records")?;
596 }
597 ensure(
598 actual == count && hash.finalize() == expected,
599 "recovered tree failed record parity",
600 )
601}
602
603fn rewrite_tree(
604 source: &Arc<Source>,
605 target: &Arc<Pager>,
606 root: u32,
607 roots: &mut BTreeMap<u32, u32>,
608 repaired_leaf_backlinks: &mut u64,
609) -> Result<u64> {
610 if root == 0 {
611 return Ok(0);
612 }
613 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
614 let tree = BTree::with_root(Arc::clone(source), root);
615 let (expected, repairs) = inventory_tree(source, &tree, true)?;
616 *repaired_leaf_backlinks += repairs;
617 let destination = BTree::new(Arc::clone(target));
618 let mut cursor = tree.cursor_first()?;
619 let mut count = 0;
620 let mut previous: Option<Vec<u8>> = None;
621 let mut batch = Vec::new();
622 let mut bytes = 0;
623 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
624 ensure(
625 previous.as_ref().is_none_or(|old| old < &key),
626 "source tree keys are not strictly ordered",
627 )?;
628 previous = Some(key.clone());
629 bytes += key.len() + value.len();
630 batch.push((key, value));
631 count += 1;
632 ensure(
633 count <= expected,
634 "tree cursor exceeds structural inventory",
635 )?;
636 if bytes >= 1024 * 1024 || batch.len() >= 512 {
637 destination.bulk_insert_sorted(&batch)?;
638 batch.clear();
639 bytes = 0;
640 }
641 }
642 destination.bulk_insert_sorted(&batch)?;
643 ensure(count == expected, "tree cursor omits owned records")?;
644 roots.insert(root, destination.root_page_id());
645 Ok(count)
646}
647fn inventory_tree(
651 source: &Source,
652 tree: &BTree<Source>,
653 allow_backlinks: bool,
654) -> Result<(u64, u64)> {
655 let pages = tree.retired_pages()?;
656 let mut leaves = Vec::new();
657 let mut expected = 0u64;
658 for id in pages {
659 source.claim(id)?;
660 let page = source.raw_page(id, true)?;
661 if page.page_type()? == PageType::BTreeLeaf {
662 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
663 expected += u64::from(page.cell_count());
664 }
665 }
666 leaves.reverse();
669 let mut repaired = 0;
670 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
671 ensure(
672 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
673 &format!(
674 "broken forward leaf chain at page {id}, root {}",
675 tree.root_page_id()
676 ),
677 )?;
678 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
679 ensure(
680 allow_backlinks,
681 &format!("broken backward leaf chain at page {id}"),
682 )?;
683 repaired += 1;
684 }
685 }
686 Ok((expected, repaired))
687}
688fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
689 let old = BTree::with_root(Arc::clone(source), old);
690 let new = BTree::with_root(Arc::clone(destination), new);
691 inventory_tree(destination, &new, false)?;
692 let mut left = old.cursor_first()?;
693 let mut right = new.cursor_first()?;
694 loop {
695 let expected = left.next_with_budget(32 * 1024 * 1024)?;
696 let actual = right.next_with_budget(32 * 1024 * 1024)?;
697 ensure(
698 expected == actual,
699 "compacted tree failed complete key/value parity",
700 )?;
701 if expected.is_none() {
702 return Ok(());
703 }
704 }
705}
706fn copy_sidecars(source: &Path, destination: &Path) -> Result<()> {
707 for (old, new) in [
710 (
711 f::unified_wal_path(source),
712 f::unified_wal_path(destination),
713 ),
714 (f::engine_wal_path(source), f::engine_wal_path(destination)),
715 (
716 f::logical_wal_path(source),
717 f::logical_wal_path(destination),
718 ),
719 (
720 f::legacy_logical_slots_path(source),
721 f::legacy_logical_slots_path(destination),
722 ),
723 (
724 f::legacy_audit_log_path(source),
725 f::legacy_audit_log_path(destination),
726 ),
727 ] {
728 match File::open(&old) {
729 Ok(mut input) => {
730 let mut output = create_new(&new)?;
731 std::io::copy(&mut input, &mut output)?;
732 output.sync_all()?;
733 verify_file_copy(&old, &new)?;
734 }
735 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
736 Err(error) => return Err(error.into()),
737 }
738 }
739 let support = f::support_dir_for(source);
740 if support.exists() {
741 validate_empty_directory(&support, 0)?;
742 copy_directory(&support, &f::support_dir_for(destination), 0)?;
743 }
744 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
745 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
746 if old.exists() {
747 validate_operational(&old)?;
748 copy_directory(&old, &new, 0)?;
749 }
750 Ok(())
751}
752fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
753 ensure(depth < 32, "operational directory nesting exceeds limit")?;
754 ensure(
755 std::fs::symlink_metadata(source)?.is_dir(),
756 "operational directory must not be a symlink",
757 )?;
758 std::fs::create_dir(destination)?;
759 for entry in std::fs::read_dir(source)? {
760 let entry = entry?;
761 let kind = entry.file_type()?;
762 let output = destination.join(entry.file_name());
763 if kind.is_dir() {
764 copy_directory(&entry.path(), &output, depth + 1)?;
765 } else {
766 ensure(
767 kind.is_file(),
768 "operational directory contains a symlink or special file",
769 )?;
770 let mut input = File::open(entry.path())?;
771 let mut target = create_new(&output)?;
772 std::io::copy(&mut input, &mut target)?;
773 target.sync_all()?;
774 verify_file_copy(&entry.path(), &output)?;
775 }
776 }
777 sync_directory(destination)
778}
779fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
780 ensure(depth < 32, "support directory nesting exceeds limit")?;
781 ensure(
782 std::fs::symlink_metadata(path)?.is_dir(),
783 "support directory must not be a symlink",
784 )?;
785 for entry in std::fs::read_dir(path)? {
786 let entry = entry?;
787 ensure(
788 entry.file_type()?.is_dir(),
789 "non-empty support layout requires separate compaction qualification",
790 )?;
791 validate_empty_directory(&entry.path(), depth + 1)?;
792 }
793 Ok(())
794}
795fn validate_operational(root: &Path) -> Result<()> {
796 ensure(
797 !root.join("forks").exists(),
798 "store forks require separate compaction qualification",
799 )?;
800 let manifest = root.join("manifest.json");
801 ensure(
802 std::fs::metadata(&manifest)?.len() <= 64 * 1024 * 1024,
803 "operational manifest exceeds budget",
804 )?;
805 let document: crate::serde_json::Value =
806 crate::serde_json::from_str(&std::fs::read_to_string(manifest)?)?;
807 fn check(value: &crate::serde_json::Value, depth: usize) -> Result<()> {
808 use crate::serde_json::Value;
809 ensure(depth < 32, "operational manifest exceeds nesting limit")?;
810 match value {
811 Value::Object(fields) => {
812 for (key, value) in fields {
813 if key == "source" || key == "fork_origin" {
814 ensure(
815 matches!(value, Value::Null),
816 "external operational owners require separate qualification",
817 )?;
818 }
819 if key == "path" {
820 let path = Path::new(value.as_str().ok_or("non-string operational path")?);
821 ensure(
822 !path.is_absolute()
823 && path
824 .components()
825 .all(|part| matches!(part, std::path::Component::Normal(_))),
826 "operational path escapes its directory",
827 )?;
828 }
829 check(value, depth + 1)?;
830 }
831 }
832 Value::Array(values) => {
833 for value in values {
834 check(value, depth + 1)?;
835 }
836 }
837 _ => {}
838 }
839 Ok(())
840 }
841 check(&document, 0)
842}
843fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
844 fn hash(path: &Path) -> Result<blake3::Hash> {
845 let mut file = File::open(path)?;
846 let mut hash = blake3::Hasher::new();
847 let mut buffer = [0; 64 * 1024];
848 loop {
849 let count = file.read(&mut buffer)?;
850 if count == 0 {
851 break;
852 }
853 hash.update(&buffer[..count]);
854 }
855 Ok(hash.finalize())
856 }
857 ensure(
858 hash(source)? == hash(destination)?,
859 "sidecar failed complete readback parity",
860 )
861}
862fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
863 #[cfg(unix)]
864 {
865 use std::os::unix::fs::MetadataExt;
866 Ok(Some(
867 std::fs::metadata(path)?
868 .blocks()
869 .checked_mul(512)
870 .ok_or("allocated byte count overflow")?,
871 ))
872 }
873 #[cfg(not(unix))]
874 {
875 let _ = path;
876 Ok(None)
877 }
878}
879fn create_new(path: &Path) -> std::io::Result<File> {
880 let mut options = OpenOptions::new();
881 options.write(true).create_new(true);
882 #[cfg(unix)]
883 {
884 use std::os::unix::fs::OpenOptionsExt;
885 options.mode(0o600);
886 }
887 options.open(path)
888}
889fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
890 let mut file = create_new(path)?;
891 file.write_all(bytes)?;
892 file.sync_all()?;
893 Ok(())
894}
895fn sync_directory(_path: &Path) -> Result<()> {
896 #[cfg(unix)]
897 File::open(_path)?.sync_all()?;
898 Ok(())
899}
900fn ensure(condition: bool, message: &str) -> Result<()> {
901 if condition {
902 Ok(())
903 } else {
904 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
905 }
906}
907fn number(bytes: &[u8], offset: usize) -> Result<u32> {
908 Ok(u32::from_le_bytes(
909 bytes
910 .get(offset..offset + 4)
911 .ok_or("truncated native integer")?
912 .try_into()?,
913 ))
914}