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
214impl<const SIZE: usize, T: FileTypeTag> From<[u8; SIZE]> for ObjectId<SIZE, T> {
215 fn from(value: [u8; SIZE]) -> Self {
216 ObjectId::new(value)
217 }
218}
219
220impl<const SIZE: usize, T: FileTypeTag> TryInto<String> for ObjectId<SIZE, T> {
221 type Error = Infallible;
222
223 fn try_into(self) -> Result<String, Self::Error> {
224 Ok(self.to_string())
225 }
226}
227
228impl<const SIZE: usize, T: FileTypeTag> TryInto<ObjectId<SIZE, T>> for String {
229 type Error = &'static str;
230
231 fn try_into(self) -> Result<ObjectId<SIZE, T>, Self::Error> {
232 self.as_str().try_into()
233 }
234}
235
236impl<const SIZE: usize, T: FileTypeTag> Display for ObjectId<SIZE, T> {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 write!(f, "{}", String::from(self))
239 }
240}
241
242#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
244pub struct ChunkIndices(pub Vec<u32>);
245
246pub type ChunkOffset = u64;
248pub type ChunkLength = u64;
250
251impl<'a> From<generated::ChunkIndices<'a>> for ChunkIndices {
252 fn from(value: generated::ChunkIndices<'a>) -> Self {
253 ChunkIndices(value.coords().iter().collect())
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Hash)]
259pub enum ByteRange {
260 Bounded(Range<ChunkOffset>),
262 From(ChunkOffset),
264 Last(ChunkLength),
266 Until(ChunkOffset),
268}
269
270impl From<Range<ChunkOffset>> for ByteRange {
271 fn from(value: Range<ChunkOffset>) -> Self {
272 ByteRange::Bounded(value)
273 }
274}
275
276impl ByteRange {
277 pub fn from_offset(offset: ChunkOffset) -> Self {
278 Self::From(offset)
279 }
280
281 pub fn from_offset_with_length(offset: ChunkOffset, length: ChunkOffset) -> Self {
282 Self::Bounded(offset..offset + length)
283 }
284
285 pub fn to_offset(offset: ChunkOffset) -> Self {
286 Self::Bounded(0..offset)
287 }
288
289 pub fn bounded(start: ChunkOffset, end: ChunkOffset) -> Self {
290 (start..end).into()
291 }
292
293 pub const ALL: Self = Self::From(0);
294
295 pub fn slice(&self, bytes: &Bytes) -> Bytes {
296 match self {
297 ByteRange::Bounded(range) => {
298 bytes.slice(range.start as usize..range.end as usize)
299 }
300 ByteRange::From(from) => bytes.slice(*from as usize..),
301 ByteRange::Last(n) => bytes.slice(bytes.len() - *n as usize..),
302 ByteRange::Until(n) => bytes.slice(0usize..bytes.len() - *n as usize),
303 }
304 }
305}
306
307impl From<(Option<ChunkOffset>, Option<ChunkOffset>)> for ByteRange {
308 fn from((start, end): (Option<ChunkOffset>, Option<ChunkOffset>)) -> Self {
309 match (start, end) {
310 (Some(start), Some(end)) => Self::Bounded(start..end),
311 (Some(start), None) => Self::From(start),
312 (None, Some(end)) => Self::Until(end),
314 (None, None) => Self::ALL,
315 }
316 }
317}
318
319pub type TableOffset = u32;
321
322#[derive(Debug, Error)]
324#[non_exhaustive]
325pub enum IcechunkFormatErrorKind {
326 #[error(transparent)]
327 VirtualReferenceError(#[from] VirtualReferenceErrorKind),
328 #[error("node not found at `{path:?}`")]
329 NodeNotFound { path: Path },
330 #[error("chunk coordinates not found `{coords:?}`")]
331 ChunkCoordinatesNotFound { coords: ChunkIndices },
332 #[error("snapshot id not found `{snapshot_id}`")]
333 SnapshotIdNotFound { snapshot_id: SnapshotId },
334 #[error("branch already exists `{branch} -> {snapshot_id}`")]
335 BranchAlreadyExists { branch: String, snapshot_id: SnapshotId },
336 #[error("branch not found `{branch}`")]
337 BranchNotFound { branch: String },
338 #[error("tag already exists `{tag}`")]
339 TagAlreadyExists { tag: String },
340 #[error("icechunk does not allow tag reuse and tag was already deleted `{tag}`")]
341 TagPreviouslyDeleted { tag: String },
342 #[error("tag not found `{tag}`")]
343 TagNotFound { tag: String },
344 #[error("snapshot id is already present in the repository: `{snapshot_id}`")]
345 DuplicateSnapshotId { snapshot_id: SnapshotId },
346 #[error("manifest information cannot be found in snapshot for id `{manifest_id}`")]
347 ManifestInfoNotFound { manifest_id: ManifestId },
348 #[error("invalid magic numbers in file")]
349 InvalidMagicNumbers, #[error(
351 "invalid icechunk header size, got {found} bytes, expected at least {expected}"
352 )]
353 InvalidIcechunkHeaderSize { found: usize, expected: usize },
354 #[error("invalid node type {found}, max supported value is {max_supported}")]
355 InvalidNodeType { found: u8, max_supported: u8 },
356 #[error(
357 "this repository uses Icechunk format version {found}, but this library only supports up to version {max_supported}. Please upgrade the icechunk library"
358 )]
359 InvalidSpecVersion { found: u8, max_supported: u8 },
360 #[error("this operation is not supported for Icechunk format version {version}")]
361 UnsupportedOperationForVersion { version: u8 },
362 #[error("Icechunk cannot read this file type, expected {expected:?} got {got}")]
363 InvalidFileType { expected: FileTypeBin, got: u8 }, #[error("Icechunk encountered an unknown file type code {got}")]
365 UnknownFileType { got: u8 },
366 #[error("Icechunk cannot read file, invalid compression algorithm")]
367 InvalidCompressionAlgorithm, #[error("Invalid Icechunk metadata file")]
369 InvalidFlatBuffer(#[from] InvalidFlatbuffer),
370 #[error("error during metadata deserialization")]
371 DeserializationError(#[from] Box<rmp_serde::decode::Error>),
372 #[error("error during metadata serialization")]
373 SerializationError(#[from] Box<rmp_serde::encode::Error>),
374 #[error("error during metadata serialization")]
375 SerializationErrorFlexBuffers(#[from] Box<flexbuffers::SerializationError>),
376 #[error("error during metadata deserialization")]
377 DeserializationErrorFlexBuffers(#[from] Box<flexbuffers::DeserializationError>),
378 #[error("I/O error")]
379 IO(#[from] std::io::Error),
380 #[error("path error")]
381 Path(#[from] PathError),
382 #[error("invalid timestamp in file")]
383 InvalidTimestamp,
384 #[error(
385 "update timestamp is invalid, please verify if the machine clock has drifted: update time: `{new_time}`, latest update time: `{latest_time}`"
386 )]
387 InvalidUpdateTimestamp { latest_time: DateTime<Utc>, new_time: DateTime<Utc> },
388 #[error("invalid feature flag name: {name}")]
389 InvalidFeatureFlagName { name: String },
390 #[error("invalid feature flag id: {id}")]
391 InvalidFeatureFlagId { id: u16 },
392 #[error("{feature_description} is disabled by a feature flag ({feature_flag})")]
393 FeatureFlagDisabled { feature_description: String, feature_flag: String },
394 #[error(
395 "compressed chunk location present but no decompression dictionary available"
396 )]
397 MissingLocationCompressionDictionary,
398 #[error("Invalid array metadata: {0}")]
399 InvalidArrayMetadata(String),
400 #[error("Move operation missing required field '{0}'")]
401 MissingRequiredField(String),
402}
403
404pub type IcechunkFormatError = ICError<IcechunkFormatErrorKind>;
405
406impl From<Infallible> for IcechunkFormatErrorKind {
407 fn from(value: Infallible) -> Self {
408 match value {}
409 }
410}
411
412pub type IcechunkResult<T> = Result<T, IcechunkFormatError>;
413
414pub mod format_constants {
416 use std::sync::LazyLock;
417
418 use icechunk_types::ICResultExt as _;
419 use serde::{Deserialize, Serialize};
420
421 use super::{IcechunkFormatError, IcechunkFormatErrorKind};
422
423 #[repr(u8)]
425 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
426 pub enum FileTypeBin {
427 Snapshot = 1u8,
428 Manifest = 2,
429 Attributes = 3,
430 TransactionLog = 4,
431 Chunk = 5,
432 RepoInfo = 6,
433 }
434
435 impl TryFrom<u8> for FileTypeBin {
436 type Error = String;
437
438 fn try_from(value: u8) -> Result<Self, Self::Error> {
439 match value {
440 n if n == FileTypeBin::Snapshot as u8 => Ok(FileTypeBin::Snapshot),
441 n if n == FileTypeBin::Manifest as u8 => Ok(FileTypeBin::Manifest),
442 n if n == FileTypeBin::Attributes as u8 => Ok(FileTypeBin::Attributes),
443 n if n == FileTypeBin::RepoInfo as u8 => Ok(FileTypeBin::RepoInfo),
444 n if n == FileTypeBin::TransactionLog as u8 => {
445 Ok(FileTypeBin::TransactionLog)
446 }
447 n if n == FileTypeBin::Chunk as u8 => Ok(FileTypeBin::Chunk),
448 n => Err(format!("Bad file type code: {n}")),
449 }
450 }
451 }
452
453 #[repr(u8)]
455 #[derive(
456 Debug,
457 Clone,
458 Copy,
459 PartialEq,
460 Eq,
461 Serialize,
462 Deserialize,
463 Default,
464 PartialOrd,
465 Ord,
466 )]
467 pub enum SpecVersionBin {
468 V1 = 1u8,
469 #[default]
470 V2 = 2u8,
471 }
474
475 impl TryFrom<u8> for SpecVersionBin {
476 type Error = IcechunkFormatErrorKind;
477
478 fn try_from(value: u8) -> Result<Self, Self::Error> {
479 match value {
480 n if n == SpecVersionBin::V1 as u8 => Ok(SpecVersionBin::V1),
481 n if n == SpecVersionBin::V2 as u8 => Ok(SpecVersionBin::V2),
482 n => Err(IcechunkFormatErrorKind::InvalidSpecVersion {
483 found: n,
484 max_supported: Self::current() as u8,
485 }),
486 }
487 }
488 }
489
490 impl SpecVersionBin {
491 pub fn current() -> Self {
492 Default::default()
493 }
494 }
495
496 impl std::fmt::Display for SpecVersionBin {
497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498 let s = match self {
499 SpecVersionBin::V1 => "1.0",
500 SpecVersionBin::V2 => "2.0",
501 };
502 write!(f, "{s}")
503 }
504 }
505
506 #[repr(u8)]
508 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
509 pub enum CompressionAlgorithmBin {
510 None = 0u8,
511 Zstd = 1u8,
512 }
513
514 impl TryFrom<u8> for CompressionAlgorithmBin {
515 type Error = String;
516
517 fn try_from(value: u8) -> Result<Self, Self::Error> {
518 match value {
519 n if n == CompressionAlgorithmBin::None as u8 => {
520 Ok(CompressionAlgorithmBin::None)
521 }
522 n if n == CompressionAlgorithmBin::Zstd as u8 => {
523 Ok(CompressionAlgorithmBin::Zstd)
524 }
525 n => Err(format!("Bad cmpression algorithm code: {n}")),
526 }
527 }
528 }
529
530 pub const ICECHUNK_FORMAT_MAGIC_BYTES: &[u8] = "ICE🧊CHUNK".as_bytes();
531 const _: () = assert!(ICECHUNK_FORMAT_MAGIC_BYTES.len() == 12);
533
534 pub const ICECHUNK_IMPL_NAME_LEN: usize = 24;
537 pub const ICECHUNK_SPEC_VERSION_OFFSET: usize =
538 ICECHUNK_FORMAT_MAGIC_BYTES.len() + ICECHUNK_IMPL_NAME_LEN;
539 pub const ICECHUNK_FILE_TYPE_OFFSET: usize = ICECHUNK_SPEC_VERSION_OFFSET + 1;
540 pub const ICECHUNK_COMPRESSION_OFFSET: usize = ICECHUNK_FILE_TYPE_OFFSET + 1;
541 pub const ICECHUNK_FILE_HEADER_LEN: usize = ICECHUNK_COMPRESSION_OFFSET + 1;
542
543 pub const LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY: &str = "icspecver";
547 pub const LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY_DEPRECATED: &str =
548 "ic_spec_ver";
549
550 pub const ICECHUNK_LIB_VERSION: &str = env!("CARGO_PKG_VERSION");
551
552 pub static ICECHUNK_CLIENT_NAME: LazyLock<String> =
553 LazyLock::new(|| "ic-".to_string() + ICECHUNK_LIB_VERSION);
554 pub const ICECHUNK_CLIENT_NAME_METADATA_KEY: &str = "icclient";
555 pub const ICECHUNK_CLIENT_NAME_METADATA_KEY_DEPRECATED: &str = "ic_client";
556
557 pub const ICECHUNK_FILE_TYPE_SNAPSHOT: &str = "snapshot";
558 pub const ICECHUNK_FILE_TYPE_MANIFEST: &str = "manifest";
559 pub const ICECHUNK_FILE_TYPE_TRANSACTION_LOG: &str = "transaction-log";
560 pub const ICECHUNK_FILE_TYPE_REPO_INFO: &str = "repo-info";
561 pub const ICECHUNK_FILE_TYPE_METADATA_KEY: &str = "icfiletype";
562 pub const ICECHUNK_FILE_TYPE_METADATA_KEY_DEPRECATED: &str = "ic_file_type";
563
564 pub const ICECHUNK_COMPRESSION_METADATA_KEY: &str = "iccompalg";
565 pub const ICECHUNK_COMPRESSION_METADATA_KEY_DEPRECATED: &str = "ic_comp_alg";
566 pub const ICECHUNK_COMPRESSION_ZSTD: &str = "zstd";
567
568 #[derive(Debug, Clone, PartialEq, Eq)]
572 pub struct FileHeader {
573 pub impl_name: String,
575 pub app_name: String,
577 pub app_version: Option<String>,
580 pub spec_version: SpecVersionBin,
581 pub file_type: FileTypeBin,
582 pub compression: CompressionAlgorithmBin,
583 }
584
585 pub fn parse_file_header(buf: &[u8]) -> Result<FileHeader, IcechunkFormatError> {
591 use IcechunkFormatErrorKind as K;
592
593 if buf.len() < ICECHUNK_FILE_HEADER_LEN {
594 return Err(K::InvalidIcechunkHeaderSize {
595 found: buf.len(),
596 expected: ICECHUNK_FILE_HEADER_LEN,
597 })
598 .capture();
599 }
600 if !buf.starts_with(ICECHUNK_FORMAT_MAGIC_BYTES) {
601 return Err(K::InvalidMagicNumbers).capture();
602 }
603
604 let impl_bytes =
605 &buf[ICECHUNK_FORMAT_MAGIC_BYTES.len()..ICECHUNK_SPEC_VERSION_OFFSET];
606 let impl_name =
607 String::from_utf8_lossy(impl_bytes).trim_end_matches([' ', '\0']).to_string();
608 let (app_name, app_version) = match impl_name.split_once('-') {
609 Some((name, version)) => (name.to_string(), Some(version.to_string())),
610 None => (impl_name.clone(), None),
611 };
612
613 let spec_version =
614 SpecVersionBin::try_from(buf[ICECHUNK_SPEC_VERSION_OFFSET]).capture()?;
615
616 let file_type_byte = buf[ICECHUNK_FILE_TYPE_OFFSET];
617 let file_type = FileTypeBin::try_from(file_type_byte)
618 .map_err(|_| K::UnknownFileType { got: file_type_byte })
619 .capture()?;
620
621 let compression =
622 CompressionAlgorithmBin::try_from(buf[ICECHUNK_COMPRESSION_OFFSET])
623 .map_err(|_| K::InvalidCompressionAlgorithm)
624 .capture()?;
625
626 Ok(FileHeader {
627 impl_name,
628 app_name,
629 app_version,
630 spec_version,
631 file_type,
632 compression,
633 })
634 }
635}
636
637#[inline(always)]
638#[expect(clippy::needless_pass_by_value)]
639pub fn lookup_index_by_key<'a, T: ::flatbuffers::Follow<'a> + 'a, K: Ord>(
640 v: ::flatbuffers::Vector<'a, T>,
641 key: K,
642 f: fn(&<T as ::flatbuffers::Follow<'a>>::Inner, &K) -> Ordering,
643) -> Option<usize> {
644 if v.is_empty() {
645 return None;
646 }
647
648 let mut left: usize = 0;
649 let mut right = v.len() - 1;
650
651 while left <= right {
652 let mid = (left + right) / 2;
653 let value = v.get(mid);
654 match f(&value, &key) {
655 Ordering::Equal => return Some(mid),
656 Ordering::Less => left = mid + 1,
657 Ordering::Greater => {
658 if mid == 0 {
659 return None;
660 }
661 right = mid - 1;
662 }
663 }
664 }
665
666 None
667}
668
669#[macro_export]
678macro_rules! roundtrip_serialization_tests {
679 ($($test_name: ident - $generator: ident), +) => {
680 $(
681 proptest!{
682 #[icechunk_macros::test]
683 fn $test_name(elem in $generator()) {
684 let bytes = rmp_serde::to_vec(&elem).unwrap();
685 let roundtrip = rmp_serde::from_slice(&bytes).unwrap();
686 assert_eq!(elem, roundtrip);
687 }
688 }
689 )*
690 }
691}
692
693#[cfg(test)]
694pub mod strategies;
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::strategies::{attributes_id, spec_version};
700 use pretty_assertions::assert_eq;
701 use proptest::prelude::*;
702
703 roundtrip_serialization_tests!(
704 serialize_and_deserialize_attribute_ids - attributes_id,
705 serialize_and_deserialize_spec_version_bin - spec_version
706 );
707
708 #[icechunk_macros::test]
709 fn test_object_id_serialization() {
710 let sid = SnapshotId::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
711 assert_eq!(
712 serde_json::to_string(&sid).unwrap(),
713 r#"[[0,1,2,3,4,5,6,7,8,9,10,11],null]"#
714 );
715 assert_eq!(String::from(&sid), "000G40R40M30E209185G");
716 assert_eq!(sid, SnapshotId::try_from("000G40R40M30E209185G").unwrap());
717 let sid = SnapshotId::random();
718 assert_eq!(
719 serde_json::from_slice::<SnapshotId>(
720 serde_json::to_vec(&sid).unwrap().as_slice()
721 )
722 .unwrap(),
723 sid,
724 );
725 }
726
727 #[icechunk_macros::test]
728 fn test_object_id_first_chars_are_every_possible_first_char() {
729 let first_chars: HashSet<char> = (0..=u8::MAX)
731 .filter_map(|first_byte| {
732 let mut buf = [0u8; 12];
733 buf[0] = first_byte;
734 String::from(&SnapshotId::new(buf)).chars().next()
735 })
736 .collect();
737 assert_eq!(first_chars, *OBJECT_ID_FIRST_CHARS);
738 }
739
740 #[icechunk_macros::test]
741 fn test_unknown_spec_version_gives_nice_error() {
742 use format_constants::SpecVersionBin;
743
744 let future_version: u8 = 3;
745 let result = SpecVersionBin::try_from(future_version);
746 assert!(result.is_err());
747 let err = result.unwrap_err();
748
749 let msg = err.to_string();
750 assert!(
751 msg.contains("format version 3"),
752 "Error should mention the found version: {msg}"
753 );
754 assert!(
755 msg.contains("upgrade the icechunk library"),
756 "Error should suggest upgrading: {msg}"
757 );
758 }
759
760 fn make_header(impl_name: &str, spec: u8, file_type: u8, compression: u8) -> Vec<u8> {
764 use format_constants::*;
765 let mut buf = Vec::with_capacity(ICECHUNK_FILE_HEADER_LEN);
766 buf.extend_from_slice(ICECHUNK_FORMAT_MAGIC_BYTES);
767 let padded = format!("{impl_name:<ICECHUNK_IMPL_NAME_LEN$}");
768 buf.extend_from_slice(&padded.as_bytes()[..ICECHUNK_IMPL_NAME_LEN]);
769 buf.push(spec);
770 buf.push(file_type);
771 buf.push(compression);
772 buf
773 }
774
775 #[icechunk_macros::test]
776 fn test_parse_file_header_roundtrip() {
777 use format_constants::*;
778 let mut buf = make_header(
779 ICECHUNK_CLIENT_NAME.as_str(),
780 SpecVersionBin::V2 as u8,
781 FileTypeBin::Snapshot as u8,
782 CompressionAlgorithmBin::Zstd as u8,
783 );
784 buf.extend_from_slice(b"a compressed body would go here");
786
787 let header = parse_file_header(&buf).unwrap();
788 assert_eq!(header.impl_name, *ICECHUNK_CLIENT_NAME);
789 assert_eq!(header.app_name, "ic");
790 assert_eq!(header.app_version.as_deref(), Some(ICECHUNK_LIB_VERSION));
791 assert_eq!(header.spec_version, SpecVersionBin::V2);
792 assert_eq!(header.file_type, FileTypeBin::Snapshot);
793 assert_eq!(header.compression, CompressionAlgorithmBin::Zstd);
794 }
795
796 #[icechunk_macros::test]
797 fn test_parse_file_header_trims_padding() {
798 use format_constants::*;
799 let buf = make_header(
801 "ic-9.9.9",
802 SpecVersionBin::V1 as u8,
803 FileTypeBin::Manifest as u8,
804 CompressionAlgorithmBin::None as u8,
805 );
806 let header = parse_file_header(&buf).unwrap();
807 assert_eq!(header.impl_name, "ic-9.9.9");
808 assert_eq!(header.app_name, "ic");
809 assert_eq!(header.app_version.as_deref(), Some("9.9.9"));
810 assert_eq!(header.spec_version, SpecVersionBin::V1);
811 assert_eq!(header.file_type, FileTypeBin::Manifest);
812 assert_eq!(header.compression, CompressionAlgorithmBin::None);
813 }
814
815 #[icechunk_macros::test]
816 fn test_parse_file_header_splits_on_first_dash() {
817 use format_constants::*;
818 let buf = make_header(
819 "ic-2.0.0-alpha.4",
820 SpecVersionBin::V2 as u8,
821 FileTypeBin::RepoInfo as u8,
822 CompressionAlgorithmBin::Zstd as u8,
823 );
824 let header = parse_file_header(&buf).unwrap();
825 assert_eq!(header.impl_name, "ic-2.0.0-alpha.4");
826 assert_eq!(header.app_name, "ic");
827 assert_eq!(header.app_version.as_deref(), Some("2.0.0-alpha.4"));
828 }
829
830 #[icechunk_macros::test]
831 fn test_parse_file_header_no_dash_fallback() {
832 use format_constants::*;
833 let buf = make_header(
834 "icjs",
835 SpecVersionBin::V2 as u8,
836 FileTypeBin::TransactionLog as u8,
837 CompressionAlgorithmBin::Zstd as u8,
838 );
839 let header = parse_file_header(&buf).unwrap();
840 assert_eq!(header.impl_name, "icjs");
841 assert_eq!(header.app_name, "icjs");
842 assert_eq!(header.app_version, None);
843 }
844
845 #[icechunk_macros::test]
846 fn test_parse_file_header_bad_magic() {
847 use format_constants::*;
848 let mut buf = make_header(
849 "ic-1.0.0",
850 SpecVersionBin::V2 as u8,
851 FileTypeBin::Snapshot as u8,
852 CompressionAlgorithmBin::Zstd as u8,
853 );
854 buf[0] = b'X';
855 let err = parse_file_header(&buf).unwrap_err();
856 assert!(matches!(err.kind(), IcechunkFormatErrorKind::InvalidMagicNumbers));
857 }
858
859 #[icechunk_macros::test]
860 fn test_parse_file_header_too_short() {
861 use format_constants::*;
862 let buf = vec![0u8; ICECHUNK_FILE_HEADER_LEN - 1];
863 let err = parse_file_header(&buf).unwrap_err();
864 assert!(matches!(
865 err.kind(),
866 IcechunkFormatErrorKind::InvalidIcechunkHeaderSize { .. }
867 ));
868 }
869
870 #[icechunk_macros::test]
871 fn test_parse_file_header_unknown_file_type() {
872 use format_constants::*;
873 let buf = make_header(
874 "ic-1.0.0",
875 SpecVersionBin::V2 as u8,
876 99,
877 CompressionAlgorithmBin::Zstd as u8,
878 );
879 let err = parse_file_header(&buf).unwrap_err();
880 assert!(matches!(
881 err.kind(),
882 IcechunkFormatErrorKind::UnknownFileType { got: 99 }
883 ));
884 }
885
886 #[icechunk_macros::test]
887 fn test_parse_file_header_bad_spec_version() {
888 use format_constants::*;
889 let buf = make_header(
890 "ic-1.0.0",
891 99,
892 FileTypeBin::Snapshot as u8,
893 CompressionAlgorithmBin::Zstd as u8,
894 );
895 let err = parse_file_header(&buf).unwrap_err();
896 assert!(matches!(
897 err.kind(),
898 IcechunkFormatErrorKind::InvalidSpecVersion { found: 99, .. }
899 ));
900 }
901
902 #[icechunk_macros::test]
903 fn test_object_metadata_keys_are_portable() {
904 use format_constants::*;
905 for key in [
906 LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY,
907 ICECHUNK_CLIENT_NAME_METADATA_KEY,
908 ICECHUNK_FILE_TYPE_METADATA_KEY,
909 ICECHUNK_COMPRESSION_METADATA_KEY,
910 ] {
911 assert!(
912 !key.is_empty()
913 && key.starts_with(|c: char| c.is_ascii_alphabetic())
914 && key.chars().all(|c| c.is_ascii_alphanumeric()),
915 "{key} must be ASCII alphanumeric and start with a letter: \
916 Azure rejects `-` and nginx-fronted S3 gateways drop `_`"
917 );
918 }
919 }
920}