Skip to main content

ostree_ext/
chunking.rs

1//! Split an OSTree commit into separate chunks
2
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5use std::borrow::{Borrow, Cow};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write;
8use std::hash::{Hash, Hasher};
9use std::num::NonZeroU32;
10use std::rc::Rc;
11use std::time::Instant;
12
13use crate::container::{COMPONENT_SEPARATOR, CONTENT_ANNOTATION};
14use crate::objectsource::{ContentID, ObjectMeta, ObjectMetaMap, ObjectSourceMeta};
15use crate::objgv::*;
16use crate::statistics;
17use anyhow::{Result, anyhow};
18use camino::{Utf8Path, Utf8PathBuf};
19use containers_image_proxy::oci_spec;
20use gvariant::aligned_bytes::TryAsAligned;
21use gvariant::{Marker, Structure};
22use indexmap::IndexMap;
23use ostree::{gio, glib};
24use serde::{Deserialize, Serialize};
25
26/// Maximum number of layers (chunks) we will use.
27// We take half the limit of 128.
28// https://github.com/ostreedev/ostree-rs-ext/issues/69
29pub(crate) const MAX_CHUNKS: u32 = 64;
30/// Minimum number of layers we can create in a "chunked" flow; otherwise
31/// we will just drop down to one.
32const MIN_CHUNKED_LAYERS: u32 = 4;
33
34/// A convenient alias for a reference-counted, immutable string.
35pub(crate) type RcStr = Rc<str>;
36/// Maps from a checksum to its size and file names (multiple in the case of
37/// hard links).
38pub(crate) type ChunkMapping = BTreeMap<RcStr, (u64, Vec<Utf8PathBuf>)>;
39// TODO type PackageSet = HashSet<RcStr>;
40
41const LOW_PARTITION: &str = "2ls";
42const HIGH_PARTITION: &str = "1hs";
43
44#[derive(Debug, Default)]
45pub(crate) struct Chunk {
46    pub(crate) name: String,
47    pub(crate) content: ChunkMapping,
48    pub(crate) size: u64,
49    pub(crate) packages: Vec<String>,
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
53/// Object metadata, but with additional size data
54pub struct ObjectSourceMetaSized {
55    /// The original metadata
56    #[serde(flatten)]
57    pub meta: ObjectSourceMeta,
58    /// Total size of associated objects
59    pub size: u64,
60}
61
62impl Hash for ObjectSourceMetaSized {
63    fn hash<H: Hasher>(&self, state: &mut H) {
64        self.meta.identifier.hash(state);
65    }
66}
67
68impl Eq for ObjectSourceMetaSized {}
69
70impl PartialEq for ObjectSourceMetaSized {
71    fn eq(&self, other: &Self) -> bool {
72        self.meta.identifier == other.meta.identifier
73    }
74}
75
76/// Extend content source metadata with sizes.
77#[derive(Debug)]
78pub struct ObjectMetaSized {
79    /// Mapping from content object to source.
80    pub map: ObjectMetaMap,
81    /// Computed sizes of each content source
82    pub sizes: Vec<ObjectSourceMetaSized>,
83}
84
85impl ObjectMetaSized {
86    /// Given object metadata and a repo, compute the size of each content source.
87    pub fn compute_sizes(repo: &ostree::Repo, meta: ObjectMeta) -> Result<ObjectMetaSized> {
88        let cancellable = gio::Cancellable::NONE;
89        // Destructure into component parts; we'll create the version with sizes
90        let map = meta.map;
91        let mut set = meta.set;
92        // Maps content id -> total size of associated objects
93        let mut sizes = BTreeMap::<&str, u64>::new();
94        // Populate two mappings above, iterating over the object -> contentid mapping
95        for (checksum, contentid) in map.iter() {
96            let finfo = repo.query_file(checksum, cancellable)?.0;
97            let sz = sizes.entry(contentid).or_default();
98            *sz += finfo.size() as u64;
99        }
100        // Combine data from sizes and the content mapping.
101        let sized: Result<Vec<_>> = sizes
102            .into_iter()
103            .map(|(id, size)| -> Result<ObjectSourceMetaSized> {
104                set.take(id)
105                    .ok_or_else(|| anyhow!("Failed to find {} in content set", id))
106                    .map(|meta| ObjectSourceMetaSized { meta, size })
107            })
108            .collect();
109        let mut sizes = sized?;
110        sizes.sort_by(|a, b| b.size.cmp(&a.size));
111        Ok(ObjectMetaSized { map, sizes })
112    }
113}
114
115/// How to split up an ostree commit into "chunks" - designed to map to container image layers.
116#[derive(Debug, Default)]
117pub struct Chunking {
118    pub(crate) metadata_size: u64,
119    pub(crate) remainder: Chunk,
120    pub(crate) chunks: Vec<Chunk>,
121
122    pub(crate) max: u32,
123
124    processed_mapping: bool,
125    /// Number of components (e.g. packages) provided originally
126    pub(crate) n_provided_components: u32,
127    /// The above, but only ones with non-zero size
128    pub(crate) n_sized_components: u32,
129}
130
131#[derive(Default)]
132struct Generation {
133    path: Utf8PathBuf,
134    metadata_size: u64,
135    dirtree_found: BTreeSet<RcStr>,
136    dirmeta_found: BTreeSet<RcStr>,
137}
138
139fn push_dirmeta(repo: &ostree::Repo, generation: &mut Generation, checksum: &str) -> Result<()> {
140    if generation.dirtree_found.contains(checksum) {
141        return Ok(());
142    }
143    let checksum = RcStr::from(checksum);
144    generation.dirmeta_found.insert(RcStr::clone(&checksum));
145    let child_v = repo.load_variant(ostree::ObjectType::DirMeta, checksum.borrow())?;
146    generation.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
147    Ok(())
148}
149
150fn push_dirtree(
151    repo: &ostree::Repo,
152    generation: &mut Generation,
153    checksum: &str,
154) -> Result<glib::Variant> {
155    let child_v = repo.load_variant(ostree::ObjectType::DirTree, checksum)?;
156    if !generation.dirtree_found.contains(checksum) {
157        generation.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
158    } else {
159        let checksum = RcStr::from(checksum);
160        generation.dirtree_found.insert(checksum);
161    }
162    Ok(child_v)
163}
164
165fn generate_chunking_recurse(
166    repo: &ostree::Repo,
167    generation: &mut Generation,
168    chunk: &mut Chunk,
169    dt: &glib::Variant,
170) -> Result<()> {
171    let dt = dt.data_as_bytes();
172    let dt = dt.try_as_aligned()?;
173    let dt = gv_dirtree!().cast(dt);
174    let (files, dirs) = dt.to_tuple();
175    // A reusable buffer to avoid heap allocating these
176    let mut hexbuf = [0u8; 64];
177    for file in files {
178        let (name, csum) = file.to_tuple();
179        let fpath = generation.path.join(name.to_str());
180        hex::encode_to_slice(csum, &mut hexbuf)?;
181        let checksum = std::str::from_utf8(&hexbuf)?;
182        let meta = repo.query_file(checksum, gio::Cancellable::NONE)?.0;
183        let size = meta.size() as u64;
184        let entry = chunk.content.entry(RcStr::from(checksum)).or_default();
185        entry.0 = size;
186        let first = entry.1.is_empty();
187        if first {
188            chunk.size += size;
189        }
190        entry.1.push(fpath);
191    }
192    for item in dirs {
193        let (name, contents_csum, meta_csum) = item.to_tuple();
194        let name = name.to_str();
195        // Extend our current path
196        generation.path.push(name);
197        hex::encode_to_slice(contents_csum, &mut hexbuf)?;
198        let checksum_s = std::str::from_utf8(&hexbuf)?;
199        let dirtree_v = push_dirtree(repo, generation, checksum_s)?;
200        generate_chunking_recurse(repo, generation, chunk, &dirtree_v)?;
201        drop(dirtree_v);
202        hex::encode_to_slice(meta_csum, &mut hexbuf)?;
203        let checksum_s = std::str::from_utf8(&hexbuf)?;
204        push_dirmeta(repo, generation, checksum_s)?;
205        // We did a push above, so pop must succeed.
206        assert!(generation.path.pop());
207    }
208    Ok(())
209}
210
211impl Chunk {
212    fn new(name: &str) -> Self {
213        Chunk {
214            name: name.to_string(),
215            ..Default::default()
216        }
217    }
218
219    pub(crate) fn move_obj(&mut self, dest: &mut Self, checksum: &str) -> bool {
220        // In most cases, we expect the object to exist in the source.  However, it's
221        // conveneient here to simply ignore objects which were already moved into
222        // a chunk.
223        if let Some((name, (size, paths))) = self.content.remove_entry(checksum) {
224            let v = dest.content.insert(name, (size, paths));
225            debug_assert!(v.is_none());
226            self.size -= size;
227            dest.size += size;
228            true
229        } else {
230            false
231        }
232    }
233
234    pub(crate) fn move_path(&mut self, dest: &mut Self, checksum: &str, path: &Utf8Path) {
235        if let Some((_size, paths)) = self.content.get_mut(checksum) {
236            let path_index = paths.iter().position(|p| *p == path);
237            if let Some(index) = path_index {
238                let removed_path = paths.remove(index);
239
240                let dest_entry = dest
241                    .content
242                    .entry(RcStr::from(checksum))
243                    .or_insert((0, Vec::new()));
244                dest_entry.1.push(removed_path);
245
246                if paths.is_empty() {
247                    self.content.remove(checksum);
248                }
249            }
250        }
251    }
252}
253
254impl Chunking {
255    /// Creates a reverse map from content IDs to checksums
256    fn create_content_id_map(
257        map: &IndexMap<String, ContentID>,
258    ) -> IndexMap<ContentID, Vec<&String>> {
259        let mut rmap = IndexMap::<ContentID, Vec<&String>>::new();
260        for (checksum, contentid) in map.iter() {
261            rmap.entry(Rc::clone(contentid)).or_default().push(checksum);
262        }
263        rmap
264    }
265
266    /// Generate an initial single chunk.
267    pub fn new(repo: &ostree::Repo, rev: &str) -> Result<Self> {
268        // Find the target commit
269        let rev = repo.require_rev(rev)?;
270
271        // Load and parse the commit object
272        let (commit_v, _) = repo.load_commit(&rev)?;
273        let commit_v = commit_v.data_as_bytes();
274        let commit_v = commit_v.try_as_aligned()?;
275        let commit = gv_commit!().cast(commit_v);
276        let commit = commit.to_tuple();
277
278        // Load it all into a single chunk
279        let mut generation = Generation {
280            path: Utf8PathBuf::from("/"),
281            ..Default::default()
282        };
283        let mut chunk: Chunk = Default::default();
284
285        // Find the root directory tree
286        let contents_checksum = &hex::encode(commit.6);
287        let contents_v = repo.load_variant(ostree::ObjectType::DirTree, contents_checksum)?;
288        push_dirtree(repo, &mut generation, contents_checksum)?;
289        let meta_checksum = &hex::encode(commit.7);
290        push_dirmeta(repo, &mut generation, meta_checksum.as_str())?;
291
292        generate_chunking_recurse(repo, &mut generation, &mut chunk, &contents_v)?;
293
294        let chunking = Chunking {
295            metadata_size: generation.metadata_size,
296            remainder: chunk,
297            ..Default::default()
298        };
299        Ok(chunking)
300    }
301
302    /// Generate a chunking from an object mapping.
303    pub fn from_mapping(
304        repo: &ostree::Repo,
305        rev: &str,
306        meta: &ObjectMetaSized,
307        max_layers: &Option<NonZeroU32>,
308        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
309        specific_contentmeta: Option<&BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>>,
310    ) -> Result<Self> {
311        let mut r = Self::new(repo, rev)?;
312        r.process_mapping(meta, max_layers, prior_build_metadata, specific_contentmeta)?;
313        Ok(r)
314    }
315
316    fn remaining(&self) -> u32 {
317        self.max.saturating_sub(self.chunks.len() as u32)
318    }
319
320    /// Given metadata about which objects are owned by a particular content source,
321    /// generate chunks that group together those objects.
322    #[allow(clippy::or_fun_call)]
323    pub fn process_mapping(
324        &mut self,
325        meta: &ObjectMetaSized,
326        max_layers: &Option<NonZeroU32>,
327        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
328        specific_contentmeta: Option<&BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>>,
329    ) -> Result<()> {
330        self.max = max_layers
331            .unwrap_or(NonZeroU32::new(MAX_CHUNKS).unwrap())
332            .get();
333
334        let sizes = &meta.sizes;
335        // It doesn't make sense to handle multiple mappings
336        assert!(!self.processed_mapping);
337        self.processed_mapping = true;
338        let remaining = self.remaining();
339        if remaining == 0 {
340            return Ok(());
341        }
342
343        // Create exclusive chunks first if specified
344        let mut processed_specific_components = BTreeSet::new();
345        if let Some(specific_meta) = specific_contentmeta {
346            for (component, files) in specific_meta {
347                let mut chunk = Chunk::new(&component);
348                chunk.packages = vec![component.to_string()];
349
350                // Move all objects belonging to this exclusive component
351                for (path, checksum) in files {
352                    self.remainder
353                        .move_path(&mut chunk, checksum.as_str(), path);
354                }
355
356                self.chunks.push(chunk);
357                processed_specific_components.insert(component.clone());
358            }
359        }
360
361        // Safety: Let's assume no one has over 4 billion components.
362        self.n_provided_components = meta.sizes.len().try_into().unwrap();
363        self.n_sized_components = sizes
364            .iter()
365            .filter(|v| v.size > 0)
366            .count()
367            .try_into()
368            .unwrap();
369
370        // Filter out exclusive components for regular packing
371        let regular_sizes: Vec<ObjectSourceMetaSized> = sizes
372            .iter()
373            .filter(|component| {
374                !processed_specific_components.contains(&*component.meta.identifier)
375            })
376            .cloned()
377            .collect();
378
379        let rmap = Self::create_content_id_map(&meta.map);
380
381        // Process regular components with bin packing if we have remaining layers
382        if let Some(remaining) = NonZeroU32::new(self.remaining()) {
383            let start = Instant::now();
384            let packing = basic_packing(&regular_sizes, remaining, prior_build_metadata)?;
385            let duration = start.elapsed();
386            tracing::debug!("Time elapsed in packing: {:#?}", duration);
387
388            for bin in packing.into_iter() {
389                let name = match bin.len() {
390                    0 => Cow::Borrowed("Reserved for new packages"),
391                    1 => {
392                        let first = bin[0];
393                        let first_name = &*first.meta.identifier;
394                        Cow::Borrowed(first_name)
395                    }
396                    2..=5 => {
397                        let first = bin[0];
398                        let first_name = &*first.meta.identifier;
399                        let r = bin.iter().map(|v| &*v.meta.identifier).skip(1).fold(
400                            String::from(first_name),
401                            |mut acc, v| {
402                                write!(acc, " and {v}").unwrap();
403                                acc
404                            },
405                        );
406                        Cow::Owned(r)
407                    }
408                    n => Cow::Owned(format!("{n} components")),
409                };
410                let mut chunk = Chunk::new(&name);
411                chunk.packages = bin.iter().map(|v| String::from(&*v.meta.name)).collect();
412                for szmeta in bin {
413                    for &obj in rmap.get(&szmeta.meta.identifier).unwrap() {
414                        self.remainder.move_obj(&mut chunk, obj.as_str());
415                    }
416                }
417                self.chunks.push(chunk);
418            }
419        }
420
421        // Check that all objects have been processed
422        if !processed_specific_components.is_empty() || !regular_sizes.is_empty() {
423            assert_eq!(self.remainder.content.len(), 0);
424        }
425
426        Ok(())
427    }
428
429    pub(crate) fn take_chunks(&mut self) -> Vec<Chunk> {
430        let mut r = Vec::new();
431        std::mem::swap(&mut self.chunks, &mut r);
432        r
433    }
434
435    /// Print information about chunking to standard output.
436    pub fn print(&self) {
437        println!("Metadata: {}", glib::format_size(self.metadata_size));
438        if self.n_provided_components > 0 {
439            println!(
440                "Components: provided={} sized={}",
441                self.n_provided_components, self.n_sized_components
442            );
443        }
444        for (n, chunk) in self.chunks.iter().enumerate() {
445            let sz = glib::format_size(chunk.size);
446            println!(
447                "Chunk {}: \"{}\": objects:{} size:{}",
448                n,
449                chunk.name,
450                chunk.content.len(),
451                sz
452            );
453        }
454        if !self.remainder.content.is_empty() {
455            let sz = glib::format_size(self.remainder.size);
456            println!(
457                "Remainder: \"{}\": objects:{} size:{}",
458                self.remainder.name,
459                self.remainder.content.len(),
460                sz
461            );
462        }
463    }
464}
465
466#[cfg(test)]
467fn components_size(components: &[&ObjectSourceMetaSized]) -> u64 {
468    components.iter().map(|k| k.size).sum()
469}
470
471/// Compute the total size of a packing
472#[cfg(test)]
473fn packing_size(packing: &[Vec<&ObjectSourceMetaSized>]) -> u64 {
474    packing.iter().map(|v| components_size(v)).sum()
475}
476
477/// Given a certain threshold, divide a list of packages into all combinations
478/// of (high, medium, low) size and (high,medium,low) using the following
479/// outlier detection methods:
480/// - Median and Median Absolute Deviation Method
481///   Aggressively detects outliers in size and classifies them by
482///   high, medium, low. The high size and low size are separate partitions
483///   and deserve bins of their own
484/// - Mean and Standard Deviation Method
485///   The medium partition from the previous step is less aggressively
486///   classified by using mean for both size and frequency
487///
488/// Note: Assumes components is sorted by descending size
489fn get_partitions_with_threshold<'a>(
490    components: &[&'a ObjectSourceMetaSized],
491    limit_hs_bins: usize,
492    threshold: f64,
493) -> Option<BTreeMap<String, Vec<&'a ObjectSourceMetaSized>>> {
494    let mut partitions: BTreeMap<String, Vec<&ObjectSourceMetaSized>> = BTreeMap::new();
495    let mut med_size: Vec<&ObjectSourceMetaSized> = Vec::new();
496    let mut high_size: Vec<&ObjectSourceMetaSized> = Vec::new();
497
498    let mut sizes: Vec<u64> = components.iter().map(|a| a.size).collect();
499    let (median_size, mad_size) = statistics::median_absolute_deviation(&mut sizes)?;
500
501    // We use abs here to ensure the lower limit stays positive
502    let size_low_limit = 0.5 * f64::abs(median_size - threshold * mad_size);
503    let size_high_limit = median_size + threshold * mad_size;
504
505    for pkg in components {
506        let size = pkg.size as f64;
507
508        // high size (hs)
509        if size >= size_high_limit {
510            high_size.push(pkg);
511        }
512        // low size (ls)
513        else if size <= size_low_limit {
514            partitions
515                .entry(LOW_PARTITION.to_string())
516                .and_modify(|bin| bin.push(pkg))
517                .or_insert_with(|| vec![pkg]);
518        }
519        // medium size (ms)
520        else {
521            med_size.push(pkg);
522        }
523    }
524
525    // Extra high-size packages
526    let mut remaining_pkgs: Vec<_> = if high_size.len() <= limit_hs_bins {
527        Vec::new()
528    } else {
529        high_size.drain(limit_hs_bins..).collect()
530    };
531    assert!(high_size.len() <= limit_hs_bins);
532
533    // Concatenate extra high-size packages + med_sizes to keep it descending sorted
534    remaining_pkgs.append(&mut med_size);
535    partitions.insert(HIGH_PARTITION.to_string(), high_size);
536
537    // Ascending sorted by frequency, so each partition within medium-size is freq sorted
538    remaining_pkgs.sort_by(|a, b| {
539        a.meta
540            .change_frequency
541            .partial_cmp(&b.meta.change_frequency)
542            .unwrap()
543    });
544    let med_sizes: Vec<u64> = remaining_pkgs.iter().map(|a| a.size).collect();
545    let med_frequencies: Vec<u64> = remaining_pkgs
546        .iter()
547        .map(|a| a.meta.change_frequency.into())
548        .collect();
549
550    let med_mean_freq = statistics::mean(&med_frequencies)?;
551    let med_stddev_freq = statistics::std_deviation(&med_frequencies)?;
552    let med_mean_size = statistics::mean(&med_sizes)?;
553    let med_stddev_size = statistics::std_deviation(&med_sizes)?;
554
555    // We use abs to avoid the lower limit being negative
556    let med_freq_low_limit = 0.5f64 * f64::abs(med_mean_freq - threshold * med_stddev_freq);
557    let med_freq_high_limit = med_mean_freq + threshold * med_stddev_freq;
558    let med_size_low_limit = 0.5f64 * f64::abs(med_mean_size - threshold * med_stddev_size);
559    let med_size_high_limit = med_mean_size + threshold * med_stddev_size;
560
561    for pkg in remaining_pkgs {
562        let size = pkg.size as f64;
563        let freq = pkg.meta.change_frequency as f64;
564
565        let size_name;
566        if size >= med_size_high_limit {
567            size_name = "hs";
568        } else if size <= med_size_low_limit {
569            size_name = "ls";
570        } else {
571            size_name = "ms";
572        }
573
574        // Numbered to maintain order of partitions in a BTreeMap of hf, mf, lf
575        let freq_name;
576        if freq >= med_freq_high_limit {
577            freq_name = "3hf";
578        } else if freq <= med_freq_low_limit {
579            freq_name = "5lf";
580        } else {
581            freq_name = "4mf";
582        }
583
584        let bucket = format!("{freq_name}_{size_name}");
585        partitions
586            .entry(bucket.to_string())
587            .and_modify(|bin| bin.push(pkg))
588            .or_insert_with(|| vec![pkg]);
589    }
590
591    for (name, pkgs) in &partitions {
592        tracing::debug!("{:#?}: {:#?}", name, pkgs.len());
593    }
594
595    Some(partitions)
596}
597
598/// If the current rpm-ostree commit to be encapsulated is not the one in which packing structure changes, then
599///  Flatten out prior_build_metadata to view all the packages in prior build as a single vec
600///  Compare the flattened vector to components to see if pkgs added, updated,
601///  removed or kept same
602///  if pkgs added, then add them to the last bin of prior
603///  if pkgs removed, then remove them from the prior\[i\]
604///  iterate through prior\[i\] and make bins according to the name in nevra of pkgs to update
605///  required packages
606/// else if pkg structure to be changed || prior build not specified
607///  Recompute optimal packaging structure (Compute partitions, place packages and optimize build)
608///
609/// A return value of `Ok(None)` indicates there was not an error, but the prior packing structure
610/// is incompatible in some way with the current build.
611fn basic_packing_with_prior_build<'a>(
612    components: &'a [ObjectSourceMetaSized],
613    bin_size: NonZeroU32,
614    prior_build: &oci_spec::image::ImageManifest,
615) -> Result<Option<Vec<Vec<&'a ObjectSourceMetaSized>>>> {
616    let before_processing_pkgs_len = components.len();
617
618    tracing::debug!("Attempting to use old package structure");
619
620    // The first layer is the ostree commit, which will always be different for different builds,
621    // so we ignore it.  For the remaining layers, extract the components/packages in each one.
622    let curr_build: Result<Vec<Vec<String>>> = prior_build
623        .layers()
624        .iter()
625        .skip(1)
626        .map(|layer| -> Result<_> {
627            let annotation_layer = layer
628                .annotations()
629                .as_ref()
630                .and_then(|annos| annos.get(CONTENT_ANNOTATION))
631                .ok_or_else(|| anyhow!("Missing {CONTENT_ANNOTATION} on prior build"))?;
632            Ok(annotation_layer
633                .split(COMPONENT_SEPARATOR)
634                .map(ToOwned::to_owned)
635                .collect())
636        })
637        .collect();
638    let mut curr_build = curr_build?;
639
640    // View the packages as unordered sets for lookups and differencing
641    let prev_pkgs_set: BTreeSet<String> = curr_build
642        .iter()
643        .flat_map(|v| v.iter().cloned())
644        .filter(|name| !name.is_empty())
645        .collect();
646    let curr_pkgs_set: BTreeSet<String> = components
647        .iter()
648        .map(|pkg| pkg.meta.name.to_string())
649        .collect();
650
651    // Names repeated within one layer still identify that layer. If a name
652    // spans layers, the annotations cannot tell which architecture/version
653    // belonged where. Move only those components to the new-packages bin.
654    let mut prior_bins = BTreeMap::new();
655    let mut split_names = BTreeSet::new();
656    for (idx, bin) in curr_build.iter().enumerate() {
657        for name in bin.iter().filter(|name| !name.is_empty()) {
658            if prior_bins.insert(name, idx).is_some_and(|prev| prev != idx) {
659                split_names.insert(name.clone());
660            }
661        }
662    }
663    for bin in &mut curr_build {
664        bin.retain(|name| !split_names.contains(name));
665    }
666
667    // Added packages are included in the last bin which was reserved space.
668    if let Some(last_bin) = curr_build.last_mut() {
669        let added = curr_pkgs_set.difference(&prev_pkgs_set);
670        last_bin.retain(|name| !name.is_empty());
671        last_bin.extend(added.into_iter().cloned());
672        last_bin.extend(split_names.intersection(&curr_pkgs_set).cloned());
673    } else {
674        tracing::debug!("No empty last bin for added packages.");
675        return Ok(None);
676    }
677
678    // Handle removed packages
679    let removed: BTreeSet<&String> = prev_pkgs_set.difference(&curr_pkgs_set).collect();
680    for bin in curr_build.iter_mut() {
681        bin.retain(|pkg| !removed.contains(pkg));
682    }
683
684    // Exclusive-component bins are already carved out by process_mapping(), so
685    // keep only the bins that still carry regular components plus the reserved
686    // "new packages" bin at the end of the prior layout.
687    let last_idx = curr_build.len().saturating_sub(1);
688    curr_build = curr_build
689        .into_iter()
690        .enumerate()
691        .filter_map(|(idx, bin)| (!bin.is_empty() || idx == last_idx).then_some(bin))
692        .collect();
693
694    if (bin_size.get() as usize) < curr_build.len() {
695        tracing::debug!("bin_size = {bin_size} is too small to be compatible with the prior build");
696        return Ok(None);
697    }
698
699    // Handle updated packages
700    let mut name_to_components: BTreeMap<String, Vec<&ObjectSourceMetaSized>> = BTreeMap::new();
701    for component in components.iter() {
702        name_to_components
703            .entry(component.meta.name.to_string())
704            .or_default()
705            .push(component);
706    }
707    let mut modified_build: Vec<Vec<&ObjectSourceMetaSized>> = Vec::new();
708    for bin in curr_build {
709        let mut mod_bin = Vec::new();
710        for pkg in bin {
711            // An empty component set can happen for the ostree commit layer; ignore that.
712            if pkg.is_empty() {
713                continue;
714            }
715            // Consume each name once, including all its current components.
716            // Repeated annotations in this bin must not duplicate objects.
717            if let Some(components) = name_to_components.remove(&pkg) {
718                mod_bin.extend(components);
719            }
720        }
721        modified_build.push(mod_bin);
722    }
723
724    // Verify all packages are included
725    let after_processing_pkgs_len: usize = modified_build.iter().map(|b| b.len()).sum();
726    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
727    assert!(modified_build.len() <= bin_size.get() as usize);
728    Ok(Some(modified_build))
729}
730
731/// Given a set of components with size metadata (e.g. boxes of a certain size)
732/// and a number of bins (possible container layers) to use, determine which components
733/// go in which bin.  This algorithm is pretty simple:
734/// Total available bins = n
735///
736/// 1 bin for all the u32_max frequency pkgs
737/// 1 bin for all newly added pkgs
738/// 1 bin for all low size pkgs
739///
740/// 60% of n-3 bins for high size pkgs
741/// 40% of n-3 bins for medium size pkgs
742///
743/// If HS bins > limit, spillover to MS to package
744/// If MS bins > limit, fold by merging 2 bins from the end
745///
746fn basic_packing<'a>(
747    components: &'a [ObjectSourceMetaSized],
748    bin_size: NonZeroU32,
749    prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
750) -> Result<Vec<Vec<&'a ObjectSourceMetaSized>>> {
751    const HIGH_SIZE_CUTOFF: f32 = 0.6;
752    let before_processing_pkgs_len = components.len();
753
754    anyhow::ensure!(bin_size.get() >= MIN_CHUNKED_LAYERS);
755
756    // If we have a prior build, then try to use that.
757    if let Some(prior_build) = prior_build_metadata {
758        if let Some(packing) = basic_packing_with_prior_build(components, bin_size, prior_build)? {
759            tracing::debug!("Keeping old package structure");
760            return Ok(packing);
761        } else {
762            tracing::debug!(
763                "Failed to use old package structure; discarding info from prior build."
764            );
765        }
766    }
767
768    tracing::debug!("Creating new packing structure");
769
770    // If there are fewer packages/components than there are bins, then we don't need to do
771    // any "bin packing" at all; just assign a single component to each and we're done.
772    if before_processing_pkgs_len < bin_size.get() as usize {
773        let mut r = components.iter().map(|pkg| vec![pkg]).collect::<Vec<_>>();
774        if before_processing_pkgs_len > 0 {
775            let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new();
776            r.push(new_pkgs_bin);
777        }
778        return Ok(r);
779    }
780
781    let mut r = Vec::new();
782    // Split off the components which are "max frequency".
783    let (components, max_freq_components) = components
784        .iter()
785        .partition::<Vec<_>, _>(|pkg| pkg.meta.change_frequency != u32::MAX);
786    if !components.is_empty() {
787        // Given a total number of bins (layers), compute how many should be assigned to our
788        // partitioning based on size and frequency.
789        let limit_ls_bins = 1usize;
790        let limit_new_bins = 1usize;
791        let _limit_new_pkgs = 0usize;
792        let limit_max_frequency_pkgs = max_freq_components.len();
793        let limit_max_frequency_bins = limit_max_frequency_pkgs.min(1);
794        let low_and_other_bin_limit = limit_ls_bins + limit_new_bins + limit_max_frequency_bins;
795        let limit_hs_bins = (HIGH_SIZE_CUTOFF
796            * (bin_size.get() - low_and_other_bin_limit as u32) as f32)
797            .floor() as usize;
798        let limit_ms_bins =
799            (bin_size.get() - (limit_hs_bins + low_and_other_bin_limit) as u32) as usize;
800        let partitions = get_partitions_with_threshold(&components, limit_hs_bins, 2f64)
801            .expect("Partitioning components into sets");
802
803        // Compute how many low-sized package/components we have.
804        let low_sized_component_count = partitions
805            .get(LOW_PARTITION)
806            .map(|p| p.len())
807            .unwrap_or_default();
808
809        // Approximate number of components we should have per medium-size bin.
810        let pkg_per_bin_ms: usize = (components.len() - limit_hs_bins - low_sized_component_count)
811            .checked_div(limit_ms_bins)
812            .ok_or_else(|| anyhow::anyhow!("number of bins should be >= {}", MIN_CHUNKED_LAYERS))?;
813
814        // Bins assignment
815        for (partition, pkgs) in partitions.iter() {
816            if partition == HIGH_PARTITION {
817                for pkg in pkgs {
818                    r.push(vec![*pkg]);
819                }
820            } else if partition == LOW_PARTITION {
821                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
822                for pkg in pkgs {
823                    bin.push(*pkg);
824                }
825                r.push(bin);
826            } else {
827                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
828                for (i, pkg) in pkgs.iter().enumerate() {
829                    if bin.len() < pkg_per_bin_ms {
830                        bin.push(*pkg);
831                    } else {
832                        r.push(bin.clone());
833                        bin.clear();
834                        bin.push(*pkg);
835                    }
836                    if i == pkgs.len() - 1 && !bin.is_empty() {
837                        r.push(bin.clone());
838                        bin.clear();
839                    }
840                }
841            }
842        }
843        tracing::debug!("Bins before unoptimized build: {}", r.len());
844
845        // Despite allocation certain number of pkgs per bin in medium-size partitions, the
846        // hard limit of number of medium-size bins can be exceeded. This is because the pkg_per_bin_ms
847        // is only upper limit and there is no lower limit. Thus, if a partition in medium-size has only 1 pkg
848        // but pkg_per_bin_ms > 1, then the entire bin will have 1 pkg. This prevents partition
849        // mixing.
850        //
851        // Addressing medium-size bins limit breach by mergin internal MS partitions
852        // The partitions in medium-size are merged beginning from the end so to not mix high-frequency bins with low-frequency bins. The
853        // bins are kept in this order: high-frequency, medium-frequency, low-frequency.
854        while r.len() > (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) {
855            for i in (limit_ls_bins + limit_hs_bins..r.len() - 1)
856                .step_by(2)
857                .rev()
858            {
859                if r.len() <= (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins)
860                {
861                    break;
862                }
863                let prev = &r[i - 1];
864                let curr = &r[i];
865                let mut merge: Vec<&ObjectSourceMetaSized> = Vec::new();
866                merge.extend(prev.iter());
867                merge.extend(curr.iter());
868                r.remove(i);
869                r.remove(i - 1);
870                r.insert(i, merge);
871            }
872        }
873        tracing::debug!("Bins after optimization: {}", r.len());
874    }
875
876    if !max_freq_components.is_empty() {
877        r.push(max_freq_components);
878    }
879
880    // Allocate an empty bin for new packages
881    r.push(Vec::new());
882    let after_processing_pkgs_len = r.iter().map(|b| b.len()).sum::<usize>();
883    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
884    assert!(r.len() <= bin_size.get() as usize);
885    Ok(r)
886}
887
888#[cfg(test)]
889mod test {
890    use super::*;
891
892    use oci_spec::image as oci_image;
893    use std::str::FromStr;
894
895    const FCOS_CONTENTMETA: &[u8] = include_bytes!("fixtures/fedora-coreos-contentmeta.json.gz");
896    const SHA256_EXAMPLE: &str =
897        "sha256:0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff";
898
899    #[test]
900    fn test_packing_basics() -> Result<()> {
901        // null cases
902        for v in [4, 7].map(|v| NonZeroU32::new(v).unwrap()) {
903            assert_eq!(basic_packing(&[], v, None).unwrap().len(), 0);
904        }
905        Ok(())
906    }
907
908    #[test]
909    fn test_packing_fcos() -> Result<()> {
910        let contentmeta: Vec<ObjectSourceMetaSized> =
911            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
912        let total_size = contentmeta.iter().map(|v| v.size).sum::<u64>();
913
914        let packing =
915            basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap(), None).unwrap();
916        assert!(!contentmeta.is_empty());
917        // We should fit into the assigned chunk size
918        assert_eq!(packing.len() as u32, MAX_CHUNKS);
919        // And verify that the sizes match
920        let packed_total_size = packing_size(&packing);
921        assert_eq!(total_size, packed_total_size);
922        Ok(())
923    }
924
925    #[test]
926    fn test_packing_one_layer() -> Result<()> {
927        let contentmeta: Vec<ObjectSourceMetaSized> =
928            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
929        let r = basic_packing(&contentmeta, NonZeroU32::new(1).unwrap(), None);
930        assert!(r.is_err());
931        Ok(())
932    }
933
934    fn create_manifest(prev_expected_structure: Vec<Vec<&str>>) -> oci_spec::image::ImageManifest {
935        use std::collections::HashMap;
936
937        let mut p = prev_expected_structure
938            .iter()
939            .map(|b| {
940                b.iter()
941                    .map(|p| p.split('.').collect::<Vec<&str>>()[0].to_string())
942                    .collect()
943            })
944            .collect();
945        let mut metadata_with_ostree_commit = vec![vec![String::from("ostree_commit")]];
946        metadata_with_ostree_commit.append(&mut p);
947
948        let config = oci_spec::image::DescriptorBuilder::default()
949            .media_type(oci_spec::image::MediaType::ImageConfig)
950            .size(7023_u64)
951            .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
952            .build()
953            .expect("build config descriptor");
954
955        let layers: Vec<oci_spec::image::Descriptor> = metadata_with_ostree_commit
956            .iter()
957            .map(|l| {
958                let mut buf = [0; 8];
959                let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf);
960                oci_spec::image::DescriptorBuilder::default()
961                    .media_type(oci_spec::image::MediaType::ImageLayerGzip)
962                    .size(100_u64)
963                    .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
964                    .annotations(HashMap::from([(
965                        CONTENT_ANNOTATION.to_string(),
966                        l.join(sep),
967                    )]))
968                    .build()
969                    .expect("build layer")
970            })
971            .collect();
972
973        oci_spec::image::ImageManifestBuilder::default()
974            .schema_version(oci_spec::image::SCHEMA_VERSION)
975            .config(config)
976            .layers(layers)
977            .build()
978            .expect("build image manifest")
979    }
980
981    #[test]
982    fn test_advanced_packing() -> Result<()> {
983        // Step1 : Initial build (Packing structure computed)
984        let contentmeta_v0: Vec<ObjectSourceMetaSized> = vec![
985            vec![1, u32::MAX, 100000],
986            vec![2, u32::MAX, 99999],
987            vec![3, 30, 99998],
988            vec![4, 100, 99997],
989            vec![10, 51, 1000],
990            vec![8, 50, 500],
991            vec![9, 1, 200],
992            vec![11, 100000, 199],
993            vec![6, 30, 2],
994            vec![7, 30, 1],
995        ]
996        .iter()
997        .map(|data| ObjectSourceMetaSized {
998            meta: ObjectSourceMeta {
999                identifier: RcStr::from(format!("pkg{}.0", data[0])),
1000                name: RcStr::from(format!("pkg{}", data[0])),
1001                srcid: RcStr::from(format!("srcpkg{}", data[0])),
1002                change_time_offset: 0,
1003                change_frequency: data[1],
1004            },
1005            size: data[2] as u64,
1006        })
1007        .collect();
1008
1009        let packing = basic_packing(
1010            &contentmeta_v0.as_slice(),
1011            NonZeroU32::new(6).unwrap(),
1012            None,
1013        )
1014        .unwrap();
1015        let structure: Vec<Vec<&str>> = packing
1016            .iter()
1017            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1018            .collect();
1019        let v0_expected_structure = vec![
1020            vec!["pkg3.0"],
1021            vec!["pkg4.0"],
1022            vec!["pkg6.0", "pkg7.0", "pkg11.0"],
1023            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1024            vec!["pkg1.0", "pkg2.0"],
1025            vec![],
1026        ];
1027        assert_eq!(structure, v0_expected_structure);
1028
1029        // Step 2: Derive packing structure from last build
1030
1031        let mut contentmeta_v1: Vec<ObjectSourceMetaSized> = contentmeta_v0;
1032        // Upgrade pkg1.0 to 1.1
1033        contentmeta_v1[0].meta.identifier = RcStr::from("pkg1.1");
1034        // Remove pkg7
1035        contentmeta_v1.remove(contentmeta_v1.len() - 1);
1036        // Add pkg5
1037        contentmeta_v1.push(ObjectSourceMetaSized {
1038            meta: ObjectSourceMeta {
1039                identifier: RcStr::from("pkg5.0"),
1040                name: RcStr::from("pkg5"),
1041                srcid: RcStr::from("srcpkg5"),
1042                change_time_offset: 0,
1043                change_frequency: 42,
1044            },
1045            size: 100000,
1046        });
1047
1048        let image_manifest_v0 = create_manifest(v0_expected_structure);
1049        let packing_derived = basic_packing(
1050            &contentmeta_v1.as_slice(),
1051            NonZeroU32::new(6).unwrap(),
1052            Some(&image_manifest_v0),
1053        )
1054        .unwrap();
1055        let structure_derived: Vec<Vec<&str>> = packing_derived
1056            .iter()
1057            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1058            .collect();
1059        let v1_expected_structure = vec![
1060            vec!["pkg3.0"],
1061            vec!["pkg4.0"],
1062            vec!["pkg6.0", "pkg11.0"],
1063            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1064            vec!["pkg1.1", "pkg2.0"],
1065            vec!["pkg5.0"],
1066        ];
1067
1068        assert_eq!(structure_derived, v1_expected_structure);
1069
1070        // Step 3: Another update on derived where the pkg in the last bin updates
1071
1072        let mut contentmeta_v2: Vec<ObjectSourceMetaSized> = contentmeta_v1;
1073        // Upgrade pkg5.0 to 5.1
1074        contentmeta_v2[9].meta.identifier = RcStr::from("pkg5.1");
1075        // Add pkg12
1076        contentmeta_v2.push(ObjectSourceMetaSized {
1077            meta: ObjectSourceMeta {
1078                identifier: RcStr::from("pkg12.0"),
1079                name: RcStr::from("pkg12"),
1080                srcid: RcStr::from("srcpkg12"),
1081                change_time_offset: 0,
1082                change_frequency: 42,
1083            },
1084            size: 100000,
1085        });
1086
1087        let image_manifest_v1 = create_manifest(v1_expected_structure);
1088        let packing_derived = basic_packing(
1089            &contentmeta_v2.as_slice(),
1090            NonZeroU32::new(6).unwrap(),
1091            Some(&image_manifest_v1),
1092        )
1093        .unwrap();
1094        let structure_derived: Vec<Vec<&str>> = packing_derived
1095            .iter()
1096            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1097            .collect();
1098        let v2_expected_structure = vec![
1099            vec!["pkg3.0"],
1100            vec!["pkg4.0"],
1101            vec!["pkg6.0", "pkg11.0"],
1102            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1103            vec!["pkg1.1", "pkg2.0"],
1104            vec!["pkg5.1", "pkg12.0"],
1105        ];
1106
1107        assert_eq!(structure_derived, v2_expected_structure);
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn test_advanced_packing_with_prior_exclusive_components() -> Result<()> {
1113        let contentmeta: Vec<ObjectSourceMetaSized> = [
1114            (1, 100, 50000),
1115            (2, 200, 40000),
1116            (3, 300, 30000),
1117            (4, 400, 20000),
1118            (5, 500, 10000),
1119            (6, 600, 5000),
1120        ]
1121        .iter()
1122        .map(|&(id, freq, size)| ObjectSourceMetaSized {
1123            meta: ObjectSourceMeta {
1124                identifier: RcStr::from(format!("pkg{id}.0")),
1125                name: RcStr::from(format!("pkg{id}")),
1126                srcid: RcStr::from(format!("srcpkg{id}")),
1127                change_time_offset: 0,
1128                change_frequency: freq,
1129            },
1130            size,
1131        })
1132        .collect();
1133
1134        let regular_components = contentmeta[2..].to_vec();
1135        let prior_structure = vec![
1136            vec!["pkg1.0"],
1137            vec!["pkg2.0"],
1138            vec!["pkg3.0", "pkg4.0"],
1139            vec!["pkg5.0", "pkg6.0"],
1140            vec![],
1141        ];
1142        let prior_build = create_manifest(prior_structure);
1143
1144        let packing = basic_packing_with_prior_build(
1145            &regular_components,
1146            NonZeroU32::new(3).unwrap(),
1147            &prior_build,
1148        )?
1149        .expect("prior layout should remain reusable after exclusive bins are removed");
1150        let structure: Vec<Vec<&str>> = packing
1151            .iter()
1152            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1153            .collect();
1154
1155        assert_eq!(
1156            structure,
1157            vec![vec!["pkg3.0", "pkg4.0"], vec!["pkg5.0", "pkg6.0"], vec![],]
1158        );
1159        Ok(())
1160    }
1161
1162    #[test]
1163    fn test_prior_packing_duplicate_names() -> Result<()> {
1164        // Different architectures or versions can share a package name.
1165        for (current_count, prior_structure, expected_structure) in [
1166            (
1167                2,
1168                vec![vec!["pkg1.0"], vec!["pkg2.0"], vec![]],
1169                vec![vec!["pkg1.0", "pkg1.1"], vec!["pkg2.0"], vec![]],
1170            ),
1171            (
1172                2,
1173                vec![vec!["pkg1.0", "pkg1.1"], vec!["pkg2.0"], vec![]],
1174                vec![vec!["pkg1.0", "pkg1.1"], vec!["pkg2.0"], vec![]],
1175            ),
1176            (
1177                1,
1178                vec![vec!["pkg1.0", "pkg1.1"], vec!["pkg2.0"], vec![]],
1179                vec![vec!["pkg1.0"], vec!["pkg2.0"], vec![]],
1180            ),
1181            (
1182                0,
1183                vec![vec!["pkg1.0"], vec!["pkg2.0"], vec!["pkg1.1"], vec![]],
1184                vec![vec!["pkg2.0"], vec![]],
1185            ),
1186            (
1187                2,
1188                vec![vec!["pkg2.0"], vec![]],
1189                vec![vec!["pkg2.0"], vec!["pkg1.0", "pkg1.1"]],
1190            ),
1191            (
1192                3,
1193                vec![vec!["pkg1.0"], vec!["pkg2.0"], vec![]],
1194                vec![vec!["pkg1.0", "pkg1.1", "pkg1.2"], vec!["pkg2.0"], vec![]],
1195            ),
1196            (
1197                2,
1198                vec![vec!["pkg1.0", "pkg2.0"], vec!["pkg1.1"], vec![]],
1199                vec![vec!["pkg2.0"], vec!["pkg1.0", "pkg1.1"]],
1200            ),
1201            (
1202                1,
1203                vec![vec!["pkg1.0"], vec!["pkg2.0"], vec!["pkg1.1"]],
1204                vec![vec!["pkg2.0"], vec!["pkg1.0"]],
1205            ),
1206        ] {
1207            let mut components = Vec::new();
1208            for i in 0..current_count {
1209                components.push(ObjectSourceMetaSized {
1210                    meta: ObjectSourceMeta {
1211                        identifier: RcStr::from(format!("pkg1.{i}")),
1212                        name: RcStr::from("pkg1"),
1213                        srcid: RcStr::from("srcpkg1"),
1214                        change_time_offset: 0,
1215                        change_frequency: 1,
1216                    },
1217                    size: 100,
1218                });
1219            }
1220            components.push(ObjectSourceMetaSized {
1221                meta: ObjectSourceMeta {
1222                    identifier: RcStr::from("pkg2.0"),
1223                    name: RcStr::from("pkg2"),
1224                    srcid: RcStr::from("srcpkg2"),
1225                    change_time_offset: 0,
1226                    change_frequency: 1,
1227                },
1228                size: 200,
1229            });
1230            components.sort_by(|a, b| b.size.cmp(&a.size));
1231            let prior = create_manifest(prior_structure);
1232            let bin_size = NonZeroU32::new(4).unwrap();
1233            let reused = basic_packing_with_prior_build(&components, bin_size, &prior)?
1234                .expect("duplicate names should preserve the reusable prior layout");
1235            let structure: Vec<Vec<&str>> = reused
1236                .iter()
1237                .map(|bin| bin.iter().map(|c| c.meta.identifier.as_ref()).collect())
1238                .collect();
1239            assert_eq!(structure, expected_structure);
1240            let packing = basic_packing(&components, bin_size, Some(&prior))?;
1241            let packed: BTreeSet<_> = packing
1242                .iter()
1243                .flatten()
1244                .map(|c| c.meta.identifier.as_ref())
1245                .collect();
1246            let expected: BTreeSet<_> = components
1247                .iter()
1248                .map(|c| c.meta.identifier.as_ref())
1249                .collect();
1250            assert_eq!(packed, expected);
1251            assert_eq!(
1252                packing.iter().map(Vec::len).sum::<usize>(),
1253                components.len()
1254            );
1255            assert_eq!(
1256                packing_size(&packing),
1257                components.iter().map(|c| c.size).sum::<u64>()
1258            );
1259            assert!(packing.len() <= bin_size.get() as usize);
1260        }
1261        Ok(())
1262    }
1263
1264    #[test]
1265    fn test_process_mapping_duplicate_names() -> Result<()> {
1266        let (_, mut meta, _, mut chunking) =
1267            setup_exclusive_test(&[(1, 1, 1000), (2, 1, 1000)], 4, Some(2))?;
1268        // The old map picked the first component twice. Its component count
1269        // matched, but checksum_1 was left in the remainder and caused a panic.
1270        meta.sizes[1].meta.name = meta.sizes[0].meta.name.clone();
1271        let prior = create_manifest(vec![vec!["pkg1.0"], vec!["pkg1.1"], vec![]]);
1272        chunking.process_mapping(&meta, &NonZeroU32::new(4), Some(&prior), None)?;
1273        assert!(chunking.remainder.content.is_empty());
1274        assert_eq!(chunking.remainder.size, 0);
1275        let objects: BTreeSet<_> = chunking
1276            .chunks
1277            .iter()
1278            .flat_map(|chunk| chunk.content.keys().map(|s| s.as_ref()))
1279            .collect();
1280        assert_eq!(objects, BTreeSet::from(["checksum_0", "checksum_1"]));
1281        assert_eq!(chunking.chunks.iter().map(|c| c.size).sum::<u64>(), 2000);
1282        Ok(())
1283    }
1284
1285    fn setup_exclusive_test(
1286        component_data: &[(u32, u32, u64)],
1287        max_layers: u32,
1288        num_fake_objects: Option<usize>,
1289    ) -> Result<(
1290        Vec<ObjectSourceMetaSized>,
1291        ObjectMetaSized,
1292        BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>,
1293        Chunking,
1294    )> {
1295        // Create content metadata from provided data
1296        let contentmeta: Vec<ObjectSourceMetaSized> = component_data
1297            .iter()
1298            .map(|&(id, freq, size)| ObjectSourceMetaSized {
1299                meta: ObjectSourceMeta {
1300                    identifier: RcStr::from(format!("pkg{id}.0")),
1301                    name: RcStr::from(format!("pkg{id}")),
1302                    srcid: RcStr::from(format!("srcpkg{id}")),
1303                    change_time_offset: 0,
1304                    change_frequency: freq,
1305                },
1306                size,
1307            })
1308            .collect();
1309
1310        // Create object maps with fake checksums
1311        let mut object_map = IndexMap::new();
1312
1313        for (i, component) in contentmeta.iter().enumerate() {
1314            let checksum = format!("checksum_{i}");
1315            object_map.insert(checksum, component.meta.identifier.clone());
1316        }
1317
1318        let regular_meta = ObjectMetaSized {
1319            map: object_map,
1320            sizes: contentmeta.clone(),
1321        };
1322
1323        // Create exclusive metadata (initially empty, to be populated by individual tests)
1324        let specific_contentmeta = BTreeMap::new();
1325
1326        // Set up chunking with remainder chunk
1327        let mut chunking = Chunking::default();
1328        chunking.max = max_layers;
1329        chunking.remainder = Chunk::new("remainder");
1330
1331        // Add fake objects to the remainder chunk if specified
1332        if let Some(num_objects) = num_fake_objects {
1333            for i in 0..num_objects {
1334                let checksum = format!("checksum_{i}");
1335                chunking.remainder.content.insert(
1336                    RcStr::from(checksum),
1337                    (
1338                        1000,
1339                        vec![Utf8PathBuf::from(format!("/path/to/checksum_{i}"))],
1340                    ),
1341                );
1342                chunking.remainder.size += 1000;
1343            }
1344        }
1345
1346        Ok((contentmeta, regular_meta, specific_contentmeta, chunking))
1347    }
1348
1349    #[test]
1350    fn test_exclusive_chunks() -> Result<()> {
1351        // Test that exclusive chunks are created first and get their own layers
1352        let component_data = [
1353            (1, 100, 50000),
1354            (2, 200, 40000),
1355            (3, 300, 30000),
1356            (4, 400, 20000),
1357            (5, 500, 10000),
1358        ];
1359
1360        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1361            setup_exclusive_test(&component_data, 8, Some(5))?;
1362
1363        // Create specific content metadata for pkg1 and pkg2
1364        specific_contentmeta.insert(
1365            contentmeta[0].meta.identifier.clone(),
1366            vec![(
1367                Utf8PathBuf::from("/path/to/checksum_0"),
1368                "checksum_0".to_string(),
1369            )],
1370        );
1371        specific_contentmeta.insert(
1372            contentmeta[1].meta.identifier.clone(),
1373            vec![(
1374                Utf8PathBuf::from("/path/to/checksum_1"),
1375                "checksum_1".to_string(),
1376            )],
1377        );
1378
1379        chunking.process_mapping(
1380            &regular_meta,
1381            &Some(NonZeroU32::new(8).unwrap()),
1382            None,
1383            Some(&specific_contentmeta),
1384        )?;
1385
1386        // Verify exclusive chunks are created first
1387        assert!(chunking.chunks.len() >= 2);
1388        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1389        assert_eq!(chunking.chunks[1].name, "pkg2.0");
1390        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1391        assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]);
1392
1393        Ok(())
1394    }
1395
1396    #[test]
1397    fn test_exclusive_chunks_with_regular_packing() -> Result<()> {
1398        // Test that exclusive chunks are created first, then regular packing continues
1399        let component_data = [
1400            (1, 100, 50000), // exclusive
1401            (2, 200, 40000), // exclusive
1402            (3, 300, 30000), // regular
1403            (4, 400, 20000), // regular
1404            (5, 500, 10000), // regular
1405            (6, 600, 5000),  // regular
1406        ];
1407
1408        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1409            setup_exclusive_test(&component_data, 8, Some(6))?;
1410
1411        // Create specific content metadata for pkg1 and pkg2
1412        specific_contentmeta.insert(
1413            contentmeta[0].meta.identifier.clone(),
1414            vec![(
1415                Utf8PathBuf::from("/path/to/checksum_0"),
1416                "checksum_0".to_string(),
1417            )],
1418        );
1419        specific_contentmeta.insert(
1420            contentmeta[1].meta.identifier.clone(),
1421            vec![(
1422                Utf8PathBuf::from("/path/to/checksum_1"),
1423                "checksum_1".to_string(),
1424            )],
1425        );
1426
1427        chunking.process_mapping(
1428            &regular_meta,
1429            &Some(NonZeroU32::new(8).unwrap()),
1430            None,
1431            Some(&specific_contentmeta),
1432        )?;
1433
1434        // Verify exclusive chunks are created first
1435        assert!(chunking.chunks.len() >= 2);
1436        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1437        assert_eq!(chunking.chunks[1].name, "pkg2.0");
1438        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1439        assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]);
1440
1441        // Verify regular components are not in exclusive chunks
1442        for chunk in &chunking.chunks[2..] {
1443            assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1444            assert!(!chunk.packages.contains(&"pkg2.0".to_string()));
1445        }
1446
1447        Ok(())
1448    }
1449
1450    #[test]
1451    fn test_exclusive_chunks_isolation() -> Result<()> {
1452        // Test that exclusive chunks properly isolate components
1453        let component_data = [(1, 100, 50000), (2, 200, 40000), (3, 300, 30000)];
1454
1455        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1456            setup_exclusive_test(&component_data, 8, Some(3))?;
1457
1458        // Create specific content metadata for pkg1 only
1459        specific_contentmeta.insert(
1460            contentmeta[0].meta.identifier.clone(),
1461            vec![(
1462                Utf8PathBuf::from("/path/to/checksum_0"),
1463                "checksum_0".to_string(),
1464            )],
1465        );
1466
1467        chunking.process_mapping(
1468            &regular_meta,
1469            &Some(NonZeroU32::new(8).unwrap()),
1470            None,
1471            Some(&specific_contentmeta),
1472        )?;
1473
1474        // Verify pkg1 is in its own exclusive chunk
1475        assert!(!chunking.chunks.is_empty());
1476        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1477        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1478
1479        // Verify pkg2 and pkg3 are in regular chunks, not mixed with pkg1
1480        let mut found_pkg2 = false;
1481        let mut found_pkg3 = false;
1482        for chunk in &chunking.chunks[1..] {
1483            if chunk.packages.contains(&"pkg2".to_string()) {
1484                found_pkg2 = true;
1485                assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1486            }
1487            if chunk.packages.contains(&"pkg3".to_string()) {
1488                found_pkg3 = true;
1489                assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1490            }
1491        }
1492        assert!(found_pkg2 && found_pkg3);
1493
1494        Ok(())
1495    }
1496
1497    #[test]
1498    fn test_process_mapping_specific_components_contain_correct_objects() -> Result<()> {
1499        // This test validates that specific components get their own dedicated layers
1500        // and that their objects are properly isolated from regular package layers
1501
1502        // Setup: Create 5 packages
1503        // - pkg1, pkg2: Will be marked as "specific components" (get their own layers)
1504        // - pkg3, pkg4, pkg5: Regular packages (will be bin-packed together)
1505        let packages = [
1506            (1, 100, 50000), // pkg1 - SPECIFIC COMPONENT
1507            (2, 200, 40000), // pkg2 - SPECIFIC COMPONENT
1508            (3, 300, 30000), // pkg3 - regular package
1509            (4, 400, 20000), // pkg4 - regular package
1510            (5, 500, 10000), // pkg5 - regular package
1511        ];
1512
1513        let (contentmeta, mut system_metadata, mut specific_contentmeta, mut chunking) =
1514            setup_exclusive_test(&packages, 8, None)?;
1515
1516        // Create object mappings
1517        // - system_objects_map: Contains ALL objects in the system (both specific and regular)
1518        let mut system_objects_map = IndexMap::new();
1519
1520        // SPECIFIC COMPONENT 1 (pkg1): owns 3 objects
1521        let pkg1_objects = ["checksum_1_a", "checksum_1_b", "checksum_1_c"];
1522
1523        // SPECIFIC COMPONENT 2 (pkg2): owns 2 objects
1524        let pkg2_objects = ["checksum_2_a", "checksum_2_b"];
1525
1526        // REGULAR PACKAGE 1 (pkg3): owns 2 objects
1527        let pkg3_objects = ["checksum_3_a", "checksum_3_b"];
1528        for obj in &pkg3_objects {
1529            system_objects_map.insert(obj.to_string(), contentmeta[2].meta.identifier.clone());
1530        }
1531
1532        // REGULAR PACKAGE 2 (pkg4): owns 1 object
1533        let pkg4_objects = ["checksum_4_a"];
1534        for obj in &pkg4_objects {
1535            system_objects_map.insert(obj.to_string(), contentmeta[3].meta.identifier.clone());
1536        }
1537
1538        // REGULAR PACKAGE 3 (pkg5): owns 3 objects
1539        let pkg5_objects = ["checksum_5_a", "checksum_5_b", "checksum_5_c"];
1540        for obj in &pkg5_objects {
1541            system_objects_map.insert(obj.to_string(), contentmeta[4].meta.identifier.clone());
1542        }
1543
1544        // Set up metadata
1545        system_metadata.map = system_objects_map;
1546
1547        // Create specific content metadata for pkg1 and pkg2
1548        specific_contentmeta.insert(
1549            contentmeta[0].meta.identifier.clone(),
1550            pkg1_objects
1551                .iter()
1552                .map(|obj| {
1553                    (
1554                        Utf8PathBuf::from(format!("/path/to/{obj}")),
1555                        obj.to_string(),
1556                    )
1557                })
1558                .collect(),
1559        );
1560        specific_contentmeta.insert(
1561            contentmeta[1].meta.identifier.clone(),
1562            pkg2_objects
1563                .iter()
1564                .map(|obj| {
1565                    (
1566                        Utf8PathBuf::from(format!("/path/to/{obj}")),
1567                        obj.to_string(),
1568                    )
1569                })
1570                .collect(),
1571        );
1572
1573        // Initialize: Add ALL objects to the remainder chunk before processing
1574        // This includes both specific component objects and regular package objects
1575        // because process_mapping needs to move them from remainder to their final layers
1576        for (checksum, _) in &system_metadata.map {
1577            chunking.remainder.content.insert(
1578                RcStr::from(checksum.as_str()),
1579                (
1580                    1000,
1581                    vec![Utf8PathBuf::from(format!("/path/to/{checksum}"))],
1582                ),
1583            );
1584            chunking.remainder.size += 1000;
1585        }
1586        for paths in specific_contentmeta.values() {
1587            for (p, checksum) in paths {
1588                chunking
1589                    .remainder
1590                    .content
1591                    .insert(RcStr::from(checksum.as_str()), (1000, vec![p.clone()]));
1592            }
1593        }
1594
1595        // Process the mapping
1596        // - system_metadata contains ALL objects in the system
1597        // - specific_contentmeta tells process_mapping which objects belong to specific components
1598        chunking.process_mapping(
1599            &system_metadata,
1600            &Some(NonZeroU32::new(8).unwrap()),
1601            None,
1602            Some(&specific_contentmeta),
1603        )?;
1604
1605        // VALIDATION PART 1: Specific components get their own dedicated chunks
1606        assert!(
1607            chunking.chunks.len() >= 2,
1608            "Should have at least 2 chunks for specific components"
1609        );
1610
1611        // Specific Component Layer 1: pkg1 only
1612        let specific_component_1_layer = &chunking.chunks[0];
1613        assert_eq!(specific_component_1_layer.name, "pkg1.0");
1614        assert_eq!(specific_component_1_layer.packages, vec!["pkg1.0"]);
1615        assert_eq!(specific_component_1_layer.content.len(), 3);
1616        for obj in &pkg1_objects {
1617            assert!(
1618                specific_component_1_layer.content.contains_key(*obj),
1619                "Specific component 1 layer should contain {obj}"
1620            );
1621        }
1622
1623        // Specific Component Layer 2: pkg2 only
1624        let specific_component_2_layer = &chunking.chunks[1];
1625        assert_eq!(specific_component_2_layer.name, "pkg2.0");
1626        assert_eq!(specific_component_2_layer.packages, vec!["pkg2.0"]);
1627        assert_eq!(specific_component_2_layer.content.len(), 2);
1628        for obj in &pkg2_objects {
1629            assert!(
1630                specific_component_2_layer.content.contains_key(*obj),
1631                "Specific component 2 layer should contain {obj}"
1632            );
1633        }
1634
1635        // VALIDATION PART 2: Specific component layers contain NO regular package objects
1636        for specific_layer in &chunking.chunks[0..2] {
1637            for obj in pkg3_objects
1638                .iter()
1639                .chain(&pkg4_objects)
1640                .chain(&pkg5_objects)
1641            {
1642                assert!(
1643                    !specific_layer.content.contains_key(*obj),
1644                    "Specific component layer '{}' should NOT contain regular package object {}",
1645                    specific_layer.name,
1646                    obj
1647                );
1648            }
1649        }
1650
1651        // VALIDATION PART 3: Regular package layers contain NO specific component objects
1652        let regular_package_layers = &chunking.chunks[2..];
1653        for regular_layer in regular_package_layers {
1654            for obj in pkg1_objects.iter().chain(&pkg2_objects) {
1655                assert!(
1656                    !regular_layer.content.contains_key(*obj),
1657                    "Regular package layer should NOT contain specific component object {obj}"
1658                );
1659            }
1660        }
1661
1662        // VALIDATION PART 4: All regular package objects are in some regular layer
1663        let mut found_regular_objects = BTreeSet::new();
1664        for regular_layer in regular_package_layers {
1665            for obj in regular_layer.content.keys() {
1666                found_regular_objects.insert(obj.as_ref());
1667            }
1668        }
1669
1670        for obj in pkg3_objects
1671            .iter()
1672            .chain(&pkg4_objects)
1673            .chain(&pkg5_objects)
1674        {
1675            assert!(
1676                found_regular_objects.contains(*obj),
1677                "Regular package object {obj} should be in some regular layer"
1678            );
1679        }
1680
1681        // VALIDATION PART 5: All objects moved from remainder
1682        assert_eq!(
1683            chunking.remainder.content.len(),
1684            0,
1685            "All objects should be moved from remainder"
1686        );
1687        assert_eq!(chunking.remainder.size, 0, "Remainder size should be 0");
1688
1689        Ok(())
1690    }
1691}