1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct SnapshotSaveOptions {
47 pub compression: SnapshotCompression,
49 pub zstd_level: i32,
51 pub include_indexes: bool,
55 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 #[must_use]
73 pub fn uncompressed() -> Self {
74 Self::default()
75 }
76
77 #[must_use]
79 pub fn zstd() -> Self {
80 Self {
81 compression: SnapshotCompression::Zstd,
82 ..Self::default()
83 }
84 }
85
86 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SnapshotCompression {
104 None,
106 Zstd,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub(crate) enum SnapshotEncodingLayout {
116 Compact,
118 CompressionFriendly,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum SnapshotFormatVersion {
125 V2,
127 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum IndexProfile {
170 Full,
172 CheckOnly,
174 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub struct SnapshotLoadOptions {
224 pub compression: SnapshotCompression,
226 pub profile: SnapshotLoadProfile,
228 pub validation: SnapshotValidationMode,
230 pub integrity: SnapshotIntegrityMode,
232 pub max_file_bytes: NonZeroU64,
239 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 #[must_use]
259 pub fn uncompressed() -> Self {
260 Self::default()
261 }
262
263 #[must_use]
265 pub fn zstd() -> Self {
266 Self {
267 compression: SnapshotCompression::Zstd,
268 ..Self::default()
269 }
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum SnapshotLoadProfile {
276 FastLoad,
278 Latency,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SnapshotValidationMode {
285 Full,
287 TrustedFastLoad,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum SnapshotIntegrityMode {
294 Checksum,
296 External,
298}
299
300#[cfg_attr(not(feature = "bench-internals"), doc(hidden))]
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
303pub struct SnapshotLoadPhaseTimings {
304 pub file_read: Duration,
306 pub decompression: Duration,
308 pub header_and_sections: Duration,
310 pub checksum: Duration,
312 pub schema_parse_compile: Duration,
314 pub symbols: Duration,
316 pub rows: Duration,
318 pub indexes: Duration,
320 pub publish: Duration,
322}
323
324#[derive(Debug, Error)]
326pub enum SnapshotIoError {
327 #[error("snapshot io failed")]
329 Io {
330 #[source]
332 source: io::Error,
333 },
334
335 #[error("snapshot format error: {reason}")]
337 Format {
338 reason: &'static str,
340 },
341
342 #[error("snapshot limit exceeded: {component}")]
344 LimitExceeded {
345 component: &'static str,
347 },
348
349 #[error("snapshot option is unsupported: {option}")]
351 UnsupportedOption {
352 option: &'static str,
354 },
355
356 #[error("snapshot schema failed")]
358 Schema {
359 #[source]
361 source: ZanzibarError,
362 },
363
364 #[error("snapshot relationship store failed")]
366 Store {
367 #[source]
369 source: StoreError,
370 },
371
372 #[error("snapshot domain validation failed")]
374 Domain {
375 #[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#[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 #[must_use]
468 pub(crate) const fn bytes(self) -> &'a [u8] {
469 self.bytes
470 }
471
472 #[must_use]
474 pub(crate) const fn flags(self) -> u16 {
475 self.flags
476 }
477
478 #[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 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(§ions)?;
556 validate_non_overlapping_sections(&mut entries)?;
557 let checksum_start = Instant::now();
558 validate_footer(bytes, §ions, integrity)?;
559 if let Some(timings) = timings {
560 timings.checksum = checksum_start.elapsed();
561 }
562 Ok(Self { header, sections })
563 }
564
565 #[must_use]
567 pub(crate) const fn header(&self) -> SnapshotHeader {
568 self.header
569 }
570
571 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 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 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")]
710pub 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(§ions)?;
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 §ions {
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, §ion.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(§ions)?;
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 §ions {
1182 bytes.extend_from_slice(§ion.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 #[must_use]
1417 pub(crate) const fn new(bytes: &'a [u8]) -> Self {
1418 Self { bytes, offset: 0 }
1419 }
1420
1421 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 pub(crate) fn read_u16(&mut self) -> Result<u16, SnapshotIoError> {
1438 Ok(u16::from_le_bytes(self.read_array()?))
1439 }
1440
1441 pub(crate) fn read_u32(&mut self) -> Result<u32, SnapshotIoError> {
1443 Ok(u32::from_le_bytes(self.read_array()?))
1444 }
1445
1446 pub(crate) fn read_u64(&mut self) -> Result<u64, SnapshotIoError> {
1448 Ok(u64::from_le_bytes(self.read_array()?))
1449 }
1450
1451 #[must_use]
1453 pub(crate) const fn is_empty(&self) -> bool {
1454 self.offset == self.bytes.len()
1455 }
1456}
1457
1458pub(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
1465pub(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
1472pub(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
1491pub(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
1498pub(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
1505pub(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}