Skip to main content

reddb_server/storage/
compact.rs

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