1use core::fmt;
8use std::{
9 cmp::Ordering,
10 collections::HashSet,
11 convert::Infallible,
12 fmt::{Debug, Display},
13 hash::Hash,
14 marker::PhantomData,
15 ops::Range,
16 sync::LazyLock,
17};
18
19use ::flatbuffers::InvalidFlatbuffer;
20use bytes::Bytes;
21use chrono::{DateTime, Utc};
22use flatbuffers::generated;
23use format_constants::FileTypeBin;
24use manifest::VirtualReferenceErrorKind;
25use rand::{RngExt as _, rng};
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use icechunk_types::error::ICError;
30use icechunk_types::sealed;
31
32pub mod attributes;
34pub mod manifest;
36
37#[path = "./flatbuffers/all_generated.rs"]
39#[allow(clippy::all, warnings)]
40pub mod flatbuffers;
41
42pub mod repo_info;
44pub mod serializers;
46pub mod snapshot;
48pub mod transaction_log;
50
51pub const CONFIG_FILE_PATH: &str = "config.yaml";
52pub const REPO_INFO_FILE_PATH: &str = "repo";
53pub const CHUNKS_FILE_PATH: &str = "chunks";
54pub const MANIFESTS_FILE_PATH: &str = "manifests";
55pub const SNAPSHOTS_FILE_PATH: &str = "snapshots";
56pub const TRANSACTION_LOGS_FILE_PATH: &str = "transactions";
57pub const OVERWRITTEN_FILES_PATH: &str = "overwritten";
58pub const V1_REFS_FILE_PATH: &str = "refs";
59
60pub use icechunk_types::{Path, PathError};
61
62pub trait FileTypeTag: sealed::Sealed {}
64
65#[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
67pub struct ObjectId<const SIZE: usize, T: FileTypeTag>(
68 #[serde(with = "serde_bytes")] pub [u8; SIZE],
69 PhantomData<T>,
70);
71
72#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
74pub struct SnapshotTag;
75
76#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
78pub struct ManifestTag;
79
80#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
82pub struct ChunkTag;
83
84#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
86pub struct AttributesTag;
87
88#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
90pub struct NodeTag;
91
92impl sealed::Sealed for SnapshotTag {}
93impl sealed::Sealed for ManifestTag {}
94impl sealed::Sealed for ChunkTag {}
95impl sealed::Sealed for AttributesTag {}
96impl sealed::Sealed for NodeTag {}
97impl FileTypeTag for SnapshotTag {}
98impl FileTypeTag for ManifestTag {}
99impl FileTypeTag for ChunkTag {}
100impl FileTypeTag for AttributesTag {}
101impl FileTypeTag for NodeTag {}
102
103pub type SnapshotId = ObjectId<12, SnapshotTag>;
108pub type ManifestId = ObjectId<12, ManifestTag>;
110pub type ChunkId = ObjectId<12, ChunkTag>;
112pub type AttributesId = ObjectId<12, AttributesTag>;
114
115pub type NodeId = ObjectId<8, NodeTag>;
118
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
120pub enum NodeType {
121 Group,
122 Array,
123}
124
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
126pub struct Move {
127 pub from: Path,
128 pub to: Path,
129 pub node_id: NodeId,
130 pub node_type: NodeType,
131}
132
133impl From<NodeType> for generated::NodeType {
134 fn from(value: NodeType) -> Self {
135 match value {
136 NodeType::Group => generated::NodeType::Group,
137 NodeType::Array => generated::NodeType::Array,
138 }
139 }
140}
141
142impl NodeType {
143 fn max_supported() -> u8 {
144 generated::NodeType::ENUM_MAX
145 }
146}
147
148impl TryFrom<generated::NodeType> for NodeType {
149 type Error = IcechunkFormatErrorKind;
150
151 fn try_from(value: generated::NodeType) -> Result<Self, Self::Error> {
152 match value {
153 generated::NodeType::Group => Ok(NodeType::Group),
154 generated::NodeType::Array => Ok(NodeType::Array),
155 generated::NodeType(v) => Err(IcechunkFormatErrorKind::InvalidNodeType {
156 found: v,
157 max_supported: Self::max_supported(),
158 }),
159 }
160 }
161}
162
163impl<const SIZE: usize, T: FileTypeTag> ObjectId<SIZE, T> {
164 pub fn random() -> Self {
165 let mut buf = [0u8; SIZE];
166 rng().fill(&mut buf[..]);
167 Self(buf, PhantomData)
168 }
169
170 pub const fn new(buf: [u8; SIZE]) -> Self {
171 Self(buf, PhantomData)
172 }
173
174 pub const FAKE: Self = Self([0; SIZE], PhantomData);
175}
176
177impl<const SIZE: usize, T: FileTypeTag> Debug for ObjectId<SIZE, T> {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 write!(f, "{}", String::from(self))
180 }
181}
182
183impl<const SIZE: usize, T: FileTypeTag> TryFrom<&[u8]> for ObjectId<SIZE, T> {
184 type Error = &'static str;
185
186 fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
187 let buf = value.try_into();
188 buf.map(|buf| ObjectId(buf, PhantomData))
189 .map_err(|_| "Invalid ObjectId buffer length")
190 }
191}
192
193impl<const SIZE: usize, T: FileTypeTag> TryFrom<&str> for ObjectId<SIZE, T> {
194 type Error = &'static str;
195
196 fn try_from(value: &str) -> Result<Self, Self::Error> {
197 let bytes = base32::decode(base32::Alphabet::Crockford, value);
198 let Some(bytes) = bytes else { return Err("Invalid ObjectId string") };
199 Self::try_from(bytes.as_slice())
200 }
201}
202
203impl<const SIZE: usize, T: FileTypeTag> From<&ObjectId<SIZE, T>> for String {
204 fn from(value: &ObjectId<SIZE, T>) -> Self {
205 base32::encode(base32::Alphabet::Crockford, &value.0)
206 }
207}
208
209pub static OBJECT_ID_FIRST_CHARS: LazyLock<HashSet<char>> =
212 LazyLock::new(|| "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars().collect());
213
214pub static OBJECT_ID_ONE_CHAR_PREFIXES: LazyLock<Vec<String>> = LazyLock::new(|| {
217 let mut prefixes: Vec<String> =
218 OBJECT_ID_FIRST_CHARS.iter().map(|c| c.to_string()).collect();
219 prefixes.sort_unstable();
220 prefixes
221});
222
223pub static OBJECT_ID_TWO_CHAR_PREFIXES: LazyLock<Vec<String>> = LazyLock::new(|| {
226 let mut prefixes: Vec<String> = OBJECT_ID_ONE_CHAR_PREFIXES
227 .iter()
228 .flat_map(|a| OBJECT_ID_ONE_CHAR_PREFIXES.iter().map(move |b| format!("{a}{b}")))
229 .collect();
230 prefixes.sort_unstable();
231 prefixes
232});
233
234impl<const SIZE: usize, T: FileTypeTag> From<[u8; SIZE]> for ObjectId<SIZE, T> {
235 fn from(value: [u8; SIZE]) -> Self {
236 ObjectId::new(value)
237 }
238}
239
240impl<const SIZE: usize, T: FileTypeTag> TryInto<String> for ObjectId<SIZE, T> {
241 type Error = Infallible;
242
243 fn try_into(self) -> Result<String, Self::Error> {
244 Ok(self.to_string())
245 }
246}
247
248impl<const SIZE: usize, T: FileTypeTag> TryInto<ObjectId<SIZE, T>> for String {
249 type Error = &'static str;
250
251 fn try_into(self) -> Result<ObjectId<SIZE, T>, Self::Error> {
252 self.as_str().try_into()
253 }
254}
255
256impl<const SIZE: usize, T: FileTypeTag> Display for ObjectId<SIZE, T> {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 write!(f, "{}", String::from(self))
259 }
260}
261
262#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
264pub struct ChunkIndices(pub Vec<u32>);
265
266pub type ChunkOffset = u64;
268pub type ChunkLength = u64;
270
271impl<'a> From<generated::ChunkIndices<'a>> for ChunkIndices {
272 fn from(value: generated::ChunkIndices<'a>) -> Self {
273 ChunkIndices(value.coords().iter().collect())
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Hash)]
279pub enum ByteRange {
280 Bounded(Range<ChunkOffset>),
282 From(ChunkOffset),
284 Last(ChunkLength),
286 Until(ChunkOffset),
288}
289
290impl From<Range<ChunkOffset>> for ByteRange {
291 fn from(value: Range<ChunkOffset>) -> Self {
292 ByteRange::Bounded(value)
293 }
294}
295
296impl ByteRange {
297 pub fn from_offset(offset: ChunkOffset) -> Self {
298 Self::From(offset)
299 }
300
301 pub fn from_offset_with_length(offset: ChunkOffset, length: ChunkOffset) -> Self {
302 Self::Bounded(offset..offset + length)
303 }
304
305 pub fn to_offset(offset: ChunkOffset) -> Self {
306 Self::Bounded(0..offset)
307 }
308
309 pub fn bounded(start: ChunkOffset, end: ChunkOffset) -> Self {
310 (start..end).into()
311 }
312
313 pub const ALL: Self = Self::From(0);
314
315 pub fn slice(&self, bytes: &Bytes) -> Bytes {
316 match self {
317 ByteRange::Bounded(range) => {
318 bytes.slice(range.start as usize..range.end as usize)
319 }
320 ByteRange::From(from) => bytes.slice(*from as usize..),
321 ByteRange::Last(n) => bytes.slice(bytes.len() - *n as usize..),
322 ByteRange::Until(n) => bytes.slice(0usize..bytes.len() - *n as usize),
323 }
324 }
325}
326
327impl From<(Option<ChunkOffset>, Option<ChunkOffset>)> for ByteRange {
328 fn from((start, end): (Option<ChunkOffset>, Option<ChunkOffset>)) -> Self {
329 match (start, end) {
330 (Some(start), Some(end)) => Self::Bounded(start..end),
331 (Some(start), None) => Self::From(start),
332 (None, Some(end)) => Self::Until(end),
334 (None, None) => Self::ALL,
335 }
336 }
337}
338
339pub type TableOffset = u32;
341
342#[derive(Debug, Error)]
344#[non_exhaustive]
345pub enum IcechunkFormatErrorKind {
346 #[error(transparent)]
347 VirtualReferenceError(#[from] VirtualReferenceErrorKind),
348 #[error("node not found at `{path:?}`")]
349 NodeNotFound { path: Path },
350 #[error("chunk coordinates not found `{coords:?}`")]
351 ChunkCoordinatesNotFound { coords: ChunkIndices },
352 #[error("snapshot id not found `{snapshot_id}`")]
353 SnapshotIdNotFound { snapshot_id: SnapshotId },
354 #[error("branch already exists `{branch} -> {snapshot_id}`")]
355 BranchAlreadyExists { branch: String, snapshot_id: SnapshotId },
356 #[error("branch not found `{branch}`")]
357 BranchNotFound { branch: String },
358 #[error("tag already exists `{tag}`")]
359 TagAlreadyExists { tag: String },
360 #[error("icechunk does not allow tag reuse and tag was already deleted `{tag}`")]
361 TagPreviouslyDeleted { tag: String },
362 #[error("tag not found `{tag}`")]
363 TagNotFound { tag: String },
364 #[error("snapshot id is already present in the repository: `{snapshot_id}`")]
365 DuplicateSnapshotId { snapshot_id: SnapshotId },
366 #[error("manifest information cannot be found in snapshot for id `{manifest_id}`")]
367 ManifestInfoNotFound { manifest_id: ManifestId },
368 #[error("invalid magic numbers in file")]
369 InvalidMagicNumbers, #[error(
371 "invalid icechunk header size, got {found} bytes, expected at least {expected}"
372 )]
373 InvalidIcechunkHeaderSize { found: usize, expected: usize },
374 #[error("invalid node type {found}, max supported value is {max_supported}")]
375 InvalidNodeType { found: u8, max_supported: u8 },
376 #[error(
377 "this repository uses Icechunk format version {found}, but this library only supports up to version {max_supported}. Please upgrade the icechunk library"
378 )]
379 InvalidSpecVersion { found: u8, max_supported: u8 },
380 #[error("this operation is not supported for Icechunk format version {version}")]
381 UnsupportedOperationForVersion { version: u8 },
382 #[error("Icechunk cannot read this file type, expected {expected:?} got {got}")]
383 InvalidFileType { expected: FileTypeBin, got: u8 }, #[error("Icechunk encountered an unknown file type code {got}")]
385 UnknownFileType { got: u8 },
386 #[error("Icechunk cannot read file, invalid compression algorithm")]
387 InvalidCompressionAlgorithm, #[error("Invalid Icechunk metadata file")]
389 InvalidFlatBuffer(#[from] InvalidFlatbuffer),
390 #[error("error during metadata deserialization")]
391 DeserializationError(#[from] Box<rmp_serde::decode::Error>),
392 #[error("error during metadata serialization")]
393 SerializationError(#[from] Box<rmp_serde::encode::Error>),
394 #[error("error during metadata serialization")]
395 SerializationErrorFlexBuffers(#[from] Box<flexbuffers::SerializationError>),
396 #[error("error during metadata deserialization")]
397 DeserializationErrorFlexBuffers(#[from] Box<flexbuffers::DeserializationError>),
398 #[error("I/O error")]
399 IO(#[from] std::io::Error),
400 #[error("path error")]
401 Path(#[from] PathError),
402 #[error("invalid timestamp in file")]
403 InvalidTimestamp,
404 #[error(
405 "update timestamp is invalid, please verify if the machine clock has drifted: update time: `{new_time}`, latest update time: `{latest_time}`"
406 )]
407 InvalidUpdateTimestamp { latest_time: DateTime<Utc>, new_time: DateTime<Utc> },
408 #[error("invalid feature flag name: {name}")]
409 InvalidFeatureFlagName { name: String },
410 #[error("invalid feature flag id: {id}")]
411 InvalidFeatureFlagId { id: u16 },
412 #[error("{feature_description} is disabled by a feature flag ({feature_flag})")]
413 FeatureFlagDisabled { feature_description: String, feature_flag: String },
414 #[error(
415 "compressed chunk location present but no decompression dictionary available"
416 )]
417 MissingLocationCompressionDictionary,
418 #[error("Invalid array metadata: {0}")]
419 InvalidArrayMetadata(String),
420 #[error("Move operation missing required field '{0}'")]
421 MissingRequiredField(String),
422}
423
424pub type IcechunkFormatError = ICError<IcechunkFormatErrorKind>;
425
426impl From<Infallible> for IcechunkFormatErrorKind {
427 fn from(value: Infallible) -> Self {
428 match value {}
429 }
430}
431
432pub type IcechunkResult<T> = Result<T, IcechunkFormatError>;
433
434pub mod format_constants {
436 use std::sync::LazyLock;
437
438 use icechunk_types::ICResultExt as _;
439 use serde::{Deserialize, Serialize};
440
441 use super::{IcechunkFormatError, IcechunkFormatErrorKind};
442
443 #[repr(u8)]
445 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
446 pub enum FileTypeBin {
447 Snapshot = 1u8,
448 Manifest = 2,
449 Attributes = 3,
450 TransactionLog = 4,
451 Chunk = 5,
452 RepoInfo = 6,
453 }
454
455 impl TryFrom<u8> for FileTypeBin {
456 type Error = String;
457
458 fn try_from(value: u8) -> Result<Self, Self::Error> {
459 match value {
460 n if n == FileTypeBin::Snapshot as u8 => Ok(FileTypeBin::Snapshot),
461 n if n == FileTypeBin::Manifest as u8 => Ok(FileTypeBin::Manifest),
462 n if n == FileTypeBin::Attributes as u8 => Ok(FileTypeBin::Attributes),
463 n if n == FileTypeBin::RepoInfo as u8 => Ok(FileTypeBin::RepoInfo),
464 n if n == FileTypeBin::TransactionLog as u8 => {
465 Ok(FileTypeBin::TransactionLog)
466 }
467 n if n == FileTypeBin::Chunk as u8 => Ok(FileTypeBin::Chunk),
468 n => Err(format!("Bad file type code: {n}")),
469 }
470 }
471 }
472
473 #[repr(u8)]
475 #[derive(
476 Debug,
477 Clone,
478 Copy,
479 PartialEq,
480 Eq,
481 Serialize,
482 Deserialize,
483 Default,
484 PartialOrd,
485 Ord,
486 )]
487 pub enum SpecVersionBin {
488 V1 = 1u8,
489 #[default]
490 V2 = 2u8,
491 }
494
495 impl TryFrom<u8> for SpecVersionBin {
496 type Error = IcechunkFormatErrorKind;
497
498 fn try_from(value: u8) -> Result<Self, Self::Error> {
499 match value {
500 n if n == SpecVersionBin::V1 as u8 => Ok(SpecVersionBin::V1),
501 n if n == SpecVersionBin::V2 as u8 => Ok(SpecVersionBin::V2),
502 n => Err(IcechunkFormatErrorKind::InvalidSpecVersion {
503 found: n,
504 max_supported: Self::current() as u8,
505 }),
506 }
507 }
508 }
509
510 impl SpecVersionBin {
511 pub fn current() -> Self {
512 Default::default()
513 }
514 }
515
516 impl std::fmt::Display for SpecVersionBin {
517 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518 let s = match self {
519 SpecVersionBin::V1 => "1.0",
520 SpecVersionBin::V2 => "2.0",
521 };
522 write!(f, "{s}")
523 }
524 }
525
526 #[repr(u8)]
528 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
529 pub enum CompressionAlgorithmBin {
530 None = 0u8,
531 Zstd = 1u8,
532 }
533
534 impl TryFrom<u8> for CompressionAlgorithmBin {
535 type Error = String;
536
537 fn try_from(value: u8) -> Result<Self, Self::Error> {
538 match value {
539 n if n == CompressionAlgorithmBin::None as u8 => {
540 Ok(CompressionAlgorithmBin::None)
541 }
542 n if n == CompressionAlgorithmBin::Zstd as u8 => {
543 Ok(CompressionAlgorithmBin::Zstd)
544 }
545 n => Err(format!("Bad cmpression algorithm code: {n}")),
546 }
547 }
548 }
549
550 pub const ICECHUNK_FORMAT_MAGIC_BYTES: &[u8] = "ICE🧊CHUNK".as_bytes();
551 const _: () = assert!(ICECHUNK_FORMAT_MAGIC_BYTES.len() == 12);
553
554 pub const ICECHUNK_IMPL_NAME_LEN: usize = 24;
557 pub const ICECHUNK_SPEC_VERSION_OFFSET: usize =
558 ICECHUNK_FORMAT_MAGIC_BYTES.len() + ICECHUNK_IMPL_NAME_LEN;
559 pub const ICECHUNK_FILE_TYPE_OFFSET: usize = ICECHUNK_SPEC_VERSION_OFFSET + 1;
560 pub const ICECHUNK_COMPRESSION_OFFSET: usize = ICECHUNK_FILE_TYPE_OFFSET + 1;
561 pub const ICECHUNK_FILE_HEADER_LEN: usize = ICECHUNK_COMPRESSION_OFFSET + 1;
562
563 pub const LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY: &str = "icspecver";
567 pub const LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY_DEPRECATED: &str =
568 "ic_spec_ver";
569
570 pub const ICECHUNK_LIB_VERSION: &str = env!("CARGO_PKG_VERSION");
573
574 pub static ICECHUNK_CLIENT_NAME: LazyLock<String> =
575 LazyLock::new(|| "ic-".to_string() + ICECHUNK_LIB_VERSION);
576 pub const ICECHUNK_CLIENT_NAME_METADATA_KEY: &str = "icclient";
577 pub const ICECHUNK_CLIENT_NAME_METADATA_KEY_DEPRECATED: &str = "ic_client";
578
579 pub const ICECHUNK_FILE_TYPE_SNAPSHOT: &str = "snapshot";
580 pub const ICECHUNK_FILE_TYPE_MANIFEST: &str = "manifest";
581 pub const ICECHUNK_FILE_TYPE_TRANSACTION_LOG: &str = "transaction-log";
582 pub const ICECHUNK_FILE_TYPE_REPO_INFO: &str = "repo-info";
583 pub const ICECHUNK_FILE_TYPE_METADATA_KEY: &str = "icfiletype";
584 pub const ICECHUNK_FILE_TYPE_METADATA_KEY_DEPRECATED: &str = "ic_file_type";
585
586 pub const ICECHUNK_COMPRESSION_METADATA_KEY: &str = "iccompalg";
587 pub const ICECHUNK_COMPRESSION_METADATA_KEY_DEPRECATED: &str = "ic_comp_alg";
588 pub const ICECHUNK_COMPRESSION_ZSTD: &str = "zstd";
589
590 #[derive(Debug, Clone, PartialEq, Eq)]
594 pub struct FileHeader {
595 pub impl_name: String,
597 pub app_name: String,
599 pub app_version: Option<String>,
602 pub spec_version: SpecVersionBin,
603 pub file_type: FileTypeBin,
604 pub compression: CompressionAlgorithmBin,
605 }
606
607 pub fn parse_file_header(buf: &[u8]) -> Result<FileHeader, IcechunkFormatError> {
613 use IcechunkFormatErrorKind as K;
614
615 if buf.len() < ICECHUNK_FILE_HEADER_LEN {
616 return Err(K::InvalidIcechunkHeaderSize {
617 found: buf.len(),
618 expected: ICECHUNK_FILE_HEADER_LEN,
619 })
620 .capture();
621 }
622 if !buf.starts_with(ICECHUNK_FORMAT_MAGIC_BYTES) {
623 return Err(K::InvalidMagicNumbers).capture();
624 }
625
626 let impl_bytes =
627 &buf[ICECHUNK_FORMAT_MAGIC_BYTES.len()..ICECHUNK_SPEC_VERSION_OFFSET];
628 let impl_name =
629 String::from_utf8_lossy(impl_bytes).trim_end_matches([' ', '\0']).to_string();
630 let (app_name, app_version) = match impl_name.split_once('-') {
631 Some((name, version)) => (name.to_string(), Some(version.to_string())),
632 None => (impl_name.clone(), None),
633 };
634
635 let spec_version =
636 SpecVersionBin::try_from(buf[ICECHUNK_SPEC_VERSION_OFFSET]).capture()?;
637
638 let file_type_byte = buf[ICECHUNK_FILE_TYPE_OFFSET];
639 let file_type = FileTypeBin::try_from(file_type_byte)
640 .map_err(|_| K::UnknownFileType { got: file_type_byte })
641 .capture()?;
642
643 let compression =
644 CompressionAlgorithmBin::try_from(buf[ICECHUNK_COMPRESSION_OFFSET])
645 .map_err(|_| K::InvalidCompressionAlgorithm)
646 .capture()?;
647
648 Ok(FileHeader {
649 impl_name,
650 app_name,
651 app_version,
652 spec_version,
653 file_type,
654 compression,
655 })
656 }
657}
658
659#[inline(always)]
660#[expect(clippy::needless_pass_by_value)]
661pub fn lookup_index_by_key<'a, T: ::flatbuffers::Follow<'a> + 'a, K: Ord>(
662 v: ::flatbuffers::Vector<'a, T>,
663 key: K,
664 f: fn(&<T as ::flatbuffers::Follow<'a>>::Inner, &K) -> Ordering,
665) -> Option<usize> {
666 if v.is_empty() {
667 return None;
668 }
669
670 let mut left: usize = 0;
671 let mut right = v.len() - 1;
672
673 while left <= right {
674 let mid = (left + right) / 2;
675 let value = v.get(mid);
676 match f(&value, &key) {
677 Ordering::Equal => return Some(mid),
678 Ordering::Less => left = mid + 1,
679 Ordering::Greater => {
680 if mid == 0 {
681 return None;
682 }
683 right = mid - 1;
684 }
685 }
686 }
687
688 None
689}
690
691#[macro_export]
700macro_rules! roundtrip_serialization_tests {
701 ($($test_name: ident - $generator: ident), +) => {
702 $(
703 proptest!{
704 #[icechunk_macros::test]
705 fn $test_name(elem in $generator()) {
706 let bytes = rmp_serde::to_vec(&elem).unwrap();
707 let roundtrip = rmp_serde::from_slice(&bytes).unwrap();
708 assert_eq!(elem, roundtrip);
709 }
710 }
711 )*
712 }
713}
714
715#[cfg(test)]
716pub mod strategies;
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use crate::strategies::{attributes_id, spec_version};
722 use pretty_assertions::assert_eq;
723 use proptest::prelude::*;
724
725 roundtrip_serialization_tests!(
726 serialize_and_deserialize_attribute_ids - attributes_id,
727 serialize_and_deserialize_spec_version_bin - spec_version
728 );
729
730 #[icechunk_macros::test]
731 fn test_object_id_serialization() {
732 let sid = SnapshotId::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
733 assert_eq!(
734 serde_json::to_string(&sid).unwrap(),
735 r#"[[0,1,2,3,4,5,6,7,8,9,10,11],null]"#
736 );
737 assert_eq!(String::from(&sid), "000G40R40M30E209185G");
738 assert_eq!(sid, SnapshotId::try_from("000G40R40M30E209185G").unwrap());
739 let sid = SnapshotId::random();
740 assert_eq!(
741 serde_json::from_slice::<SnapshotId>(
742 serde_json::to_vec(&sid).unwrap().as_slice()
743 )
744 .unwrap(),
745 sid,
746 );
747 }
748
749 #[icechunk_macros::test]
750 fn test_object_id_first_chars_are_every_possible_first_char() {
751 let first_chars: HashSet<char> = (0..=u8::MAX)
753 .filter_map(|first_byte| {
754 let mut buf = [0u8; 12];
755 buf[0] = first_byte;
756 String::from(&SnapshotId::new(buf)).chars().next()
757 })
758 .collect();
759 assert_eq!(first_chars, *OBJECT_ID_FIRST_CHARS);
760 }
761
762 #[icechunk_macros::test]
763 fn two_char_prefixes_cover_the_alphabet() {
764 let p = &*OBJECT_ID_TWO_CHAR_PREFIXES;
765 assert_eq!(p.len(), 1024);
766 assert!(p.windows(2).all(|w| w[0] < w[1]), "sorted and distinct");
767 assert!(p.iter().all(
768 |s| s.len() == 2 && s.chars().all(|c| OBJECT_ID_FIRST_CHARS.contains(&c))
769 ));
770 for _ in 0..1000 {
772 let id = ChunkId::random().to_string();
773 assert_eq!(p.iter().filter(|s| id.starts_with(s.as_str())).count(), 1);
774 }
775 }
776
777 #[icechunk_macros::test]
778 fn test_unknown_spec_version_gives_nice_error() {
779 use format_constants::SpecVersionBin;
780
781 let future_version: u8 = 3;
782 let result = SpecVersionBin::try_from(future_version);
783 assert!(result.is_err());
784 let err = result.unwrap_err();
785
786 let msg = err.to_string();
787 assert!(
788 msg.contains("format version 3"),
789 "Error should mention the found version: {msg}"
790 );
791 assert!(
792 msg.contains("upgrade the icechunk library"),
793 "Error should suggest upgrading: {msg}"
794 );
795 }
796
797 fn make_header(impl_name: &str, spec: u8, file_type: u8, compression: u8) -> Vec<u8> {
801 use format_constants::*;
802 let mut buf = Vec::with_capacity(ICECHUNK_FILE_HEADER_LEN);
803 buf.extend_from_slice(ICECHUNK_FORMAT_MAGIC_BYTES);
804 let padded = format!("{impl_name:<ICECHUNK_IMPL_NAME_LEN$}");
805 buf.extend_from_slice(&padded.as_bytes()[..ICECHUNK_IMPL_NAME_LEN]);
806 buf.push(spec);
807 buf.push(file_type);
808 buf.push(compression);
809 buf
810 }
811
812 #[icechunk_macros::test]
813 fn test_parse_file_header_roundtrip() {
814 use format_constants::*;
815 let mut buf = make_header(
816 ICECHUNK_CLIENT_NAME.as_str(),
817 SpecVersionBin::V2 as u8,
818 FileTypeBin::Snapshot as u8,
819 CompressionAlgorithmBin::Zstd as u8,
820 );
821 buf.extend_from_slice(b"a compressed body would go here");
823
824 let header = parse_file_header(&buf).unwrap();
825 assert_eq!(header.impl_name, *ICECHUNK_CLIENT_NAME);
826 assert_eq!(header.app_name, "ic");
827 assert_eq!(header.app_version.as_deref(), Some(ICECHUNK_LIB_VERSION));
828 assert_eq!(header.spec_version, SpecVersionBin::V2);
829 assert_eq!(header.file_type, FileTypeBin::Snapshot);
830 assert_eq!(header.compression, CompressionAlgorithmBin::Zstd);
831 }
832
833 #[icechunk_macros::test]
834 fn test_parse_file_header_trims_padding() {
835 use format_constants::*;
836 let buf = make_header(
838 "ic-9.9.9",
839 SpecVersionBin::V1 as u8,
840 FileTypeBin::Manifest as u8,
841 CompressionAlgorithmBin::None as u8,
842 );
843 let header = parse_file_header(&buf).unwrap();
844 assert_eq!(header.impl_name, "ic-9.9.9");
845 assert_eq!(header.app_name, "ic");
846 assert_eq!(header.app_version.as_deref(), Some("9.9.9"));
847 assert_eq!(header.spec_version, SpecVersionBin::V1);
848 assert_eq!(header.file_type, FileTypeBin::Manifest);
849 assert_eq!(header.compression, CompressionAlgorithmBin::None);
850 }
851
852 #[icechunk_macros::test]
853 fn test_parse_file_header_splits_on_first_dash() {
854 use format_constants::*;
855 let buf = make_header(
856 "ic-2.0.0-alpha.4",
857 SpecVersionBin::V2 as u8,
858 FileTypeBin::RepoInfo as u8,
859 CompressionAlgorithmBin::Zstd as u8,
860 );
861 let header = parse_file_header(&buf).unwrap();
862 assert_eq!(header.impl_name, "ic-2.0.0-alpha.4");
863 assert_eq!(header.app_name, "ic");
864 assert_eq!(header.app_version.as_deref(), Some("2.0.0-alpha.4"));
865 }
866
867 #[icechunk_macros::test]
868 fn test_parse_file_header_no_dash_fallback() {
869 use format_constants::*;
870 let buf = make_header(
871 "icjs",
872 SpecVersionBin::V2 as u8,
873 FileTypeBin::TransactionLog as u8,
874 CompressionAlgorithmBin::Zstd as u8,
875 );
876 let header = parse_file_header(&buf).unwrap();
877 assert_eq!(header.impl_name, "icjs");
878 assert_eq!(header.app_name, "icjs");
879 assert_eq!(header.app_version, None);
880 }
881
882 #[icechunk_macros::test]
883 fn test_parse_file_header_bad_magic() {
884 use format_constants::*;
885 let mut buf = make_header(
886 "ic-1.0.0",
887 SpecVersionBin::V2 as u8,
888 FileTypeBin::Snapshot as u8,
889 CompressionAlgorithmBin::Zstd as u8,
890 );
891 buf[0] = b'X';
892 let err = parse_file_header(&buf).unwrap_err();
893 assert!(matches!(err.kind(), IcechunkFormatErrorKind::InvalidMagicNumbers));
894 }
895
896 #[icechunk_macros::test]
897 fn test_parse_file_header_too_short() {
898 use format_constants::*;
899 let buf = vec![0u8; ICECHUNK_FILE_HEADER_LEN - 1];
900 let err = parse_file_header(&buf).unwrap_err();
901 assert!(matches!(
902 err.kind(),
903 IcechunkFormatErrorKind::InvalidIcechunkHeaderSize { .. }
904 ));
905 }
906
907 #[icechunk_macros::test]
908 fn test_parse_file_header_unknown_file_type() {
909 use format_constants::*;
910 let buf = make_header(
911 "ic-1.0.0",
912 SpecVersionBin::V2 as u8,
913 99,
914 CompressionAlgorithmBin::Zstd as u8,
915 );
916 let err = parse_file_header(&buf).unwrap_err();
917 assert!(matches!(
918 err.kind(),
919 IcechunkFormatErrorKind::UnknownFileType { got: 99 }
920 ));
921 }
922
923 #[icechunk_macros::test]
924 fn test_parse_file_header_bad_spec_version() {
925 use format_constants::*;
926 let buf = make_header(
927 "ic-1.0.0",
928 99,
929 FileTypeBin::Snapshot as u8,
930 CompressionAlgorithmBin::Zstd as u8,
931 );
932 let err = parse_file_header(&buf).unwrap_err();
933 assert!(matches!(
934 err.kind(),
935 IcechunkFormatErrorKind::InvalidSpecVersion { found: 99, .. }
936 ));
937 }
938
939 #[icechunk_macros::test]
940 fn test_object_metadata_keys_are_portable() {
941 use format_constants::*;
942 for key in [
943 LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY,
944 ICECHUNK_CLIENT_NAME_METADATA_KEY,
945 ICECHUNK_FILE_TYPE_METADATA_KEY,
946 ICECHUNK_COMPRESSION_METADATA_KEY,
947 ] {
948 assert!(
949 !key.is_empty()
950 && key.starts_with(|c: char| c.is_ascii_alphabetic())
951 && key.chars().all(|c| c.is_ascii_alphanumeric()),
952 "{key} must be ASCII alphanumeric and start with a letter: \
953 Azure rejects `-` and nginx-fronted S3 gateways drop `_`"
954 );
955 }
956 }
957}