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