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