Skip to main content

ostree_ext/container/
store.rs

1//! # Storing layered container images as OSTree commits
2//!
3//! This module implements the core storage and import logic for container images in
4//! ostree. It handles fetching images from registries, caching layers as ostree commits,
5//! and creating merge commits that represent the complete image filesystem.
6//!
7//! ## Overview
8//!
9//! The primary entry point is [`ImageImporter`], which orchestrates the import of a
10//! container image. The import process efficiently handles incremental updates by
11//! caching each layer as a separate ostree commit, only fetching layers that aren't
12//! already present.
13//!
14//! ## Reference Namespace Constants
15//!
16//! Layers and images are stored using these ref prefixes (defined as constants in this module):
17//!
18//! - `ostree/container/blob`: Individual OCI layers stored as commits
19//! - `ostree/container/image`: Merge commits for complete images
20//! - [`BASE_IMAGE_PREFIX`] (`ostree/container/baseimage`): Protected base images (public)
21//!
22//! Layer refs use escaped digests (e.g., `sha256:abc...` becomes `sha256_3A_abc...`)
23//! via [`crate::refescape`] to conform to ostree ref naming requirements.
24//!
25//! ## Import Process
26//!
27//! A typical import flow:
28//!
29//! 1. **Create importer**: [`ImageImporter::new`] initializes the proxy connection
30//!    to the container registry (via containers-image-proxy/skopeo).
31//!
32//! 2. **Prepare import**: [`ImageImporter::prepare`] fetches the manifest and
33//!    analyzes which layers need to be downloaded:
34//!    - Returns [`PrepareResult::AlreadyPresent`] if the image is unchanged
35//!    - Returns [`PrepareResult::Ready`] with a [`PreparedImport`] containing
36//!      the download plan
37//!
38//! 3. **Execute import**: [`ImageImporter::import`] downloads missing layers and
39//!    creates the merge commit:
40//!    - Each layer is fetched, decompressed, and imported as an ostree commit
41//!    - The merge commit overlays all layers, processing whiteouts
42//!    - Image metadata (manifest, config) is stored in commit metadata
43//!
44//! ## Layer Types
45//!
46//! The manifest layout is parsed to identify different layer types:
47//!
48//! - **Commit layer**: The base ostree commit layer (identified by `ostree.final-diffid`)
49//! - **Component layers**: Additional ostree "chunk" layers containing split objects
50//! - **Derived layers**: Non-ostree layers from Containerfile `RUN` commands
51//!
52//! Each layer type is handled differently during import:
53//!
54//! - Ostree layers use object-set import mode for efficient reconstruction
55//! - Derived layers are imported as regular filesystem trees with SELinux labeling
56//!
57//! ## Merge Commit Metadata
58//!
59//! The merge commit stores essential metadata for image management:
60//!
61//! - `ostree.manifest-digest`: The canonical manifest digest (e.g., `sha256:...`)
62//! - `ostree.manifest`: Complete OCI manifest as canonical JSON
63//! - `ostree.container.image-config`: OCI image configuration as canonical JSON
64//!
65//! This metadata enables:
66//! - Detecting when updates are available
67//! - Re-exporting images with preserved structure
68//! - Querying image state via [`query_image`] and [`query_image_commit`]
69//!
70//! ## Layer Caching and Deduplication
71//!
72//! Layers are cached by their content digest, enabling:
73//!
74//! - **Incremental updates**: Only changed layers are downloaded
75//! - **Cross-image sharing**: Images sharing layers reuse cached commits
76//! - **Efficient storage**: Ostree's content-addressed storage deduplicates files
77//!
78//! The `query_layer` function checks if a layer is already cached by looking up
79//! its ref. During import, cached layers are skipped entirely.
80//!
81//! ## Garbage Collection
82//!
83//! Unreferenced layers are automatically pruned after imports via [`gc_image_layers`]:
84//!
85//! 1. Collect all layer digests referenced by stored images and deployments
86//! 2. List all layer refs under `ostree/container/blob/`
87//! 3. Remove refs for layers not in the referenced set
88//!
89//! Note: This only removes refs; actual object pruning requires a separate
90//! call to `ostree::Repo::prune`.
91//!
92//! ## Key Types
93//!
94//! - [`ImageImporter`]: Main import orchestrator with progress tracking
95//! - [`PrepareResult`]: Result of preparing an import (already present vs. ready)
96//! - [`PreparedImport`]: Detailed import plan with layer analysis
97//! - [`ManifestLayerState`]: Per-layer state (descriptor, ref, cached commit)
98//! - [`LayeredImageState`]: Complete state of a pulled image
99//! - [`CachedImageUpdate`]: Cached metadata for pending updates
100//! - [`ImportProgress`]: Progress events for layer fetches
101//! - [`LayerProgress`]: Byte-level progress for a single layer
102//!
103//! ## Example Usage
104//!
105//! ```ignore
106//! use ostree_ext::container::{OstreeImageReference, store::ImageImporter};
107//!
108//! let imgref: OstreeImageReference = "ostree-unverified-registry:quay.io/fedora/fedora-bootc:latest".parse()?;
109//! let mut importer = ImageImporter::new(&repo, &imgref, Default::default()).await?;
110//!
111//! match importer.prepare().await? {
112//!     PrepareResult::AlreadyPresent(state) => {
113//!         println!("Image already at {}", state.manifest_digest);
114//!     }
115//!     PrepareResult::Ready(prep) => {
116//!         println!("Fetching {} layers", prep.layers_to_fetch().count());
117//!         let state = importer.import(prep).await?;
118//!         println!("Imported {}", state.merge_commit);
119//!     }
120//! }
121//! ```
122//!
123//! ## See Also
124//!
125//! - [`fn@super::encapsulate`]: Export ostree commits to container images
126//! - [`crate::tar`]: Tar stream format for layer content
127
128use super::*;
129use crate::chunking::{self, Chunk};
130use crate::generic_decompress::Decompressor;
131use crate::logging::system_repo_journal_print;
132use crate::refescape;
133use crate::sysroot::SysrootLock;
134use anyhow::{Context, anyhow};
135use bootc_utils::ResultExt;
136use camino::{Utf8Path, Utf8PathBuf};
137use canon_json::CanonJsonSerialize;
138use cap_std_ext::cap_std;
139use cap_std_ext::cap_std::fs::{Dir, MetadataExt};
140
141use cap_std_ext::dirext::CapStdExtDirExt;
142use containers_image_proxy::{ImageProxy, OpenedImage};
143use flate2::Compression;
144use fn_error_context::context;
145use futures_util::TryFutureExt;
146use glib::prelude::*;
147use oci_spec::image::{
148    self as oci_image, Arch, Descriptor, Digest, History, ImageConfiguration, ImageManifest,
149};
150use ocidir::oci_spec::distribution::Reference;
151use ostree::prelude::{Cast, FileEnumeratorExt, FileExt, ToVariant};
152use ostree::{gio, glib};
153use std::collections::{BTreeMap, BTreeSet, HashMap};
154use std::fmt::Write as _;
155use std::iter::FromIterator;
156use std::num::NonZeroUsize;
157use tokio::sync::mpsc::{Receiver, Sender};
158
159/// Configuration for the proxy.
160///
161/// We re-export this rather than inventing our own wrapper
162/// in the interest of avoiding duplication.
163pub use containers_image_proxy::ImageProxyConfig;
164
165/// The ostree ref prefix for blobs.
166const LAYER_PREFIX: &str = "ostree/container/blob";
167/// The ostree ref prefix for image references.
168const IMAGE_PREFIX: &str = "ostree/container/image";
169/// The ostree ref prefix for "base" image references that are used by derived images.
170/// If you maintain tooling which is locally building derived commits, write a ref
171/// with this prefix that is owned by your code.  It's a best practice to prefix the
172/// ref with the project name, so the final ref may be of the form e.g. `ostree/container/baseimage/bootc/foo`.
173pub const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage";
174
175/// The key injected into the merge commit for the manifest digest.
176pub(crate) const META_MANIFEST_DIGEST: &str = "ostree.manifest-digest";
177/// The key injected into the merge commit with the manifest serialized as JSON.
178const META_MANIFEST: &str = "ostree.manifest";
179/// The key injected into the merge commit with the image configuration serialized as JSON.
180const META_CONFIG: &str = "ostree.container.image-config";
181/// The type used to store content filtering information.
182pub type MetaFilteredData = HashMap<String, HashMap<String, u32>>;
183
184/// The ref prefixes which point to ostree deployments.  (TODO: Add an official API for this)
185const OSTREE_BASE_DEPLOYMENT_REFS: &[&str] = &["ostree/0", "ostree/1"];
186/// A layering violation we'll carry for a bit to band-aid over <https://github.com/coreos/rpm-ostree/issues/4185>
187const RPMOSTREE_BASE_REFS: &[&str] = &["rpmostree/base"];
188
189/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`.
190fn ref_for_blob_digest(d: &str) -> Result<String> {
191    refescape::prefix_escape_for_ref(LAYER_PREFIX, d)
192}
193
194/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`.
195fn ref_for_layer(l: &oci_image::Descriptor) -> Result<String> {
196    ref_for_blob_digest(&l.digest().as_ref())
197}
198
199/// Convert e.g. sha256:12345... into `/ostree/container/blob/sha256_2B12345...`.
200fn ref_for_image(l: &ImageReference) -> Result<String> {
201    refescape::prefix_escape_for_ref(IMAGE_PREFIX, &l.to_string())
202}
203
204/// Sent across a channel to track start and end of a container fetch.
205#[derive(Debug)]
206pub enum ImportProgress {
207    /// Started fetching this layer.
208    OstreeChunkStarted(Descriptor),
209    /// Successfully completed the fetch of this layer.
210    OstreeChunkCompleted(Descriptor),
211    /// Started fetching this layer.
212    DerivedLayerStarted(Descriptor),
213    /// Successfully completed the fetch of this layer.
214    DerivedLayerCompleted(Descriptor),
215}
216
217impl ImportProgress {
218    /// Returns `true` if this message signifies the start of a new layer being fetched.
219    pub fn is_starting(&self) -> bool {
220        match self {
221            ImportProgress::OstreeChunkStarted(_) => true,
222            ImportProgress::OstreeChunkCompleted(_) => false,
223            ImportProgress::DerivedLayerStarted(_) => true,
224            ImportProgress::DerivedLayerCompleted(_) => false,
225        }
226    }
227}
228
229/// Sent across a channel to track the byte-level progress of a layer fetch.
230#[derive(Clone, Debug)]
231pub struct LayerProgress {
232    /// Index of the layer in the manifest
233    pub layer_index: usize,
234    /// Number of bytes downloaded
235    pub fetched: u64,
236    /// Total number of bytes outstanding
237    pub total: u64,
238}
239
240/// State of an already pulled layered image.
241#[derive(Debug, PartialEq, Eq)]
242pub struct LayeredImageState {
243    /// The base ostree commit
244    pub base_commit: String,
245    /// The merge commit unions all layers
246    pub merge_commit: String,
247    /// The digest of the original manifest
248    pub manifest_digest: Digest,
249    /// The image manifest
250    pub manifest: ImageManifest,
251    /// The image configuration
252    pub configuration: ImageConfiguration,
253    /// Metadata for (cached, previously fetched) updates to the image, if any.
254    pub cached_update: Option<CachedImageUpdate>,
255    /// The signature verification text from libostree for the base commit;
256    /// in the future we should probably instead just proxy a signature object
257    /// instead, but this is sufficient for now.
258    pub verify_text: Option<String>,
259    /// Files that were filtered out during the import.
260    pub filtered_files: Option<MetaFilteredData>,
261}
262
263impl LayeredImageState {
264    /// Return the merged ostree commit for this image.
265    ///
266    /// This is not the same as the underlying base ostree commit.
267    pub fn get_commit(&self) -> &str {
268        self.merge_commit.as_str()
269    }
270
271    /// Retrieve the container image version.
272    pub fn version(&self) -> Option<&str> {
273        super::version_for_config(&self.configuration)
274    }
275}
276
277/// Locally cached metadata for an update to an existing image.
278#[derive(Debug, PartialEq, Eq)]
279pub struct CachedImageUpdate {
280    /// The image manifest
281    pub manifest: ImageManifest,
282    /// The image configuration
283    pub config: ImageConfiguration,
284    /// The digest of the manifest
285    pub manifest_digest: Digest,
286}
287
288impl CachedImageUpdate {
289    /// Retrieve the container image version.
290    pub fn version(&self) -> Option<&str> {
291        super::version_for_config(&self.config)
292    }
293}
294
295/// A layer in the ostree repo, identified by its ref and commit checksum.
296struct LayerRef {
297    ostree_ref: String,
298    commit: String,
299}
300
301/// Context for importing a container image.
302#[derive(Debug)]
303pub struct ImageImporter {
304    repo: ostree::Repo,
305    /// The root filesystem directory, used for policy lookups.
306    root: Dir,
307    pub(crate) proxy: ImageProxy,
308    imgref: OstreeImageReference,
309    target_imgref: Option<OstreeImageReference>,
310    no_imgref: bool,  // If true, do not write final image ref
311    disable_gc: bool, // If true, don't prune unused image layers
312    /// Optional commit to use as SELinux policy source for non-ostree container layers.
313    sepolicy_commit: Option<String>,
314    /// If true, require the image has the bootable flag
315    require_bootable: bool,
316    /// Do not attempt to contact the network
317    offline: bool,
318    /// If true, we have ostree v2024.3 or newer.
319    ostree_v2024_3: bool,
320    /// Mapping from diff_id to blob digest for layer deduplication
321    diffid_to_digest: HashMap<String, String>,
322
323    layer_progress: Option<Sender<ImportProgress>>,
324    layer_byte_progress: Option<tokio::sync::watch::Sender<Option<LayerProgress>>>,
325}
326
327/// Result of invoking [`ImageImporter::prepare`].
328#[derive(Debug)]
329pub enum PrepareResult {
330    /// The image reference is already present; the contained string is the OSTree commit.
331    AlreadyPresent(Box<LayeredImageState>),
332    /// The image needs to be downloaded
333    Ready(Box<PreparedImport>),
334}
335
336/// A container image layer with associated downloaded-or-not state.
337#[derive(Debug)]
338pub struct ManifestLayerState {
339    /// The underlying layer descriptor.
340    pub layer: oci_image::Descriptor,
341    // TODO semver: Make this readonly via an accessor
342    /// The ostree ref name for this layer.
343    pub ostree_ref: String,
344    // TODO semver: Make this readonly via an accessor
345    /// The ostree commit that caches this layer, if present.
346    pub commit: Option<String>,
347}
348
349impl ManifestLayerState {
350    /// Return the layer descriptor.
351    pub fn layer(&self) -> &oci_image::Descriptor {
352        &self.layer
353    }
354}
355
356/// Information about which layers need to be downloaded.
357#[derive(Debug)]
358pub struct PreparedImport {
359    /// The manifest digest that was found
360    pub manifest_digest: Digest,
361    /// The deserialized manifest.
362    pub manifest: oci_image::ImageManifest,
363    /// The deserialized configuration.
364    pub config: oci_image::ImageConfiguration,
365    /// The previous manifest
366    pub previous_state: Option<Box<LayeredImageState>>,
367    /// The previously stored manifest digest.
368    pub previous_manifest_digest: Option<Digest>,
369    /// The previously stored image ID.
370    pub previous_imageid: Option<String>,
371    /// The layers containing split objects
372    pub ostree_layers: Vec<ManifestLayerState>,
373    /// The layer for the ostree commit.
374    pub ostree_commit_layer: Option<ManifestLayerState>,
375    /// Any further non-ostree (derived) layers.
376    pub layers: Vec<ManifestLayerState>,
377    /// OSTree remote signature verification text, if enabled.
378    pub verify_text: Option<String>,
379    /// Our open image reference
380    proxy_img: OpenedImage,
381}
382
383impl PreparedImport {
384    /// Iterate over all layers; the commit layer, the ostree split object layers, and any non-ostree layers.
385    pub fn all_layers(&self) -> impl Iterator<Item = &ManifestLayerState> {
386        self.ostree_commit_layer
387            .iter()
388            .chain(self.ostree_layers.iter())
389            .chain(self.layers.iter())
390    }
391
392    /// Retrieve the container image version.
393    pub fn version(&self) -> Option<&str> {
394        super::version_for_config(&self.config)
395    }
396
397    /// If this image is using any deprecated features, return a message saying so.
398    pub fn deprecated_warning(&self) -> Option<&'static str> {
399        None
400    }
401
402    /// Iterate over all layers paired with their history entry.
403    /// An error will be returned if the history does not cover all entries.
404    pub fn layers_with_history(
405        &self,
406    ) -> impl Iterator<Item = Result<(&ManifestLayerState, &History)>> {
407        // FIXME use .filter(|h| h.empty_layer.unwrap_or_default()) after https://github.com/containers/oci-spec-rs/pull/100 lands.
408        let truncated = std::iter::once_with(|| Err(anyhow::anyhow!("Truncated history")));
409        let history = self
410            .config
411            .history()
412            .iter()
413            .flatten()
414            .map(Ok)
415            .chain(truncated);
416        self.all_layers()
417            .zip(history)
418            .map(|(s, h)| h.map(|h| (s, h)))
419    }
420
421    /// Iterate over all layers that are not present, along with their history description.
422    pub fn layers_to_fetch(&self) -> impl Iterator<Item = Result<(&ManifestLayerState, &str)>> {
423        self.layers_with_history().filter_map(|r| {
424            r.map(|(l, h)| {
425                l.commit.is_none().then(|| {
426                    let comment = h.created_by().as_deref().unwrap_or("");
427                    (l, comment)
428                })
429            })
430            .transpose()
431        })
432    }
433
434    /// Common helper to format a string for the status
435    pub(crate) fn format_layer_status(&self) -> Option<String> {
436        let (stored, to_fetch, to_fetch_size) =
437            self.all_layers()
438                .fold((0u32, 0u32, 0u64), |(stored, to_fetch, sz), v| {
439                    if v.commit.is_some() {
440                        (stored + 1, to_fetch, sz)
441                    } else {
442                        (stored, to_fetch + 1, sz + v.layer().size())
443                    }
444                });
445        (to_fetch > 0).then(|| {
446            let size = crate::glib::format_size(to_fetch_size);
447            format!("layers already present: {stored}; layers needed: {to_fetch} ({size})")
448        })
449    }
450}
451
452// Given a manifest, compute its ostree ref name and cached ostree commit
453pub(crate) fn query_layer(
454    repo: &ostree::Repo,
455    layer: oci_image::Descriptor,
456) -> Result<ManifestLayerState> {
457    let ostree_ref = ref_for_layer(&layer)?;
458    let commit = repo.resolve_rev(&ostree_ref, true)?.map(|s| s.to_string());
459    Ok(ManifestLayerState {
460        layer,
461        ostree_ref,
462        commit,
463    })
464}
465
466#[context("Reading manifest data from commit")]
467fn manifest_data_from_commitmeta(
468    commit_meta: &glib::VariantDict,
469) -> Result<(oci_image::ImageManifest, Digest)> {
470    let digest = commit_meta
471        .lookup::<String>(META_MANIFEST_DIGEST)?
472        .ok_or_else(|| anyhow!("Missing {} metadata on merge commit", META_MANIFEST_DIGEST))?;
473    let digest = Digest::from_str(&digest)?;
474    let manifest_bytes: String = commit_meta
475        .lookup::<String>(META_MANIFEST)?
476        .ok_or_else(|| anyhow!("Failed to find {} metadata key", META_MANIFEST))?;
477    let r = serde_json::from_str(&manifest_bytes)?;
478    Ok((r, digest))
479}
480
481fn image_config_from_commitmeta(commit_meta: &glib::VariantDict) -> Result<ImageConfiguration> {
482    let config = if let Some(config) = commit_meta
483        .lookup::<String>(META_CONFIG)?
484        .filter(|v| v != "null") // Format v0 apparently old versions injected `null` here sadly...
485        .map(|v| serde_json::from_str(&v).map_err(anyhow::Error::msg))
486        .transpose()?
487    {
488        config
489    } else {
490        tracing::debug!("No image configuration found");
491        Default::default()
492    };
493    Ok(config)
494}
495
496/// Return the original digest of the manifest stored in the commit metadata.
497/// This will be a string of the form e.g. `sha256:<digest>`.
498///
499/// This can be used to uniquely identify the image.  For example, it can be used
500/// in a "digested pull spec" like `quay.io/someuser/exampleos@sha256:...`.
501pub fn manifest_digest_from_commit(commit: &glib::Variant) -> Result<Digest> {
502    let commit_meta = &commit.child_value(0);
503    let commit_meta = &glib::VariantDict::new(Some(commit_meta));
504    Ok(manifest_data_from_commitmeta(commit_meta)?.1)
505}
506
507/// Given a target diffid, return its corresponding layer.  In our current model,
508/// we require a 1-to-1 mapping between the two up until the ostree level.
509/// For a bit more information on this, see <https://github.com/opencontainers/image-spec/blob/main/config.md>
510fn layer_from_diffid<'a>(
511    manifest: &'a ImageManifest,
512    config: &ImageConfiguration,
513    diffid: &str,
514) -> Result<&'a Descriptor> {
515    let idx = config
516        .rootfs()
517        .diff_ids()
518        .iter()
519        .position(|x| x.as_str() == diffid)
520        .ok_or_else(|| anyhow!("Missing {} {}", DIFFID_LABEL, diffid))?;
521    manifest.layers().get(idx).ok_or_else(|| {
522        anyhow!(
523            "diffid position {} exceeds layer count {}",
524            idx,
525            manifest.layers().len()
526        )
527    })
528}
529
530#[context("Parsing manifest layout")]
531pub(crate) fn parse_manifest_layout<'a>(
532    manifest: &'a ImageManifest,
533    config: &ImageConfiguration,
534) -> Result<(
535    Option<&'a Descriptor>,
536    Vec<&'a Descriptor>,
537    Vec<&'a Descriptor>,
538)> {
539    let config_labels = super::labels_of(config);
540
541    let first_layer = manifest
542        .layers()
543        .first()
544        .ok_or_else(|| anyhow!("No layers in manifest"))?;
545    let Some(target_diffid) = config_labels.and_then(|labels| labels.get(DIFFID_LABEL)) else {
546        return Ok((None, Vec::new(), manifest.layers().iter().collect()));
547    };
548
549    let target_layer = layer_from_diffid(manifest, config, target_diffid.as_str())?;
550    let mut chunk_layers = Vec::new();
551    let mut derived_layers = Vec::new();
552    let mut after_target = false;
553    // Gather the ostree layer
554    let ostree_layer = first_layer;
555    for layer in manifest.layers() {
556        if layer == target_layer {
557            if after_target {
558                anyhow::bail!("Multiple entries for {}", layer.digest());
559            }
560            after_target = true;
561            if layer != ostree_layer {
562                chunk_layers.push(layer);
563            }
564        } else if !after_target {
565            if layer != ostree_layer {
566                chunk_layers.push(layer);
567            }
568        } else {
569            derived_layers.push(layer);
570        }
571    }
572
573    Ok((Some(ostree_layer), chunk_layers, derived_layers))
574}
575
576/// Like [`parse_manifest_layout`] but requires the image has an ostree base.
577#[context("Parsing manifest layout")]
578pub(crate) fn parse_ostree_manifest_layout<'a>(
579    manifest: &'a ImageManifest,
580    config: &ImageConfiguration,
581) -> Result<(&'a Descriptor, Vec<&'a Descriptor>, Vec<&'a Descriptor>)> {
582    let (ostree_layer, component_layers, derived_layers) = parse_manifest_layout(manifest, config)?;
583    let ostree_layer = ostree_layer.ok_or_else(|| {
584        anyhow!("No {DIFFID_LABEL} label found, not an ostree encapsulated container")
585    })?;
586    Ok((ostree_layer, component_layers, derived_layers))
587}
588
589/// Find the timestamp of the manifest (or config), ignoring errors.
590fn timestamp_of_manifest_or_config(
591    manifest: &ImageManifest,
592    config: &ImageConfiguration,
593) -> Option<u64> {
594    // The manifest timestamp seems to not be widely used, but let's
595    // try it in preference to the config one.
596    let timestamp = manifest
597        .annotations()
598        .as_ref()
599        .and_then(|a| a.get(oci_image::ANNOTATION_CREATED))
600        .or_else(|| config.created().as_ref());
601    // Try to parse the timestamp
602    timestamp
603        .map(|t| {
604            chrono::DateTime::parse_from_rfc3339(t)
605                .context("Failed to parse manifest timestamp")
606                .map(|t| t.timestamp() as u64)
607        })
608        .transpose()
609        .log_err_default()
610}
611
612/// Automatically clean up files that may have been injected by container
613/// builds. xref <https://github.com/containers/buildah/issues/4242>
614fn cleanup_root(root: &Dir) -> Result<()> {
615    const RUNTIME_INJECTED: &[&str] = &["usr/etc/hostname", "usr/etc/resolv.conf"];
616    for ent in RUNTIME_INJECTED {
617        if let Some(meta) = root.symlink_metadata_optional(ent)? {
618            if meta.is_file() && meta.size() == 0 {
619                tracing::debug!("Removing {ent}");
620                root.remove_file(ent)?;
621            }
622        }
623    }
624    Ok(())
625}
626
627impl ImageImporter {
628    /// The metadata key used in ostree commit metadata to serialize
629    const CACHED_KEY_MANIFEST_DIGEST: &'static str = "ostree-ext.cached.manifest-digest";
630    const CACHED_KEY_MANIFEST: &'static str = "ostree-ext.cached.manifest";
631    const CACHED_KEY_CONFIG: &'static str = "ostree-ext.cached.config";
632
633    /// Create a new importer.
634    #[context("Creating importer")]
635    pub async fn new(
636        repo: &ostree::Repo,
637        imgref: &OstreeImageReference,
638        mut config: ImageProxyConfig,
639    ) -> Result<Self> {
640        apply_container_proxy_opts_for_transport(&mut config, imgref.imgref.transport)?;
641        let proxy = ImageProxy::new_with_config(config).await?;
642
643        system_repo_journal_print(
644            repo,
645            libsystemd::logging::Priority::Info,
646            &format!("Fetching {imgref}"),
647        );
648
649        let repo = repo.clone();
650
651        let diffid_to_digest = Self::build_diffid_to_digest_map(&repo)?;
652
653        let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
654
655        Ok(ImageImporter {
656            repo,
657            root,
658            proxy,
659            target_imgref: None,
660            no_imgref: false,
661            ostree_v2024_3: ostree::check_version(2024, 3),
662            disable_gc: false,
663            sepolicy_commit: None,
664            require_bootable: false,
665            offline: false,
666            imgref: imgref.clone(),
667            diffid_to_digest,
668            layer_progress: None,
669            layer_byte_progress: None,
670        })
671    }
672
673    /// Write cached data as if the image came from this source.
674    pub fn set_target(&mut self, target: &OstreeImageReference) {
675        self.target_imgref = Some(target.clone())
676    }
677
678    /// Do not write the final image ref, but do write refs for shared layers.
679    /// This is useful in scenarios where you want to "pre-pull" an image,
680    /// but in such a way that it does not need to be manually removed later.
681    pub fn set_no_imgref(&mut self) {
682        self.no_imgref = true;
683    }
684
685    /// Do not attempt to contact the network
686    pub fn set_offline(&mut self) {
687        self.offline = true;
688    }
689
690    /// Require that the image has the bootable metadata field
691    pub fn require_bootable(&mut self) {
692        self.require_bootable = true;
693    }
694
695    /// Override the ostree version being targeted
696    pub fn set_ostree_version(&mut self, year: u32, v: u32) {
697        self.ostree_v2024_3 = (year > 2024) || (year == 2024 && v >= 3)
698    }
699
700    /// Do not prune image layers.
701    pub fn disable_gc(&mut self) {
702        self.disable_gc = true;
703    }
704
705    /// Set the commit to use as SELinux policy source when importing
706    /// non-ostree container layers. Has no effect on ostree-native
707    /// containers (which have their own base commit).
708    pub fn set_sepolicy_commit(&mut self, commit: String) {
709        self.sepolicy_commit = Some(commit);
710    }
711
712    /// Determine if there is a new manifest, and if so return its digest.
713    /// This will also serialize the new manifest and configuration into
714    /// metadata associated with the image, so that invocations of `[query_cached]`
715    /// can re-fetch it without accessing the network.
716    #[context("Preparing import")]
717    pub async fn prepare(&mut self) -> Result<PrepareResult> {
718        self.prepare_internal(false).await
719    }
720
721    /// Create a channel receiver that will get notifications for layer fetches.
722    pub fn request_progress(&mut self) -> Receiver<ImportProgress> {
723        assert!(self.layer_progress.is_none());
724        let (s, r) = tokio::sync::mpsc::channel(2);
725        self.layer_progress = Some(s);
726        r
727    }
728
729    /// Create a channel receiver that will get notifications for byte-level progress of layer fetches.
730    pub fn request_layer_progress(
731        &mut self,
732    ) -> tokio::sync::watch::Receiver<Option<LayerProgress>> {
733        assert!(self.layer_byte_progress.is_none());
734        let (s, r) = tokio::sync::watch::channel(None);
735        self.layer_byte_progress = Some(s);
736        r
737    }
738
739    /// Serialize the metadata about a pending fetch as detached metadata on the commit object,
740    /// so it can be retrieved later offline
741    #[context("Writing cached pending manifest")]
742    pub(crate) async fn cache_pending(
743        &self,
744        commit: &str,
745        manifest_digest: &Digest,
746        manifest: &ImageManifest,
747        config: &ImageConfiguration,
748    ) -> Result<()> {
749        let commitmeta = glib::VariantDict::new(None);
750        commitmeta.insert(
751            Self::CACHED_KEY_MANIFEST_DIGEST,
752            manifest_digest.to_string(),
753        );
754        let cached_manifest = manifest
755            .to_canon_json_string()
756            .context("Serializing manifest")?;
757        commitmeta.insert(Self::CACHED_KEY_MANIFEST, cached_manifest);
758        let cached_config = config
759            .to_canon_json_string()
760            .context("Serializing config")?;
761        commitmeta.insert(Self::CACHED_KEY_CONFIG, cached_config);
762        let commitmeta = commitmeta.to_variant();
763        // Clone these to move into blocking method
764        let commit = commit.to_string();
765        let repo = self.repo.clone();
766        crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
767            repo.write_commit_detached_metadata(&commit, Some(&commitmeta), Some(cancellable))
768                .map_err(anyhow::Error::msg)
769        })
770        .await
771    }
772
773    /// Build a mapping from diff_id to blob_digest by enumerating all stored images.
774    /// This allows us to reuse layers with the same content even if they have different blob digests.
775    fn build_diffid_to_digest_map(repo: &ostree::Repo) -> Result<HashMap<String, String>> {
776        let mut map = HashMap::new();
777        let all_images = list_images(repo)?;
778
779        for imgref_str in all_images {
780            let imgref = match ImageReference::try_from(imgref_str.as_str()) {
781                Ok(r) => r,
782                Err(e) => {
783                    tracing::warn!("Failed to parse image reference {}: {}", imgref_str, e);
784                    continue;
785                }
786            };
787
788            let state = match query_image(repo, &imgref)? {
789                Some(s) => s,
790                None => continue,
791            };
792
793            // Map each layer's diff_id to its blob digest
794            for (layer_desc, diff_id) in state
795                .manifest
796                .layers()
797                .iter()
798                .zip(state.configuration.rootfs().diff_ids())
799            {
800                let diff_id_str = diff_id.to_string();
801                // Only store first found
802                map.entry(diff_id_str)
803                    .or_insert_with(|| layer_desc.digest().to_string());
804            }
805        }
806
807        Ok(map)
808    }
809
810    fn find_digest_by_diffid(
811        &self,
812        manifest: &oci_image::ImageManifest,
813        config: &oci_image::ImageConfiguration,
814        layer: &oci_image::Descriptor,
815    ) -> Option<&String> {
816        let idx = manifest
817            .layers()
818            .iter()
819            .position(|l| l.digest() == layer.digest())?;
820        let layer_diffid = config.rootfs().diff_ids().get(idx)?;
821        self.diffid_to_digest.get(layer_diffid.as_str())
822    }
823
824    /// Try to resolve a layer commit by looking up its diff_id in already-imported images.
825    fn resolve_commit_by_diffid(
826        &self,
827        manifest: &oci_image::ImageManifest,
828        config: &oci_image::ImageConfiguration,
829        layer: &oci_image::Descriptor,
830    ) -> Result<Option<String>> {
831        let Some(existing_digest) = self.find_digest_by_diffid(manifest, config, layer) else {
832            return Ok(None);
833        };
834        let existing_ref = ref_for_blob_digest(existing_digest)?;
835        Ok(self
836            .repo
837            .resolve_rev(&existing_ref, true)?
838            .map(|s| s.to_string()))
839    }
840
841    /// Query a layer by digest, falling back to diff_id lookup if the direct
842    /// ref is not found.
843    fn query_layer(
844        &self,
845        manifest: &oci_image::ImageManifest,
846        config: &oci_image::ImageConfiguration,
847        layer: &oci_image::Descriptor,
848    ) -> Result<ManifestLayerState> {
849        let ostree_ref = ref_for_layer(layer)?;
850        let commit = self
851            .repo
852            .resolve_rev(&ostree_ref, true)?
853            .map(|s| s.to_string());
854        // If no direct ref match, try to find a layer with the same diff_id
855        // but a different blob digest (e.g. due to recompression).
856        let commit = match commit {
857            Some(c) => Some(c),
858            None => self.resolve_commit_by_diffid(manifest, config, layer)?,
859        };
860
861        Ok(ManifestLayerState {
862            layer: layer.clone(),
863            ostree_ref,
864            commit,
865        })
866    }
867
868    /// Ensure a ref exists for a layer, creating it if needed.
869    fn ensure_ref_for_layer(repo: &ostree::Repo, ostree_ref: &str, commit: &str) -> Result<()> {
870        let ref_exists = repo.resolve_rev(ostree_ref, true)?.is_some();
871        if !ref_exists {
872            tracing::debug!("Creating ref {} for reused commit {}", ostree_ref, commit);
873            repo.set_ref_immediate(None, ostree_ref, Some(commit), gio::Cancellable::NONE)?;
874        }
875        Ok(())
876    }
877
878    /// Given existing metadata (manifest, config, previous image statE) generate a PreparedImport structure
879    /// which e.g. includes a diff of the layers.
880    fn create_prepared_import(
881        &mut self,
882        manifest_digest: Digest,
883        manifest: ImageManifest,
884        config: ImageConfiguration,
885        previous_state: Option<Box<LayeredImageState>>,
886        previous_imageid: Option<String>,
887        proxy_img: OpenedImage,
888    ) -> Result<Box<PreparedImport>> {
889        let config_labels = super::labels_of(&config);
890        if self.require_bootable {
891            let bootable_key = ostree::METADATA_KEY_BOOTABLE;
892            let bootable = config_labels.is_some_and(|l| {
893                l.contains_key(bootable_key.as_str()) || l.contains_key(BOOTC_LABEL)
894            });
895            if !bootable {
896                anyhow::bail!("Target image does not have {bootable_key} label");
897            }
898            let container_arch = config.architecture();
899            let target_arch = &Arch::default();
900            if container_arch != target_arch {
901                anyhow::bail!("Image has architecture {container_arch}; expected {target_arch}");
902            }
903        }
904
905        let (commit_layer, component_layers, remaining_layers) =
906            parse_manifest_layout(&manifest, &config)?;
907
908        let commit_layer = commit_layer
909            .map(|layer| self.query_layer(&manifest, &config, layer))
910            .transpose()?;
911
912        let component_layers = component_layers
913            .into_iter()
914            .map(|l| self.query_layer(&manifest, &config, l))
915            .collect::<Result<Vec<_>>>()?;
916
917        let remaining_layers = remaining_layers
918            .into_iter()
919            .map(|l| self.query_layer(&manifest, &config, l))
920            .collect::<Result<Vec<_>>>()?;
921
922        let previous_manifest_digest = previous_state.as_ref().map(|s| s.manifest_digest.clone());
923        let imp = PreparedImport {
924            manifest_digest,
925            manifest,
926            config,
927            previous_state,
928            previous_manifest_digest,
929            previous_imageid,
930            ostree_layers: component_layers,
931            ostree_commit_layer: commit_layer,
932            layers: remaining_layers,
933            verify_text: None,
934            proxy_img,
935        };
936        Ok(Box::new(imp))
937    }
938
939    /// Determine if there is a new manifest, and if so return its digest.
940    #[context("Fetching manifest")]
941    pub(crate) async fn prepare_internal(&mut self, verify_layers: bool) -> Result<PrepareResult> {
942        match &self.imgref.sigverify {
943            SignatureSource::ContainerPolicy
944                if skopeo::container_policy_is_default_insecure(&self.root)? =>
945            {
946                return Err(anyhow!(
947                    "containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage"
948                ));
949            }
950            SignatureSource::OstreeRemote(_) if verify_layers => {
951                return Err(anyhow!(
952                    "Cannot currently verify layered containers via ostree remote"
953                ));
954            }
955            _ => {}
956        }
957
958        // Check if we have an image already pulled
959        let previous_state = try_query_image(&self.repo, &self.imgref.imgref)?;
960
961        // Parse the target reference to see if it's a digested pull
962        let target_reference = self.imgref.imgref.name.parse::<Reference>().ok();
963        let previous_state = if let Some(target_digest) = target_reference
964            .as_ref()
965            .and_then(|v| v.digest())
966            .map(Digest::from_str)
967            .transpose()?
968        {
969            if let Some(previous_state) = previous_state {
970                // A digested pull spec, and our existing state matches.
971                if previous_state.manifest_digest == target_digest {
972                    tracing::debug!("Digest-based pullspec {:?} already present", self.imgref);
973                    return Ok(PrepareResult::AlreadyPresent(previous_state));
974                }
975                Some(previous_state)
976            } else {
977                None
978            }
979        } else {
980            previous_state
981        };
982
983        if self.offline {
984            anyhow::bail!("Manifest fetch required in offline mode");
985        }
986
987        let proxy_img = self
988            .proxy
989            .open_image(&self.imgref.imgref.to_string())
990            .await?;
991
992        let (manifest_digest, manifest) = self.proxy.fetch_manifest(&proxy_img).await?;
993        let manifest_digest = Digest::from_str(&manifest_digest)?;
994        let new_imageid = manifest.config().digest();
995
996        // Query for previous stored state
997
998        let (previous_state, previous_imageid) = if let Some(previous_state) = previous_state {
999            // If the manifest digests match, we're done.
1000            if previous_state.manifest_digest == manifest_digest {
1001                return Ok(PrepareResult::AlreadyPresent(previous_state));
1002            }
1003            // Failing that, if they have the same imageID, we're also done.
1004            let previous_imageid = previous_state.manifest.config().digest();
1005            if previous_imageid == new_imageid {
1006                return Ok(PrepareResult::AlreadyPresent(previous_state));
1007            }
1008            let previous_imageid = previous_imageid.to_string();
1009            (Some(previous_state), Some(previous_imageid))
1010        } else {
1011            (None, None)
1012        };
1013
1014        let config = self.proxy.fetch_config(&proxy_img).await?;
1015
1016        // If there is a currently fetched image, cache the new pending manifest+config
1017        // as detached commit metadata, so that future fetches can query it offline.
1018        if let Some(previous_state) = previous_state.as_ref() {
1019            self.cache_pending(
1020                previous_state.merge_commit.as_str(),
1021                &manifest_digest,
1022                &manifest,
1023                &config,
1024            )
1025            .await?;
1026        }
1027
1028        let imp = self.create_prepared_import(
1029            manifest_digest,
1030            manifest,
1031            config,
1032            previous_state,
1033            previous_imageid,
1034            proxy_img,
1035        )?;
1036        Ok(PrepareResult::Ready(imp))
1037    }
1038
1039    /// Extract the base ostree commit.
1040    #[context("Unencapsulating base")]
1041    pub(crate) async fn unencapsulate_base(
1042        &self,
1043        import: &mut store::PreparedImport,
1044        require_ostree: bool,
1045        write_refs: bool,
1046    ) -> Result<()> {
1047        tracing::debug!("Fetching base");
1048        if matches!(self.imgref.sigverify, SignatureSource::ContainerPolicy)
1049            && skopeo::container_policy_is_default_insecure(&self.root)?
1050        {
1051            return Err(anyhow!(
1052                "containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage"
1053            ));
1054        }
1055        let remote = match &self.imgref.sigverify {
1056            SignatureSource::OstreeRemote(remote) => Some(remote.clone()),
1057            SignatureSource::ContainerPolicy | SignatureSource::ContainerPolicyAllowInsecure => {
1058                None
1059            }
1060        };
1061        let Some(commit_layer) = import.ostree_commit_layer.as_mut() else {
1062            if require_ostree {
1063                anyhow::bail!(
1064                    "No {DIFFID_LABEL} label found, not an ostree encapsulated container"
1065                );
1066            }
1067            return Ok(());
1068        };
1069        let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?;
1070        for layer in import.ostree_layers.iter_mut() {
1071            if let Some(commit) = layer.commit.as_ref() {
1072                if write_refs {
1073                    Self::ensure_ref_for_layer(&self.repo, &layer.ostree_ref, commit)?;
1074                }
1075                continue;
1076            }
1077            if let Some(p) = self.layer_progress.as_ref() {
1078                p.send(ImportProgress::OstreeChunkStarted(layer.layer.clone()))
1079                    .await?;
1080            }
1081            let (blob, driver, media_type) = fetch_layer(
1082                &self.proxy,
1083                &import.proxy_img,
1084                &import.manifest,
1085                &layer.layer,
1086                self.layer_byte_progress.as_ref(),
1087                des_layers.as_ref(),
1088                self.imgref.imgref.transport,
1089            )
1090            .await?;
1091            let repo = self.repo.clone();
1092            let target_ref = layer.ostree_ref.clone();
1093            let import_task =
1094                crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
1095                    let txn = repo.auto_transaction(Some(cancellable))?;
1096                    let mut importer = crate::tar::Importer::new_for_object_set(&repo);
1097                    let blob = tokio_util::io::SyncIoBridge::new(blob);
1098                    let mut blob = Decompressor::new(&media_type, blob)?;
1099                    let mut archive = tar::Archive::new(&mut blob);
1100                    importer.import_objects(&mut archive, Some(cancellable))?;
1101                    let commit = if write_refs {
1102                        let commit = importer.finish_import_object_set()?;
1103                        repo.transaction_set_ref(None, &target_ref, Some(commit.as_str()));
1104                        tracing::debug!("Wrote {} => {}", target_ref, commit);
1105                        Some(commit)
1106                    } else {
1107                        None
1108                    };
1109                    txn.commit(Some(cancellable))?;
1110                    blob.finish()?;
1111                    Ok::<_, anyhow::Error>(commit)
1112                })
1113                .map_err(|e| e.context(format!("Layer {}", layer.layer.digest())));
1114            let commit = super::unencapsulate::join_fetch(import_task, driver).await?;
1115            layer.commit = commit;
1116            if let Some(p) = self.layer_progress.as_ref() {
1117                p.send(ImportProgress::OstreeChunkCompleted(layer.layer.clone()))
1118                    .await?;
1119            }
1120        }
1121        if let Some(commit) = commit_layer.commit.as_ref() {
1122            if write_refs {
1123                Self::ensure_ref_for_layer(&self.repo, &commit_layer.ostree_ref, commit)?;
1124            }
1125        } else {
1126            if let Some(p) = self.layer_progress.as_ref() {
1127                p.send(ImportProgress::OstreeChunkStarted(
1128                    commit_layer.layer.clone(),
1129                ))
1130                .await?;
1131            }
1132            let (blob, driver, media_type) = fetch_layer(
1133                &self.proxy,
1134                &import.proxy_img,
1135                &import.manifest,
1136                &commit_layer.layer,
1137                self.layer_byte_progress.as_ref(),
1138                des_layers.as_ref(),
1139                self.imgref.imgref.transport,
1140            )
1141            .await?;
1142            let repo = self.repo.clone();
1143            let target_ref = commit_layer.ostree_ref.clone();
1144            let import_task =
1145                crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
1146                    let txn = repo.auto_transaction(Some(cancellable))?;
1147                    let mut importer = crate::tar::Importer::new_for_commit(&repo, remote);
1148                    let blob = tokio_util::io::SyncIoBridge::new(blob);
1149                    let mut blob = Decompressor::new(&media_type, blob)?;
1150                    let mut archive = tar::Archive::new(&mut blob);
1151                    importer.import_commit(&mut archive, Some(cancellable))?;
1152                    let (commit, verify_text) = importer.finish_import_commit();
1153                    if write_refs {
1154                        repo.transaction_set_ref(None, &target_ref, Some(commit.as_str()));
1155                        tracing::debug!("Wrote {} => {}", target_ref, commit);
1156                    }
1157                    repo.mark_commit_partial(&commit, false)?;
1158                    txn.commit(Some(cancellable))?;
1159                    blob.finish()?;
1160                    Ok::<_, anyhow::Error>((commit, verify_text))
1161                });
1162            let (commit, verify_text) =
1163                super::unencapsulate::join_fetch(import_task, driver).await?;
1164            commit_layer.commit = Some(commit);
1165            import.verify_text = verify_text;
1166            if let Some(p) = self.layer_progress.as_ref() {
1167                p.send(ImportProgress::OstreeChunkCompleted(
1168                    commit_layer.layer.clone(),
1169                ))
1170                .await?;
1171            }
1172        }
1173        Ok(())
1174    }
1175
1176    /// Retrieve an inner ostree commit.
1177    ///
1178    /// This does not write cached references for each blob, and errors out if
1179    /// the image has any non-ostree layers.
1180    pub async fn unencapsulate(mut self) -> Result<Import> {
1181        let mut prep = match self.prepare_internal(false).await? {
1182            PrepareResult::AlreadyPresent(_) => {
1183                panic!("Should not have image present for unencapsulation")
1184            }
1185            PrepareResult::Ready(r) => r,
1186        };
1187        if !prep.layers.is_empty() {
1188            anyhow::bail!("Image has {} non-ostree layers", prep.layers.len());
1189        }
1190        let deprecated_warning = prep.deprecated_warning().map(ToOwned::to_owned);
1191        self.unencapsulate_base(&mut prep, true, false).await?;
1192        // TODO change the imageproxy API to ensure this happens automatically when
1193        // the image reference is dropped
1194        self.proxy.close_image(&prep.proxy_img).await?;
1195        // SAFETY: We know we have a commit
1196        let ostree_commit = prep.ostree_commit_layer.unwrap().commit.unwrap();
1197        let image_digest = prep.manifest_digest;
1198        Ok(Import {
1199            ostree_commit,
1200            image_digest,
1201            deprecated_warning,
1202        })
1203    }
1204
1205    /// Generate a single ostree commit that combines all layers, and also
1206    /// includes container image metadata such as the manifest and config.
1207    fn write_merge_commit_impl(
1208        repo: &ostree::Repo,
1209        base_commit: Option<&str>,
1210        layer_commits: &[LayerRef],
1211        have_derived_layers: bool,
1212        metadata: glib::Variant,
1213        timestamp: u64,
1214        ostree_ref: &str,
1215        no_imgref: bool,
1216        disable_gc: bool,
1217        cancellable: Option<&gio::Cancellable>,
1218    ) -> Result<Box<LayeredImageState>> {
1219        use rustix::fd::AsRawFd;
1220
1221        let txn = repo.auto_transaction(cancellable)?;
1222
1223        let devino = ostree::RepoDevInoCache::new();
1224        let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
1225        let repo_tmp = repodir.open_dir("tmp")?;
1226        let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?;
1227
1228        let rootpath = "root";
1229        let checkout_mode = if repo.mode() == ostree::RepoMode::Bare {
1230            ostree::RepoCheckoutMode::None
1231        } else {
1232            ostree::RepoCheckoutMode::User
1233        };
1234        let mut checkout_opts = ostree::RepoCheckoutAtOptions {
1235            mode: checkout_mode,
1236            overwrite_mode: ostree::RepoCheckoutOverwriteMode::UnionFiles,
1237            devino_to_csum_cache: Some(devino.clone()),
1238            no_copy_fallback: true,
1239            force_copy_zerosized: true,
1240            process_whiteouts: false,
1241            ..Default::default()
1242        };
1243        if let Some(base) = base_commit.as_ref() {
1244            repo.checkout_at(
1245                Some(&checkout_opts),
1246                (*td).as_raw_fd(),
1247                rootpath,
1248                &base,
1249                cancellable,
1250            )
1251            .context("Checking out base commit")?;
1252        }
1253
1254        // Layer all subsequent commits
1255        checkout_opts.process_whiteouts = true;
1256        for lc in layer_commits {
1257            let commit = &lc.commit;
1258            tracing::debug!("Unpacking {commit}");
1259            repo.checkout_at(
1260                Some(&checkout_opts),
1261                (*td).as_raw_fd(),
1262                rootpath,
1263                commit,
1264                cancellable,
1265            )
1266            .with_context(|| format!("Checking out layer {commit}"))?;
1267        }
1268
1269        let root_dir = td.open_dir(rootpath)?;
1270
1271        let modifier =
1272            ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::empty(), None);
1273        modifier.set_devino_cache(&devino);
1274        // If we have derived layers, then we need to handle the case where
1275        // the derived layers include custom policy. Just relabel everything
1276        // in this case. Note "derived layers" also include the case where the
1277        // image has no OSTree repo at all.
1278        //
1279        // Track whether we need to relabel layer commits afterwards. Which is
1280        // only relevant if they're derived layers.
1281        let should_relabel;
1282        if have_derived_layers {
1283            let sepolicy = ostree::SePolicy::new_at(root_dir.as_raw_fd(), cancellable)?;
1284            should_relabel = sepolicy.name().map_or(false, |s| !s.is_empty());
1285            if should_relabel {
1286                tracing::debug!("labeling from merged tree");
1287                modifier.set_sepolicy(Some(&sepolicy));
1288            }
1289        } else if let Some(base) = base_commit.as_ref() {
1290            tracing::debug!("labeling from base tree");
1291            should_relabel = false;
1292            // TODO: We can likely drop this; we know all labels should be pre-computed.
1293            modifier.set_sepolicy_from_commit(repo, &base, cancellable)?;
1294        } else {
1295            panic!("Unexpected state: no derived layers and no base")
1296        }
1297
1298        cleanup_root(&root_dir)?;
1299
1300        let mt = ostree::MutableTree::new();
1301        repo.write_dfd_to_mtree(
1302            (*td).as_raw_fd(),
1303            rootpath,
1304            &mt,
1305            Some(&modifier),
1306            cancellable,
1307        )
1308        .context("Writing merged filesystem to mtree")?;
1309
1310        let merged_root = repo
1311            .write_mtree(&mt, cancellable)
1312            .context("Writing mtree")?;
1313        let merged_root = merged_root.downcast::<ostree::RepoFile>().unwrap();
1314        // The merge has the base commit as a parent, if it exists. See
1315        // https://github.com/ostreedev/ostree/pull/3523
1316        let parent = base_commit.as_deref();
1317        let merged_commit = repo
1318            .write_commit_with_time(
1319                parent,
1320                None,
1321                None,
1322                Some(&metadata),
1323                &merged_root,
1324                timestamp,
1325                cancellable,
1326            )
1327            .context("Writing commit")?;
1328        if !no_imgref {
1329            repo.transaction_set_ref(None, ostree_ref, Some(merged_commit.as_str()));
1330        }
1331
1332        // Relabel layer commits with the SELinux policy from the merged tree.
1333        // Since the merge commit already wrote correctly-labeled objects, most
1334        // writes here are no-ops (objects already exist in the repo).  This
1335        // ensures layer and merge commits share the same objects, avoiding
1336        // duplicate storage.
1337        let n_relabeled_layers = if should_relabel {
1338            let n =
1339                Self::relabel_layers(repo, layer_commits, &modifier, checkout_mode, cancellable)?;
1340            tracing::debug!("relabeled {n} layer commits");
1341            n
1342        } else {
1343            0
1344        };
1345
1346        txn.commit(cancellable)?;
1347
1348        if !disable_gc {
1349            let n: u32 = gc_image_layers_impl(repo, cancellable)?;
1350            tracing::debug!("pruned {n} layers");
1351            // Prune orphaned objects from the old (pre-relabel) layer commits.
1352            if n_relabeled_layers > 0 {
1353                let (_, n_pruned, _) =
1354                    repo.prune(ostree::RepoPruneFlags::REFS_ONLY, 0, cancellable)?;
1355                tracing::debug!("pruned {n_pruned} orphaned objects after relabeling");
1356            }
1357        }
1358
1359        // Here we re-query state just to run through the same code path,
1360        // though it'd be cheaper to synthesize it from the data we already have.
1361        let state = query_image_commit(repo, &merged_commit)?;
1362        Ok(state)
1363    }
1364
1365    /// Relabel layer commits with the given commit modifier (which carries
1366    /// an SELinux policy).  Each layer is checked out, recommitted with the
1367    /// policy applied, and its ref is updated to point at the new commit.
1368    /// Returns the number of layers that were actually relabeled (i.e. whose
1369    /// commit changed).
1370    fn relabel_layers(
1371        repo: &ostree::Repo,
1372        layer_commits: &[LayerRef],
1373        modifier: &ostree::RepoCommitModifier,
1374        checkout_mode: ostree::RepoCheckoutMode,
1375        cancellable: Option<&gio::Cancellable>,
1376    ) -> Result<u32> {
1377        use rustix::fd::AsRawFd;
1378
1379        let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
1380        let repo_tmp = repodir.open_dir("tmp")?;
1381        let rootpath = "root";
1382        let mut n_relabeled = 0u32;
1383        let checkout_opts = ostree::RepoCheckoutAtOptions {
1384            mode: checkout_mode,
1385            no_copy_fallback: true,
1386            force_copy_zerosized: true,
1387            ..Default::default()
1388        };
1389
1390        for lc in layer_commits {
1391            let (layer_ref, old_commit) = (&lc.ostree_ref, &lc.commit);
1392            let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?;
1393            repo.checkout_at(
1394                Some(&checkout_opts),
1395                (*td).as_raw_fd(),
1396                rootpath,
1397                old_commit,
1398                cancellable,
1399            )
1400            .with_context(|| format!("Checking out layer {old_commit} for relabeling"))?;
1401
1402            let mt = ostree::MutableTree::new();
1403            repo.write_dfd_to_mtree(
1404                (*td).as_raw_fd(),
1405                rootpath,
1406                &mt,
1407                Some(modifier),
1408                cancellable,
1409            )
1410            .with_context(|| format!("Writing relabeled layer {old_commit} to mtree"))?;
1411
1412            let root = repo
1413                .write_mtree(&mt, cancellable)
1414                .context("Writing mtree")?;
1415            let root = root.downcast::<ostree::RepoFile>().unwrap();
1416
1417            // Preserve the original commit's metadata and timestamp
1418            let (commit_v, _) = repo.load_commit(old_commit)?;
1419            let old_metadata = commit_v.child_value(0);
1420            let old_timestamp = ostree::commit_get_timestamp(&commit_v);
1421
1422            let new_commit = repo
1423                .write_commit_with_time(
1424                    None,
1425                    None,
1426                    None,
1427                    Some(&old_metadata),
1428                    &root,
1429                    old_timestamp,
1430                    cancellable,
1431                )
1432                .with_context(|| format!("Writing relabeled commit for layer {layer_ref}"))?;
1433
1434            if new_commit != *old_commit {
1435                repo.transaction_set_ref(None, layer_ref, Some(new_commit.as_str()));
1436                n_relabeled += 1;
1437                tracing::debug!("Relabeled layer {layer_ref}: {old_commit} -> {new_commit}");
1438            }
1439        }
1440
1441        Ok(n_relabeled)
1442    }
1443
1444    /// Import a layered container image.
1445    ///
1446    /// If enabled, this will also prune unused container image layers.
1447    #[context("Importing")]
1448    pub async fn import(
1449        mut self,
1450        mut import: Box<PreparedImport>,
1451    ) -> Result<Box<LayeredImageState>> {
1452        if let Some(status) = import.format_layer_status() {
1453            system_repo_journal_print(&self.repo, libsystemd::logging::Priority::Info, &status);
1454        }
1455        // First download all layers for the base image (if necessary) - we need the SELinux policy
1456        // there to label all following layers.
1457        self.unencapsulate_base(&mut import, false, true).await?;
1458        let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?;
1459        let proxy = self.proxy;
1460        let target_imgref = self.target_imgref.as_ref().unwrap_or(&self.imgref);
1461        let base_commit = import
1462            .ostree_commit_layer
1463            .as_ref()
1464            .map(|c| c.commit.clone().unwrap());
1465
1466        let root_is_transient = if let Some(base) = base_commit.as_ref() {
1467            let rootf = self.repo.read_commit(&base, gio::Cancellable::NONE)?.0;
1468            let rootf = rootf.downcast_ref::<ostree::RepoFile>().unwrap();
1469            crate::ostree_prepareroot::overlayfs_root_enabled(rootf)?
1470        } else {
1471            // For generic images we assume they're using composefs
1472            true
1473        };
1474        tracing::debug!("Base rootfs is transient: {root_is_transient}");
1475
1476        let ostree_ref = ref_for_image(&target_imgref.imgref)?;
1477
1478        let mut layer_commits: Vec<LayerRef> = Vec::new();
1479        let mut layer_filtered_content: Option<MetaFilteredData> = None;
1480        let have_derived_layers = !import.layers.is_empty();
1481        tracing::debug!("Processing layers: {}", import.layers.len());
1482        for layer in import.layers {
1483            if let Some(c) = layer.commit {
1484                tracing::debug!("Reusing fetched commit {}", c);
1485                Self::ensure_ref_for_layer(&self.repo, &layer.ostree_ref, &c)?;
1486
1487                layer_commits.push(LayerRef {
1488                    ostree_ref: layer.ostree_ref.clone(),
1489                    commit: c.to_string(),
1490                });
1491            } else {
1492                if let Some(p) = self.layer_progress.as_ref() {
1493                    p.send(ImportProgress::DerivedLayerStarted(layer.layer.clone()))
1494                        .await?;
1495                }
1496                let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
1497                    &proxy,
1498                    &import.proxy_img,
1499                    &import.manifest,
1500                    &layer.layer,
1501                    self.layer_byte_progress.as_ref(),
1502                    des_layers.as_ref(),
1503                    self.imgref.imgref.transport,
1504                )
1505                .await?;
1506                // SELinux label derived layers using the base policy.  For non-ostree
1507                // containers (base_commit is None), fall back to the caller-provided
1508                // sepolicy_commit (typically the booted deployment's commit).
1509                let opts = crate::tar::WriteTarOptions {
1510                    base: base_commit.clone().or(self.sepolicy_commit.clone()),
1511                    selinux: true,
1512                    allow_nonusr: root_is_transient,
1513                    retain_var: self.ostree_v2024_3,
1514                };
1515                let r = crate::tar::write_tar(
1516                    &self.repo,
1517                    blob,
1518                    media_type,
1519                    layer.ostree_ref.as_str(),
1520                    Some(opts),
1521                );
1522                let r = super::unencapsulate::join_fetch(r, driver)
1523                    .await
1524                    .with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?;
1525                tracing::debug!("Imported layer: {}", r.commit.as_str());
1526                layer_commits.push(LayerRef {
1527                    ostree_ref: layer.ostree_ref.clone(),
1528                    commit: r.commit,
1529                });
1530                let filtered_owned = HashMap::from_iter(r.filtered.clone());
1531                if let Some((filtered, n_rest)) = bootc_utils::collect_until(
1532                    r.filtered.iter(),
1533                    const { NonZeroUsize::new(5).unwrap() },
1534                ) {
1535                    let mut msg = String::new();
1536                    for (path, n) in filtered {
1537                        write!(msg, "{path}: {n} ").unwrap();
1538                    }
1539                    if n_rest > 0 {
1540                        write!(msg, "...and {n_rest} more").unwrap();
1541                    }
1542                    tracing::debug!("Found filtered toplevels: {msg}");
1543                    layer_filtered_content
1544                        .get_or_insert_default()
1545                        .insert(layer.layer.digest().to_string(), filtered_owned);
1546                } else {
1547                    tracing::debug!("No filtered content");
1548                }
1549                if let Some(p) = self.layer_progress.as_ref() {
1550                    p.send(ImportProgress::DerivedLayerCompleted(layer.layer.clone()))
1551                        .await?;
1552                }
1553            }
1554        }
1555
1556        // TODO change the imageproxy API to ensure this happens automatically when
1557        // the image reference is dropped
1558        proxy.close_image(&import.proxy_img).await?;
1559
1560        // We're done with the proxy, make sure it didn't have any errors.
1561        proxy.finalize().await?;
1562        tracing::debug!("finalized proxy");
1563
1564        // Disconnect progress notifiers to signal we're done with fetching.
1565        let _ = self.layer_byte_progress.take();
1566        let _ = self.layer_progress.take();
1567
1568        let mut metadata = BTreeMap::new();
1569        metadata.insert(
1570            META_MANIFEST_DIGEST,
1571            import.manifest_digest.to_string().to_variant(),
1572        );
1573        metadata.insert(
1574            META_MANIFEST,
1575            import.manifest.to_canon_json_string()?.to_variant(),
1576        );
1577        metadata.insert(
1578            META_CONFIG,
1579            import.config.to_canon_json_string()?.to_variant(),
1580        );
1581        metadata.insert(
1582            "ostree.importer.version",
1583            env!("CARGO_PKG_VERSION").to_variant(),
1584        );
1585        let metadata = metadata.to_variant();
1586
1587        let timestamp = timestamp_of_manifest_or_config(&import.manifest, &import.config)
1588            .unwrap_or_else(|| chrono::offset::Utc::now().timestamp() as u64);
1589        // Destructure to transfer ownership to thread
1590        let repo = self.repo;
1591        let mut state = crate::tokio_util::spawn_blocking_cancellable_flatten(
1592            move |cancellable| -> Result<Box<LayeredImageState>> {
1593                Self::write_merge_commit_impl(
1594                    &repo,
1595                    base_commit.as_deref(),
1596                    &layer_commits,
1597                    have_derived_layers,
1598                    metadata,
1599                    timestamp,
1600                    &ostree_ref,
1601                    self.no_imgref,
1602                    self.disable_gc,
1603                    Some(cancellable),
1604                )
1605            },
1606        )
1607        .await?;
1608        // We can at least avoid re-verifying the base commit.
1609        state.verify_text = import.verify_text;
1610        state.filtered_files = layer_filtered_content;
1611        Ok(state)
1612    }
1613}
1614
1615/// List all images stored
1616pub fn list_images(repo: &ostree::Repo) -> Result<Vec<String>> {
1617    let cancellable = gio::Cancellable::NONE;
1618    let refs = repo.list_refs_ext(
1619        Some(IMAGE_PREFIX),
1620        ostree::RepoListRefsExtFlags::empty(),
1621        cancellable,
1622    )?;
1623    refs.keys()
1624        .map(|imgname| refescape::unprefix_unescape_ref(IMAGE_PREFIX, imgname))
1625        .collect()
1626}
1627
1628/// Attempt to query metadata for a pulled image; if it is corrupted,
1629/// the error is printed to stderr and None is returned.
1630fn try_query_image(
1631    repo: &ostree::Repo,
1632    imgref: &ImageReference,
1633) -> Result<Option<Box<LayeredImageState>>> {
1634    let ostree_ref = &ref_for_image(imgref)?;
1635    if let Some(merge_rev) = repo.resolve_rev(ostree_ref, true)? {
1636        match query_image_commit(repo, merge_rev.as_str()) {
1637            Ok(r) => Ok(Some(r)),
1638            Err(e) => {
1639                eprintln!("error: failed to query image commit: {e}");
1640                Ok(None)
1641            }
1642        }
1643    } else {
1644        Ok(None)
1645    }
1646}
1647
1648/// Query metadata for a pulled image.
1649#[context("Querying image {imgref}")]
1650pub fn query_image(
1651    repo: &ostree::Repo,
1652    imgref: &ImageReference,
1653) -> Result<Option<Box<LayeredImageState>>> {
1654    let ostree_ref = &ref_for_image(imgref)?;
1655    let merge_rev = repo.resolve_rev(ostree_ref, true)?;
1656    merge_rev
1657        .map(|r| query_image_commit(repo, r.as_str()))
1658        .transpose()
1659}
1660
1661/// Given detached commit metadata, parse the data that we serialized for a pending update (if any).
1662fn parse_cached_update(meta: &glib::VariantDict) -> Result<Option<CachedImageUpdate>> {
1663    // Try to retrieve the manifest digest key from the commit detached metadata.
1664    let manifest_digest =
1665        if let Some(d) = meta.lookup::<String>(ImageImporter::CACHED_KEY_MANIFEST_DIGEST)? {
1666            d
1667        } else {
1668            // It's possible that something *else* wrote detached metadata, but without
1669            // our key; gracefully handle that.
1670            return Ok(None);
1671        };
1672    let manifest_digest = Digest::from_str(&manifest_digest)?;
1673    // If we found the cached manifest digest key, then we must have the manifest and config;
1674    // otherwise that's an error.
1675    let manifest = meta.lookup_value(ImageImporter::CACHED_KEY_MANIFEST, None);
1676    let manifest: oci_image::ImageManifest = manifest
1677        .as_ref()
1678        .and_then(|v| v.str())
1679        .map(serde_json::from_str)
1680        .transpose()?
1681        .ok_or_else(|| {
1682            anyhow!(
1683                "Expected cached manifest {}",
1684                ImageImporter::CACHED_KEY_MANIFEST
1685            )
1686        })?;
1687    let config = meta.lookup_value(ImageImporter::CACHED_KEY_CONFIG, None);
1688    let config: oci_image::ImageConfiguration = config
1689        .as_ref()
1690        .and_then(|v| v.str())
1691        .map(serde_json::from_str)
1692        .transpose()?
1693        .ok_or_else(|| {
1694            anyhow!(
1695                "Expected cached manifest {}",
1696                ImageImporter::CACHED_KEY_CONFIG
1697            )
1698        })?;
1699    Ok(Some(CachedImageUpdate {
1700        manifest,
1701        config,
1702        manifest_digest,
1703    }))
1704}
1705
1706/// Remove any cached
1707#[context("Clearing cached update {imgref}")]
1708pub fn clear_cached_update(repo: &ostree::Repo, imgref: &ImageReference) -> Result<()> {
1709    let cancellable = gio::Cancellable::NONE;
1710    let ostree_ref = ref_for_image(imgref)?;
1711    let rev = repo.require_rev(&ostree_ref)?;
1712    let Some(commitmeta) = repo.read_commit_detached_metadata(&rev, cancellable)? else {
1713        return Ok(());
1714    };
1715
1716    // SAFETY: We know this is an a{sv}
1717    let mut commitmeta: BTreeMap<String, glib::Variant> =
1718        BTreeMap::from_variant(&commitmeta).unwrap();
1719    let mut changed = false;
1720    for key in [
1721        ImageImporter::CACHED_KEY_CONFIG,
1722        ImageImporter::CACHED_KEY_MANIFEST,
1723        ImageImporter::CACHED_KEY_MANIFEST_DIGEST,
1724    ] {
1725        if commitmeta.remove(key).is_some() {
1726            changed = true;
1727        }
1728    }
1729    if !changed {
1730        return Ok(());
1731    }
1732    let commitmeta = glib::Variant::from(commitmeta);
1733    repo.write_commit_detached_metadata(&rev, Some(&commitmeta), cancellable)?;
1734    Ok(())
1735}
1736
1737/// Query metadata for a pulled image via an OSTree commit digest.
1738/// The digest must refer to a pulled container image's merge commit.
1739pub fn query_image_commit(repo: &ostree::Repo, commit: &str) -> Result<Box<LayeredImageState>> {
1740    let merge_commit = commit.to_string();
1741    let merge_commit_obj = repo.load_commit(commit)?.0;
1742    let commit_meta = &merge_commit_obj.child_value(0);
1743    let commit_meta = &ostree::glib::VariantDict::new(Some(commit_meta));
1744    let (manifest, manifest_digest) = manifest_data_from_commitmeta(commit_meta)?;
1745    let configuration = image_config_from_commitmeta(commit_meta)?;
1746    let mut layers = manifest.layers().iter().cloned();
1747    // We require a base layer.
1748    let base_layer = layers.next().ok_or_else(|| anyhow!("No layers found"))?;
1749    let base_layer = query_layer(repo, base_layer)?;
1750    let ostree_ref = base_layer.ostree_ref.as_str();
1751    let base_commit = base_layer
1752        .commit
1753        .ok_or_else(|| anyhow!("Missing base image ref {ostree_ref}"))?;
1754
1755    let detached_commitmeta =
1756        repo.read_commit_detached_metadata(&merge_commit, gio::Cancellable::NONE)?;
1757    let detached_commitmeta = detached_commitmeta
1758        .as_ref()
1759        .map(|v| glib::VariantDict::new(Some(v)));
1760    let cached_update = detached_commitmeta
1761        .as_ref()
1762        .map(parse_cached_update)
1763        .transpose()?
1764        .flatten();
1765    let state = Box::new(LayeredImageState {
1766        base_commit,
1767        merge_commit,
1768        manifest_digest,
1769        manifest,
1770        configuration,
1771        cached_update,
1772        // we can't cross-reference with a remote here
1773        verify_text: None,
1774        filtered_files: None,
1775    });
1776    tracing::debug!("Wrote merge commit {}", state.merge_commit);
1777    Ok(state)
1778}
1779
1780fn manifest_for_image(repo: &ostree::Repo, imgref: &ImageReference) -> Result<ImageManifest> {
1781    let ostree_ref = ref_for_image(imgref)?;
1782    let rev = repo.require_rev(&ostree_ref)?;
1783    let (commit_obj, _) = repo.load_commit(rev.as_str())?;
1784    let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0)));
1785    Ok(manifest_data_from_commitmeta(commit_meta)?.0)
1786}
1787
1788/// Copy a downloaded image from one repository to another, while also
1789/// optionally changing the image reference type.
1790#[context("Copying image")]
1791pub async fn copy(
1792    src_repo: &ostree::Repo,
1793    src_imgref: &ImageReference,
1794    dest_repo: &ostree::Repo,
1795    dest_imgref: &ImageReference,
1796) -> Result<()> {
1797    let src_ostree_ref = ref_for_image(src_imgref)?;
1798    let src_commit = src_repo.require_rev(&src_ostree_ref)?;
1799    let manifest = manifest_for_image(src_repo, src_imgref)?;
1800    // Create a task to copy each layer, plus the final ref
1801    let layer_refs = manifest
1802        .layers()
1803        .iter()
1804        .map(ref_for_layer)
1805        .chain(std::iter::once(Ok(src_commit.to_string())));
1806    for ostree_ref in layer_refs {
1807        let ostree_ref = ostree_ref?;
1808        let src_repo = src_repo.clone();
1809        let dest_repo = dest_repo.clone();
1810        crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> {
1811            let cancellable = Some(cancellable);
1812            let srcfd = &format!("file:///proc/self/fd/{}", src_repo.dfd());
1813            let flags = ostree::RepoPullFlags::MIRROR;
1814            let opts = glib::VariantDict::new(None);
1815            let refs = [ostree_ref.as_str()];
1816            // Some older archives may have bindings, we don't need to verify them.
1817            opts.insert("disable-verify-bindings", true);
1818            opts.insert("refs", &refs[..]);
1819            opts.insert("flags", flags.bits() as i32);
1820            let options = opts.to_variant();
1821            dest_repo.pull_with_options(srcfd, &options, None, cancellable)?;
1822            Ok(())
1823        })
1824        .await?;
1825    }
1826
1827    let dest_ostree_ref = ref_for_image(dest_imgref)?;
1828    dest_repo.set_ref_immediate(
1829        None,
1830        &dest_ostree_ref,
1831        Some(&src_commit),
1832        gio::Cancellable::NONE,
1833    )?;
1834
1835    Ok(())
1836}
1837
1838/// Options controlling commit export into OCI
1839#[derive(Clone, Debug, Default)]
1840#[non_exhaustive]
1841pub struct ExportToOCIOpts {
1842    /// If true, do not perform gzip compression of the tar layers.
1843    pub skip_compression: bool,
1844    /// Path to Docker-formatted authentication file.
1845    pub authfile: Option<std::path::PathBuf>,
1846    /// Output progress to stdout
1847    pub progress_to_stdout: bool,
1848}
1849
1850/// The way we store "chunk" layers in ostree is by writing a commit
1851/// whose filenames are their own object identifier. This function parses
1852/// what is written by the `ImporterMode::ObjectSet` logic, turning
1853/// it back into a "chunked" structure that is used by the export code.
1854fn chunking_from_layer_committed(
1855    repo: &ostree::Repo,
1856    l: &Descriptor,
1857    chunking: &mut chunking::Chunking,
1858) -> Result<()> {
1859    let mut chunk = Chunk::default();
1860    let layer_ref = &ref_for_layer(l)?;
1861    let root = repo.read_commit(layer_ref, gio::Cancellable::NONE)?.0;
1862    let e = root.enumerate_children(
1863        "standard::name,standard::size",
1864        gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS,
1865        gio::Cancellable::NONE,
1866    )?;
1867    for child in e.clone() {
1868        let child = &child?;
1869        // The name here should be a valid checksum
1870        let name = child.name();
1871        // SAFETY: ostree doesn't give us non-UTF8 filenames
1872        let name = Utf8Path::from_path(&name).unwrap();
1873        ostree::validate_checksum_string(name.as_str())?;
1874        chunking.remainder.move_obj(&mut chunk, name.as_str());
1875    }
1876    chunking.chunks.push(chunk);
1877    Ok(())
1878}
1879
1880/// Export an imported container image to a target OCI directory.
1881#[context("Copying image")]
1882pub(crate) fn export_to_oci(
1883    repo: &ostree::Repo,
1884    imgref: &ImageReference,
1885    dest_oci: &Dir,
1886    tag: Option<&str>,
1887    opts: ExportToOCIOpts,
1888) -> Result<Descriptor> {
1889    let srcinfo = query_image(repo, imgref)?.ok_or_else(|| anyhow!("No such image"))?;
1890    let (commit_layer, component_layers, remaining_layers) =
1891        parse_manifest_layout(&srcinfo.manifest, &srcinfo.configuration)?;
1892
1893    // Unfortunately today we can't guarantee we reserialize the same tar stream
1894    // or compression, so we'll need to generate a new copy of the manifest and config
1895    // with the layers reset.
1896    let mut new_manifest = srcinfo.manifest.clone();
1897    new_manifest.layers_mut().clear();
1898    let mut new_config = srcinfo.configuration.clone();
1899    if let Some(history) = new_config.history_mut() {
1900        history.clear();
1901    }
1902    new_config.rootfs_mut().diff_ids_mut().clear();
1903
1904    let opts = ExportOpts {
1905        skip_compression: opts.skip_compression,
1906        authfile: opts.authfile,
1907        ..Default::default()
1908    };
1909
1910    let mut labels = HashMap::new();
1911
1912    let mut dest_oci = ocidir::OciDir::ensure(dest_oci.try_clone()?)?;
1913
1914    let commit_chunk_ref = commit_layer
1915        .as_ref()
1916        .map(|l| ref_for_layer(l))
1917        .transpose()?;
1918    let commit_chunk_rev = commit_chunk_ref
1919        .as_ref()
1920        .map(|r| repo.require_rev(&r))
1921        .transpose()?;
1922    if let Some(commit_chunk_rev) = commit_chunk_rev {
1923        let mut chunking = chunking::Chunking::new(repo, &commit_chunk_rev)?;
1924        for layer in component_layers {
1925            chunking_from_layer_committed(repo, layer, &mut chunking)?;
1926        }
1927
1928        // Given the object chunking information we recomputed from what
1929        // we found on disk, re-serialize to layers (tarballs).
1930        export_chunked(
1931            repo,
1932            &srcinfo.base_commit,
1933            &mut dest_oci,
1934            &mut new_manifest,
1935            &mut new_config,
1936            &mut labels,
1937            chunking,
1938            &opts,
1939            "",
1940        )?;
1941    }
1942
1943    // Now, handle the non-ostree layers.
1944    let compression = opts.skip_compression.then_some(Compression::none());
1945    for (i, layer) in remaining_layers.iter().enumerate() {
1946        let layer_ref = &ref_for_layer(layer)?;
1947        let mut target_blob = dest_oci.create_gzip_layer(compression)?;
1948        // We accepted these images as raw (non-ostree) so export them the same way
1949        let export_opts = crate::tar::ExportOptions { raw: true };
1950        crate::tar::export_commit(
1951            repo,
1952            layer_ref.as_str(),
1953            &mut target_blob,
1954            Some(export_opts),
1955        )?;
1956        let layer = target_blob.complete()?;
1957        let previous_annotations = srcinfo
1958            .manifest
1959            .layers()
1960            .get(i)
1961            .and_then(|l| l.annotations().as_ref())
1962            .cloned();
1963        let history = srcinfo.configuration.history().as_ref();
1964        let history_entry = history.and_then(|v| v.get(i));
1965        let previous_description = history_entry
1966            .clone()
1967            .and_then(|h| h.comment().as_deref())
1968            .unwrap_or_default();
1969
1970        let previous_created = history_entry
1971            .and_then(|h| h.created().as_deref())
1972            .and_then(bootc_utils::try_deserialize_timestamp)
1973            .unwrap_or_default();
1974
1975        dest_oci.push_layer_full(
1976            &mut new_manifest,
1977            &mut new_config,
1978            layer,
1979            previous_annotations,
1980            previous_description,
1981            previous_created,
1982        )
1983    }
1984
1985    let new_config = dest_oci.write_config(new_config)?;
1986    new_manifest.set_config(new_config);
1987
1988    Ok(dest_oci.insert_manifest(new_manifest, tag, oci_image::Platform::default())?)
1989}
1990
1991/// Given a container image reference which is stored in `repo`, export it to the
1992/// target image location.
1993#[context("Export")]
1994pub async fn export(
1995    repo: &ostree::Repo,
1996    src_imgref: &ImageReference,
1997    dest_imgref: &ImageReference,
1998    opts: Option<ExportToOCIOpts>,
1999) -> Result<oci_image::Digest> {
2000    let opts = opts.unwrap_or_default();
2001    let target_oci = dest_imgref.transport == Transport::OciDir;
2002    let tempdir = if !target_oci {
2003        let vartmp = cap_std::fs::Dir::open_ambient_dir("/var/tmp", cap_std::ambient_authority())?;
2004        let td = cap_std_ext::cap_tempfile::TempDir::new_in(&vartmp)?;
2005        // Always skip compression when making a temporary copy
2006        let opts = ExportToOCIOpts {
2007            skip_compression: true,
2008            progress_to_stdout: opts.progress_to_stdout,
2009            ..Default::default()
2010        };
2011        export_to_oci(repo, src_imgref, &td, None, opts)?;
2012        td
2013    } else {
2014        let (path, tag) = parse_oci_path_and_tag(dest_imgref.name.as_str());
2015        tracing::debug!("using OCI path={path} tag={tag:?}");
2016        let path = Dir::open_ambient_dir(path, cap_std::ambient_authority())
2017            .with_context(|| format!("Opening {path}"))?;
2018        let descriptor = export_to_oci(repo, src_imgref, &path, tag, opts)?;
2019        return Ok(descriptor.digest().clone());
2020    };
2021    // Pass the temporary oci directory as the current working directory for the skopeo process
2022    let target_fd = 3i32;
2023    let tempoci = ImageReference {
2024        transport: Transport::OciDir,
2025        name: format!("/proc/self/fd/{target_fd}"),
2026    };
2027    let authfile = opts.authfile.as_deref();
2028    skopeo::copy(
2029        &tempoci,
2030        dest_imgref,
2031        authfile,
2032        Some((std::sync::Arc::new(tempdir.try_clone()?.into()), target_fd)),
2033        opts.progress_to_stdout,
2034    )
2035    .await
2036}
2037
2038/// Iterate over deployment commits, returning the manifests from
2039/// commits which point to a container image.
2040#[context("Listing deployment manifests")]
2041fn list_container_deployment_manifests(
2042    repo: &ostree::Repo,
2043    cancellable: Option<&gio::Cancellable>,
2044) -> Result<Vec<ImageManifest>> {
2045    // Gather all refs which start with ostree/0/ or ostree/1/ or rpmostree/base/
2046    // and create a set of the commits which they reference.
2047    let commits = OSTREE_BASE_DEPLOYMENT_REFS
2048        .iter()
2049        .chain(RPMOSTREE_BASE_REFS)
2050        .chain(std::iter::once(&BASE_IMAGE_PREFIX))
2051        .try_fold(
2052            std::collections::HashSet::new(),
2053            |mut acc, &p| -> Result<_> {
2054                let refs = repo.list_refs_ext(
2055                    Some(p),
2056                    ostree::RepoListRefsExtFlags::empty(),
2057                    cancellable,
2058                )?;
2059                for (_, v) in refs {
2060                    acc.insert(v);
2061                }
2062                Ok(acc)
2063            },
2064        )?;
2065    // Loop over the commits - if they refer to a container image, add that to our return value.
2066    let mut r = Vec::new();
2067    for commit in commits {
2068        let commit_obj = repo.load_commit(&commit)?.0;
2069        let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0)));
2070        if commit_meta
2071            .lookup::<String>(META_MANIFEST_DIGEST)?
2072            .is_some()
2073        {
2074            tracing::trace!("Commit {commit} is a container image");
2075            let manifest = manifest_data_from_commitmeta(commit_meta)?.0;
2076            r.push(manifest);
2077        }
2078    }
2079    Ok(r)
2080}
2081
2082/// Garbage collect unused image layer references.
2083///
2084/// This function assumes no transaction is active on the repository.
2085/// The underlying objects are *not* pruned; that requires a separate invocation
2086/// of [`ostree::Repo::prune`].
2087pub fn gc_image_layers(repo: &ostree::Repo) -> Result<u32> {
2088    gc_image_layers_impl(repo, gio::Cancellable::NONE)
2089}
2090
2091#[context("Pruning image layers")]
2092fn gc_image_layers_impl(
2093    repo: &ostree::Repo,
2094    cancellable: Option<&gio::Cancellable>,
2095) -> Result<u32> {
2096    let all_images = list_images(repo)?;
2097    let deployment_commits = list_container_deployment_manifests(repo, cancellable)?;
2098    let all_manifests = all_images
2099        .into_iter()
2100        .map(|img| -> Result<_> {
2101            let ir = ImageReference::try_from(img.as_str())?;
2102            manifest_for_image(repo, &ir)
2103        })
2104        .chain(deployment_commits.into_iter().map(Ok))
2105        .collect::<Result<Vec<_>>>()?;
2106    tracing::debug!("Images found: {}", all_manifests.len());
2107    let mut referenced_layers = BTreeSet::new();
2108    for m in all_manifests.iter() {
2109        for layer in m.layers() {
2110            referenced_layers.insert(layer.digest().to_string());
2111        }
2112    }
2113    tracing::debug!("Referenced layers: {}", referenced_layers.len());
2114    let found_layers = repo
2115        .list_refs_ext(
2116            Some(LAYER_PREFIX),
2117            ostree::RepoListRefsExtFlags::empty(),
2118            cancellable,
2119        )?
2120        .into_iter()
2121        .map(|v| v.0);
2122    tracing::debug!("Found layers: {}", found_layers.len());
2123    let mut pruned = 0u32;
2124    for layer_ref in found_layers {
2125        let layer_digest = refescape::unprefix_unescape_ref(LAYER_PREFIX, &layer_ref)?;
2126        if referenced_layers.remove(layer_digest.as_str()) {
2127            continue;
2128        }
2129        pruned += 1;
2130        tracing::debug!("Pruning: {}", layer_ref.as_str());
2131        repo.set_ref_immediate(None, layer_ref.as_str(), None, cancellable)?;
2132    }
2133
2134    Ok(pruned)
2135}
2136
2137#[cfg(feature = "internal-testing-api")]
2138/// Return how many container blobs (layers) are stored
2139pub fn count_layer_references(repo: &ostree::Repo) -> Result<u32> {
2140    let cancellable = gio::Cancellable::NONE;
2141    let n = repo
2142        .list_refs_ext(
2143            Some(LAYER_PREFIX),
2144            ostree::RepoListRefsExtFlags::empty(),
2145            cancellable,
2146        )?
2147        .len();
2148    Ok(n as u32)
2149}
2150
2151/// Generate a suitable warning message from given list of filtered files, if any.
2152pub fn image_filtered_content_warning(
2153    filtered_files: &Option<MetaFilteredData>,
2154) -> Result<Option<String>> {
2155    use std::fmt::Write;
2156
2157    let r = filtered_files.as_ref().map(|v| {
2158        let mut filtered = BTreeMap::<&String, u32>::new();
2159        for paths in v.values() {
2160            for (k, v) in paths {
2161                let e = filtered.entry(k).or_default();
2162                *e += v;
2163            }
2164        }
2165        let mut buf = "Image contains non-ostree compatible file paths:".to_string();
2166        for (k, v) in filtered {
2167            write!(buf, " {k}: {v}").unwrap();
2168        }
2169        buf
2170    });
2171    Ok(r)
2172}
2173
2174/// Remove the specified image reference.  If the image is already
2175/// not present, this function will successfully perform no operation.
2176///
2177/// This function assumes no transaction is active on the repository.
2178/// The underlying layers are *not* pruned; that requires a separate invocation
2179/// of [`gc_image_layers`].
2180#[context("Pruning {img}")]
2181pub fn remove_image(repo: &ostree::Repo, img: &ImageReference) -> Result<bool> {
2182    let ostree_ref = &ref_for_image(img)?;
2183    let found = repo.resolve_rev(ostree_ref, true)?.is_some();
2184    // Note this API is already idempotent, but we might as well avoid another
2185    // trip into ostree.
2186    if found {
2187        repo.set_ref_immediate(None, ostree_ref, None, gio::Cancellable::NONE)?;
2188    }
2189    Ok(found)
2190}
2191
2192/// Remove the specified image references.  If an image is not found, further
2193/// images will be removed, but an error will be returned.
2194///
2195/// This function assumes no transaction is active on the repository.
2196/// The underlying layers are *not* pruned; that requires a separate invocation
2197/// of [`gc_image_layers`].
2198pub fn remove_images<'a>(
2199    repo: &ostree::Repo,
2200    imgs: impl IntoIterator<Item = &'a ImageReference>,
2201) -> Result<()> {
2202    let mut missing = Vec::new();
2203    for img in imgs.into_iter() {
2204        let found = remove_image(repo, img)?;
2205        if !found {
2206            missing.push(img);
2207        }
2208    }
2209    if !missing.is_empty() {
2210        let missing = missing.into_iter().fold("".to_string(), |mut a, v| {
2211            a.push_str(&v.to_string());
2212            a
2213        });
2214        return Err(anyhow::anyhow!("Missing images: {missing}"));
2215    }
2216    Ok(())
2217}
2218
2219#[derive(Debug, Default)]
2220struct CompareState {
2221    verified: BTreeSet<Utf8PathBuf>,
2222    inode_corrupted: BTreeSet<Utf8PathBuf>,
2223    unknown_corrupted: BTreeSet<Utf8PathBuf>,
2224}
2225
2226impl CompareState {
2227    fn is_ok(&self) -> bool {
2228        self.inode_corrupted.is_empty() && self.unknown_corrupted.is_empty()
2229    }
2230}
2231
2232fn compare_file_info(src: &gio::FileInfo, target: &gio::FileInfo) -> bool {
2233    if src.file_type() != target.file_type() {
2234        return false;
2235    }
2236    if src.size() != target.size() {
2237        return false;
2238    }
2239    for attr in ["unix::uid", "unix::gid", "unix::mode"] {
2240        if src.attribute_uint32(attr) != target.attribute_uint32(attr) {
2241            return false;
2242        }
2243    }
2244    true
2245}
2246
2247#[context("Querying object inode")]
2248fn inode_of_object(repo: &ostree::Repo, checksum: &str) -> Result<u64> {
2249    let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
2250    let (prefix, suffix) = checksum.split_at(2);
2251    let objpath = format!("objects/{prefix}/{suffix}.file");
2252    let metadata = repodir.symlink_metadata(objpath)?;
2253    Ok(metadata.ino())
2254}
2255
2256fn compare_commit_trees(
2257    repo: &ostree::Repo,
2258    root: &Utf8Path,
2259    target: &ostree::RepoFile,
2260    expected: &ostree::RepoFile,
2261    exact: bool,
2262    colliding_inodes: &BTreeSet<u64>,
2263    state: &mut CompareState,
2264) -> Result<()> {
2265    let cancellable = gio::Cancellable::NONE;
2266    let queryattrs = "standard::name,standard::type";
2267    let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS;
2268    let expected_iter = expected.enumerate_children(queryattrs, queryflags, cancellable)?;
2269
2270    while let Some(expected_info) = expected_iter.next_file(cancellable)? {
2271        let expected_child = expected_iter.child(&expected_info);
2272        let name = expected_info.name();
2273        let name = name.to_str().expect("UTF-8 ostree name");
2274        let path = Utf8PathBuf::from(format!("{root}{name}"));
2275        let target_child = target.child(name);
2276        let target_info = crate::diff::query_info_optional(&target_child, queryattrs, queryflags)
2277            .context("querying optional to")?;
2278        let is_dir = matches!(expected_info.file_type(), gio::FileType::Directory);
2279        if let Some(target_info) = target_info {
2280            let to_child = target_child
2281                .downcast::<ostree::RepoFile>()
2282                .expect("downcast");
2283            to_child.ensure_resolved()?;
2284            let from_child = expected_child
2285                .downcast::<ostree::RepoFile>()
2286                .expect("downcast");
2287            from_child.ensure_resolved()?;
2288
2289            if is_dir {
2290                let from_contents_checksum = from_child.tree_get_contents_checksum();
2291                let to_contents_checksum = to_child.tree_get_contents_checksum();
2292                if from_contents_checksum != to_contents_checksum {
2293                    let subpath = Utf8PathBuf::from(format!("{path}/"));
2294                    compare_commit_trees(
2295                        repo,
2296                        &subpath,
2297                        &from_child,
2298                        &to_child,
2299                        exact,
2300                        colliding_inodes,
2301                        state,
2302                    )?;
2303                }
2304            } else {
2305                let from_checksum = from_child.checksum();
2306                let to_checksum = to_child.checksum();
2307                let matches = if exact {
2308                    from_checksum == to_checksum
2309                } else {
2310                    compare_file_info(&target_info, &expected_info)
2311                };
2312                if !matches {
2313                    let from_inode = inode_of_object(repo, &from_checksum)?;
2314                    let to_inode = inode_of_object(repo, &to_checksum)?;
2315                    if colliding_inodes.contains(&from_inode)
2316                        || colliding_inodes.contains(&to_inode)
2317                    {
2318                        state.inode_corrupted.insert(path);
2319                    } else {
2320                        state.unknown_corrupted.insert(path);
2321                    }
2322                } else {
2323                    state.verified.insert(path);
2324                }
2325            }
2326        } else {
2327            eprintln!("Missing {path}");
2328            state.unknown_corrupted.insert(path);
2329        }
2330    }
2331    Ok(())
2332}
2333
2334#[context("Verifying container image state")]
2335pub(crate) fn verify_container_image(
2336    sysroot: &SysrootLock,
2337    imgref: &ImageReference,
2338    state: &LayeredImageState,
2339    colliding_inodes: &BTreeSet<u64>,
2340    verbose: bool,
2341) -> Result<bool> {
2342    let cancellable = gio::Cancellable::NONE;
2343    let repo = &sysroot.repo();
2344    let merge_commit = state.merge_commit.as_str();
2345    let merge_commit_root = repo.read_commit(merge_commit, gio::Cancellable::NONE)?.0;
2346    let merge_commit_root = merge_commit_root
2347        .downcast::<ostree::RepoFile>()
2348        .expect("downcast");
2349    merge_commit_root.ensure_resolved()?;
2350
2351    let (commit_layer, _component_layers, remaining_layers) =
2352        parse_manifest_layout(&state.manifest, &state.configuration)?;
2353
2354    let mut comparison_state = CompareState::default();
2355
2356    let query = |l: &Descriptor| query_layer(repo, l.clone());
2357
2358    let base_tree = repo
2359        .read_commit(&state.base_commit, cancellable)?
2360        .0
2361        .downcast::<ostree::RepoFile>()
2362        .expect("downcast");
2363    if let Some(commit_layer) = commit_layer {
2364        println!(
2365            "Verifying with base ostree layer {}",
2366            ref_for_layer(commit_layer)?
2367        );
2368    }
2369    compare_commit_trees(
2370        repo,
2371        "/".into(),
2372        &merge_commit_root,
2373        &base_tree,
2374        true,
2375        colliding_inodes,
2376        &mut comparison_state,
2377    )?;
2378
2379    let remaining_layers = remaining_layers
2380        .into_iter()
2381        .map(query)
2382        .collect::<Result<Vec<_>>>()?;
2383
2384    println!("Image has {} derived layers", remaining_layers.len());
2385
2386    for layer in remaining_layers.iter().rev() {
2387        let layer_ref = layer.ostree_ref.as_str();
2388        let layer_commit = layer
2389            .commit
2390            .as_deref()
2391            .ok_or_else(|| anyhow!("Missing layer {layer_ref}"))?;
2392        let layer_tree = repo
2393            .read_commit(layer_commit, cancellable)?
2394            .0
2395            .downcast::<ostree::RepoFile>()
2396            .expect("downcast");
2397        compare_commit_trees(
2398            repo,
2399            "/".into(),
2400            &merge_commit_root,
2401            &layer_tree,
2402            false,
2403            colliding_inodes,
2404            &mut comparison_state,
2405        )?;
2406    }
2407
2408    let n_verified = comparison_state.verified.len();
2409    if comparison_state.is_ok() {
2410        println!("OK image {imgref} (verified={n_verified})");
2411        println!();
2412    } else {
2413        let n_inode = comparison_state.inode_corrupted.len();
2414        let n_other = comparison_state.unknown_corrupted.len();
2415        eprintln!("warning: Found corrupted merge commit");
2416        eprintln!("  inode clashes: {n_inode}");
2417        eprintln!("  unknown:       {n_other}");
2418        eprintln!("  ok:            {n_verified}");
2419        if verbose {
2420            eprintln!("Mismatches:");
2421            for path in comparison_state.inode_corrupted {
2422                eprintln!("  inode: {path}");
2423            }
2424            for path in comparison_state.unknown_corrupted {
2425                eprintln!("  other: {path}");
2426            }
2427        }
2428        eprintln!();
2429        return Ok(false);
2430    }
2431
2432    Ok(true)
2433}
2434
2435#[cfg(test)]
2436mod tests {
2437    use cap_std_ext::cap_tempfile;
2438    use oci_image::{DescriptorBuilder, MediaType, Sha256Digest};
2439
2440    use super::*;
2441
2442    #[test]
2443    fn test_ref_for_descriptor() {
2444        let d = DescriptorBuilder::default()
2445            .size(42u64)
2446            .media_type(MediaType::ImageManifest)
2447            .digest(
2448                Sha256Digest::from_str(
2449                    "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
2450                )
2451                .unwrap(),
2452            )
2453            .build()
2454            .unwrap();
2455        assert_eq!(
2456            ref_for_layer(&d).unwrap(),
2457            "ostree/container/blob/sha256_3A_2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
2458        );
2459    }
2460
2461    #[test]
2462    fn test_cleanup_root() -> Result<()> {
2463        let td = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
2464        let usretc = "usr/etc";
2465        cleanup_root(&td).unwrap();
2466        td.create_dir_all(usretc)?;
2467        let usretc = &td.open_dir(usretc)?;
2468        usretc.write("hostname", b"hostname")?;
2469        cleanup_root(&td).unwrap();
2470        assert!(usretc.try_exists("hostname")?);
2471        usretc.write("hostname", b"")?;
2472        cleanup_root(&td).unwrap();
2473        assert!(!td.try_exists("hostname")?);
2474
2475        usretc.symlink_contents("../run/systemd/stub-resolv.conf", "resolv.conf")?;
2476        cleanup_root(&td).unwrap();
2477        assert!(usretc.symlink_metadata("resolv.conf")?.is_symlink());
2478        usretc.remove_file("resolv.conf")?;
2479        usretc.write("resolv.conf", b"")?;
2480        cleanup_root(&td).unwrap();
2481        assert!(!usretc.try_exists("resolv.conf")?);
2482
2483        Ok(())
2484    }
2485}