Skip to main content

simple_zanzibar/
snapshot.rs

1//! Versioned compact snapshot artifact save/load support.
2//!
3//! Snapshot files are deployment artifacts for prebuilt local authorization data. They are treated
4//! as untrusted input during load: headers, section bounds, symbol ids, row ids, index order, and
5//! checksums are validated before a service publishes the loaded snapshot.
6
7use std::{
8    collections::{HashMap, HashSet},
9    ffi::OsString,
10    fs::{self, File},
11    io::{self, BufReader, BufWriter, Read, Write},
12    num::{NonZeroU64, NonZeroUsize},
13    path::{Path, PathBuf},
14    sync::Arc,
15    time::{Duration, Instant},
16};
17
18use thiserror::Error;
19
20use crate::{
21    domain::DomainError,
22    error::ZanzibarError,
23    model::NamespaceConfig,
24    policy,
25    relationship::{IndexedRelationshipStore, RelationshipStoreView, StoreError},
26    revision::{Revision, SchemaHash},
27    schema::{self, CompiledSchema},
28};
29
30const MAGIC_PREFIX: [u8; 7] = *b"SZSNAP\0";
31const FORMAT_V2_MAGIC: [u8; 8] = *b"SZSNAP\0\x02";
32const FORMAT_V3_MAGIC: [u8; 8] = *b"SZSNAP\0\x03";
33const CURRENT_FORMAT_VERSION: SnapshotFormatVersion = SnapshotFormatVersion::V3;
34const HEADER_LEN: usize = 76;
35const HEADER_LEN_U32: u32 = 76;
36const DIRECTORY_ENTRY_LEN: usize = 28;
37const FOOTER_LEN: usize = 32;
38const REQUIRED_SECTION_COUNT: usize = 11;
39const REQUIRED_SECTION_COUNT_U32: u32 = 11;
40const MAX_SCHEMA_BYTES: usize = 4 * 1024 * 1024;
41const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
42const DEFAULT_ZSTD_LEVEL: i32 = 3;
43
44/// Options used when saving a compact snapshot artifact.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct SnapshotSaveOptions {
47    /// Snapshot compression mode.
48    pub compression: SnapshotCompression,
49    /// Zstd compression level used when `compression` is [`SnapshotCompression::Zstd`].
50    pub zstd_level: i32,
51    /// Whether serialized lookup indexes are included.
52    ///
53    /// Snapshot artifacts require indexes because fast loading depends on stable sorted arrays.
54    pub include_indexes: bool,
55    /// Index profile encoded in the snapshot artifact.
56    pub index_profile: IndexProfile,
57}
58
59impl Default for SnapshotSaveOptions {
60    fn default() -> Self {
61        Self {
62            compression: SnapshotCompression::None,
63            zstd_level: DEFAULT_ZSTD_LEVEL,
64            include_indexes: true,
65            index_profile: IndexProfile::Full,
66        }
67    }
68}
69
70impl SnapshotSaveOptions {
71    /// Creates save options for an uncompressed `.szsnap` artifact.
72    #[must_use]
73    pub fn uncompressed() -> Self {
74        Self::default()
75    }
76
77    /// Creates save options for a zstd-wrapped `.szsnap.zst` artifact.
78    #[must_use]
79    pub fn zstd() -> Self {
80        Self {
81            compression: SnapshotCompression::Zstd,
82            ..Self::default()
83        }
84    }
85
86    /// Returns options with a specific zstd compression level.
87    #[must_use]
88    pub fn with_zstd_level(mut self, zstd_level: i32) -> Self {
89        self.zstd_level = zstd_level;
90        self
91    }
92
93    pub(crate) const fn section_layout(self) -> SnapshotEncodingLayout {
94        match self.compression {
95            SnapshotCompression::None => SnapshotEncodingLayout::Compact,
96            SnapshotCompression::Zstd => SnapshotEncodingLayout::CompressionFriendly,
97        }
98    }
99}
100
101/// Snapshot compression mode.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SnapshotCompression {
104    /// Store all sections uncompressed.
105    None,
106    /// Wrap a valid `.szsnap` payload in a single zstd frame.
107    ///
108    /// The inner payload may use section widths chosen for better compression while remaining a
109    /// normal versioned snapshot parsed by the same reader.
110    Zstd,
111}
112
113/// Internal section layout used before any outer compression is applied.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub(crate) enum SnapshotEncodingLayout {
116    /// Minimize the raw `.szsnap` artifact size with variable-width section fields.
117    Compact,
118    /// Preserve fixed-width row and symbol metadata when an outer compressor can absorb zeros.
119    CompressionFriendly,
120}
121
122/// Supported compact snapshot artifact format versions.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum SnapshotFormatVersion {
125    /// Version 2 stores fixed-width `u32` posting overflow row ids.
126    V2,
127    /// Version 3 stores posting overflow row ids as per-range delta varints.
128    V3,
129}
130
131impl SnapshotFormatVersion {
132    const fn raw(self) -> u16 {
133        match self {
134            Self::V2 => 2,
135            Self::V3 => 3,
136        }
137    }
138
139    const fn magic(self) -> [u8; 8] {
140        match self {
141            Self::V2 => FORMAT_V2_MAGIC,
142            Self::V3 => FORMAT_V3_MAGIC,
143        }
144    }
145
146    fn from_header(magic: [u8; 8], version: u16) -> Result<Self, SnapshotIoError> {
147        let Some(prefix) = magic.get(..MAGIC_PREFIX.len()) else {
148            return Err(SnapshotIoError::Format {
149                reason: "snapshot magic is invalid",
150            });
151        };
152        if prefix != MAGIC_PREFIX {
153            return Err(SnapshotIoError::Format {
154                reason: "snapshot magic is invalid",
155            });
156        }
157        match (magic, version) {
158            (FORMAT_V2_MAGIC, 2) => Ok(Self::V2),
159            (FORMAT_V3_MAGIC, 3) => Ok(Self::V3),
160            _ => Err(SnapshotIoError::Format {
161                reason: "snapshot format version is unsupported",
162            }),
163        }
164    }
165}
166
167/// Relationship index profile encoded in runtime state and snapshot artifacts.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum IndexProfile {
170    /// All indexes required by the complete public API.
171    Full,
172    /// Resource-side indexes for check, expand, and object-bounded permission audit.
173    CheckOnly,
174    /// Resource-side indexes plus object-bounded audit support.
175    CheckAndObjectAudit,
176}
177
178impl IndexProfile {
179    pub(crate) const fn flag_bits(self) -> u16 {
180        match self {
181            Self::Full => 0,
182            Self::CheckOnly => 1,
183            Self::CheckAndObjectAudit => 2,
184        }
185    }
186
187    fn from_flag_bits(value: u16) -> Result<Self, SnapshotIoError> {
188        match value {
189            0 => Ok(Self::Full),
190            1 => Ok(Self::CheckOnly),
191            2 => Ok(Self::CheckAndObjectAudit),
192            _ => Err(SnapshotIoError::Format {
193                reason: "snapshot index profile is unsupported",
194            }),
195        }
196    }
197
198    /// Returns true when subject reverse lookup indexes are present.
199    #[must_use]
200    pub const fn supports_subject_reverse_lookup(self) -> bool {
201        matches!(self, Self::Full)
202    }
203
204    pub(crate) const fn satisfies(self, required: Self) -> bool {
205        matches!(
206            (self, required),
207            (Self::Full, _)
208                | (
209                    Self::CheckAndObjectAudit,
210                    Self::CheckAndObjectAudit | Self::CheckOnly
211                )
212                | (Self::CheckOnly, Self::CheckOnly)
213        )
214    }
215
216    pub(crate) const fn supports_broad_resource_indexes(self) -> bool {
217        matches!(self, Self::Full)
218    }
219}
220
221/// Options used when loading a compact snapshot artifact.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct SnapshotLoadOptions {
224    /// Snapshot compression mode expected for the file.
225    pub compression: SnapshotCompression,
226    /// Runtime index profile to construct from serialized sorted arrays.
227    pub profile: SnapshotLoadProfile,
228    /// Validation mode to apply while loading the artifact.
229    pub validation: SnapshotValidationMode,
230    /// Integrity proof expected before publishing the loaded snapshot.
231    pub integrity: SnapshotIntegrityMode,
232    /// Maximum accepted file size in bytes.
233    ///
234    /// For zstd snapshots this cap is enforced against both the compressed file and the
235    /// decompressed inner snapshot payload. The inner payload is a normal versioned snapshot and
236    /// may be larger than an independently saved uncompressed artifact because zstd saves can
237    /// choose compression-friendly section widths.
238    pub max_file_bytes: NonZeroU64,
239    /// Minimum index capability required by the caller.
240    pub required_index_profile: IndexProfile,
241}
242
243impl Default for SnapshotLoadOptions {
244    fn default() -> Self {
245        Self {
246            compression: SnapshotCompression::None,
247            profile: SnapshotLoadProfile::FastLoad,
248            validation: SnapshotValidationMode::Full,
249            integrity: SnapshotIntegrityMode::Checksum,
250            max_file_bytes: non_zero_u64(DEFAULT_MAX_FILE_BYTES),
251            required_index_profile: IndexProfile::Full,
252        }
253    }
254}
255
256impl SnapshotLoadOptions {
257    /// Creates load options for an uncompressed `.szsnap` artifact.
258    #[must_use]
259    pub fn uncompressed() -> Self {
260        Self::default()
261    }
262
263    /// Creates load options for a zstd-wrapped `.szsnap.zst` artifact.
264    #[must_use]
265    pub fn zstd() -> Self {
266        Self {
267            compression: SnapshotCompression::Zstd,
268            ..Self::default()
269        }
270    }
271}
272
273/// Runtime index profile for a loaded snapshot.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum SnapshotLoadProfile {
276    /// Keep sorted-array indexes and binary-search on query.
277    FastLoad,
278    /// Rebuild hash-backed in-memory indexes after validation.
279    Latency,
280}
281
282/// Validation boundary to apply when loading a compact snapshot artifact.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SnapshotValidationMode {
285    /// Treat the file as hostile input and revalidate all semantic row/index invariants.
286    Full,
287    /// Trust a build-pipeline validated artifact and perform only structural checks at startup.
288    TrustedFastLoad,
289}
290
291/// Integrity proof mode to apply to the snapshot envelope.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum SnapshotIntegrityMode {
294    /// Verify the file footer checksum during load.
295    Checksum,
296    /// Assume the artifact bytes were verified by an external content-address or signature layer.
297    External,
298}
299
300/// Benchmark-only snapshot load phase timing evidence.
301#[cfg_attr(not(feature = "bench-internals"), doc(hidden))]
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
303pub struct SnapshotLoadPhaseTimings {
304    /// Reading bytes from the filesystem.
305    pub file_read: Duration,
306    /// Decompressing a zstd payload, if configured.
307    pub decompression: Duration,
308    /// Parsing header and section directory metadata.
309    pub header_and_sections: Duration,
310    /// Verifying the snapshot footer checksum.
311    pub checksum: Duration,
312    /// Parsing and compiling the schema section.
313    pub schema_parse_compile: Duration,
314    /// Decoding symbol sections.
315    pub symbols: Duration,
316    /// Decoding relationship rows.
317    pub rows: Duration,
318    /// Decoding relationship indexes.
319    pub indexes: Duration,
320    /// Publishing the loaded structures into the returned value.
321    pub publish: Duration,
322}
323
324/// Errors returned by compact snapshot save/load operations.
325#[derive(Debug, Error)]
326pub enum SnapshotIoError {
327    /// Filesystem I/O failed.
328    #[error("snapshot io failed")]
329    Io {
330        /// Source I/O error.
331        #[source]
332        source: io::Error,
333    },
334
335    /// The snapshot artifact failed format validation.
336    #[error("snapshot format error: {reason}")]
337    Format {
338        /// Static format failure reason.
339        reason: &'static str,
340    },
341
342    /// A configured or format-defined limit was exceeded.
343    #[error("snapshot limit exceeded: {component}")]
344    LimitExceeded {
345        /// Component that exceeded its limit.
346        component: &'static str,
347    },
348
349    /// A public option is not supported by the current artifact version.
350    #[error("snapshot option is unsupported: {option}")]
351    UnsupportedOption {
352        /// Unsupported option name.
353        option: &'static str,
354    },
355
356    /// Schema text in the artifact could not be parsed or compiled.
357    #[error("snapshot schema failed")]
358    Schema {
359        /// Source schema error.
360        #[source]
361        source: ZanzibarError,
362    },
363
364    /// Compact relationship data failed validation.
365    #[error("snapshot relationship store failed")]
366    Store {
367        /// Source store error.
368        #[source]
369        source: StoreError,
370    },
371
372    /// Domain identifier validation failed while checking compact rows.
373    #[error("snapshot domain validation failed")]
374    Domain {
375        /// Source domain error.
376        #[source]
377        source: DomainError,
378    },
379}
380
381impl From<io::Error> for SnapshotIoError {
382    fn from(source: io::Error) -> Self {
383        Self::Io { source }
384    }
385}
386
387impl From<StoreError> for SnapshotIoError {
388    fn from(source: StoreError) -> Self {
389        Self::Store { source }
390    }
391}
392
393impl From<DomainError> for SnapshotIoError {
394    fn from(source: DomainError) -> Self {
395        Self::Domain { source }
396    }
397}
398
399pub(crate) struct LoadedSnapshot {
400    pub(crate) configs: HashMap<String, NamespaceConfig>,
401    pub(crate) schema: CompiledSchema,
402    pub(crate) relationships: Arc<RelationshipStoreView>,
403    pub(crate) revision: Revision,
404    pub(crate) schema_hash: SchemaHash,
405}
406
407/// Stable snapshot section identifiers.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
409pub(crate) enum SectionKind {
410    Schema = 1,
411    SymbolBytes = 2,
412    SymbolTable = 3,
413    RelationshipRows = 4,
414    IndexDirectory = 5,
415    IndexKeys = 6,
416    PostingRanges = 7,
417    PostingRowIds = 8,
418    SymbolHashes = 9,
419    SymbolLookup = 10,
420    Footer = 11,
421}
422
423impl SectionKind {
424    fn from_raw(value: u16) -> Result<Self, SnapshotIoError> {
425        match value {
426            1 => Ok(Self::Schema),
427            2 => Ok(Self::SymbolBytes),
428            3 => Ok(Self::SymbolTable),
429            4 => Ok(Self::RelationshipRows),
430            5 => Ok(Self::IndexDirectory),
431            6 => Ok(Self::IndexKeys),
432            7 => Ok(Self::PostingRanges),
433            8 => Ok(Self::PostingRowIds),
434            9 => Ok(Self::SymbolHashes),
435            10 => Ok(Self::SymbolLookup),
436            11 => Ok(Self::Footer),
437            _ => Err(SnapshotIoError::Format {
438                reason: "unknown snapshot section kind",
439            }),
440        }
441    }
442
443    const fn raw(self) -> u16 {
444        self as u16
445    }
446}
447
448#[derive(Debug, Clone, Copy)]
449pub(crate) struct SnapshotHeader {
450    pub(crate) format_version: SnapshotFormatVersion,
451    pub(crate) schema_hash: SchemaHash,
452    pub(crate) relationship_count: u32,
453    pub(crate) symbol_count: u32,
454    pub(crate) created_revision: Revision,
455    pub(crate) index_profile: IndexProfile,
456}
457
458#[derive(Debug, Clone, Copy)]
459pub(crate) struct SnapshotSection<'a> {
460    bytes: &'a [u8],
461    flags: u16,
462    row_count: u64,
463}
464
465impl<'a> SnapshotSection<'a> {
466    /// Returns the raw section bytes.
467    #[must_use]
468    pub(crate) const fn bytes(self) -> &'a [u8] {
469        self.bytes
470    }
471
472    /// Returns the section-local encoding flags from the directory entry.
473    #[must_use]
474    pub(crate) const fn flags(self) -> u16 {
475        self.flags
476    }
477
478    /// Returns the section row count from the directory entry.
479    #[must_use]
480    pub(crate) const fn row_count(self) -> u64 {
481        self.row_count
482    }
483}
484
485#[derive(Debug)]
486pub(crate) struct SnapshotReader<'a> {
487    header: SnapshotHeader,
488    sections: SnapshotSections<'a>,
489}
490
491type SnapshotSections<'a> = [Option<SnapshotSection<'a>>; REQUIRED_SECTION_COUNT];
492
493impl<'a> SnapshotReader<'a> {
494    /// Parses and validates the outer snapshot envelope.
495    pub(crate) fn parse(
496        bytes: &'a [u8],
497        integrity: SnapshotIntegrityMode,
498        timings: Option<&mut SnapshotLoadPhaseTimings>,
499    ) -> Result<Self, SnapshotIoError> {
500        let header = parse_header(bytes)?;
501        let directory_start = checked_usize_from_u32(HEADER_LEN_U32)?;
502        let section_count = parse_section_count(bytes)?;
503        if section_count != REQUIRED_SECTION_COUNT {
504            return Err(SnapshotIoError::Format {
505                reason: "unexpected snapshot section count",
506            });
507        }
508        let directory_len = checked_mul_usize(section_count, DIRECTORY_ENTRY_LEN)?;
509        let directory_end = checked_add_usize(directory_start, directory_len)?;
510        let directory_bytes =
511            bytes
512                .get(directory_start..directory_end)
513                .ok_or(SnapshotIoError::Format {
514                    reason: "section directory is out of bounds",
515                })?;
516
517        let mut cursor = BinaryCursor::new(directory_bytes);
518        let mut entries = Vec::with_capacity(section_count);
519        let mut sections = empty_sections();
520        for _ in 0..section_count {
521            let raw_kind = cursor.read_u16()?;
522            let kind = SectionKind::from_raw(raw_kind)?;
523            let flags = cursor.read_u16()?;
524            validate_section_flags(header.format_version, kind, flags)?;
525            let offset = cursor.read_u64()?;
526            let len = cursor.read_u64()?;
527            let row_count = cursor.read_u64()?;
528            let slot = section_slot(kind);
529            if sections.get(slot).copied().flatten().is_some() {
530                return Err(SnapshotIoError::Format {
531                    reason: "duplicate snapshot section",
532                });
533            }
534            let start = checked_usize_from_u64(offset)?;
535            let length = checked_usize_from_u64(len)?;
536            let end = checked_add_usize(start, length)?;
537            if start < directory_end || end > bytes.len() {
538                return Err(SnapshotIoError::Format {
539                    reason: "snapshot section is out of bounds",
540                });
541            }
542            let section_bytes = bytes.get(start..end).ok_or(SnapshotIoError::Format {
543                reason: "snapshot section range is invalid",
544            })?;
545            if let Some(section) = sections.get_mut(slot) {
546                *section = Some(SnapshotSection {
547                    bytes: section_bytes,
548                    flags,
549                    row_count,
550                });
551            }
552            entries.push((offset, len, kind));
553        }
554
555        validate_required_sections(&sections)?;
556        validate_non_overlapping_sections(&mut entries)?;
557        let checksum_start = Instant::now();
558        validate_footer(bytes, &sections, integrity)?;
559        if let Some(timings) = timings {
560            timings.checksum = checksum_start.elapsed();
561        }
562        Ok(Self { header, sections })
563    }
564
565    /// Returns the parsed snapshot header.
566    #[must_use]
567    pub(crate) const fn header(&self) -> SnapshotHeader {
568        self.header
569    }
570
571    /// Returns a required section.
572    pub(crate) fn section(
573        &self,
574        kind: SectionKind,
575    ) -> Result<SnapshotSection<'a>, SnapshotIoError> {
576        self.sections
577            .get(section_slot(kind))
578            .copied()
579            .flatten()
580            .ok_or(SnapshotIoError::Format {
581                reason: "missing required snapshot section",
582            })
583    }
584}
585
586const fn empty_sections<'a>() -> SnapshotSections<'a> {
587    [None; REQUIRED_SECTION_COUNT]
588}
589
590const fn section_slot(kind: SectionKind) -> usize {
591    kind as usize - 1
592}
593
594#[derive(Debug, Default)]
595pub(crate) struct SnapshotSectionWriter {
596    sections: Vec<SectionPayload>,
597}
598
599impl SnapshotSectionWriter {
600    /// Adds one snapshot section payload.
601    pub(crate) fn add_section(
602        &mut self,
603        kind: SectionKind,
604        bytes: Vec<u8>,
605        row_count: u64,
606    ) -> Result<(), SnapshotIoError> {
607        self.add_section_with_flags(kind, 0, bytes, row_count)
608    }
609
610    /// Adds one snapshot section payload with section-local encoding flags.
611    pub(crate) fn add_section_with_flags(
612        &mut self,
613        kind: SectionKind,
614        flags: u16,
615        bytes: Vec<u8>,
616        row_count: u64,
617    ) -> Result<(), SnapshotIoError> {
618        if self.sections.iter().any(|section| section.kind == kind) {
619            return Err(SnapshotIoError::Format {
620                reason: "duplicate snapshot section",
621            });
622        }
623        self.sections.push(SectionPayload {
624            kind,
625            flags,
626            bytes,
627            row_count,
628        });
629        Ok(())
630    }
631
632    fn row_count(&self, kind: SectionKind) -> Result<u64, SnapshotIoError> {
633        self.sections
634            .iter()
635            .find(|section| section.kind == kind)
636            .map(|section| section.row_count)
637            .ok_or(SnapshotIoError::Format {
638                reason: "missing required snapshot section",
639            })
640    }
641
642    fn into_sorted_sections(mut self) -> Vec<SectionPayload> {
643        self.sections.sort_by_key(|section| section.kind);
644        self.sections
645    }
646}
647
648#[derive(Debug)]
649struct SectionPayload {
650    kind: SectionKind,
651    flags: u16,
652    bytes: Vec<u8>,
653    row_count: u64,
654}
655
656#[derive(Debug)]
657struct SectionDirectoryEntry {
658    kind: SectionKind,
659    flags: u16,
660    offset: u64,
661    len: u64,
662    row_count: u64,
663}
664
665pub(crate) fn save_snapshot_file(
666    path: &Path,
667    snapshot: &crate::revision::PublishedSnapshot,
668    options: SnapshotSaveOptions,
669) -> Result<(), SnapshotIoError> {
670    if !options.include_indexes {
671        return Err(SnapshotIoError::UnsupportedOption {
672            option: "include_indexes=false",
673        });
674    }
675    validate_compression_options(options)?;
676
677    let mut writer = SnapshotSectionWriter::default();
678    let schema_source = policy::canonical_schema_source(snapshot.configs());
679    if schema_source.len() > MAX_SCHEMA_BYTES {
680        return Err(SnapshotIoError::LimitExceeded {
681            component: "schema section",
682        });
683    }
684    writer.add_section(SectionKind::Schema, schema_source.into_bytes(), 1)?;
685    snapshot.relationships().encode_snapshot_sections(
686        &mut writer,
687        options.index_profile,
688        options.section_layout(),
689    )?;
690    match options.compression {
691        SnapshotCompression::None => {
692            write_uncompressed_snapshot_file(path, snapshot, writer, options.index_profile)
693        }
694        SnapshotCompression::Zstd => {
695            let bytes = encode_file(snapshot, writer, options.index_profile)?;
696            fs::write(path, encode_payload(bytes, options)?)?;
697            Ok(())
698        }
699    }
700}
701
702pub(crate) fn load_snapshot_file(
703    path: &Path,
704    options: SnapshotLoadOptions,
705) -> Result<LoadedSnapshot, SnapshotIoError> {
706    load_snapshot_file_inner(path, options, None)
707}
708
709#[cfg(feature = "bench-internals")]
710/// Loads a snapshot and returns benchmark-only phase timing evidence.
711///
712/// # Errors
713///
714/// Returns [`SnapshotIoError`] when the artifact cannot be read or fails validation.
715pub fn load_snapshot_phase_timings(
716    path: &Path,
717    options: SnapshotLoadOptions,
718) -> Result<SnapshotLoadPhaseTimings, SnapshotIoError> {
719    let mut timings = SnapshotLoadPhaseTimings::default();
720    let _loaded = load_snapshot_file_inner(path, options, Some(&mut timings))?;
721    Ok(timings)
722}
723
724fn load_snapshot_file_inner(
725    path: &Path,
726    options: SnapshotLoadOptions,
727    mut timings: Option<&mut SnapshotLoadPhaseTimings>,
728) -> Result<LoadedSnapshot, SnapshotIoError> {
729    validate_load_options(options)?;
730    let bytes = read_decode_payload(path, options, &mut timings)?;
731    let reader = parse_reader(&bytes, options, &mut timings)?;
732    let phase_start = Instant::now();
733    let (configs_vec, schema, schema_hash) = compile_snapshot_schema(&reader)?;
734    record_phase(
735        &mut timings,
736        |timings, elapsed| {
737            timings.schema_parse_compile = elapsed;
738        },
739        phase_start,
740    );
741    let relationships = decode_relationships_with_optional_timings(
742        &reader,
743        options.profile,
744        options.validation,
745        &mut timings,
746    )?;
747    let phase_start = Instant::now();
748    let configs = configs_vec
749        .into_iter()
750        .map(|config| (config.name.clone(), config))
751        .collect::<HashMap<_, _>>();
752    let loaded = LoadedSnapshot {
753        configs,
754        schema,
755        relationships,
756        revision: reader.header().created_revision,
757        schema_hash,
758    };
759    record_phase(
760        &mut timings,
761        |timings, elapsed| {
762            timings.publish = elapsed;
763        },
764        phase_start,
765    );
766    Ok(loaded)
767}
768
769fn validate_load_options(options: SnapshotLoadOptions) -> Result<(), SnapshotIoError> {
770    if options.validation == SnapshotValidationMode::TrustedFastLoad
771        && options.profile != SnapshotLoadProfile::FastLoad
772    {
773        return Err(SnapshotIoError::UnsupportedOption {
774            option: "trusted validation with latency profile",
775        });
776    }
777    if options.integrity == SnapshotIntegrityMode::External
778        && options.validation != SnapshotValidationMode::TrustedFastLoad
779    {
780        return Err(SnapshotIoError::UnsupportedOption {
781            option: "external integrity without trusted validation",
782        });
783    }
784    Ok(())
785}
786
787fn read_decode_payload(
788    path: &Path,
789    options: SnapshotLoadOptions,
790    timings: &mut Option<&mut SnapshotLoadPhaseTimings>,
791) -> Result<Vec<u8>, SnapshotIoError> {
792    if options.compression == SnapshotCompression::Zstd {
793        return read_decode_zstd_payload(path, options.max_file_bytes, timings);
794    }
795
796    let phase_start = Instant::now();
797    let bytes = read_capped_file(path, options.max_file_bytes)?;
798    record_phase(
799        timings,
800        |timings, elapsed| timings.file_read = elapsed,
801        phase_start,
802    );
803    let phase_start = Instant::now();
804    let bytes = decode_payload(bytes, options)?;
805    record_phase(
806        timings,
807        |timings, elapsed| {
808            timings.decompression = elapsed;
809        },
810        phase_start,
811    );
812    Ok(bytes)
813}
814
815fn read_decode_zstd_payload(
816    path: &Path,
817    max_file_bytes: NonZeroU64,
818    timings: &mut Option<&mut SnapshotLoadPhaseTimings>,
819) -> Result<Vec<u8>, SnapshotIoError> {
820    let phase_start = Instant::now();
821    let (file, metadata_len) = open_capped_file(path, max_file_bytes)?;
822    record_phase(
823        timings,
824        |timings, elapsed| timings.file_read = elapsed,
825        phase_start,
826    );
827    let phase_start = Instant::now();
828    let bytes = decode_zstd_bounded(BufReader::new(file.take(metadata_len)), max_file_bytes)?;
829    record_phase(
830        timings,
831        |timings, elapsed| {
832            timings.decompression = elapsed;
833        },
834        phase_start,
835    );
836    Ok(bytes)
837}
838
839fn parse_reader<'a>(
840    bytes: &'a [u8],
841    options: SnapshotLoadOptions,
842    timings: &mut Option<&mut SnapshotLoadPhaseTimings>,
843) -> Result<SnapshotReader<'a>, SnapshotIoError> {
844    let phase_start = Instant::now();
845    let reader = SnapshotReader::parse(bytes, options.integrity, timings.as_deref_mut())?;
846    if !reader
847        .header()
848        .index_profile
849        .satisfies(options.required_index_profile)
850    {
851        return Err(SnapshotIoError::UnsupportedOption {
852            option: "snapshot index profile does not satisfy load requirements",
853        });
854    }
855    record_phase(
856        timings,
857        |timings, elapsed| {
858            timings.header_and_sections = elapsed.saturating_sub(timings.checksum);
859        },
860        phase_start,
861    );
862    Ok(reader)
863}
864
865fn compile_snapshot_schema(
866    reader: &SnapshotReader<'_>,
867) -> Result<(Vec<NamespaceConfig>, CompiledSchema, SchemaHash), SnapshotIoError> {
868    let schema_section = reader.section(SectionKind::Schema)?;
869    if schema_section.bytes().len() > MAX_SCHEMA_BYTES {
870        return Err(SnapshotIoError::LimitExceeded {
871            component: "schema section",
872        });
873    }
874    let schema_source =
875        std::str::from_utf8(schema_section.bytes()).map_err(|_| SnapshotIoError::Format {
876            reason: "schema section is not valid utf-8",
877        })?;
878    let configs_vec = crate::parser::parse_dsl(schema_source)
879        .map_err(|source| SnapshotIoError::Schema { source })?;
880    let schema = schema::compile_legacy_configs(configs_vec.clone())
881        .map_err(|source| SnapshotIoError::Schema { source })?;
882    let schema_hash = SchemaHash::for_schema(&schema);
883    if schema_hash != reader.header().schema_hash {
884        return Err(SnapshotIoError::Format {
885            reason: "schema hash does not match schema section",
886        });
887    }
888    Ok((configs_vec, schema, schema_hash))
889}
890
891fn decode_relationships_with_optional_timings(
892    reader: &SnapshotReader<'_>,
893    profile: SnapshotLoadProfile,
894    validation: SnapshotValidationMode,
895    timings: &mut Option<&mut SnapshotLoadPhaseTimings>,
896) -> Result<Arc<RelationshipStoreView>, SnapshotIoError> {
897    #[cfg(not(feature = "bench-internals"))]
898    let _ = timings;
899    #[cfg(feature = "bench-internals")]
900    if let Some(timings) = timings.as_deref_mut() {
901        let store = Arc::new(
902            IndexedRelationshipStore::decode_snapshot_sections_with_timings(
903                reader, profile, validation, timings,
904            )?,
905        );
906        return Ok(Arc::new(RelationshipStoreView::from_checkpoint(store)));
907    }
908    let store = Arc::new(IndexedRelationshipStore::decode_snapshot_sections(
909        reader, profile, validation,
910    )?);
911    Ok(Arc::new(RelationshipStoreView::from_checkpoint(store)))
912}
913
914fn record_phase(
915    timings: &mut Option<&mut SnapshotLoadPhaseTimings>,
916    record: impl FnOnce(&mut SnapshotLoadPhaseTimings, Duration),
917    phase_start: Instant,
918) {
919    if let Some(timings) = timings.as_deref_mut() {
920        record(timings, phase_start.elapsed());
921    }
922}
923
924fn validate_compression_options(options: SnapshotSaveOptions) -> Result<(), SnapshotIoError> {
925    if options.compression == SnapshotCompression::Zstd
926        && !zstd::compression_level_range().contains(&options.zstd_level)
927    {
928        return Err(SnapshotIoError::UnsupportedOption {
929            option: "zstd_level",
930        });
931    }
932    Ok(())
933}
934
935fn encode_payload(
936    bytes: Vec<u8>,
937    options: SnapshotSaveOptions,
938) -> Result<Vec<u8>, SnapshotIoError> {
939    match options.compression {
940        SnapshotCompression::None => Ok(bytes),
941        SnapshotCompression::Zstd => {
942            zstd::stream::encode_all(bytes.as_slice(), options.zstd_level).map_err(Into::into)
943        }
944    }
945}
946
947fn decode_payload(
948    bytes: Vec<u8>,
949    options: SnapshotLoadOptions,
950) -> Result<Vec<u8>, SnapshotIoError> {
951    match options.compression {
952        SnapshotCompression::None => Ok(bytes),
953        SnapshotCompression::Zstd => decode_zstd_bounded(bytes.as_slice(), options.max_file_bytes),
954    }
955}
956
957fn decode_zstd_bounded<R>(reader: R, max_file_bytes: NonZeroU64) -> Result<Vec<u8>, SnapshotIoError>
958where
959    R: Read,
960{
961    let read_limit = max_file_bytes
962        .get()
963        .checked_add(1)
964        .ok_or(SnapshotIoError::LimitExceeded {
965            component: "decompressed snapshot file",
966        })?;
967    let decoder = zstd::stream::read::Decoder::new(reader)?;
968    let mut limited_decoder = decoder.take(read_limit);
969    let mut output = Vec::new();
970    limited_decoder.read_to_end(&mut output)?;
971    if checked_u64_from_usize(output.len())? > max_file_bytes.get() {
972        return Err(SnapshotIoError::LimitExceeded {
973            component: "decompressed snapshot file",
974        });
975    }
976    Ok(output)
977}
978
979fn read_capped_file(path: &Path, max_file_bytes: NonZeroU64) -> Result<Vec<u8>, SnapshotIoError> {
980    let (file, metadata_len) = open_capped_file(path, max_file_bytes)?;
981    let mut bytes = Vec::with_capacity(checked_usize_from_u64(metadata_len)?);
982    file.take(metadata_len).read_to_end(&mut bytes)?;
983    if checked_u64_from_usize(bytes.len())? != metadata_len {
984        return Err(SnapshotIoError::Format {
985            reason: "snapshot file changed during read",
986        });
987    }
988    Ok(bytes)
989}
990
991fn open_capped_file(
992    path: &Path,
993    max_file_bytes: NonZeroU64,
994) -> Result<(File, u64), SnapshotIoError> {
995    let file = File::open(path)?;
996    let metadata = file.metadata()?;
997    if !metadata.is_file() {
998        return Err(SnapshotIoError::Format {
999            reason: "snapshot path is not a regular file",
1000        });
1001    }
1002    let metadata_len = metadata.len();
1003    if metadata_len > max_file_bytes.get() {
1004        return Err(SnapshotIoError::LimitExceeded {
1005            component: "snapshot file",
1006        });
1007    }
1008    Ok((file, metadata_len))
1009}
1010
1011fn write_uncompressed_snapshot_file(
1012    path: &Path,
1013    snapshot: &crate::revision::PublishedSnapshot,
1014    writer: SnapshotSectionWriter,
1015    index_profile: IndexProfile,
1016) -> Result<(), SnapshotIoError> {
1017    let tmp_path = snapshot_tmp_path(path, snapshot.revision());
1018    let result = write_uncompressed_snapshot_file_inner(&tmp_path, snapshot, writer, index_profile)
1019        .and_then(|()| fs::rename(&tmp_path, path).map_err(Into::into));
1020    if result.is_err() {
1021        let _ = fs::remove_file(&tmp_path);
1022    }
1023    result
1024}
1025
1026fn write_uncompressed_snapshot_file_inner(
1027    path: &Path,
1028    snapshot: &crate::revision::PublishedSnapshot,
1029    writer: SnapshotSectionWriter,
1030    index_profile: IndexProfile,
1031) -> Result<(), SnapshotIoError> {
1032    let relationship_count =
1033        checked_u32_from_u64(writer.row_count(SectionKind::RelationshipRows)?)?;
1034    let symbol_count = checked_u32_from_u64(writer.row_count(SectionKind::SymbolTable)?)?;
1035    let sections = snapshot_sections_with_footer(writer)?;
1036    let directory = section_directory(&sections)?;
1037    let file_len = directory_file_len(&directory)?;
1038
1039    let mut header = Vec::with_capacity(HEADER_LEN);
1040    write_header(
1041        &mut header,
1042        snapshot.schema_hash(),
1043        relationship_count,
1044        symbol_count,
1045        snapshot.revision(),
1046        file_len,
1047        index_profile,
1048    );
1049    let mut directory_bytes =
1050        Vec::with_capacity(checked_mul_usize(directory.len(), DIRECTORY_ENTRY_LEN)?);
1051    for entry in &directory {
1052        write_directory_entry(&mut directory_bytes, entry);
1053    }
1054
1055    let file = File::create(path)?;
1056    let mut file = BufWriter::new(file);
1057    let mut hasher = blake3::Hasher::new();
1058    let mut written = 0_u64;
1059    write_hashed(&mut file, &mut hasher, &mut written, &header)?;
1060    write_hashed(&mut file, &mut hasher, &mut written, &directory_bytes)?;
1061    for section in &sections {
1062        if section.kind == SectionKind::Footer {
1063            let digest = hasher.finalize();
1064            file.write_all(digest.as_bytes())?;
1065            written = written
1066                .checked_add(FOOTER_LEN as u64)
1067                .ok_or(SnapshotIoError::Format {
1068                    reason: "snapshot writer length overflowed",
1069                })?;
1070        } else {
1071            write_hashed(&mut file, &mut hasher, &mut written, &section.bytes)?;
1072        }
1073    }
1074    file.flush()?;
1075    if written != file_len {
1076        return Err(SnapshotIoError::Format {
1077            reason: "snapshot writer length mismatch",
1078        });
1079    }
1080    Ok(())
1081}
1082
1083fn snapshot_sections_with_footer(
1084    writer: SnapshotSectionWriter,
1085) -> Result<Vec<SectionPayload>, SnapshotIoError> {
1086    let mut sections = writer.into_sorted_sections();
1087    sections.push(SectionPayload {
1088        kind: SectionKind::Footer,
1089        flags: 0,
1090        bytes: vec![0; FOOTER_LEN],
1091        row_count: 1,
1092    });
1093    if sections.len() != REQUIRED_SECTION_COUNT {
1094        return Err(SnapshotIoError::Format {
1095            reason: "snapshot writer did not produce all required sections",
1096        });
1097    }
1098    Ok(sections)
1099}
1100
1101fn section_directory(
1102    sections: &[SectionPayload],
1103) -> Result<Vec<SectionDirectoryEntry>, SnapshotIoError> {
1104    let directory_len = checked_mul_usize(sections.len(), DIRECTORY_ENTRY_LEN)?;
1105    let mut next_offset = checked_add_usize(HEADER_LEN, directory_len)?;
1106    let mut directory = Vec::with_capacity(sections.len());
1107    for section in sections {
1108        let len = section.bytes.len();
1109        directory.push(SectionDirectoryEntry {
1110            kind: section.kind,
1111            flags: section.flags,
1112            offset: checked_u64_from_usize(next_offset)?,
1113            len: checked_u64_from_usize(len)?,
1114            row_count: section.row_count,
1115        });
1116        next_offset = checked_add_usize(next_offset, len)?;
1117    }
1118    Ok(directory)
1119}
1120
1121fn directory_file_len(directory: &[SectionDirectoryEntry]) -> Result<u64, SnapshotIoError> {
1122    let last = directory.last().ok_or(SnapshotIoError::Format {
1123        reason: "snapshot directory is empty",
1124    })?;
1125    last.offset
1126        .checked_add(last.len)
1127        .ok_or(SnapshotIoError::Format {
1128            reason: "snapshot writer length overflowed",
1129        })
1130}
1131
1132fn write_hashed(
1133    file: &mut BufWriter<File>,
1134    hasher: &mut blake3::Hasher,
1135    written: &mut u64,
1136    bytes: &[u8],
1137) -> Result<(), SnapshotIoError> {
1138    file.write_all(bytes)?;
1139    hasher.update(bytes);
1140    *written = written
1141        .checked_add(checked_u64_from_usize(bytes.len())?)
1142        .ok_or(SnapshotIoError::Format {
1143            reason: "snapshot writer length overflowed",
1144        })?;
1145    Ok(())
1146}
1147
1148fn snapshot_tmp_path(path: &Path, revision: Revision) -> PathBuf {
1149    let mut file_name = path
1150        .file_name()
1151        .map_or_else(|| OsString::from("snapshot"), OsString::from);
1152    file_name.push(format!(".{}.{}.tmp", std::process::id(), revision.get()));
1153    path.with_file_name(file_name)
1154}
1155
1156fn encode_file(
1157    snapshot: &crate::revision::PublishedSnapshot,
1158    writer: SnapshotSectionWriter,
1159    index_profile: IndexProfile,
1160) -> Result<Vec<u8>, SnapshotIoError> {
1161    let relationship_count =
1162        checked_u32_from_u64(writer.row_count(SectionKind::RelationshipRows)?)?;
1163    let symbol_count = checked_u32_from_u64(writer.row_count(SectionKind::SymbolTable)?)?;
1164    let sections = snapshot_sections_with_footer(writer)?;
1165    let directory = section_directory(&sections)?;
1166    let file_len = directory_file_len(&directory)?;
1167    let file_len_usize = checked_usize_from_u64(file_len)?;
1168    let mut bytes = Vec::with_capacity(file_len_usize);
1169    write_header(
1170        &mut bytes,
1171        snapshot.schema_hash(),
1172        relationship_count,
1173        symbol_count,
1174        snapshot.revision(),
1175        file_len,
1176        index_profile,
1177    );
1178    for entry in &directory {
1179        write_directory_entry(&mut bytes, entry);
1180    }
1181    for section in &sections {
1182        bytes.extend_from_slice(&section.bytes);
1183    }
1184    if bytes.len() != file_len_usize {
1185        return Err(SnapshotIoError::Format {
1186            reason: "snapshot writer length mismatch",
1187        });
1188    }
1189    let footer = directory
1190        .iter()
1191        .find(|entry| entry.kind == SectionKind::Footer)
1192        .ok_or(SnapshotIoError::Format {
1193            reason: "missing footer section",
1194        })?;
1195    let footer_offset = checked_usize_from_u64(footer.offset)?;
1196    let digest = blake3::hash(bytes.get(..footer_offset).ok_or(SnapshotIoError::Format {
1197        reason: "footer offset is invalid",
1198    })?);
1199    let footer_end = checked_add_usize(footer_offset, FOOTER_LEN)?;
1200    let footer_bytes = bytes
1201        .get_mut(footer_offset..footer_end)
1202        .ok_or(SnapshotIoError::Format {
1203            reason: "footer range is invalid",
1204        })?;
1205    footer_bytes.copy_from_slice(digest.as_bytes());
1206    Ok(bytes)
1207}
1208
1209fn write_header(
1210    target: &mut Vec<u8>,
1211    schema_hash: SchemaHash,
1212    relationship_count: u32,
1213    symbol_count: u32,
1214    revision: Revision,
1215    file_len: u64,
1216    index_profile: IndexProfile,
1217) {
1218    target.extend_from_slice(&CURRENT_FORMAT_VERSION.magic());
1219    target.extend_from_slice(&CURRENT_FORMAT_VERSION.raw().to_le_bytes());
1220    target.extend_from_slice(&index_profile.flag_bits().to_le_bytes());
1221    target.extend_from_slice(&HEADER_LEN_U32.to_le_bytes());
1222    target.extend_from_slice(&REQUIRED_SECTION_COUNT_U32.to_le_bytes());
1223    target.extend_from_slice(&file_len.to_le_bytes());
1224    target.extend_from_slice(schema_hash.as_bytes());
1225    target.extend_from_slice(&relationship_count.to_le_bytes());
1226    target.extend_from_slice(&symbol_count.to_le_bytes());
1227    target.extend_from_slice(&revision.get().to_le_bytes());
1228}
1229
1230fn write_directory_entry(target: &mut Vec<u8>, entry: &SectionDirectoryEntry) {
1231    target.extend_from_slice(&entry.kind.raw().to_le_bytes());
1232    target.extend_from_slice(&entry.flags.to_le_bytes());
1233    target.extend_from_slice(&entry.offset.to_le_bytes());
1234    target.extend_from_slice(&entry.len.to_le_bytes());
1235    target.extend_from_slice(&entry.row_count.to_le_bytes());
1236}
1237
1238fn parse_header(bytes: &[u8]) -> Result<SnapshotHeader, SnapshotIoError> {
1239    let mut cursor = BinaryCursor::new(bytes.get(..HEADER_LEN).ok_or(SnapshotIoError::Format {
1240        reason: "snapshot header is truncated",
1241    })?);
1242    let magic = cursor.read_array::<8>()?;
1243    let version = cursor.read_u16()?;
1244    let format_version = SnapshotFormatVersion::from_header(magic, version)?;
1245    let index_profile = IndexProfile::from_flag_bits(cursor.read_u16()?)?;
1246    let header_len = cursor.read_u32()?;
1247    if header_len != HEADER_LEN_U32 {
1248        return Err(SnapshotIoError::Format {
1249            reason: "snapshot header length is unsupported",
1250        });
1251    }
1252    let section_count = cursor.read_u32()?;
1253    if section_count != REQUIRED_SECTION_COUNT_U32 {
1254        return Err(SnapshotIoError::Format {
1255            reason: "snapshot section count is unsupported",
1256        });
1257    }
1258    let file_len = cursor.read_u64()?;
1259    let actual_len = checked_u64_from_usize(bytes.len())?;
1260    if file_len != actual_len {
1261        return Err(SnapshotIoError::Format {
1262            reason: "snapshot file length does not match header",
1263        });
1264    }
1265    let schema_hash = SchemaHash::from_bytes(cursor.read_array::<32>()?);
1266    let relationship_count = cursor.read_u32()?;
1267    let symbol_count = cursor.read_u32()?;
1268    let revision_raw = cursor.read_u64()?;
1269    let revision =
1270        NonZeroU64::new(revision_raw)
1271            .map(Revision::new)
1272            .ok_or(SnapshotIoError::Format {
1273                reason: "snapshot revision must be non-zero",
1274            })?;
1275    Ok(SnapshotHeader {
1276        format_version,
1277        schema_hash,
1278        relationship_count,
1279        symbol_count,
1280        created_revision: revision,
1281        index_profile,
1282    })
1283}
1284
1285fn parse_section_count(bytes: &[u8]) -> Result<usize, SnapshotIoError> {
1286    let start = 16;
1287    let end = checked_add_usize(start, 4)?;
1288    let count_bytes = bytes.get(start..end).ok_or(SnapshotIoError::Format {
1289        reason: "snapshot section count is missing",
1290    })?;
1291    let mut array = [0_u8; 4];
1292    array.copy_from_slice(count_bytes);
1293    checked_usize_from_u32(u32::from_le_bytes(array))
1294}
1295
1296fn validate_section_flags(
1297    version: SnapshotFormatVersion,
1298    kind: SectionKind,
1299    flags: u16,
1300) -> Result<(), SnapshotIoError> {
1301    if flags == 0 {
1302        return Ok(());
1303    }
1304    if version == SnapshotFormatVersion::V3
1305        && matches!(
1306            kind,
1307            SectionKind::SymbolTable | SectionKind::RelationshipRows | SectionKind::SymbolLookup
1308        )
1309    {
1310        return Ok(());
1311    }
1312    Err(SnapshotIoError::Format {
1313        reason: "section flags are unsupported",
1314    })
1315}
1316
1317fn validate_required_sections(sections: &SnapshotSections<'_>) -> Result<(), SnapshotIoError> {
1318    for kind in [
1319        SectionKind::Schema,
1320        SectionKind::SymbolBytes,
1321        SectionKind::SymbolTable,
1322        SectionKind::RelationshipRows,
1323        SectionKind::IndexDirectory,
1324        SectionKind::IndexKeys,
1325        SectionKind::PostingRanges,
1326        SectionKind::PostingRowIds,
1327        SectionKind::SymbolHashes,
1328        SectionKind::SymbolLookup,
1329        SectionKind::Footer,
1330    ] {
1331        if sections
1332            .get(section_slot(kind))
1333            .copied()
1334            .flatten()
1335            .is_none()
1336        {
1337            return Err(SnapshotIoError::Format {
1338                reason: "missing required snapshot section",
1339            });
1340        }
1341    }
1342    Ok(())
1343}
1344
1345fn validate_non_overlapping_sections(
1346    entries: &mut [(u64, u64, SectionKind)],
1347) -> Result<(), SnapshotIoError> {
1348    entries.sort_by_key(|(offset, _, _)| *offset);
1349    let mut previous_end = 0_u64;
1350    for (offset, len, _) in entries {
1351        if *offset < previous_end {
1352            return Err(SnapshotIoError::Format {
1353                reason: "snapshot sections overlap",
1354            });
1355        }
1356        previous_end = offset.checked_add(*len).ok_or(SnapshotIoError::Format {
1357            reason: "snapshot section range overflowed",
1358        })?;
1359    }
1360    Ok(())
1361}
1362
1363fn validate_footer(
1364    bytes: &[u8],
1365    sections: &SnapshotSections<'_>,
1366    integrity: SnapshotIntegrityMode,
1367) -> Result<(), SnapshotIoError> {
1368    let footer = sections
1369        .get(section_slot(SectionKind::Footer))
1370        .copied()
1371        .flatten()
1372        .ok_or(SnapshotIoError::Format {
1373            reason: "missing footer section",
1374        })?;
1375    if footer.bytes().len() != FOOTER_LEN {
1376        return Err(SnapshotIoError::Format {
1377            reason: "footer length is invalid",
1378        });
1379    }
1380    let footer_start = bytes
1381        .len()
1382        .checked_sub(FOOTER_LEN)
1383        .ok_or(SnapshotIoError::Format {
1384            reason: "footer range is invalid",
1385        })?;
1386    let expected_footer = bytes.get(footer_start..).ok_or(SnapshotIoError::Format {
1387        reason: "footer range is invalid",
1388    })?;
1389    if expected_footer != footer.bytes() {
1390        return Err(SnapshotIoError::Format {
1391            reason: "footer must be the final section",
1392        });
1393    }
1394    if integrity == SnapshotIntegrityMode::External {
1395        return Ok(());
1396    }
1397    let digest = blake3::hash(bytes.get(..footer_start).ok_or(SnapshotIoError::Format {
1398        reason: "footer offset is invalid",
1399    })?);
1400    if digest.as_bytes().as_slice() != footer.bytes() {
1401        return Err(SnapshotIoError::Format {
1402            reason: "snapshot checksum mismatch",
1403        });
1404    }
1405    Ok(())
1406}
1407
1408#[derive(Debug)]
1409pub(crate) struct BinaryCursor<'a> {
1410    bytes: &'a [u8],
1411    offset: usize,
1412}
1413
1414impl<'a> BinaryCursor<'a> {
1415    /// Creates a cursor over little-endian binary data.
1416    #[must_use]
1417    pub(crate) const fn new(bytes: &'a [u8]) -> Self {
1418        Self { bytes, offset: 0 }
1419    }
1420
1421    /// Reads a fixed-size byte array.
1422    pub(crate) fn read_array<const N: usize>(&mut self) -> Result<[u8; N], SnapshotIoError> {
1423        let end = checked_add_usize(self.offset, N)?;
1424        let slice = self
1425            .bytes
1426            .get(self.offset..end)
1427            .ok_or(SnapshotIoError::Format {
1428                reason: "snapshot section is truncated",
1429            })?;
1430        let mut array = [0_u8; N];
1431        array.copy_from_slice(slice);
1432        self.offset = end;
1433        Ok(array)
1434    }
1435
1436    /// Reads a little-endian `u16`.
1437    pub(crate) fn read_u16(&mut self) -> Result<u16, SnapshotIoError> {
1438        Ok(u16::from_le_bytes(self.read_array()?))
1439    }
1440
1441    /// Reads a little-endian `u32`.
1442    pub(crate) fn read_u32(&mut self) -> Result<u32, SnapshotIoError> {
1443        Ok(u32::from_le_bytes(self.read_array()?))
1444    }
1445
1446    /// Reads a little-endian `u64`.
1447    pub(crate) fn read_u64(&mut self) -> Result<u64, SnapshotIoError> {
1448        Ok(u64::from_le_bytes(self.read_array()?))
1449    }
1450
1451    /// Returns true when no unread bytes remain.
1452    #[must_use]
1453    pub(crate) const fn is_empty(&self) -> bool {
1454        self.offset == self.bytes.len()
1455    }
1456}
1457
1458/// Converts a little-endian u32 count to usize.
1459pub(crate) fn checked_usize_from_u32(value: u32) -> Result<usize, SnapshotIoError> {
1460    usize::try_from(value).map_err(|_| SnapshotIoError::LimitExceeded {
1461        component: "snapshot u32 count",
1462    })
1463}
1464
1465/// Converts a little-endian u64 count to usize.
1466pub(crate) fn checked_usize_from_u64(value: u64) -> Result<usize, SnapshotIoError> {
1467    usize::try_from(value).map_err(|_| SnapshotIoError::LimitExceeded {
1468        component: "snapshot u64 count",
1469    })
1470}
1471
1472/// Converts a usize count to u32.
1473pub(crate) fn checked_u32_from_usize(value: usize) -> Result<u32, SnapshotIoError> {
1474    u32::try_from(value).map_err(|_| SnapshotIoError::LimitExceeded {
1475        component: "snapshot u32 count",
1476    })
1477}
1478
1479fn checked_u32_from_u64(value: u64) -> Result<u32, SnapshotIoError> {
1480    u32::try_from(value).map_err(|_| SnapshotIoError::LimitExceeded {
1481        component: "snapshot u32 count",
1482    })
1483}
1484
1485fn checked_u64_from_usize(value: usize) -> Result<u64, SnapshotIoError> {
1486    u64::try_from(value).map_err(|_| SnapshotIoError::LimitExceeded {
1487        component: "snapshot u64 count",
1488    })
1489}
1490
1491/// Checked `usize` addition for snapshot parsing.
1492pub(crate) fn checked_add_usize(left: usize, right: usize) -> Result<usize, SnapshotIoError> {
1493    left.checked_add(right).ok_or(SnapshotIoError::Format {
1494        reason: "snapshot offset overflowed",
1495    })
1496}
1497
1498/// Checked `usize` multiplication for snapshot parsing.
1499pub(crate) fn checked_mul_usize(left: usize, right: usize) -> Result<usize, SnapshotIoError> {
1500    left.checked_mul(right).ok_or(SnapshotIoError::Format {
1501        reason: "snapshot length overflowed",
1502    })
1503}
1504
1505/// Validates that `value` is unique in `seen`.
1506pub(crate) fn insert_unique<T>(
1507    seen: &mut HashSet<T>,
1508    value: T,
1509    reason: &'static str,
1510) -> Result<(), SnapshotIoError>
1511where
1512    T: Eq + std::hash::Hash,
1513{
1514    if seen.insert(value) {
1515        Ok(())
1516    } else {
1517        Err(SnapshotIoError::Format { reason })
1518    }
1519}
1520
1521fn non_zero_u64(value: u64) -> NonZeroU64 {
1522    match NonZeroU64::new(value) {
1523        Some(value) => value,
1524        None => NonZeroU64::MIN,
1525    }
1526}
1527
1528pub(crate) fn one_snapshot_retention() -> NonZeroUsize {
1529    match NonZeroUsize::new(1) {
1530        Some(value) => value,
1531        None => NonZeroUsize::MIN,
1532    }
1533}