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