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