Skip to main content

icechunk_format/
manifest.rs

1//! Chunk reference tables mapping coordinates to storage locations.
2
3use std::{
4    borrow::Cow,
5    cmp::{max, min},
6    iter::zip,
7    ops::Range,
8    string::FromUtf8Error,
9    sync::Arc,
10};
11
12use crate::flatbuffers::generated;
13use bytes::Bytes;
14use flatbuffers::VerifierOptions;
15use futures::{Stream, TryStreamExt as _};
16use itertools::{Itertools as _, any, multiunzip};
17use rand::{RngExt as _, rngs::SmallRng};
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21use crate::{IcechunkFormatError, IcechunkFormatErrorKind};
22use icechunk_types::{ETag, error::ICError};
23
24/// Resolved configuration for virtual chunk location compression within a manifest.
25#[derive(Debug, Clone, Copy)]
26pub struct LocationCompressionConfig {
27    pub min_num_chunks: u16,
28    pub dictionary_max_training_samples: u16,
29    pub dictionary_max_size_bytes: u32,
30    pub compression_level: i32,
31}
32
33use crate::{
34    ChunkId, ChunkIndices, ChunkLength, ChunkOffset, IcechunkResult, ManifestId, NodeId,
35};
36use icechunk_types::ICResultExt as _;
37
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum Overlap {
40    Complete,
41    Partial,
42    None,
43}
44
45#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ManifestExtents(Vec<Range<u32>>);
47
48impl ManifestExtents {
49    // sentinel for a "universal set"
50    pub const ALL: Self = Self(Vec::new());
51
52    pub fn new(from: &[u32], to: &[u32]) -> Self {
53        let v = from
54            .iter()
55            .zip(to.iter())
56            .map(|(a, b)| Range { start: *a, end: *b })
57            .collect();
58        Self(v)
59    }
60
61    pub fn from_ranges_iter(ranges: impl IntoIterator<Item = Range<u32>>) -> Self {
62        Self(ranges.into_iter().collect())
63    }
64
65    #[inline(always)]
66    pub fn contains(&self, coord: &[u32]) -> bool {
67        self.iter().zip(coord.iter()).all(|(range, that)| range.contains(that))
68    }
69
70    pub fn iter(&self) -> impl Iterator<Item = &Range<u32>> {
71        self.0.iter()
72    }
73
74    pub fn len(&self) -> usize {
75        self.0.len()
76    }
77
78    pub fn is_empty(&self) -> bool {
79        self.0.is_empty()
80    }
81
82    pub fn intersection(&self, other: &Self) -> Option<Self> {
83        if self == &Self::ALL {
84            return Some(other.clone());
85        }
86
87        debug_assert_eq!(self.len(), other.len());
88        let ranges = zip(self.iter(), other.iter())
89            .map(|(a, b)| max(a.start, b.start)..min(a.end, b.end))
90            .collect::<Vec<_>>();
91        if any(ranges.iter(), |r| r.end <= r.start) { None } else { Some(Self(ranges)) }
92    }
93
94    pub fn union(&self, other: &Self) -> Self {
95        if self == &Self::ALL {
96            return Self::ALL;
97        }
98        debug_assert_eq!(self.len(), other.len());
99        Self::from_ranges_iter(
100            zip(self.iter(), other.iter())
101                .map(|(a, b)| min(a.start, b.start)..max(a.end, b.end)),
102        )
103    }
104
105    pub fn overlap_with(&self, other: &Self) -> Overlap {
106        // Important: this is not symmetric.
107        if *other == Self::ALL {
108            return Overlap::Complete;
109        } else if *self == Self::ALL {
110            return Overlap::Partial;
111        }
112        debug_assert!(
113            self.len() == other.len(),
114            "Length mismatch: self = {:?}, other = {:?}",
115            &self,
116            &other
117        );
118        let mut overlap = Overlap::Complete;
119        for (a, b) in zip(other.iter(), self.iter()) {
120            debug_assert!(a.start <= a.end, "Invalid range: {:?}", a.clone());
121            debug_assert!(b.start <= b.end, "Invalid range: {:?}", b.clone());
122            if (a.end <= b.start) || (a.start >= b.end) {
123                return Overlap::None;
124            } else if !((a.start <= b.start) && (a.end >= b.end)) {
125                overlap = Overlap::Partial;
126            }
127        }
128        overlap
129    }
130
131    pub fn matches(&self, other: &ManifestExtents) -> bool {
132        // used in `.filter`
133        // ALL always matches any other extents
134        if *self == Self::ALL { true } else { self == other }
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct ManifestRef {
140    pub object_id: ManifestId,
141    pub extents: ManifestExtents,
142}
143
144// ManifestSplits can be constructed from a iterable of shard edges or boundaries
145// along each dimension.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct ManifestSplits(pub Vec<Vec<u32>>);
148
149impl ManifestSplits {
150    pub fn is_empty(&self) -> bool {
151        self.0.is_empty()
152    }
153
154    pub fn from_edges(iter: impl IntoIterator<Item = Vec<u32>>) -> Self {
155        Self(iter.into_iter().collect())
156    }
157
158    pub fn iter(&self) -> impl Iterator<Item = ManifestExtents> {
159        self.0
160            .iter()
161            // assume
162            // vec![vec![0u32, 1, 2], vec![3u32, 4, 5]]
163            .cloned()
164            // vec![(0, 1), (1, 2)], vec![(3, 4), (4, 5)]
165            .map(|x| x.into_iter().tuple_windows())
166            // vec![((0, 1), (3, 4)), ((0, 1), (4, 5)),
167            //      ((1, 2), (3, 4)), ((1, 2), (4, 5))]
168            .multi_cartesian_product()
169            // vec![((0, 3), (1, 4)), ((0, 4), (1, 5)),
170            //      ((1, 3), (2, 4)), ((1, 4), (2, 5))]
171            .map(multiunzip)
172            .map(|(from, to): (Vec<u32>, Vec<u32>)| {
173                ManifestExtents::new(from.as_slice(), to.as_slice())
174            })
175    }
176
177    pub fn len(&self) -> usize {
178        self.0.len()
179    }
180
181    /// Binary search to locate `ManifestExtents` for a given chunk coordinate.
182    #[inline(always)]
183    pub fn find<'a>(&'a self, coord: &'a ChunkIndices) -> Option<ManifestExtents> {
184        debug_assert_eq!(coord.0.len(), self.0.len());
185        let mut ranges = Vec::with_capacity(self.0.len());
186        for (edges, loc) in self.0.iter().zip(coord.0.iter()) {
187            let bin = edges.partition_point(|&e| e <= *loc);
188            if bin == 0 || bin >= edges.len() {
189                return None;
190            }
191            ranges.push(edges[bin - 1]..edges[bin]);
192        }
193        Some(ManifestExtents::from_ranges_iter(ranges))
194    }
195
196    pub fn compatible_with(&self, other: &Self) -> bool {
197        // this is not a simple zip + all(equals) because
198        // ordering might differ though both sets of splits
199        // must be complete.
200        for ours in self.iter() {
201            if any(other.iter(), |theirs| {
202                ours.overlap_with(&theirs) == Overlap::Partial
203                    || theirs.overlap_with(&ours) == Overlap::Partial
204            }) {
205                return false;
206            }
207        }
208        true
209    }
210}
211
212/// Helper function for constructing uniformly spaced manifest split edges
213pub fn uniform_manifest_split_edges(num_chunks: u32, split_size: &u32) -> Vec<u32> {
214    (0u32..=num_chunks)
215        .step_by(*split_size as usize)
216        .chain((!num_chunks.is_multiple_of(*split_size)).then_some(num_chunks))
217        .collect()
218}
219
220#[derive(Debug, Error)]
221#[non_exhaustive]
222pub enum VirtualReferenceErrorKind {
223    #[error(
224        "no virtual chunk container can handle the chunk location ({0}), edit the repository configuration adding a virtual chunk container for the chunk references, see https://icechunk.io/en/stable/virtual/"
225    )]
226    NoContainerForUrl(String),
227    #[error("error parsing virtual ref URL: {url:?}")]
228    CannotParseUrl {
229        #[source]
230        cause: url::ParseError,
231        url: String,
232    },
233    #[error("invalid credentials for virtual reference of type {0}")]
234    InvalidCredentials(String),
235    #[error("{}", format_unauthorized_vcc(.url_prefix, .name))]
236    UnauthorizedVirtualChunkContainer { url_prefix: String, name: Option<String> },
237    #[error("virtual reference has no path segments {0}")]
238    NoPathSegments(String),
239    #[error("unsupported scheme for virtual chunk refs: {0}")]
240    UnsupportedScheme(String),
241    #[error("error parsing bucket name from virtual ref URL {0}")]
242    CannotParseBucketName(String),
243    #[error(
244        "object store backend cannot address key {0:?}: it contains empty (//), '.' or '..' path segments. If you need support for keys like this, please reach out to the Icechunk team by opening an issue describing your situation at https://github.com/earth-mover/icechunk/issues"
245    )]
246    UnsupportedObjectKeyForBackend(String),
247    #[error("error fetching virtual reference")]
248    FetchError(#[source] Box<dyn std::error::Error + Send + Sync>),
249    #[error("the checksum of the object owning the virtual chunk has changed ({0})")]
250    ObjectModified(String),
251    #[error(
252        "error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})"
253    )]
254    InvalidObjectSize { expected: u64, available: u64 },
255    #[error("azure store configuration must include an account")]
256    AzureConfigurationMustIncludeAccount,
257    #[error("decoding virtual chunk url")]
258    Decoding(#[from] FromUtf8Error),
259    #[error(
260        "no virtual chunk container named '{0}' found, check the repository configuration"
261    )]
262    NoContainerForName(String),
263    #[error("unknown error")]
264    OtherError(#[from] Box<dyn std::error::Error + Send + Sync>),
265}
266
267fn format_unauthorized_vcc(url_prefix: &str, name: &Option<String>) -> String {
268    let container = match name {
269        Some(n) => format!(" (container: {n})"),
270        None => String::new(),
271    };
272    format!(
273        "a virtual chunk in this repository resolves to the url prefix {url_prefix}{container}, \
274         to be able to fetch the chunk you need to authorize the virtual chunk container \
275         when you open/create the repository, see https://icechunk.io/en/stable/virtual/"
276    )
277}
278
279pub type VirtualReferenceError = ICError<VirtualReferenceErrorKind>;
280
281pub const VCC_RELATIVE_URL_SCHEME: &str = "vcc://";
282
283#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
284pub struct VirtualChunkLocation(String);
285
286impl VirtualChunkLocation {
287    pub fn url(&self) -> &str {
288        self.0.as_str()
289    }
290
291    /// Wrap a pre-validated location string without re-parsing.
292    fn from_trusted(s: String) -> Self {
293        VirtualChunkLocation(s)
294    }
295
296    /// Returns true if this is a relative `vcc://` location.
297    pub fn is_relative(&self) -> bool {
298        self.0.starts_with(VCC_RELATIVE_URL_SCHEME)
299    }
300
301    /// If this is a `vcc://name/path` location, returns `(name, relative_path)`.
302    pub fn parse_vcc(&self) -> Option<(&str, &str)> {
303        let rest = self.0.strip_prefix(VCC_RELATIVE_URL_SCHEME)?;
304        let slash = rest.find('/')?;
305        Some((&rest[..slash], &rest[slash + 1..]))
306    }
307
308    /// Creates a relative location from a VCC name and a path relative to its prefix.
309    pub fn from_vcc_path(
310        container_name: &str,
311        relative_path: &str,
312    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
313        if container_name.is_empty() || container_name.contains('/') {
314            return Err(VirtualReferenceError::capture(
315                VirtualReferenceErrorKind::NoContainerForName(container_name.to_string()),
316            ));
317        }
318        let mut result = String::with_capacity(
319            VCC_RELATIVE_URL_SCHEME.len()
320                + container_name.len()
321                + 1
322                + relative_path.len(),
323        );
324        result.push_str(VCC_RELATIVE_URL_SCHEME);
325        result.push_str(container_name);
326        for segment in relative_path.split('/').filter(|s| !s.is_empty()) {
327            result.push('/');
328            result.push_str(segment);
329        }
330        Ok(VirtualChunkLocation(result))
331    }
332
333    /// Parse a virtual chunk location from a URL string.
334    ///
335    /// `path` is a URL: an absolute location (`s3://`, `gs://`, `file://`,
336    /// `https://`, …) or a `vcc://name/relative` reference into a named container.
337    ///
338    /// For object-store schemes the object key is the URL's path and is kept
339    /// **verbatim** — repeated slashes (`//`) and `.`/`..` segments are preserved,
340    /// not normalized, so the reference always addresses the exact object. Because
341    /// the input is a URL, any character that is reserved in a URL and is meant to
342    /// be part of the key must be percent-encoded. In particular `?` and `#` start
343    /// the URL query and fragment, so a key that literally contains them (or a
344    /// literal `%`) must be encoded: the key `a?b#c` is the URL
345    /// `s3://bucket/a%3Fb%23c`, and a literal `%` is written `%25`. A query or
346    /// fragment, if present, is preserved in the stored location but is not part
347    /// of the object key.
348    ///
349    /// `file://` is the exception: its path is WHATWG-normalized (resolving
350    /// `.`/`..`), which is required so a reference cannot escape its container.
351    pub fn from_url(path: &str) -> Result<VirtualChunkLocation, VirtualReferenceError> {
352        // vcc:// is a valid URL scheme, so from_absolute_path handles absolute
353        // (s3://, gcs://, file://, …) and relative (vcc://) URLs alike.
354        Self::from_absolute_path(path)
355    }
356
357    fn from_absolute_path(
358        path: &str,
359    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
360        match path.split_once("://") {
361            // Object-store and `vcc://` schemes use the verbatim path as location
362            Some((scheme, after_scheme)) if !scheme.eq_ignore_ascii_case("file") => {
363                Self::from_verbatim_url(path, scheme, after_scheme)
364            }
365            // `file://` is a security boundary: there `..` is a path traversal
366            // and must be normalized away.
367            // Anything without a clean `scheme://` (like `mailto:foo`,
368            // single-slash forms like `s3:/x`, or malformed input) is rare and
369            // goes through the parser too for normalization and good errors
370            _ => Self::from_normalized_url(path),
371        }
372    }
373
374    /// Cheap validation for virtual chunk locations in object stores or vcc schemes
375    fn from_verbatim_url(
376        path: &str,
377        scheme: &str,
378        after_scheme: &str,
379    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
380        // Authority is the substring between "://" and the first '/', '?' or '#'.
381        // It must be non-empty: a bucket/host, or a `vcc://` container name.
382        let authority_len =
383            after_scheme.find(['/', '?', '#']).unwrap_or(after_scheme.len());
384        if authority_len == 0 {
385            let kind = if scheme.eq_ignore_ascii_case("vcc") {
386                VirtualReferenceErrorKind::NoContainerForName(path.to_string())
387            } else {
388                VirtualReferenceErrorKind::CannotParseBucketName(path.to_string())
389            };
390            return Err(VirtualReferenceError::capture(kind));
391        }
392
393        // We normalize scheme that is not part of the actual object store key
394        let mut location = path.to_string();
395        location[..scheme.len()].make_ascii_lowercase();
396        Ok(VirtualChunkLocation(location))
397    }
398
399    /// Normalize the full url, including the filesystem "key"
400    fn from_normalized_url(
401        path: &str,
402    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
403        let url = url::Url::parse(path)
404            .map_err(|e| VirtualReferenceErrorKind::CannotParseUrl {
405                cause: e,
406                url: path.to_string(),
407            })
408            .capture()?;
409        let scheme = url.scheme();
410        // cannot-be-a-base URLs (e.g. `mailto:foo`) have no path segments and
411        // cannot name an object in a store.
412        url.path_segments()
413            .ok_or_else(|| VirtualReferenceErrorKind::NoPathSegments(path.into()))
414            .capture()?;
415
416        if url.host_str().is_none() {
417            match scheme {
418                "file" => {}
419                "vcc" => {
420                    return Err(VirtualReferenceError::capture(
421                        VirtualReferenceErrorKind::NoContainerForName(path.into()),
422                    ));
423                }
424                _ => {
425                    return Err(VirtualReferenceError::capture(
426                        VirtualReferenceErrorKind::CannotParseBucketName(path.into()),
427                    ));
428                }
429            }
430        }
431
432        Ok(VirtualChunkLocation(url.as_str().to_string()))
433    }
434}
435
436#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
437pub struct SecondsSinceEpoch(pub u32);
438
439#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
440pub enum Checksum {
441    LastModified(SecondsSinceEpoch),
442    ETag(ETag),
443}
444
445#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
446pub struct VirtualChunkRef {
447    pub location: VirtualChunkLocation,
448    pub offset: ChunkOffset,
449    pub length: ChunkLength,
450    pub checksum: Option<Checksum>,
451}
452
453#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
454pub struct ChunkRef {
455    pub id: ChunkId,
456    pub offset: ChunkOffset,
457    pub length: ChunkLength,
458}
459
460#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
461#[non_exhaustive]
462pub enum ChunkPayload {
463    Inline(Bytes),
464    Virtual(VirtualChunkRef),
465    Ref(ChunkRef),
466}
467
468impl ChunkPayload {
469    /// Number of bytes of chunk data this payload refers to.
470    pub fn length(&self) -> ChunkLength {
471        match self {
472            ChunkPayload::Inline(bytes) => bytes.len() as ChunkLength,
473            ChunkPayload::Virtual(VirtualChunkRef { length, .. }) => *length,
474            ChunkPayload::Ref(ChunkRef { length, .. }) => *length,
475        }
476    }
477}
478
479#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
480pub struct ChunkInfo {
481    pub node: NodeId,
482    pub coord: ChunkIndices,
483    pub payload: ChunkPayload,
484}
485
486const COMPRESSION_ALG_NONE: u8 = 0;
487const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
488// This is the maximum size we support for a virtual chunk url that will be compressed
489const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
490
491#[derive(PartialEq)]
492pub struct Manifest {
493    buffer: Vec<u8>,
494}
495
496impl std::fmt::Debug for Manifest {
497    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498        f.debug_struct("Manifest")
499            .field("id", &self.id())
500            .field("chunks", &self.len())
501            .finish_non_exhaustive()
502    }
503}
504
505impl Manifest {
506    pub fn id(&self) -> ManifestId {
507        ManifestId::new(self.root().id().0)
508    }
509
510    pub fn bytes(&self) -> &[u8] {
511        self.buffer.as_slice()
512    }
513
514    pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
515        let _ = flatbuffers::root_with_opts::<generated::Manifest<'_>>(
516            &ROOT_OPTIONS,
517            buffer.as_slice(),
518        )
519        .capture()?;
520        Ok(Manifest { buffer })
521    }
522
523    /// Create a zstd decompressor from the manifest's location dictionary, if present.
524    fn decompressor(
525        &self,
526    ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
527        let root = self.root();
528        if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
529            return Ok(None);
530        }
531        match root.location_dictionary() {
532            Some(dict_bytes) => {
533                let decompressor =
534                    zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
535                        .capture()?;
536                Ok(Some(decompressor))
537            }
538            None => Ok(None),
539        }
540    }
541
542    pub fn from_sorted_vec(
543        manifest_id: &ManifestId,
544        sorted_chunks: Vec<ChunkInfo>,
545        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
546    ) -> IcechunkResult<Option<Self>> {
547        let location_compression_dict =
548            train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
549        // we generate all compressed locations if needed
550        let compressed_locations =
551            match (&location_compression_dict, virtual_chunks_compression_config) {
552                (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
553                _ => vec![None; sorted_chunks.len()],
554            };
555
556        // Sequential FlatBuffer building using pre-compressed locations
557        // TODO: what's a good capacity?
558        let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
559
560        let len = sorted_chunks.len();
561        let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
562
563        let mut array_manifests = Vec::with_capacity(1);
564        while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
565            // TODO: adjust capacity when multiple arrays have their manifests consolidated in to one.
566            let mut refs = Vec::with_capacity(len);
567            while let Some((chunk, precompressed)) =
568                all.next_if(|(chunk, _)| chunk.node == current_node)
569            {
570                refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
571            }
572
573            let node_id = Some(generated::ObjectId8::new(&current_node.0));
574            let refs = Some(builder.create_vector(refs.as_slice()));
575            let array_manifest = generated::ArrayManifest::create(
576                &mut builder,
577                &generated::ArrayManifestArgs {
578                    node_id: node_id.as_ref(),
579                    refs,
580                    ..Default::default()
581                },
582            );
583            array_manifests.push(array_manifest);
584        }
585
586        if array_manifests.is_empty() {
587            // empty manifest
588            return Ok(None);
589        }
590
591        let arrays = builder.create_vector(array_manifests.as_slice());
592        let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
593
594        let (location_dictionary, compression_algorithm) =
595            if let Some(ref dict) = location_compression_dict {
596                (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
597            } else {
598                (None, COMPRESSION_ALG_NONE)
599            };
600
601        let manifest = generated::Manifest::create(
602            &mut builder,
603            &generated::ManifestArgs {
604                id: Some(&bin_manifest_id),
605                arrays: Some(arrays),
606                location_dictionary,
607                compression_algorithm,
608                ..Default::default()
609            },
610        );
611
612        builder.finish(manifest, Some("Ichk"));
613        let (mut buffer, offset) = builder.collapse();
614        buffer.drain(0..offset);
615        buffer.shrink_to_fit();
616        Ok(Some(Manifest { buffer }))
617    }
618
619    pub async fn from_stream<E>(
620        manifest_id: &ManifestId,
621        stream: impl Stream<Item = Result<ChunkInfo, E>>,
622        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
623    ) -> Result<Option<Self>, E>
624    where
625        E: From<IcechunkFormatError>,
626    {
627        let mut all = stream.try_collect::<Vec<_>>().await?;
628        all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
629        Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
630    }
631
632    /// Used for tests
633    pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
634        manifest_id: &ManifestId,
635        iter: T,
636        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
637    ) -> IcechunkResult<Option<Self>> {
638        Self::from_stream(
639            manifest_id,
640            futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
641            virtual_chunks_compression_config,
642        )
643        .await
644    }
645
646    pub fn len(&self) -> usize {
647        self.root().arrays().iter().map(|am| am.refs().len()).sum()
648    }
649
650    #[must_use]
651    pub fn is_empty(&self) -> bool {
652        self.len() == 0
653    }
654
655    #[expect(unsafe_code)]
656    fn root(&self) -> generated::Manifest<'_> {
657        // SAFETY: self.buffer was serialized by our own flatbuffers serialization code.
658        // We skip validation for performance; a corrupt buffer here indicates
659        // file corruption or a bad Icechunk implementation, not a caller error.
660        unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
661    }
662
663    pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
664        self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
665    }
666
667    pub fn uses_location_compression(&self) -> bool {
668        self.root().compression_algorithm() != COMPRESSION_ALG_NONE
669    }
670
671    pub fn location_dictionary_size(&self) -> Option<usize> {
672        self.root().location_dictionary().map(|d| d.len())
673    }
674
675    pub fn num_compressed_refs(&self) -> usize {
676        self.root()
677            .arrays()
678            .iter()
679            .flat_map(|am| am.refs().iter())
680            .filter(|r| r.compressed_location().is_some())
681            .count()
682    }
683
684    pub fn get_chunk_payload(
685        &self,
686        node: &NodeId,
687        coord: &ChunkIndices,
688    ) -> IcechunkResult<ChunkPayload> {
689        let mut decompressor = self.decompressor()?;
690        let manifest = self.root();
691        let chunk_ref = lookup_node(manifest, node)
692            .and_then(|array_manifest| lookup_ref(array_manifest, coord))
693            .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
694                coords: coord.clone(),
695            })
696            .capture()?;
697        ref_to_payload(chunk_ref, decompressor.as_mut())
698    }
699
700    pub fn iter(
701        self: Arc<Self>,
702        node: NodeId,
703    ) -> Result<
704        impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
705        IcechunkFormatError,
706    > {
707        PayloadIterator::new(self, node)
708    }
709
710    pub fn chunk_payloads(
711        &self,
712    ) -> Result<
713        impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
714        IcechunkFormatError,
715    > {
716        let mut decompressor = self.decompressor()?;
717        let refs: Vec<_> =
718            self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
719        Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
720    }
721}
722
723fn lookup_node<'a>(
724    manifest: generated::Manifest<'a>,
725    node: &NodeId,
726) -> Option<generated::ArrayManifest<'a>> {
727    manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
728}
729
730fn lookup_ref<'a>(
731    array_manifest: generated::ArrayManifest<'a>,
732    coord: &ChunkIndices,
733) -> Option<generated::ChunkRef<'a>> {
734    array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
735        chunk_ref.index().iter().cmp(coords.iter().copied())
736    })
737}
738
739pub struct PayloadIterator {
740    manifest: Arc<Manifest>,
741    node_id: NodeId,
742    last_ref_index: usize,
743    decompressor: Option<zstd::bulk::Decompressor<'static>>,
744}
745
746impl std::fmt::Debug for PayloadIterator {
747    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        f.debug_struct("PayloadIterator")
749            .field("node_id", &self.node_id)
750            .field("last_ref_index", &self.last_ref_index)
751            .finish_non_exhaustive()
752    }
753}
754
755impl PayloadIterator {
756    fn new(
757        manifest: Arc<Manifest>,
758        node_id: NodeId,
759    ) -> Result<Self, IcechunkFormatError> {
760        let decompressor = manifest.decompressor()?;
761        Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
762    }
763}
764
765impl Iterator for PayloadIterator {
766    type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
767
768    fn next(&mut self) -> Option<Self::Item> {
769        let manifest = self.manifest.root();
770        lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
771            let refs = array_manifest.refs();
772            if self.last_ref_index >= refs.len() {
773                return None;
774            }
775
776            let chunk_ref = refs.get(self.last_ref_index);
777            self.last_ref_index += 1;
778            Some(
779                ref_to_payload(chunk_ref, self.decompressor.as_mut())
780                    .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
781            )
782        })
783    }
784}
785
786fn ref_to_payload(
787    chunk_ref: generated::ChunkRef<'_>,
788    decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
789) -> Result<ChunkPayload, IcechunkFormatError> {
790    if let Some(chunk_id) = chunk_ref.chunk_id() {
791        let id = ChunkId::new(chunk_id.0);
792        Ok(ChunkPayload::Ref(ChunkRef {
793            id,
794            offset: chunk_ref.offset(),
795            length: chunk_ref.length(),
796        }))
797    } else if let Some(compressed) = chunk_ref.compressed_location() {
798        let decompressor = decompressor
799            .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
800            .capture()?;
801        let decompressed = decompressor
802            .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
803            .capture()?;
804        let location_string = String::from_utf8(decompressed)
805            .map_err(|e| {
806                IcechunkFormatErrorKind::IO(std::io::Error::new(
807                    std::io::ErrorKind::InvalidData,
808                    e,
809                ))
810            })
811            .capture()?;
812        let location = VirtualChunkLocation::from_trusted(location_string);
813        Ok(ChunkPayload::Virtual(VirtualChunkRef {
814            location,
815            checksum: checksum(&chunk_ref),
816            offset: chunk_ref.offset(),
817            length: chunk_ref.length(),
818        }))
819    } else if let Some(location) = chunk_ref.location() {
820        let location = VirtualChunkLocation::from_trusted(location.to_string());
821        Ok(ChunkPayload::Virtual(VirtualChunkRef {
822            location,
823            checksum: checksum(&chunk_ref),
824            offset: chunk_ref.offset(),
825            length: chunk_ref.length(),
826        }))
827    } else if let Some(data) = chunk_ref.inline() {
828        Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
829    } else {
830        Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
831            flatbuffers::InvalidFlatbuffer::InconsistentUnion {
832                field: Cow::Borrowed("chunk_id+location+inline"),
833                field_type: Cow::Borrowed("invalid"),
834                error_trace: Default::default(),
835            },
836        ))
837        .capture()
838    }
839}
840
841fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
842    if let Some(etag) = payload.checksum_etag() {
843        Some(Checksum::ETag(ETag(etag.to_string())))
844    } else if payload.checksum_last_modified() > 0 {
845        Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
846    } else {
847        None
848    }
849}
850
851/// Sample virtual chunk URLs and train a zstd dictionary for compressing them.
852///
853/// Uses reservoir sampling (Algorithm R) to collect a uniform random sample in a single
854/// pass without knowing the total virtual chunk count in advance.
855/// See: <https://en.wikipedia.org/wiki/Reservoir_sampling#Simple>:_`Algorithm_R`
856///
857/// Returns `Some(dict_bytes)` if compression is enabled and there are enough virtual
858/// chunks, `None` if compression is disabled or cannot be executed.
859fn train_location_dictionary(
860    chunks: &[ChunkInfo],
861    virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
862) -> IcechunkResult<Option<Vec<u8>>> {
863    let Some(config) = virtual_chunks_compression_config else {
864        return Ok(None);
865    };
866    let max_samples = config.dictionary_max_training_samples as usize;
867    let min_chunks = config.min_num_chunks as usize;
868    let max_dict_size = config.dictionary_max_size_bytes as usize;
869
870    let mut virtual_count: usize = 0;
871    let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
872    let mut rng: SmallRng = rand::make_rng();
873
874    if chunks.len() < min_chunks {
875        return Ok(None);
876    }
877
878    for chunk in chunks {
879        if let ChunkPayload::Virtual(vref) = &chunk.payload {
880            let loc = vref.location.url();
881            if virtual_count < max_samples {
882                // Fill phase: reservoir not yet full
883                reservoir.push(loc);
884            } else {
885                // Replace phase: include new item with decreasing probability
886                let j = rng.random_range(0..=virtual_count);
887                if j < max_samples {
888                    reservoir[j] = loc;
889                }
890            }
891            virtual_count += 1;
892        }
893    }
894
895    if virtual_count < min_chunks {
896        return Ok(None);
897    }
898
899    let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
900
901    // zstd doesn't like it if many samples are too small, in that case we don't compress
902    let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
903    if small_count >= sample_bytes.len() / 2 {
904        tracing::warn!(
905            "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
906            sample_bytes.len()
907        );
908        return Ok(None);
909    }
910
911    let mut sample_data: Vec<u8> =
912        sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
913    let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
914    let total_sample_size = sample_data.len();
915
916    // zstd requires total sample data >= max_dict_size; repeat samples if needed
917    if total_sample_size > 0 && total_sample_size < max_dict_size {
918        let repeats = (max_dict_size / total_sample_size) + 1;
919        let original_data = sample_data.clone();
920        let original_sizes = sample_sizes.clone();
921        for _ in 0..repeats {
922            sample_data.extend_from_slice(&original_data);
923            sample_sizes.extend_from_slice(&original_sizes);
924        }
925    }
926
927    Ok(Some(
928        zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
929            .capture()?,
930    ))
931}
932
933/// Compress virtual chunk locations in parallel using a pre-trained zstd dictionary.
934///
935/// Returns one entry per chunk: `Some(compressed_bytes)` for virtual chunks,
936/// `None` for inline/ref chunks.
937fn compress_locations(
938    chunks: &[ChunkInfo],
939    dict: &[u8],
940    config: &LocationCompressionConfig,
941) -> Vec<Option<Vec<u8>>> {
942    let compression_level = config.compression_level;
943
944    #[cfg(not(target_family = "wasm"))]
945    {
946        let num_threads =
947            std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
948        let slice_size = chunks.len().div_ceil(num_threads);
949
950        std::thread::scope(|s| {
951            let handles: Vec<_> = chunks
952                .chunks(slice_size)
953                .map(|slice| {
954                    s.spawn(|| {
955                        let mut comp = zstd::bulk::Compressor::with_dictionary(
956                            compression_level,
957                            dict,
958                        )
959                        .ok();
960                        slice
961                            .iter()
962                            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
963                                (ChunkPayload::Virtual(vref), Some(comp)) => {
964                                    comp.compress(vref.location.url().as_bytes()).ok()
965                                }
966                                _ => None,
967                            })
968                            .collect::<Vec<_>>()
969                    })
970                })
971                .collect();
972
973            #[expect(clippy::expect_used)]
974            handles
975                .into_iter()
976                .flat_map(|h| {
977                    h.join()
978                        .expect("Cannot join threads compressing virtual chunk locations")
979                })
980                .collect()
981        })
982    }
983
984    #[cfg(target_family = "wasm")]
985    {
986        let mut comp =
987            zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
988        chunks
989            .iter()
990            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
991                (ChunkPayload::Virtual(vref), Some(comp)) => {
992                    comp.compress(vref.location.url().as_bytes()).ok()
993                }
994                _ => None,
995            })
996            .collect()
997    }
998}
999
1000fn mk_chunk_ref<'bldr>(
1001    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1002    chunk: ChunkInfo,
1003    precompressed_location: Option<Vec<u8>>,
1004) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
1005    let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
1006    match chunk.payload {
1007        ChunkPayload::Inline(bytes) => {
1008            let bytes = builder.create_vector(bytes.as_ref());
1009            let args = generated::ChunkRefArgs {
1010                inline: Some(bytes),
1011                index,
1012                ..Default::default()
1013            };
1014            generated::ChunkRef::create(builder, &args)
1015        }
1016        ChunkPayload::Virtual(virtual_chunk_ref) => {
1017            let location_str = virtual_chunk_ref.location.0.as_str();
1018            let (location, compressed_location) =
1019                if let Some(compressed) = precompressed_location {
1020                    (None, Some(builder.create_vector(&compressed)))
1021                } else {
1022                    (Some(builder.create_string(location_str)), None)
1023                };
1024            let args = generated::ChunkRefArgs {
1025                index,
1026                location,
1027                compressed_location,
1028                offset: virtual_chunk_ref.offset,
1029                length: virtual_chunk_ref.length,
1030                checksum_etag: match &virtual_chunk_ref.checksum {
1031                    Some(cs) => match cs {
1032                        Checksum::LastModified(_) => None,
1033                        Checksum::ETag(etag) => {
1034                            Some(builder.create_string(etag.0.as_str()))
1035                        }
1036                    },
1037                    None => None,
1038                },
1039                checksum_last_modified: match &virtual_chunk_ref.checksum {
1040                    Some(cs) => match cs {
1041                        Checksum::LastModified(seconds) => seconds.0,
1042                        Checksum::ETag(_) => 0,
1043                    },
1044                    None => 0,
1045                },
1046                ..Default::default()
1047            };
1048            generated::ChunkRef::create(builder, &args)
1049        }
1050        ChunkPayload::Ref(chunk_ref) => {
1051            let id = generated::ObjectId12::new(&chunk_ref.id.0);
1052            let args = generated::ChunkRefArgs {
1053                index,
1054                offset: chunk_ref.offset,
1055                length: chunk_ref.length,
1056                chunk_id: Some(&id),
1057                ..Default::default()
1058            };
1059            generated::ChunkRef::create(builder, &args)
1060        }
1061    }
1062}
1063
1064static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1065    max_depth: 64,
1066    max_tables: 500_000_000,
1067    max_apparent_size: 1 << 31, // taken from the default
1068    ignore_missing_null_terminator: true,
1069};
1070
1071#[cfg(test)]
1072#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
1073mod tests {
1074    use super::*;
1075    use crate::roundtrip_serialization_tests;
1076    use crate::strategies::{
1077        ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1078        manifest_splits, shapes_and_dims,
1079    };
1080    use icechunk_macros::{self, tokio_test};
1081    use itertools::{all, multizip};
1082    use proptest::collection::vec;
1083    use proptest::prelude::*;
1084    use std::error::Error;
1085    use test_strategy::proptest as alt_proptest;
1086
1087    roundtrip_serialization_tests!(
1088        serialize_and_deserialize_manifest_ref - manifest_ref,
1089        serialize_and_deserialize_manifest_splits - manifest_splits
1090    );
1091
1092    #[alt_proptest]
1093    fn test_property_extents_set_ops_same(
1094        #[strategy(manifest_extents(4))] e: ManifestExtents,
1095    ) {
1096        prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1097        prop_assert_eq!(e.union(&e), e.clone());
1098        prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1099    }
1100
1101    #[alt_proptest]
1102    fn test_property_extents_set_ops(
1103        #[strategy(manifest_extents(4))] e1: ManifestExtents,
1104        #[strategy(manifest_extents(4))] e2: ManifestExtents,
1105    ) {
1106        let union = e1.union(&e2);
1107        let intersection = e1.intersection(&e2);
1108
1109        prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1110        prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1111        prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1112        prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1113
1114        // order is important for the next 2
1115        prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1116        prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1117
1118        if intersection.is_some() {
1119            let int = intersection.unwrap();
1120            let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1121            prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1122            prop_assert_eq!(int.overlap_with(&e2), expected);
1123        } else {
1124            prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1125            prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1126        }
1127    }
1128
1129    #[alt_proptest]
1130    fn test_property_extents_widths(
1131        #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1132        #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1133        #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1134    ) {
1135        let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1136        let extent2 = ManifestExtents::from_ranges_iter(
1137            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1138                |(extent, dleft, dright)| {
1139                    ((extent.start as i32 + dleft) as u32)
1140                        ..((extent.end as i32 + dright) as u32)
1141                },
1142            ),
1143        );
1144
1145        if all(delta_left.iter(), |elem| elem == &0i32)
1146            && all(delta_right.iter(), |elem| elem == &0i32)
1147        {
1148            prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1149        }
1150
1151        let extent2 = ManifestExtents::from_ranges_iter(
1152            multizip((
1153                extent1.iter(),
1154                widths.iter(),
1155                delta_left.iter(),
1156                delta_right.iter(),
1157            ))
1158            .map(|(extent, width, dleft, dright)| {
1159                let (low, high) = (dleft.min(dright), dleft.max(dright));
1160                ((extent.start as i32 + width + low) as u32)
1161                    ..((extent.end as i32 + width + high) as u32)
1162            }),
1163        );
1164
1165        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1166
1167        let extent2 = ManifestExtents::from_ranges_iter(
1168            multizip((
1169                extent1.iter(),
1170                widths.iter(),
1171                delta_left.iter(),
1172                delta_right.iter(),
1173            ))
1174            .map(|(extent, width, dleft, dright)| {
1175                let (low, high) = (dleft.min(dright), dleft.max(dright));
1176                ((extent.start as i32 - width - high).max(0i32) as u32)
1177                    ..((extent.end as i32 - width - low) as u32)
1178            }),
1179        );
1180
1181        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1182
1183        let extent2 = ManifestExtents::from_ranges_iter(
1184            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1185                |(extent, dleft, dright)| {
1186                    ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1187                        ..((extent.end as i32 + dright + 1) as u32)
1188                },
1189            ),
1190        );
1191        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1192    }
1193
1194    // Regression test for https://github.com/earth-mover/icechunk/issues/2218
1195    // VirtualChunkLocation::from_url used to drop userinfo, port, query and
1196    // fragment from the URL, which corrupts opaque object keys.
1197    // Non-`file://` locations are now stored verbatim: every URL part,
1198    // including repeated `//` and literal `..`, is preserved exactly.
1199    #[icechunk_macros::test]
1200    fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1201        let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1202        let stored = VirtualChunkLocation::from_url(input)?;
1203        assert_eq!(stored.url(), input);
1204        Ok(())
1205    }
1206
1207    // Object-store keys are opaque: `//`, `.` and `..` are literal bytes that
1208    // must survive verbatim, never collapsed by URL normalization.
1209    #[icechunk_macros::test]
1210    fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1211        for input in [
1212            "s3://foo/bar//../baz",
1213            "gcs://b/a//b",
1214            "s3://bucket/key.with.dots/../sibling",
1215            "az://container/a/./b//c",
1216            "tigris://bucket/x//y",
1217            "https://host/a//b/../c.bin?v=1#frag",
1218        ] {
1219            assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1220        }
1221        // userinfo, port, query and fragment all survive untouched too.
1222        let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1223        assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1224        Ok(())
1225    }
1226
1227    // `vcc://` relative locations are opaque key suffixes too, so they are also
1228    // stored verbatim (their `//`/`..` must not be normalized before expansion).
1229    #[icechunk_macros::test]
1230    fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1231        let input = "vcc://name/a//b/../c";
1232        assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1233        Ok(())
1234    }
1235
1236    // `file://` is a security boundary: it's processed via normalization, so
1237    // `/../` and `%2e%2e` are resolved away
1238    #[icechunk_macros::test]
1239    fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1240        assert_eq!(
1241            VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1242            "file:///a/c"
1243        );
1244        assert_eq!(
1245            VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1246            "file:///secret"
1247        );
1248        Ok(())
1249    }
1250
1251    // Scheme casing is normalized (it is not part of the key), so container
1252    // matching stays stable, but the key bytes are untouched.
1253    #[icechunk_macros::test]
1254    fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1255        assert_eq!(
1256            VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1257            "s3://Bucket/Key//.."
1258        );
1259        Ok(())
1260    }
1261
1262    #[icechunk_macros::test]
1263    fn test_from_url_rejects_bad_input() {
1264        use VirtualReferenceErrorKind::*;
1265        let kind =
1266            |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1267
1268        // Not a URL at all: a precise parse error.
1269        assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1270        assert!(matches!(kind(""), CannotParseUrl { .. }));
1271        // cannot-be-a-base URLs (no authority, no path segments).
1272        assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1273        // Has a scheme and an authority slot, but the authority is empty.
1274        assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1275        assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1276        // Single-slash forms parse with a path but no authority/bucket: the
1277        // error must reflect the missing bucket
1278        assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1279    }
1280
1281    // `file:/x` (single slash, no authority) is accepted and normalized to
1282    // `file:///x`
1283    #[icechunk_macros::test]
1284    fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1285        assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1286        Ok(())
1287    }
1288
1289    #[icechunk_macros::test]
1290    fn test_overlaps() -> Result<(), Box<dyn Error>> {
1291        let e1 = ManifestExtents::new(
1292            vec![0u32, 1, 2].as_slice(),
1293            vec![2u32, 4, 6].as_slice(),
1294        );
1295
1296        let e2 = ManifestExtents::new(
1297            vec![10u32, 1, 2].as_slice(),
1298            vec![12u32, 4, 6].as_slice(),
1299        );
1300
1301        let union = ManifestExtents::new(
1302            vec![0u32, 1, 2].as_slice(),
1303            vec![12u32, 4, 6].as_slice(),
1304        );
1305
1306        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1307        assert_eq!(e1.intersection(&e2), None);
1308        assert_eq!(e1.union(&e2), union);
1309
1310        let e1 = ManifestExtents::new(
1311            vec![0u32, 1, 2].as_slice(),
1312            vec![2u32, 4, 6].as_slice(),
1313        );
1314        let e2 = ManifestExtents::new(
1315            vec![2u32, 1, 2].as_slice(),
1316            vec![42u32, 4, 6].as_slice(),
1317        );
1318        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1319        assert_eq!(e1.overlap_with(&e2), Overlap::None);
1320
1321        // asymmetric case
1322        let e1 = ManifestExtents::new(
1323            vec![0u32, 1, 2].as_slice(),
1324            vec![3u32, 4, 6].as_slice(),
1325        );
1326        let e2 = ManifestExtents::new(
1327            vec![2u32, 1, 2].as_slice(),
1328            vec![3u32, 4, 6].as_slice(),
1329        );
1330        let union = ManifestExtents::new(
1331            vec![0u32, 1, 2].as_slice(),
1332            vec![3u32, 4, 6].as_slice(),
1333        );
1334        let intersection = ManifestExtents::new(
1335            vec![2u32, 1, 2].as_slice(),
1336            vec![3u32, 4, 6].as_slice(),
1337        );
1338        assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1339        assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1340        assert_eq!(e1.union(&e2), union.clone());
1341        assert_eq!(e2.union(&e1), union.clone());
1342        assert_eq!(e1.intersection(&e2), Some(intersection));
1343
1344        // empty set
1345        let e1 = ManifestExtents::new(
1346            vec![0u32, 1, 2].as_slice(),
1347            vec![3u32, 4, 6].as_slice(),
1348        );
1349        let e2 = ManifestExtents::new(
1350            vec![2u32, 1, 2].as_slice(),
1351            vec![2u32, 4, 6].as_slice(),
1352        );
1353        assert_eq!(e1.intersection(&e2), None);
1354
1355        // this should create non-overlapping extents
1356        let splits = ManifestSplits::from_edges(vec![
1357            vec![0, 10, 20],
1358            vec![0, 1, 2],
1359            vec![0, 21, 22],
1360        ]);
1361        for vec in splits.iter().combinations(2) {
1362            assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1363            assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1364        }
1365
1366        Ok(())
1367    }
1368
1369    #[alt_proptest]
1370    fn test_manifest_split_from_edges(
1371        #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1372    ) {
1373        // Note: using the shape, chunks strategy to generate chunk_shape, split_shape
1374        let ShapeDim { shape, .. } = shape_dim;
1375
1376        let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1377        let split_shape: Vec<u64> = shape
1378            .iter()
1379            .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1380            .collect();
1381
1382        let ndim = shape.len();
1383        let edges: Vec<Vec<u32>> = (0usize..ndim)
1384            .map(|axis| {
1385                uniform_manifest_split_edges(
1386                    num_chunks[axis],
1387                    &(split_shape[axis] as u32),
1388                )
1389            })
1390            .collect();
1391
1392        let splits = ManifestSplits::from_edges(edges.into_iter());
1393        for edge in splits.iter() {
1394            // must be ndim ranges
1395            prop_assert_eq!(edge.len(), ndim);
1396            for range in edge.iter() {
1397                prop_assert!(range.end > range.start);
1398            }
1399        }
1400
1401        // when using from_edges, extents must not exactly overlap
1402        for edges in splits.iter().combinations(2) {
1403            let is_equal =
1404                zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1405                    (range1.start == range2.start) && (range1.end == range2.end)
1406                });
1407            prop_assert!(!is_equal);
1408        }
1409    }
1410
1411    const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1412        min_num_chunks: 10,
1413        dictionary_max_training_samples: 500,
1414        dictionary_max_size_bytes: 16 * 1024,
1415        compression_level: 3,
1416    };
1417
1418    fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1419        let node = NodeId::random();
1420        (0..n)
1421            .map(|i| ChunkInfo {
1422                node: node.clone(),
1423                coord: ChunkIndices(vec![i as u32]),
1424                payload: ChunkPayload::Virtual(VirtualChunkRef {
1425                    location: VirtualChunkLocation::from_url(&format!(
1426                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1427                    ))
1428                    .unwrap(),
1429                    offset: i as u64 * 1024,
1430                    length: 1024,
1431                    checksum: None,
1432                }),
1433            })
1434            .collect()
1435    }
1436
1437    #[tokio_test]
1438    async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1439        // >= threshold virtual chunks with compress=true should round-trip
1440        let chunks = make_virtual_chunks(50);
1441        let manifest = Manifest::from_iter(
1442            &ManifestId::random(),
1443            chunks.clone(),
1444            Some(&COMPRESS_CONFIG),
1445        )
1446        .await?
1447        .unwrap();
1448
1449        let root = manifest.root();
1450        assert!(root.location_dictionary().is_some());
1451        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1452        for am in root.arrays().iter() {
1453            for r in am.refs().iter() {
1454                assert!(r.compressed_location().is_some());
1455                assert!(r.location().is_none());
1456            }
1457        }
1458
1459        for chunk in &chunks {
1460            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1461            assert_eq!(payload, chunk.payload);
1462        }
1463        Ok(())
1464    }
1465
1466    #[tokio_test]
1467    async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1468        // Below threshold: compress=true but few chunks → no compression, readback works
1469        let chunks = make_virtual_chunks(5);
1470        let manifest = Manifest::from_iter(
1471            &ManifestId::random(),
1472            chunks.clone(),
1473            Some(&COMPRESS_CONFIG),
1474        )
1475        .await?
1476        .unwrap();
1477
1478        let root = manifest.root();
1479        assert!(root.location_dictionary().is_none());
1480        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1481        for am in root.arrays().iter() {
1482            for r in am.refs().iter() {
1483                assert!(r.compressed_location().is_none());
1484                assert!(r.location().is_some());
1485            }
1486        }
1487
1488        for chunk in &chunks {
1489            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1490            assert_eq!(payload, chunk.payload);
1491        }
1492        Ok(())
1493    }
1494
1495    #[tokio_test]
1496    async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1497        // Mix of virtual, inline, and ref payloads with compression
1498        let node = NodeId::random();
1499        let mut chunks: Vec<ChunkInfo> = (0..30)
1500            .map(|i| ChunkInfo {
1501                node: node.clone(),
1502                coord: ChunkIndices(vec![i]),
1503                payload: ChunkPayload::Virtual(VirtualChunkRef {
1504                    location: VirtualChunkLocation::from_url(&format!(
1505                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1506                    ))
1507                    .unwrap(),
1508                    offset: i as u64 * 1024,
1509                    length: 1024,
1510                    checksum: None,
1511                }),
1512            })
1513            .collect();
1514        chunks.push(ChunkInfo {
1515            node: node.clone(),
1516            coord: ChunkIndices(vec![100]),
1517            payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1518        });
1519        chunks.push(ChunkInfo {
1520            node: node.clone(),
1521            coord: ChunkIndices(vec![101]),
1522            payload: ChunkPayload::Ref(ChunkRef {
1523                id: ChunkId::random(),
1524                offset: 0,
1525                length: 512,
1526            }),
1527        });
1528
1529        let manifest = Manifest::from_iter(
1530            &ManifestId::random(),
1531            chunks.clone(),
1532            Some(&COMPRESS_CONFIG),
1533        )
1534        .await?
1535        .unwrap();
1536
1537        for chunk in &chunks {
1538            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1539            assert_eq!(payload, chunk.payload);
1540        }
1541        Ok(())
1542    }
1543
1544    #[tokio_test]
1545    async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1546        // bytes() → from_buffer() round-trip
1547        let chunks = make_virtual_chunks(50);
1548        let manifest = Manifest::from_iter(
1549            &ManifestId::random(),
1550            chunks.clone(),
1551            Some(&COMPRESS_CONFIG),
1552        )
1553        .await?
1554        .unwrap();
1555        let bytes = manifest.bytes().to_vec();
1556        let manifest2 = Manifest::from_buffer(bytes)?;
1557
1558        for chunk in &chunks {
1559            let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1560            assert_eq!(payload, chunk.payload);
1561        }
1562        Ok(())
1563    }
1564
1565    #[tokio_test]
1566    async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1567        // Iterator-based access with compression
1568        let chunks = make_virtual_chunks(50);
1569        let node = chunks[0].node.clone();
1570        let manifest = Arc::new(
1571            Manifest::from_iter(
1572                &ManifestId::random(),
1573                chunks.clone(),
1574                Some(&COMPRESS_CONFIG),
1575            )
1576            .await?
1577            .unwrap(),
1578        );
1579
1580        let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1581        assert_eq!(iter_results.len(), 50);
1582        for (i, result) in iter_results.into_iter().enumerate() {
1583            let (coord, payload) = result?;
1584            assert_eq!(coord, chunks[i].coord);
1585            assert_eq!(payload, chunks[i].payload);
1586        }
1587        Ok(())
1588    }
1589
1590    #[tokio_test]
1591    async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1592        // compress=false → locations in `location` field, no compressed_location, no dictionary
1593        let chunks = make_virtual_chunks(50);
1594        let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1595            .await?
1596            .unwrap();
1597
1598        let root = manifest.root();
1599        assert!(root.location_dictionary().is_none());
1600        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1601
1602        // Verify location field is populated (not compressed_location)
1603        for am in root.arrays().iter() {
1604            for r in am.refs().iter() {
1605                if r.chunk_id().is_none() && r.inline().is_none() {
1606                    assert!(r.location().is_some(), "location should be set");
1607                    assert!(
1608                        r.compressed_location().is_none(),
1609                        "compressed_location should be None"
1610                    );
1611                }
1612            }
1613        }
1614
1615        // Readback still works
1616        for chunk in &chunks {
1617            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1618            assert_eq!(payload, chunk.payload);
1619        }
1620        Ok(())
1621    }
1622
1623    #[tokio_test]
1624    async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1625        // compress=true with enough virtual chunks → compressed_location, dictionary present
1626        let chunks = make_virtual_chunks(50);
1627        let manifest = Manifest::from_iter(
1628            &ManifestId::random(),
1629            chunks.clone(),
1630            Some(&COMPRESS_CONFIG),
1631        )
1632        .await?
1633        .unwrap();
1634
1635        let root = manifest.root();
1636        assert!(root.location_dictionary().is_some());
1637        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1638
1639        // Verify compressed_location is populated and location is None
1640        for am in root.arrays().iter() {
1641            for r in am.refs().iter() {
1642                if r.chunk_id().is_none() && r.inline().is_none() {
1643                    assert!(
1644                        r.compressed_location().is_some(),
1645                        "compressed_location should be set"
1646                    );
1647                    assert!(
1648                        r.location().is_none(),
1649                        "location should be None for compressed chunks"
1650                    );
1651                }
1652            }
1653        }
1654
1655        // Readback still works
1656        for chunk in &chunks {
1657            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1658            assert_eq!(payload, chunk.payload);
1659        }
1660        Ok(())
1661    }
1662
1663    #[tokio_test]
1664    async fn test_compression_skipped_when_locations_too_short()
1665    -> Result<(), Box<dyn Error>> {
1666        // When most virtual chunk locations are < 8 bytes, dictionary training
1667        // is skipped and the manifest falls back to uncompressed storage.
1668        // With longer locations (>= 8 bytes), compression kicks in.
1669        let config = LocationCompressionConfig {
1670            min_num_chunks: 2,
1671            dictionary_max_training_samples: 500,
1672            dictionary_max_size_bytes: 256,
1673            compression_level: 3,
1674        };
1675
1676        let node = NodeId::random();
1677        // "s3://a/" is 7 bytes, below the 8-byte threshold
1678        let short_chunks: Vec<ChunkInfo> = (0..2)
1679            .map(|i| ChunkInfo {
1680                node: node.clone(),
1681                coord: ChunkIndices(vec![i as u32]),
1682                payload: ChunkPayload::Virtual(VirtualChunkRef {
1683                    location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1684                    offset: i as u64 * 1024,
1685                    length: 1024,
1686                    checksum: None,
1687                }),
1688            })
1689            .collect();
1690
1691        let manifest = Manifest::from_iter(
1692            &ManifestId::random(),
1693            short_chunks.clone(),
1694            Some(&config),
1695        )
1696        .await?
1697        .unwrap();
1698
1699        let root = manifest.root();
1700        assert!(root.location_dictionary().is_none());
1701        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1702        for am in root.arrays().iter() {
1703            for r in am.refs().iter() {
1704                assert!(r.compressed_location().is_none());
1705                assert!(r.location().is_some());
1706            }
1707        }
1708
1709        for chunk in &short_chunks {
1710            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1711            assert_eq!(payload, chunk.payload);
1712        }
1713
1714        // "s3://abc/" is 9 bytes, above the 8-byte threshold — compression should apply
1715        let long_chunks: Vec<ChunkInfo> = (0..2)
1716            .map(|i| ChunkInfo {
1717                node: node.clone(),
1718                coord: ChunkIndices(vec![i as u32]),
1719                payload: ChunkPayload::Virtual(VirtualChunkRef {
1720                    location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1721                    offset: i as u64 * 1024,
1722                    length: 1024,
1723                    checksum: None,
1724                }),
1725            })
1726            .collect();
1727
1728        let manifest = Manifest::from_iter(
1729            &ManifestId::random(),
1730            long_chunks.clone(),
1731            Some(&config),
1732        )
1733        .await?
1734        .unwrap();
1735
1736        let root = manifest.root();
1737        assert!(root.location_dictionary().is_some());
1738        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1739        for am in root.arrays().iter() {
1740            for r in am.refs().iter() {
1741                assert!(r.compressed_location().is_some());
1742                assert!(r.location().is_none());
1743            }
1744        }
1745
1746        for chunk in &long_chunks {
1747            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1748            assert_eq!(payload, chunk.payload);
1749        }
1750        Ok(())
1751    }
1752
1753    #[tokio_test]
1754    async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1755        // 100 virtual chunks with highly redundant, large location strings.
1756        // Compression should produce a significantly smaller manifest.
1757        let node = NodeId::random();
1758        let chunks: Vec<ChunkInfo> = (0..100)
1759            .map(|i| ChunkInfo {
1760                node: node.clone(),
1761                coord: ChunkIndices(vec![i as u32]),
1762                payload: ChunkPayload::Virtual(VirtualChunkRef {
1763                    location: VirtualChunkLocation::from_url(&format!(
1764                        "s3://my-very-long-bucket-name/some/deeply/nested/path/to/dataset/v1.2.3/year=2024/month=01/day=15/chunk_{i:06}.parquet"
1765                    ))
1766                    .unwrap(),
1767                    offset: i as u64 * 4096,
1768                    length: 4096,
1769                    checksum: None,
1770                }),
1771            })
1772            .collect();
1773
1774        // Build without compression
1775        let manifest_uncompressed =
1776            Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1777                .await?
1778                .unwrap();
1779        let size_uncompressed = manifest_uncompressed.bytes().len();
1780
1781        assert!(!manifest_uncompressed.uses_location_compression());
1782        assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1783
1784        // Build with compression
1785        let manifest_compressed = Manifest::from_iter(
1786            &ManifestId::random(),
1787            chunks.clone(),
1788            Some(&COMPRESS_CONFIG),
1789        )
1790        .await?
1791        .unwrap();
1792        let size_compressed = manifest_compressed.bytes().len();
1793
1794        assert!(manifest_compressed.uses_location_compression());
1795        assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1796        assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1797
1798        // Each compressed_location should be much shorter than the raw URL (118 bytes)
1799        let root = manifest_compressed.root();
1800        for am in root.arrays().iter() {
1801            for r in am.refs().iter() {
1802                let compressed = r.compressed_location().unwrap();
1803                assert!(
1804                    compressed.len() < 40,
1805                    "compressed_location ({} bytes) should be under 40 bytes \
1806                     (raw location is 118 bytes)",
1807                    compressed.len(),
1808                );
1809            }
1810        }
1811
1812        // Compressed manifest should be meaningfully smaller
1813        assert!(
1814            size_compressed < size_uncompressed,
1815            "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1816        );
1817
1818        let saving_pct =
1819            (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1820        assert!(
1821            saving_pct > 15.0,
1822            "expected at least 15% size reduction with redundant locations, \
1823             got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1824        );
1825
1826        // Both manifests must round-trip correctly
1827        for chunk in &chunks {
1828            let p1 = manifest_uncompressed
1829                .get_chunk_payload(&chunk.node, &chunk.coord)
1830                .unwrap();
1831            let p2 =
1832                manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1833            assert_eq!(p1, chunk.payload);
1834            assert_eq!(p2, chunk.payload);
1835        }
1836
1837        Ok(())
1838    }
1839
1840    #[tokio_test]
1841    async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1842        // chunk_payloads() works with compressed manifests
1843        let chunks = make_virtual_chunks(50);
1844        let manifest = Manifest::from_iter(
1845            &ManifestId::random(),
1846            chunks.clone(),
1847            Some(&COMPRESS_CONFIG),
1848        )
1849        .await?
1850        .unwrap();
1851
1852        let payloads: Vec<_> =
1853            manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1854        assert_eq!(payloads.len(), 50);
1855        for (i, payload) in payloads.iter().enumerate() {
1856            assert_eq!(payload, &chunks[i].payload);
1857        }
1858        Ok(())
1859    }
1860}