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