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        // Carefully so we don't keep the whole string buffer in memory
804        let mut buffer = [0u8; MAX_DECOMPRESSED_LOCATION_SIZE];
805        let len = decompressor
806            .decompress_to_buffer(compressed.bytes(), &mut buffer)
807            .capture()?;
808        let location_string = std::str::from_utf8(&buffer[..len])
809            .map_err(|e| {
810                IcechunkFormatErrorKind::IO(std::io::Error::new(
811                    std::io::ErrorKind::InvalidData,
812                    e,
813                ))
814            })
815            .capture()?
816            .to_owned();
817        let location = VirtualChunkLocation::from_trusted(location_string);
818        Ok(ChunkPayload::Virtual(VirtualChunkRef {
819            location,
820            checksum: checksum(&chunk_ref),
821            offset: chunk_ref.offset(),
822            length: chunk_ref.length(),
823        }))
824    } else if let Some(location) = chunk_ref.location() {
825        let location = VirtualChunkLocation::from_trusted(location.to_string());
826        Ok(ChunkPayload::Virtual(VirtualChunkRef {
827            location,
828            checksum: checksum(&chunk_ref),
829            offset: chunk_ref.offset(),
830            length: chunk_ref.length(),
831        }))
832    } else if let Some(data) = chunk_ref.inline() {
833        Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
834    } else {
835        Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
836            flatbuffers::InvalidFlatbuffer::InconsistentUnion {
837                field: Cow::Borrowed("chunk_id+location+inline"),
838                field_type: Cow::Borrowed("invalid"),
839                error_trace: Default::default(),
840            },
841        ))
842        .capture()
843    }
844}
845
846fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
847    if let Some(etag) = payload.checksum_etag() {
848        Some(Checksum::ETag(ETag(etag.to_string())))
849    } else if payload.checksum_last_modified() > 0 {
850        Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
851    } else {
852        None
853    }
854}
855
856/// Sample virtual chunk URLs and train a zstd dictionary for compressing them.
857///
858/// Uses reservoir sampling (Algorithm R) to collect a uniform random sample in a single
859/// pass without knowing the total virtual chunk count in advance.
860/// See: <https://en.wikipedia.org/wiki/Reservoir_sampling#Simple>:_`Algorithm_R`
861///
862/// Returns `Some(dict_bytes)` if compression is enabled and there are enough virtual
863/// chunks, `None` if compression is disabled or cannot be executed.
864fn train_location_dictionary(
865    chunks: &[ChunkInfo],
866    virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
867) -> IcechunkResult<Option<Vec<u8>>> {
868    let Some(config) = virtual_chunks_compression_config else {
869        return Ok(None);
870    };
871    let max_samples = config.dictionary_max_training_samples as usize;
872    let min_chunks = config.min_num_chunks as usize;
873    let max_dict_size = config.dictionary_max_size_bytes as usize;
874
875    let mut virtual_count: usize = 0;
876    let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
877    let mut rng: SmallRng = rand::make_rng();
878
879    if chunks.len() < min_chunks {
880        return Ok(None);
881    }
882
883    for chunk in chunks {
884        if let ChunkPayload::Virtual(vref) = &chunk.payload {
885            let loc = vref.location.url();
886            if virtual_count < max_samples {
887                // Fill phase: reservoir not yet full
888                reservoir.push(loc);
889            } else {
890                // Replace phase: include new item with decreasing probability
891                let j = rng.random_range(0..=virtual_count);
892                if j < max_samples {
893                    reservoir[j] = loc;
894                }
895            }
896            virtual_count += 1;
897        }
898    }
899
900    if virtual_count < min_chunks {
901        return Ok(None);
902    }
903
904    let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
905
906    // zstd doesn't like it if many samples are too small, in that case we don't compress
907    let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
908    if small_count >= sample_bytes.len() / 2 {
909        tracing::warn!(
910            "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
911            sample_bytes.len()
912        );
913        return Ok(None);
914    }
915
916    let mut sample_data: Vec<u8> =
917        sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
918    let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
919    let total_sample_size = sample_data.len();
920
921    // zstd requires total sample data >= max_dict_size; repeat samples if needed
922    if total_sample_size > 0 && total_sample_size < max_dict_size {
923        let repeats = (max_dict_size / total_sample_size) + 1;
924        let original_data = sample_data.clone();
925        let original_sizes = sample_sizes.clone();
926        for _ in 0..repeats {
927            sample_data.extend_from_slice(&original_data);
928            sample_sizes.extend_from_slice(&original_sizes);
929        }
930    }
931
932    Ok(Some(
933        zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
934            .capture()?,
935    ))
936}
937
938/// Compress virtual chunk locations in parallel using a pre-trained zstd dictionary.
939///
940/// Returns one entry per chunk: `Some(compressed_bytes)` for virtual chunks,
941/// `None` for inline/ref chunks.
942fn compress_locations(
943    chunks: &[ChunkInfo],
944    dict: &[u8],
945    config: &LocationCompressionConfig,
946) -> Vec<Option<Vec<u8>>> {
947    let compression_level = config.compression_level;
948
949    #[cfg(not(target_family = "wasm"))]
950    {
951        let num_threads =
952            std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
953        let slice_size = chunks.len().div_ceil(num_threads);
954
955        std::thread::scope(|s| {
956            let handles: Vec<_> = chunks
957                .chunks(slice_size)
958                .map(|slice| {
959                    s.spawn(|| {
960                        let mut comp = zstd::bulk::Compressor::with_dictionary(
961                            compression_level,
962                            dict,
963                        )
964                        .ok();
965                        slice
966                            .iter()
967                            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
968                                (ChunkPayload::Virtual(vref), Some(comp)) => {
969                                    comp.compress(vref.location.url().as_bytes()).ok()
970                                }
971                                _ => None,
972                            })
973                            .collect::<Vec<_>>()
974                    })
975                })
976                .collect();
977
978            #[expect(clippy::expect_used)]
979            handles
980                .into_iter()
981                .flat_map(|h| {
982                    h.join()
983                        .expect("Cannot join threads compressing virtual chunk locations")
984                })
985                .collect()
986        })
987    }
988
989    #[cfg(target_family = "wasm")]
990    {
991        let mut comp =
992            zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
993        chunks
994            .iter()
995            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
996                (ChunkPayload::Virtual(vref), Some(comp)) => {
997                    comp.compress(vref.location.url().as_bytes()).ok()
998                }
999                _ => None,
1000            })
1001            .collect()
1002    }
1003}
1004
1005fn mk_chunk_ref<'bldr>(
1006    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1007    chunk: ChunkInfo,
1008    precompressed_location: Option<Vec<u8>>,
1009) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
1010    let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
1011    match chunk.payload {
1012        ChunkPayload::Inline(bytes) => {
1013            let bytes = builder.create_vector(bytes.as_ref());
1014            let args = generated::ChunkRefArgs {
1015                inline: Some(bytes),
1016                index,
1017                ..Default::default()
1018            };
1019            generated::ChunkRef::create(builder, &args)
1020        }
1021        ChunkPayload::Virtual(virtual_chunk_ref) => {
1022            let location_str = virtual_chunk_ref.location.0.as_str();
1023            let (location, compressed_location) =
1024                if let Some(compressed) = precompressed_location {
1025                    (None, Some(builder.create_vector(&compressed)))
1026                } else {
1027                    (Some(builder.create_string(location_str)), None)
1028                };
1029            let args = generated::ChunkRefArgs {
1030                index,
1031                location,
1032                compressed_location,
1033                offset: virtual_chunk_ref.offset,
1034                length: virtual_chunk_ref.length,
1035                checksum_etag: match &virtual_chunk_ref.checksum {
1036                    Some(cs) => match cs {
1037                        Checksum::LastModified(_) => None,
1038                        Checksum::ETag(etag) => {
1039                            Some(builder.create_string(etag.0.as_str()))
1040                        }
1041                    },
1042                    None => None,
1043                },
1044                checksum_last_modified: match &virtual_chunk_ref.checksum {
1045                    Some(cs) => match cs {
1046                        Checksum::LastModified(seconds) => seconds.0,
1047                        Checksum::ETag(_) => 0,
1048                    },
1049                    None => 0,
1050                },
1051                ..Default::default()
1052            };
1053            generated::ChunkRef::create(builder, &args)
1054        }
1055        ChunkPayload::Ref(chunk_ref) => {
1056            let id = generated::ObjectId12::new(&chunk_ref.id.0);
1057            let args = generated::ChunkRefArgs {
1058                index,
1059                offset: chunk_ref.offset,
1060                length: chunk_ref.length,
1061                chunk_id: Some(&id),
1062                ..Default::default()
1063            };
1064            generated::ChunkRef::create(builder, &args)
1065        }
1066    }
1067}
1068
1069static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1070    max_depth: 64,
1071    max_tables: 500_000_000,
1072    // Large enough that any buffer the builder can produce is readable: the
1073    // builder refuses to grow past FLATBUFFERS_MAX_BUFFER_SIZE, and the
1074    // verifier's counter runs at most MAX_APPARENT_SIZE_INFLATION times the
1075    // buffer it walks. Saturating because the product doesn't fit a 32 bit
1076    // usize (wasm32), where it lands on usize::MAX instead.
1077    max_apparent_size: crate::serializers::MAX_APPARENT_SIZE_INFLATION
1078        .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE),
1079    ignore_missing_null_terminator: true,
1080};
1081
1082/// The verifier limits applied to every manifest buffer we read.
1083#[cfg(test)]
1084pub(crate) fn root_options() -> &'static VerifierOptions {
1085    &ROOT_OPTIONS
1086}
1087
1088#[cfg(test)]
1089#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
1090mod tests {
1091    use super::*;
1092    use crate::roundtrip_serialization_tests;
1093    use crate::strategies::{
1094        ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1095        manifest_splits, shapes_and_dims,
1096    };
1097    use icechunk_macros::{self, tokio_test};
1098    use itertools::{all, multizip};
1099    use proptest::collection::vec;
1100    use proptest::prelude::*;
1101    use std::error::Error;
1102    use test_strategy::proptest as alt_proptest;
1103
1104    roundtrip_serialization_tests!(
1105        serialize_and_deserialize_manifest_ref - manifest_ref,
1106        serialize_and_deserialize_manifest_splits - manifest_splits
1107    );
1108
1109    #[alt_proptest]
1110    fn test_property_extents_set_ops_same(
1111        #[strategy(manifest_extents(4))] e: ManifestExtents,
1112    ) {
1113        prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1114        prop_assert_eq!(e.union(&e), e.clone());
1115        prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1116    }
1117
1118    #[alt_proptest]
1119    fn test_property_extents_set_ops(
1120        #[strategy(manifest_extents(4))] e1: ManifestExtents,
1121        #[strategy(manifest_extents(4))] e2: ManifestExtents,
1122    ) {
1123        let union = e1.union(&e2);
1124        let intersection = e1.intersection(&e2);
1125
1126        prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1127        prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1128        prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1129        prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1130
1131        // order is important for the next 2
1132        prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1133        prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1134
1135        if intersection.is_some() {
1136            let int = intersection.unwrap();
1137            let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1138            prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1139            prop_assert_eq!(int.overlap_with(&e2), expected);
1140        } else {
1141            prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1142            prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1143        }
1144    }
1145
1146    #[alt_proptest]
1147    fn test_property_extents_widths(
1148        #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1149        #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1150        #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1151    ) {
1152        let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1153        let extent2 = ManifestExtents::from_ranges_iter(
1154            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1155                |(extent, dleft, dright)| {
1156                    ((extent.start as i32 + dleft) as u32)
1157                        ..((extent.end as i32 + dright) as u32)
1158                },
1159            ),
1160        );
1161
1162        if all(delta_left.iter(), |elem| elem == &0i32)
1163            && all(delta_right.iter(), |elem| elem == &0i32)
1164        {
1165            prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1166        }
1167
1168        let extent2 = ManifestExtents::from_ranges_iter(
1169            multizip((
1170                extent1.iter(),
1171                widths.iter(),
1172                delta_left.iter(),
1173                delta_right.iter(),
1174            ))
1175            .map(|(extent, width, dleft, dright)| {
1176                let (low, high) = (dleft.min(dright), dleft.max(dright));
1177                ((extent.start as i32 + width + low) as u32)
1178                    ..((extent.end as i32 + width + high) as u32)
1179            }),
1180        );
1181
1182        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1183
1184        let extent2 = ManifestExtents::from_ranges_iter(
1185            multizip((
1186                extent1.iter(),
1187                widths.iter(),
1188                delta_left.iter(),
1189                delta_right.iter(),
1190            ))
1191            .map(|(extent, width, dleft, dright)| {
1192                let (low, high) = (dleft.min(dright), dleft.max(dright));
1193                ((extent.start as i32 - width - high).max(0i32) as u32)
1194                    ..((extent.end as i32 - width - low) as u32)
1195            }),
1196        );
1197
1198        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1199
1200        let extent2 = ManifestExtents::from_ranges_iter(
1201            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1202                |(extent, dleft, dright)| {
1203                    ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1204                        ..((extent.end as i32 + dright + 1) as u32)
1205                },
1206            ),
1207        );
1208        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1209    }
1210
1211    // Regression test for https://github.com/earth-mover/icechunk/issues/2218
1212    // VirtualChunkLocation::from_url used to drop userinfo, port, query and
1213    // fragment from the URL, which corrupts opaque object keys.
1214    // Non-`file://` locations are now stored verbatim: every URL part,
1215    // including repeated `//` and literal `..`, is preserved exactly.
1216    #[icechunk_macros::test]
1217    fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1218        let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1219        let stored = VirtualChunkLocation::from_url(input)?;
1220        assert_eq!(stored.url(), input);
1221        Ok(())
1222    }
1223
1224    // Object-store keys are opaque: `//`, `.` and `..` are literal bytes that
1225    // must survive verbatim, never collapsed by URL normalization.
1226    #[icechunk_macros::test]
1227    fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1228        for input in [
1229            "s3://foo/bar//../baz",
1230            "gcs://b/a//b",
1231            "s3://bucket/key.with.dots/../sibling",
1232            "az://container/a/./b//c",
1233            "tigris://bucket/x//y",
1234            "https://host/a//b/../c.bin?v=1#frag",
1235        ] {
1236            assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1237        }
1238        // userinfo, port, query and fragment all survive untouched too.
1239        let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1240        assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1241        Ok(())
1242    }
1243
1244    // `vcc://` relative locations are opaque key suffixes too, so they are also
1245    // stored verbatim (their `//`/`..` must not be normalized before expansion).
1246    #[icechunk_macros::test]
1247    fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1248        let input = "vcc://name/a//b/../c";
1249        assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1250        Ok(())
1251    }
1252
1253    // `file://` is a security boundary: it's processed via normalization, so
1254    // `/../` and `%2e%2e` are resolved away
1255    #[icechunk_macros::test]
1256    fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1257        assert_eq!(
1258            VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1259            "file:///a/c"
1260        );
1261        assert_eq!(
1262            VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1263            "file:///secret"
1264        );
1265        Ok(())
1266    }
1267
1268    // Scheme casing is normalized (it is not part of the key), so container
1269    // matching stays stable, but the key bytes are untouched.
1270    #[icechunk_macros::test]
1271    fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1272        assert_eq!(
1273            VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1274            "s3://Bucket/Key//.."
1275        );
1276        Ok(())
1277    }
1278
1279    #[icechunk_macros::test]
1280    fn test_from_url_rejects_bad_input() {
1281        use VirtualReferenceErrorKind::*;
1282        let kind =
1283            |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1284
1285        // Not a URL at all: a precise parse error.
1286        assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1287        assert!(matches!(kind(""), CannotParseUrl { .. }));
1288        // cannot-be-a-base URLs (no authority, no path segments).
1289        assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1290        // Has a scheme and an authority slot, but the authority is empty.
1291        assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1292        assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1293        // Single-slash forms parse with a path but no authority/bucket: the
1294        // error must reflect the missing bucket
1295        assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1296    }
1297
1298    // `file:/x` (single slash, no authority) is accepted and normalized to
1299    // `file:///x`
1300    #[icechunk_macros::test]
1301    fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1302        assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1303        Ok(())
1304    }
1305
1306    #[icechunk_macros::test]
1307    fn test_overlaps() -> Result<(), Box<dyn Error>> {
1308        let e1 = ManifestExtents::new(
1309            vec![0u32, 1, 2].as_slice(),
1310            vec![2u32, 4, 6].as_slice(),
1311        );
1312
1313        let e2 = ManifestExtents::new(
1314            vec![10u32, 1, 2].as_slice(),
1315            vec![12u32, 4, 6].as_slice(),
1316        );
1317
1318        let union = ManifestExtents::new(
1319            vec![0u32, 1, 2].as_slice(),
1320            vec![12u32, 4, 6].as_slice(),
1321        );
1322
1323        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1324        assert_eq!(e1.intersection(&e2), None);
1325        assert_eq!(e1.union(&e2), union);
1326
1327        let e1 = ManifestExtents::new(
1328            vec![0u32, 1, 2].as_slice(),
1329            vec![2u32, 4, 6].as_slice(),
1330        );
1331        let e2 = ManifestExtents::new(
1332            vec![2u32, 1, 2].as_slice(),
1333            vec![42u32, 4, 6].as_slice(),
1334        );
1335        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1336        assert_eq!(e1.overlap_with(&e2), Overlap::None);
1337
1338        // asymmetric case
1339        let e1 = ManifestExtents::new(
1340            vec![0u32, 1, 2].as_slice(),
1341            vec![3u32, 4, 6].as_slice(),
1342        );
1343        let e2 = ManifestExtents::new(
1344            vec![2u32, 1, 2].as_slice(),
1345            vec![3u32, 4, 6].as_slice(),
1346        );
1347        let union = ManifestExtents::new(
1348            vec![0u32, 1, 2].as_slice(),
1349            vec![3u32, 4, 6].as_slice(),
1350        );
1351        let intersection = ManifestExtents::new(
1352            vec![2u32, 1, 2].as_slice(),
1353            vec![3u32, 4, 6].as_slice(),
1354        );
1355        assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1356        assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1357        assert_eq!(e1.union(&e2), union.clone());
1358        assert_eq!(e2.union(&e1), union.clone());
1359        assert_eq!(e1.intersection(&e2), Some(intersection));
1360
1361        // empty set
1362        let e1 = ManifestExtents::new(
1363            vec![0u32, 1, 2].as_slice(),
1364            vec![3u32, 4, 6].as_slice(),
1365        );
1366        let e2 = ManifestExtents::new(
1367            vec![2u32, 1, 2].as_slice(),
1368            vec![2u32, 4, 6].as_slice(),
1369        );
1370        assert_eq!(e1.intersection(&e2), None);
1371
1372        // this should create non-overlapping extents
1373        let splits = ManifestSplits::from_edges(vec![
1374            vec![0, 10, 20],
1375            vec![0, 1, 2],
1376            vec![0, 21, 22],
1377        ]);
1378        for vec in splits.iter().combinations(2) {
1379            assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1380            assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1381        }
1382
1383        Ok(())
1384    }
1385
1386    #[alt_proptest]
1387    fn test_manifest_split_from_edges(
1388        #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1389    ) {
1390        // Note: using the shape, chunks strategy to generate chunk_shape, split_shape
1391        let ShapeDim { shape, .. } = shape_dim;
1392
1393        let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1394        let split_shape: Vec<u64> = shape
1395            .iter()
1396            .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1397            .collect();
1398
1399        let ndim = shape.len();
1400        let edges: Vec<Vec<u32>> = (0usize..ndim)
1401            .map(|axis| {
1402                uniform_manifest_split_edges(
1403                    num_chunks[axis],
1404                    &(split_shape[axis] as u32),
1405                )
1406            })
1407            .collect();
1408
1409        let splits = ManifestSplits::from_edges(edges.into_iter());
1410        for edge in splits.iter() {
1411            // must be ndim ranges
1412            prop_assert_eq!(edge.len(), ndim);
1413            for range in edge.iter() {
1414                prop_assert!(range.end > range.start);
1415            }
1416        }
1417
1418        // when using from_edges, extents must not exactly overlap
1419        for edges in splits.iter().combinations(2) {
1420            let is_equal =
1421                zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1422                    (range1.start == range2.start) && (range1.end == range2.end)
1423                });
1424            prop_assert!(!is_equal);
1425        }
1426    }
1427
1428    const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1429        min_num_chunks: 10,
1430        dictionary_max_training_samples: 500,
1431        dictionary_max_size_bytes: 16 * 1024,
1432        compression_level: 3,
1433    };
1434
1435    fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1436        let node = NodeId::random();
1437        (0..n)
1438            .map(|i| ChunkInfo {
1439                node: node.clone(),
1440                coord: ChunkIndices(vec![i as u32]),
1441                payload: ChunkPayload::Virtual(VirtualChunkRef {
1442                    location: VirtualChunkLocation::from_url(&format!(
1443                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1444                    ))
1445                    .unwrap(),
1446                    offset: i as u64 * 1024,
1447                    length: 1024,
1448                    checksum: None,
1449                }),
1450            })
1451            .collect()
1452    }
1453
1454    #[tokio_test]
1455    async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1456        // >= threshold virtual chunks with compress=true should round-trip
1457        let chunks = make_virtual_chunks(50);
1458        let manifest = Manifest::from_iter(
1459            &ManifestId::random(),
1460            chunks.clone(),
1461            Some(&COMPRESS_CONFIG),
1462        )
1463        .await?
1464        .unwrap();
1465
1466        let root = manifest.root();
1467        assert!(root.location_dictionary().is_some());
1468        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1469        for am in root.arrays().iter() {
1470            for r in am.refs().iter() {
1471                assert!(r.compressed_location().is_some());
1472                assert!(r.location().is_none());
1473            }
1474        }
1475
1476        for chunk in &chunks {
1477            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1478            assert_eq!(payload, chunk.payload);
1479        }
1480        Ok(())
1481    }
1482
1483    #[tokio_test]
1484    async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1485        // Below threshold: compress=true but few chunks → no compression, readback works
1486        let chunks = make_virtual_chunks(5);
1487        let manifest = Manifest::from_iter(
1488            &ManifestId::random(),
1489            chunks.clone(),
1490            Some(&COMPRESS_CONFIG),
1491        )
1492        .await?
1493        .unwrap();
1494
1495        let root = manifest.root();
1496        assert!(root.location_dictionary().is_none());
1497        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1498        for am in root.arrays().iter() {
1499            for r in am.refs().iter() {
1500                assert!(r.compressed_location().is_none());
1501                assert!(r.location().is_some());
1502            }
1503        }
1504
1505        for chunk in &chunks {
1506            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1507            assert_eq!(payload, chunk.payload);
1508        }
1509        Ok(())
1510    }
1511
1512    #[tokio_test]
1513    async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1514        // Mix of virtual, inline, and ref payloads with compression
1515        let node = NodeId::random();
1516        let mut chunks: Vec<ChunkInfo> = (0..30)
1517            .map(|i| ChunkInfo {
1518                node: node.clone(),
1519                coord: ChunkIndices(vec![i]),
1520                payload: ChunkPayload::Virtual(VirtualChunkRef {
1521                    location: VirtualChunkLocation::from_url(&format!(
1522                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1523                    ))
1524                    .unwrap(),
1525                    offset: i as u64 * 1024,
1526                    length: 1024,
1527                    checksum: None,
1528                }),
1529            })
1530            .collect();
1531        chunks.push(ChunkInfo {
1532            node: node.clone(),
1533            coord: ChunkIndices(vec![100]),
1534            payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1535        });
1536        chunks.push(ChunkInfo {
1537            node: node.clone(),
1538            coord: ChunkIndices(vec![101]),
1539            payload: ChunkPayload::Ref(ChunkRef {
1540                id: ChunkId::random(),
1541                offset: 0,
1542                length: 512,
1543            }),
1544        });
1545
1546        let manifest = Manifest::from_iter(
1547            &ManifestId::random(),
1548            chunks.clone(),
1549            Some(&COMPRESS_CONFIG),
1550        )
1551        .await?
1552        .unwrap();
1553
1554        for chunk in &chunks {
1555            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1556            assert_eq!(payload, chunk.payload);
1557        }
1558        Ok(())
1559    }
1560
1561    #[tokio_test]
1562    async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1563        // bytes() → from_buffer() round-trip
1564        let chunks = make_virtual_chunks(50);
1565        let manifest = Manifest::from_iter(
1566            &ManifestId::random(),
1567            chunks.clone(),
1568            Some(&COMPRESS_CONFIG),
1569        )
1570        .await?
1571        .unwrap();
1572        let bytes = manifest.bytes().to_vec();
1573        let manifest2 = Manifest::from_buffer(bytes)?;
1574
1575        for chunk in &chunks {
1576            let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1577            assert_eq!(payload, chunk.payload);
1578        }
1579        Ok(())
1580    }
1581
1582    #[tokio_test]
1583    async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1584        // Iterator-based access with compression
1585        let chunks = make_virtual_chunks(50);
1586        let node = chunks[0].node.clone();
1587        let manifest = Arc::new(
1588            Manifest::from_iter(
1589                &ManifestId::random(),
1590                chunks.clone(),
1591                Some(&COMPRESS_CONFIG),
1592            )
1593            .await?
1594            .unwrap(),
1595        );
1596
1597        let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1598        assert_eq!(iter_results.len(), 50);
1599        for (i, result) in iter_results.into_iter().enumerate() {
1600            let (coord, payload) = result?;
1601            assert_eq!(coord, chunks[i].coord);
1602            assert_eq!(payload, chunks[i].payload);
1603        }
1604        Ok(())
1605    }
1606
1607    /// Walks like GC and stats hold millions of decoded locations at once, so a
1608    /// location must not keep the decompression buffer's spare capacity.
1609    #[tokio_test]
1610    async fn test_decompressed_locations_are_exact_size() -> Result<(), Box<dyn Error>> {
1611        let chunks = make_virtual_chunks(50);
1612        let node = chunks[0].node.clone();
1613        let manifest = Arc::new(
1614            Manifest::from_iter(
1615                &ManifestId::random(),
1616                chunks.clone(),
1617                Some(&COMPRESS_CONFIG),
1618            )
1619            .await?
1620            .unwrap(),
1621        );
1622        assert!(manifest.root().location_dictionary().is_some());
1623
1624        let assert_exact = |payload: ChunkPayload| match payload {
1625            ChunkPayload::Virtual(v) => {
1626                assert_eq!(v.location.0.capacity(), v.location.0.len());
1627            }
1628            other => panic!("expected a virtual payload, got {other:?}"),
1629        };
1630        for payload in manifest.chunk_payloads()? {
1631            assert_exact(payload?);
1632        }
1633        for result in Arc::clone(&manifest).iter(node)? {
1634            assert_exact(result?.1);
1635        }
1636        for chunk in &chunks {
1637            assert_exact(manifest.get_chunk_payload(&chunk.node, &chunk.coord)?);
1638        }
1639        Ok(())
1640    }
1641
1642    #[tokio_test]
1643    async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1644        // compress=false → locations in `location` field, no compressed_location, no dictionary
1645        let chunks = make_virtual_chunks(50);
1646        let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1647            .await?
1648            .unwrap();
1649
1650        let root = manifest.root();
1651        assert!(root.location_dictionary().is_none());
1652        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1653
1654        // Verify location field is populated (not compressed_location)
1655        for am in root.arrays().iter() {
1656            for r in am.refs().iter() {
1657                if r.chunk_id().is_none() && r.inline().is_none() {
1658                    assert!(r.location().is_some(), "location should be set");
1659                    assert!(
1660                        r.compressed_location().is_none(),
1661                        "compressed_location should be None"
1662                    );
1663                }
1664            }
1665        }
1666
1667        // Readback still works
1668        for chunk in &chunks {
1669            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1670            assert_eq!(payload, chunk.payload);
1671        }
1672        Ok(())
1673    }
1674
1675    #[tokio_test]
1676    async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1677        // compress=true with enough virtual chunks → compressed_location, dictionary present
1678        let chunks = make_virtual_chunks(50);
1679        let manifest = Manifest::from_iter(
1680            &ManifestId::random(),
1681            chunks.clone(),
1682            Some(&COMPRESS_CONFIG),
1683        )
1684        .await?
1685        .unwrap();
1686
1687        let root = manifest.root();
1688        assert!(root.location_dictionary().is_some());
1689        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1690
1691        // Verify compressed_location is populated and location is None
1692        for am in root.arrays().iter() {
1693            for r in am.refs().iter() {
1694                if r.chunk_id().is_none() && r.inline().is_none() {
1695                    assert!(
1696                        r.compressed_location().is_some(),
1697                        "compressed_location should be set"
1698                    );
1699                    assert!(
1700                        r.location().is_none(),
1701                        "location should be None for compressed chunks"
1702                    );
1703                }
1704            }
1705        }
1706
1707        // Readback still works
1708        for chunk in &chunks {
1709            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1710            assert_eq!(payload, chunk.payload);
1711        }
1712        Ok(())
1713    }
1714
1715    #[tokio_test]
1716    async fn test_compression_skipped_when_locations_too_short()
1717    -> Result<(), Box<dyn Error>> {
1718        // When most virtual chunk locations are < 8 bytes, dictionary training
1719        // is skipped and the manifest falls back to uncompressed storage.
1720        // With longer locations (>= 8 bytes), compression kicks in.
1721        let config = LocationCompressionConfig {
1722            min_num_chunks: 2,
1723            dictionary_max_training_samples: 500,
1724            dictionary_max_size_bytes: 256,
1725            compression_level: 3,
1726        };
1727
1728        let node = NodeId::random();
1729        // "s3://a/" is 7 bytes, below the 8-byte threshold
1730        let short_chunks: Vec<ChunkInfo> = (0..2)
1731            .map(|i| ChunkInfo {
1732                node: node.clone(),
1733                coord: ChunkIndices(vec![i as u32]),
1734                payload: ChunkPayload::Virtual(VirtualChunkRef {
1735                    location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1736                    offset: i as u64 * 1024,
1737                    length: 1024,
1738                    checksum: None,
1739                }),
1740            })
1741            .collect();
1742
1743        let manifest = Manifest::from_iter(
1744            &ManifestId::random(),
1745            short_chunks.clone(),
1746            Some(&config),
1747        )
1748        .await?
1749        .unwrap();
1750
1751        let root = manifest.root();
1752        assert!(root.location_dictionary().is_none());
1753        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1754        for am in root.arrays().iter() {
1755            for r in am.refs().iter() {
1756                assert!(r.compressed_location().is_none());
1757                assert!(r.location().is_some());
1758            }
1759        }
1760
1761        for chunk in &short_chunks {
1762            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1763            assert_eq!(payload, chunk.payload);
1764        }
1765
1766        // "s3://abc/" is 9 bytes, above the 8-byte threshold — compression should apply
1767        let long_chunks: Vec<ChunkInfo> = (0..2)
1768            .map(|i| ChunkInfo {
1769                node: node.clone(),
1770                coord: ChunkIndices(vec![i as u32]),
1771                payload: ChunkPayload::Virtual(VirtualChunkRef {
1772                    location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1773                    offset: i as u64 * 1024,
1774                    length: 1024,
1775                    checksum: None,
1776                }),
1777            })
1778            .collect();
1779
1780        let manifest = Manifest::from_iter(
1781            &ManifestId::random(),
1782            long_chunks.clone(),
1783            Some(&config),
1784        )
1785        .await?
1786        .unwrap();
1787
1788        let root = manifest.root();
1789        assert!(root.location_dictionary().is_some());
1790        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1791        for am in root.arrays().iter() {
1792            for r in am.refs().iter() {
1793                assert!(r.compressed_location().is_some());
1794                assert!(r.location().is_none());
1795            }
1796        }
1797
1798        for chunk in &long_chunks {
1799            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1800            assert_eq!(payload, chunk.payload);
1801        }
1802        Ok(())
1803    }
1804
1805    #[tokio_test]
1806    async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1807        // 100 virtual chunks with highly redundant, large location strings.
1808        // Compression should produce a significantly smaller manifest.
1809        let node = NodeId::random();
1810        let chunks: Vec<ChunkInfo> = (0..100)
1811            .map(|i| ChunkInfo {
1812                node: node.clone(),
1813                coord: ChunkIndices(vec![i as u32]),
1814                payload: ChunkPayload::Virtual(VirtualChunkRef {
1815                    location: VirtualChunkLocation::from_url(&format!(
1816                        "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"
1817                    ))
1818                    .unwrap(),
1819                    offset: i as u64 * 4096,
1820                    length: 4096,
1821                    checksum: None,
1822                }),
1823            })
1824            .collect();
1825
1826        // Build without compression
1827        let manifest_uncompressed =
1828            Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1829                .await?
1830                .unwrap();
1831        let size_uncompressed = manifest_uncompressed.bytes().len();
1832
1833        assert!(!manifest_uncompressed.uses_location_compression());
1834        assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1835
1836        // Build with compression
1837        let manifest_compressed = Manifest::from_iter(
1838            &ManifestId::random(),
1839            chunks.clone(),
1840            Some(&COMPRESS_CONFIG),
1841        )
1842        .await?
1843        .unwrap();
1844        let size_compressed = manifest_compressed.bytes().len();
1845
1846        assert!(manifest_compressed.uses_location_compression());
1847        assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1848        assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1849
1850        // Each compressed_location should be much shorter than the raw URL (118 bytes)
1851        let root = manifest_compressed.root();
1852        for am in root.arrays().iter() {
1853            for r in am.refs().iter() {
1854                let compressed = r.compressed_location().unwrap();
1855                assert!(
1856                    compressed.len() < 40,
1857                    "compressed_location ({} bytes) should be under 40 bytes \
1858                     (raw location is 118 bytes)",
1859                    compressed.len(),
1860                );
1861            }
1862        }
1863
1864        // Compressed manifest should be meaningfully smaller
1865        assert!(
1866            size_compressed < size_uncompressed,
1867            "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1868        );
1869
1870        let saving_pct =
1871            (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1872        assert!(
1873            saving_pct > 15.0,
1874            "expected at least 15% size reduction with redundant locations, \
1875             got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1876        );
1877
1878        // Both manifests must round-trip correctly
1879        for chunk in &chunks {
1880            let p1 = manifest_uncompressed
1881                .get_chunk_payload(&chunk.node, &chunk.coord)
1882                .unwrap();
1883            let p2 =
1884                manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1885            assert_eq!(p1, chunk.payload);
1886            assert_eq!(p2, chunk.payload);
1887        }
1888
1889        Ok(())
1890    }
1891
1892    #[tokio_test]
1893    async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1894        // chunk_payloads() works with compressed manifests
1895        let chunks = make_virtual_chunks(50);
1896        let manifest = Manifest::from_iter(
1897            &ManifestId::random(),
1898            chunks.clone(),
1899            Some(&COMPRESS_CONFIG),
1900        )
1901        .await?
1902        .unwrap();
1903
1904        let payloads: Vec<_> =
1905            manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1906        assert_eq!(payloads.len(), 50);
1907        for (i, payload) in payloads.iter().enumerate() {
1908            assert_eq!(payload, &chunks[i].payload);
1909        }
1910        Ok(())
1911    }
1912}