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//! - [`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/// Context for importing a container image.
296#[derive(Debug)]
297pub struct ImageImporter {
298    repo: ostree::Repo,
299    pub(crate) proxy: ImageProxy,
300    imgref: OstreeImageReference,
301    target_imgref: Option<OstreeImageReference>,
302    no_imgref: bool,  // If true, do not write final image ref
303    disable_gc: bool, // If true, don't prune unused image layers
304    /// If true, require the image has the bootable flag
305    require_bootable: bool,
306    /// Do not attempt to contact the network
307    offline: bool,
308    /// If true, we have ostree v2024.3 or newer.
309    ostree_v2024_3: bool,
310
311    layer_progress: Option<Sender<ImportProgress>>,
312    layer_byte_progress: Option<tokio::sync::watch::Sender<Option<LayerProgress>>>,
313}
314
315/// Result of invoking [`ImageImporter::prepare`].
316#[derive(Debug)]
317pub enum PrepareResult {
318    /// The image reference is already present; the contained string is the OSTree commit.
319    AlreadyPresent(Box<LayeredImageState>),
320    /// The image needs to be downloaded
321    Ready(Box<PreparedImport>),
322}
323
324/// A container image layer with associated downloaded-or-not state.
325#[derive(Debug)]
326pub struct ManifestLayerState {
327    /// The underlying layer descriptor.
328    pub layer: oci_image::Descriptor,
329    // TODO semver: Make this readonly via an accessor
330    /// The ostree ref name for this layer.
331    pub ostree_ref: String,
332    // TODO semver: Make this readonly via an accessor
333    /// The ostree commit that caches this layer, if present.
334    pub commit: Option<String>,
335}
336
337impl ManifestLayerState {
338    /// Return the layer descriptor.
339    pub fn layer(&self) -> &oci_image::Descriptor {
340        &self.layer
341    }
342}
343
344/// Information about which layers need to be downloaded.
345#[derive(Debug)]
346pub struct PreparedImport {
347    /// The manifest digest that was found
348    pub manifest_digest: Digest,
349    /// The deserialized manifest.
350    pub manifest: oci_image::ImageManifest,
351    /// The deserialized configuration.
352    pub config: oci_image::ImageConfiguration,
353    /// The previous manifest
354    pub previous_state: Option<Box<LayeredImageState>>,
355    /// The previously stored manifest digest.
356    pub previous_manifest_digest: Option<Digest>,
357    /// The previously stored image ID.
358    pub previous_imageid: Option<String>,
359    /// The layers containing split objects
360    pub ostree_layers: Vec<ManifestLayerState>,
361    /// The layer for the ostree commit.
362    pub ostree_commit_layer: Option<ManifestLayerState>,
363    /// Any further non-ostree (derived) layers.
364    pub layers: Vec<ManifestLayerState>,
365    /// OSTree remote signature verification text, if enabled.
366    pub verify_text: Option<String>,
367    /// Our open image reference
368    proxy_img: OpenedImage,
369}
370
371impl PreparedImport {
372    /// Iterate over all layers; the commit layer, the ostree split object layers, and any non-ostree layers.
373    pub fn all_layers(&self) -> impl Iterator<Item = &ManifestLayerState> {
374        self.ostree_commit_layer
375            .iter()
376            .chain(self.ostree_layers.iter())
377            .chain(self.layers.iter())
378    }
379
380    /// Retrieve the container image version.
381    pub fn version(&self) -> Option<&str> {
382        super::version_for_config(&self.config)
383    }
384
385    /// If this image is using any deprecated features, return a message saying so.
386    pub fn deprecated_warning(&self) -> Option<&'static str> {
387        None
388    }
389
390    /// Iterate over all layers paired with their history entry.
391    /// An error will be returned if the history does not cover all entries.
392    pub fn layers_with_history(
393        &self,
394    ) -> impl Iterator<Item = Result<(&ManifestLayerState, &History)>> {
395        // FIXME use .filter(|h| h.empty_layer.unwrap_or_default()) after https://github.com/containers/oci-spec-rs/pull/100 lands.
396        let truncated = std::iter::once_with(|| Err(anyhow::anyhow!("Truncated history")));
397        let history = self
398            .config
399            .history()
400            .iter()
401            .flatten()
402            .map(Ok)
403            .chain(truncated);
404        self.all_layers()
405            .zip(history)
406            .map(|(s, h)| h.map(|h| (s, h)))
407    }
408
409    /// Iterate over all layers that are not present, along with their history description.
410    pub fn layers_to_fetch(&self) -> impl Iterator<Item = Result<(&ManifestLayerState, &str)>> {
411        self.layers_with_history().filter_map(|r| {
412            r.map(|(l, h)| {
413                l.commit.is_none().then(|| {
414                    let comment = h.created_by().as_deref().unwrap_or("");
415                    (l, comment)
416                })
417            })
418            .transpose()
419        })
420    }
421
422    /// Common helper to format a string for the status
423    pub(crate) fn format_layer_status(&self) -> Option<String> {
424        let (stored, to_fetch, to_fetch_size) =
425            self.all_layers()
426                .fold((0u32, 0u32, 0u64), |(stored, to_fetch, sz), v| {
427                    if v.commit.is_some() {
428                        (stored + 1, to_fetch, sz)
429                    } else {
430                        (stored, to_fetch + 1, sz + v.layer().size())
431                    }
432                });
433        (to_fetch > 0).then(|| {
434            let size = crate::glib::format_size(to_fetch_size);
435            format!("layers already present: {stored}; layers needed: {to_fetch} ({size})")
436        })
437    }
438}
439
440// Given a manifest, compute its ostree ref name and cached ostree commit
441pub(crate) fn query_layer(
442    repo: &ostree::Repo,
443    layer: oci_image::Descriptor,
444) -> Result<ManifestLayerState> {
445    let ostree_ref = ref_for_layer(&layer)?;
446    let commit = repo.resolve_rev(&ostree_ref, true)?.map(|s| s.to_string());
447    Ok(ManifestLayerState {
448        layer,
449        ostree_ref,
450        commit,
451    })
452}
453
454#[context("Reading manifest data from commit")]
455fn manifest_data_from_commitmeta(
456    commit_meta: &glib::VariantDict,
457) -> Result<(oci_image::ImageManifest, Digest)> {
458    let digest = commit_meta
459        .lookup::<String>(META_MANIFEST_DIGEST)?
460        .ok_or_else(|| anyhow!("Missing {} metadata on merge commit", META_MANIFEST_DIGEST))?;
461    let digest = Digest::from_str(&digest)?;
462    let manifest_bytes: String = commit_meta
463        .lookup::<String>(META_MANIFEST)?
464        .ok_or_else(|| anyhow!("Failed to find {} metadata key", META_MANIFEST))?;
465    let r = serde_json::from_str(&manifest_bytes)?;
466    Ok((r, digest))
467}
468
469fn image_config_from_commitmeta(commit_meta: &glib::VariantDict) -> Result<ImageConfiguration> {
470    let config = if let Some(config) = commit_meta
471        .lookup::<String>(META_CONFIG)?
472        .filter(|v| v != "null") // Format v0 apparently old versions injected `null` here sadly...
473        .map(|v| serde_json::from_str(&v).map_err(anyhow::Error::msg))
474        .transpose()?
475    {
476        config
477    } else {
478        tracing::debug!("No image configuration found");
479        Default::default()
480    };
481    Ok(config)
482}
483
484/// Return the original digest of the manifest stored in the commit metadata.
485/// This will be a string of the form e.g. `sha256:<digest>`.
486///
487/// This can be used to uniquely identify the image.  For example, it can be used
488/// in a "digested pull spec" like `quay.io/someuser/exampleos@sha256:...`.
489pub fn manifest_digest_from_commit(commit: &glib::Variant) -> Result<Digest> {
490    let commit_meta = &commit.child_value(0);
491    let commit_meta = &glib::VariantDict::new(Some(commit_meta));
492    Ok(manifest_data_from_commitmeta(commit_meta)?.1)
493}
494
495/// Given a target diffid, return its corresponding layer.  In our current model,
496/// we require a 1-to-1 mapping between the two up until the ostree level.
497/// For a bit more information on this, see https://github.com/opencontainers/image-spec/blob/main/config.md
498fn layer_from_diffid<'a>(
499    manifest: &'a ImageManifest,
500    config: &ImageConfiguration,
501    diffid: &str,
502) -> Result<&'a Descriptor> {
503    let idx = config
504        .rootfs()
505        .diff_ids()
506        .iter()
507        .position(|x| x.as_str() == diffid)
508        .ok_or_else(|| anyhow!("Missing {} {}", DIFFID_LABEL, diffid))?;
509    manifest.layers().get(idx).ok_or_else(|| {
510        anyhow!(
511            "diffid position {} exceeds layer count {}",
512            idx,
513            manifest.layers().len()
514        )
515    })
516}
517
518#[context("Parsing manifest layout")]
519pub(crate) fn parse_manifest_layout<'a>(
520    manifest: &'a ImageManifest,
521    config: &ImageConfiguration,
522) -> Result<(
523    Option<&'a Descriptor>,
524    Vec<&'a Descriptor>,
525    Vec<&'a Descriptor>,
526)> {
527    let config_labels = super::labels_of(config);
528
529    let first_layer = manifest
530        .layers()
531        .first()
532        .ok_or_else(|| anyhow!("No layers in manifest"))?;
533    let Some(target_diffid) = config_labels.and_then(|labels| labels.get(DIFFID_LABEL)) else {
534        return Ok((None, Vec::new(), manifest.layers().iter().collect()));
535    };
536
537    let target_layer = layer_from_diffid(manifest, config, target_diffid.as_str())?;
538    let mut chunk_layers = Vec::new();
539    let mut derived_layers = Vec::new();
540    let mut after_target = false;
541    // Gather the ostree layer
542    let ostree_layer = first_layer;
543    for layer in manifest.layers() {
544        if layer == target_layer {
545            if after_target {
546                anyhow::bail!("Multiple entries for {}", layer.digest());
547            }
548            after_target = true;
549            if layer != ostree_layer {
550                chunk_layers.push(layer);
551            }
552        } else if !after_target {
553            if layer != ostree_layer {
554                chunk_layers.push(layer);
555            }
556        } else {
557            derived_layers.push(layer);
558        }
559    }
560
561    Ok((Some(ostree_layer), chunk_layers, derived_layers))
562}
563
564/// Like [`parse_manifest_layout`] but requires the image has an ostree base.
565#[context("Parsing manifest layout")]
566pub(crate) fn parse_ostree_manifest_layout<'a>(
567    manifest: &'a ImageManifest,
568    config: &ImageConfiguration,
569) -> Result<(&'a Descriptor, Vec<&'a Descriptor>, Vec<&'a Descriptor>)> {
570    let (ostree_layer, component_layers, derived_layers) = parse_manifest_layout(manifest, config)?;
571    let ostree_layer = ostree_layer.ok_or_else(|| {
572        anyhow!("No {DIFFID_LABEL} label found, not an ostree encapsulated container")
573    })?;
574    Ok((ostree_layer, component_layers, derived_layers))
575}
576
577/// Find the timestamp of the manifest (or config), ignoring errors.
578fn timestamp_of_manifest_or_config(
579    manifest: &ImageManifest,
580    config: &ImageConfiguration,
581) -> Option<u64> {
582    // The manifest timestamp seems to not be widely used, but let's
583    // try it in preference to the config one.
584    let timestamp = manifest
585        .annotations()
586        .as_ref()
587        .and_then(|a| a.get(oci_image::ANNOTATION_CREATED))
588        .or_else(|| config.created().as_ref());
589    // Try to parse the timestamp
590    timestamp
591        .map(|t| {
592            chrono::DateTime::parse_from_rfc3339(t)
593                .context("Failed to parse manifest timestamp")
594                .map(|t| t.timestamp() as u64)
595        })
596        .transpose()
597        .log_err_default()
598}
599
600/// Automatically clean up files that may have been injected by container
601/// builds. xref https://github.com/containers/buildah/issues/4242
602fn cleanup_root(root: &Dir) -> Result<()> {
603    const RUNTIME_INJECTED: &[&str] = &["usr/etc/hostname", "usr/etc/resolv.conf"];
604    for ent in RUNTIME_INJECTED {
605        if let Some(meta) = root.symlink_metadata_optional(ent)? {
606            if meta.is_file() && meta.size() == 0 {
607                tracing::debug!("Removing {ent}");
608                root.remove_file(ent)?;
609            }
610        }
611    }
612    Ok(())
613}
614
615impl ImageImporter {
616    /// The metadata key used in ostree commit metadata to serialize
617    const CACHED_KEY_MANIFEST_DIGEST: &'static str = "ostree-ext.cached.manifest-digest";
618    const CACHED_KEY_MANIFEST: &'static str = "ostree-ext.cached.manifest";
619    const CACHED_KEY_CONFIG: &'static str = "ostree-ext.cached.config";
620
621    /// Create a new importer.
622    #[context("Creating importer")]
623    pub async fn new(
624        repo: &ostree::Repo,
625        imgref: &OstreeImageReference,
626        mut config: ImageProxyConfig,
627    ) -> Result<Self> {
628        if imgref.imgref.transport == Transport::ContainerStorage {
629            // Fetching from containers-storage, may require privileges to read files
630            merge_default_container_proxy_opts_with_isolation(&mut config, None)?;
631        } else {
632            // Apply our defaults to the proxy config
633            merge_default_container_proxy_opts(&mut config)?;
634        }
635        let proxy = ImageProxy::new_with_config(config).await?;
636
637        system_repo_journal_print(
638            repo,
639            libsystemd::logging::Priority::Info,
640            &format!("Fetching {imgref}"),
641        );
642
643        let repo = repo.clone();
644        Ok(ImageImporter {
645            repo,
646            proxy,
647            target_imgref: None,
648            no_imgref: false,
649            ostree_v2024_3: ostree::check_version(2024, 3),
650            disable_gc: false,
651            require_bootable: false,
652            offline: false,
653            imgref: imgref.clone(),
654            layer_progress: None,
655            layer_byte_progress: None,
656        })
657    }
658
659    /// Write cached data as if the image came from this source.
660    pub fn set_target(&mut self, target: &OstreeImageReference) {
661        self.target_imgref = Some(target.clone())
662    }
663
664    /// Do not write the final image ref, but do write refs for shared layers.
665    /// This is useful in scenarios where you want to "pre-pull" an image,
666    /// but in such a way that it does not need to be manually removed later.
667    pub fn set_no_imgref(&mut self) {
668        self.no_imgref = true;
669    }
670
671    /// Do not attempt to contact the network
672    pub fn set_offline(&mut self) {
673        self.offline = true;
674    }
675
676    /// Require that the image has the bootable metadata field
677    pub fn require_bootable(&mut self) {
678        self.require_bootable = true;
679    }
680
681    /// Override the ostree version being targeted
682    pub fn set_ostree_version(&mut self, year: u32, v: u32) {
683        self.ostree_v2024_3 = (year > 2024) || (year == 2024 && v >= 3)
684    }
685
686    /// Do not prune image layers.
687    pub fn disable_gc(&mut self) {
688        self.disable_gc = true;
689    }
690
691    /// Determine if there is a new manifest, and if so return its digest.
692    /// This will also serialize the new manifest and configuration into
693    /// metadata associated with the image, so that invocations of `[query_cached]`
694    /// can re-fetch it without accessing the network.
695    #[context("Preparing import")]
696    pub async fn prepare(&mut self) -> Result<PrepareResult> {
697        self.prepare_internal(false).await
698    }
699
700    /// Create a channel receiver that will get notifications for layer fetches.
701    pub fn request_progress(&mut self) -> Receiver<ImportProgress> {
702        assert!(self.layer_progress.is_none());
703        let (s, r) = tokio::sync::mpsc::channel(2);
704        self.layer_progress = Some(s);
705        r
706    }
707
708    /// Create a channel receiver that will get notifications for byte-level progress of layer fetches.
709    pub fn request_layer_progress(
710        &mut self,
711    ) -> tokio::sync::watch::Receiver<Option<LayerProgress>> {
712        assert!(self.layer_byte_progress.is_none());
713        let (s, r) = tokio::sync::watch::channel(None);
714        self.layer_byte_progress = Some(s);
715        r
716    }
717
718    /// Serialize the metadata about a pending fetch as detached metadata on the commit object,
719    /// so it can be retrieved later offline
720    #[context("Writing cached pending manifest")]
721    pub(crate) async fn cache_pending(
722        &self,
723        commit: &str,
724        manifest_digest: &Digest,
725        manifest: &ImageManifest,
726        config: &ImageConfiguration,
727    ) -> Result<()> {
728        let commitmeta = glib::VariantDict::new(None);
729        commitmeta.insert(
730            Self::CACHED_KEY_MANIFEST_DIGEST,
731            manifest_digest.to_string(),
732        );
733        let cached_manifest = manifest
734            .to_canon_json_string()
735            .context("Serializing manifest")?;
736        commitmeta.insert(Self::CACHED_KEY_MANIFEST, cached_manifest);
737        let cached_config = config
738            .to_canon_json_string()
739            .context("Serializing config")?;
740        commitmeta.insert(Self::CACHED_KEY_CONFIG, cached_config);
741        let commitmeta = commitmeta.to_variant();
742        // Clone these to move into blocking method
743        let commit = commit.to_string();
744        let repo = self.repo.clone();
745        crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
746            repo.write_commit_detached_metadata(&commit, Some(&commitmeta), Some(cancellable))
747                .map_err(anyhow::Error::msg)
748        })
749        .await
750    }
751
752    /// Given existing metadata (manifest, config, previous image statE) generate a PreparedImport structure
753    /// which e.g. includes a diff of the layers.
754    fn create_prepared_import(
755        &mut self,
756        manifest_digest: Digest,
757        manifest: ImageManifest,
758        config: ImageConfiguration,
759        previous_state: Option<Box<LayeredImageState>>,
760        previous_imageid: Option<String>,
761        proxy_img: OpenedImage,
762    ) -> Result<Box<PreparedImport>> {
763        let config_labels = super::labels_of(&config);
764        if self.require_bootable {
765            let bootable_key = ostree::METADATA_KEY_BOOTABLE;
766            let bootable = config_labels.is_some_and(|l| {
767                l.contains_key(bootable_key.as_str()) || l.contains_key(BOOTC_LABEL)
768            });
769            if !bootable {
770                anyhow::bail!("Target image does not have {bootable_key} label");
771            }
772            let container_arch = config.architecture();
773            let target_arch = &Arch::default();
774            if container_arch != target_arch {
775                anyhow::bail!("Image has architecture {container_arch}; expected {target_arch}");
776            }
777        }
778
779        let (commit_layer, component_layers, remaining_layers) =
780            parse_manifest_layout(&manifest, &config)?;
781
782        let query = |l: &Descriptor| query_layer(&self.repo, l.clone());
783        let commit_layer = commit_layer.map(query).transpose()?;
784        let component_layers = component_layers
785            .into_iter()
786            .map(query)
787            .collect::<Result<Vec<_>>>()?;
788        let remaining_layers = remaining_layers
789            .into_iter()
790            .map(query)
791            .collect::<Result<Vec<_>>>()?;
792
793        let previous_manifest_digest = previous_state.as_ref().map(|s| s.manifest_digest.clone());
794        let imp = PreparedImport {
795            manifest_digest,
796            manifest,
797            config,
798            previous_state,
799            previous_manifest_digest,
800            previous_imageid,
801            ostree_layers: component_layers,
802            ostree_commit_layer: commit_layer,
803            layers: remaining_layers,
804            verify_text: None,
805            proxy_img,
806        };
807        Ok(Box::new(imp))
808    }
809
810    /// Determine if there is a new manifest, and if so return its digest.
811    #[context("Fetching manifest")]
812    pub(crate) async fn prepare_internal(&mut self, verify_layers: bool) -> Result<PrepareResult> {
813        match &self.imgref.sigverify {
814            SignatureSource::ContainerPolicy if skopeo::container_policy_is_default_insecure()? => {
815                return Err(anyhow!(
816                    "containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage"
817                ));
818            }
819            SignatureSource::OstreeRemote(_) if verify_layers => {
820                return Err(anyhow!(
821                    "Cannot currently verify layered containers via ostree remote"
822                ));
823            }
824            _ => {}
825        }
826
827        // Check if we have an image already pulled
828        let previous_state = try_query_image(&self.repo, &self.imgref.imgref)?;
829
830        // Parse the target reference to see if it's a digested pull
831        let target_reference = self.imgref.imgref.name.parse::<Reference>().ok();
832        let previous_state = if let Some(target_digest) = target_reference
833            .as_ref()
834            .and_then(|v| v.digest())
835            .map(Digest::from_str)
836            .transpose()?
837        {
838            if let Some(previous_state) = previous_state {
839                // A digested pull spec, and our existing state matches.
840                if previous_state.manifest_digest == target_digest {
841                    tracing::debug!("Digest-based pullspec {:?} already present", self.imgref);
842                    return Ok(PrepareResult::AlreadyPresent(previous_state));
843                }
844                Some(previous_state)
845            } else {
846                None
847            }
848        } else {
849            previous_state
850        };
851
852        if self.offline {
853            anyhow::bail!("Manifest fetch required in offline mode");
854        }
855
856        let proxy_img = self
857            .proxy
858            .open_image(&self.imgref.imgref.to_string())
859            .await?;
860
861        let (manifest_digest, manifest) = self.proxy.fetch_manifest(&proxy_img).await?;
862        let manifest_digest = Digest::from_str(&manifest_digest)?;
863        let new_imageid = manifest.config().digest();
864
865        // Query for previous stored state
866
867        let (previous_state, previous_imageid) = if let Some(previous_state) = previous_state {
868            // If the manifest digests match, we're done.
869            if previous_state.manifest_digest == manifest_digest {
870                return Ok(PrepareResult::AlreadyPresent(previous_state));
871            }
872            // Failing that, if they have the same imageID, we're also done.
873            let previous_imageid = previous_state.manifest.config().digest();
874            if previous_imageid == new_imageid {
875                return Ok(PrepareResult::AlreadyPresent(previous_state));
876            }
877            let previous_imageid = previous_imageid.to_string();
878            (Some(previous_state), Some(previous_imageid))
879        } else {
880            (None, None)
881        };
882
883        let config = self.proxy.fetch_config(&proxy_img).await?;
884
885        // If there is a currently fetched image, cache the new pending manifest+config
886        // as detached commit metadata, so that future fetches can query it offline.
887        if let Some(previous_state) = previous_state.as_ref() {
888            self.cache_pending(
889                previous_state.merge_commit.as_str(),
890                &manifest_digest,
891                &manifest,
892                &config,
893            )
894            .await?;
895        }
896
897        let imp = self.create_prepared_import(
898            manifest_digest,
899            manifest,
900            config,
901            previous_state,
902            previous_imageid,
903            proxy_img,
904        )?;
905        Ok(PrepareResult::Ready(imp))
906    }
907
908    /// Extract the base ostree commit.
909    #[context("Unencapsulating base")]
910    pub(crate) async fn unencapsulate_base(
911        &self,
912        import: &mut store::PreparedImport,
913        require_ostree: bool,
914        write_refs: bool,
915    ) -> Result<()> {
916        tracing::debug!("Fetching base");
917        if matches!(self.imgref.sigverify, SignatureSource::ContainerPolicy)
918            && skopeo::container_policy_is_default_insecure()?
919        {
920            return Err(anyhow!(
921                "containers-policy.json specifies a default of `insecureAcceptAnything`; refusing usage"
922            ));
923        }
924        let remote = match &self.imgref.sigverify {
925            SignatureSource::OstreeRemote(remote) => Some(remote.clone()),
926            SignatureSource::ContainerPolicy | SignatureSource::ContainerPolicyAllowInsecure => {
927                None
928            }
929        };
930        let Some(commit_layer) = import.ostree_commit_layer.as_mut() else {
931            if require_ostree {
932                anyhow::bail!(
933                    "No {DIFFID_LABEL} label found, not an ostree encapsulated container"
934                );
935            }
936            return Ok(());
937        };
938        let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?;
939        for layer in import.ostree_layers.iter_mut() {
940            if layer.commit.is_some() {
941                continue;
942            }
943            if let Some(p) = self.layer_progress.as_ref() {
944                p.send(ImportProgress::OstreeChunkStarted(layer.layer.clone()))
945                    .await?;
946            }
947            let (blob, driver, media_type) = fetch_layer(
948                &self.proxy,
949                &import.proxy_img,
950                &import.manifest,
951                &layer.layer,
952                self.layer_byte_progress.as_ref(),
953                des_layers.as_ref(),
954                self.imgref.imgref.transport,
955            )
956            .await?;
957            let repo = self.repo.clone();
958            let target_ref = layer.ostree_ref.clone();
959            let import_task =
960                crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
961                    let txn = repo.auto_transaction(Some(cancellable))?;
962                    let mut importer = crate::tar::Importer::new_for_object_set(&repo);
963                    let blob = tokio_util::io::SyncIoBridge::new(blob);
964                    let mut blob = Decompressor::new(&media_type, blob)?;
965                    let mut archive = tar::Archive::new(&mut blob);
966                    importer.import_objects(&mut archive, Some(cancellable))?;
967                    let commit = if write_refs {
968                        let commit = importer.finish_import_object_set()?;
969                        repo.transaction_set_ref(None, &target_ref, Some(commit.as_str()));
970                        tracing::debug!("Wrote {} => {}", target_ref, commit);
971                        Some(commit)
972                    } else {
973                        None
974                    };
975                    txn.commit(Some(cancellable))?;
976                    blob.finish()?;
977                    Ok::<_, anyhow::Error>(commit)
978                })
979                .map_err(|e| e.context(format!("Layer {}", layer.layer.digest())));
980            let commit = super::unencapsulate::join_fetch(import_task, driver).await?;
981            layer.commit = commit;
982            if let Some(p) = self.layer_progress.as_ref() {
983                p.send(ImportProgress::OstreeChunkCompleted(layer.layer.clone()))
984                    .await?;
985            }
986        }
987        if commit_layer.commit.is_none() {
988            if let Some(p) = self.layer_progress.as_ref() {
989                p.send(ImportProgress::OstreeChunkStarted(
990                    commit_layer.layer.clone(),
991                ))
992                .await?;
993            }
994            let (blob, driver, media_type) = fetch_layer(
995                &self.proxy,
996                &import.proxy_img,
997                &import.manifest,
998                &commit_layer.layer,
999                self.layer_byte_progress.as_ref(),
1000                des_layers.as_ref(),
1001                self.imgref.imgref.transport,
1002            )
1003            .await?;
1004            let repo = self.repo.clone();
1005            let target_ref = commit_layer.ostree_ref.clone();
1006            let import_task =
1007                crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
1008                    let txn = repo.auto_transaction(Some(cancellable))?;
1009                    let mut importer = crate::tar::Importer::new_for_commit(&repo, remote);
1010                    let blob = tokio_util::io::SyncIoBridge::new(blob);
1011                    let mut blob = Decompressor::new(&media_type, blob)?;
1012                    let mut archive = tar::Archive::new(&mut blob);
1013                    importer.import_commit(&mut archive, Some(cancellable))?;
1014                    let (commit, verify_text) = importer.finish_import_commit();
1015                    if write_refs {
1016                        repo.transaction_set_ref(None, &target_ref, Some(commit.as_str()));
1017                        tracing::debug!("Wrote {} => {}", target_ref, commit);
1018                    }
1019                    repo.mark_commit_partial(&commit, false)?;
1020                    txn.commit(Some(cancellable))?;
1021                    blob.finish()?;
1022                    Ok::<_, anyhow::Error>((commit, verify_text))
1023                });
1024            let (commit, verify_text) =
1025                super::unencapsulate::join_fetch(import_task, driver).await?;
1026            commit_layer.commit = Some(commit);
1027            import.verify_text = verify_text;
1028            if let Some(p) = self.layer_progress.as_ref() {
1029                p.send(ImportProgress::OstreeChunkCompleted(
1030                    commit_layer.layer.clone(),
1031                ))
1032                .await?;
1033            }
1034        };
1035        Ok(())
1036    }
1037
1038    /// Retrieve an inner ostree commit.
1039    ///
1040    /// This does not write cached references for each blob, and errors out if
1041    /// the image has any non-ostree layers.
1042    pub async fn unencapsulate(mut self) -> Result<Import> {
1043        let mut prep = match self.prepare_internal(false).await? {
1044            PrepareResult::AlreadyPresent(_) => {
1045                panic!("Should not have image present for unencapsulation")
1046            }
1047            PrepareResult::Ready(r) => r,
1048        };
1049        if !prep.layers.is_empty() {
1050            anyhow::bail!("Image has {} non-ostree layers", prep.layers.len());
1051        }
1052        let deprecated_warning = prep.deprecated_warning().map(ToOwned::to_owned);
1053        self.unencapsulate_base(&mut prep, true, false).await?;
1054        // TODO change the imageproxy API to ensure this happens automatically when
1055        // the image reference is dropped
1056        self.proxy.close_image(&prep.proxy_img).await?;
1057        // SAFETY: We know we have a commit
1058        let ostree_commit = prep.ostree_commit_layer.unwrap().commit.unwrap();
1059        let image_digest = prep.manifest_digest;
1060        Ok(Import {
1061            ostree_commit,
1062            image_digest,
1063            deprecated_warning,
1064        })
1065    }
1066
1067    /// Generate a single ostree commit that combines all layers, and also
1068    /// includes container image metadata such as the manifest and config.
1069    fn write_merge_commit_impl(
1070        repo: &ostree::Repo,
1071        base_commit: Option<&str>,
1072        layer_commits: &[String],
1073        have_derived_layers: bool,
1074        metadata: glib::Variant,
1075        timestamp: u64,
1076        ostree_ref: &str,
1077        no_imgref: bool,
1078        disable_gc: bool,
1079        cancellable: Option<&gio::Cancellable>,
1080    ) -> Result<Box<LayeredImageState>> {
1081        use rustix::fd::AsRawFd;
1082
1083        let txn = repo.auto_transaction(cancellable)?;
1084
1085        let devino = ostree::RepoDevInoCache::new();
1086        let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
1087        let repo_tmp = repodir.open_dir("tmp")?;
1088        let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?;
1089
1090        let rootpath = "root";
1091        let checkout_mode = if repo.mode() == ostree::RepoMode::Bare {
1092            ostree::RepoCheckoutMode::None
1093        } else {
1094            ostree::RepoCheckoutMode::User
1095        };
1096        let mut checkout_opts = ostree::RepoCheckoutAtOptions {
1097            mode: checkout_mode,
1098            overwrite_mode: ostree::RepoCheckoutOverwriteMode::UnionFiles,
1099            devino_to_csum_cache: Some(devino.clone()),
1100            no_copy_fallback: true,
1101            force_copy_zerosized: true,
1102            process_whiteouts: false,
1103            ..Default::default()
1104        };
1105        if let Some(base) = base_commit.as_ref() {
1106            repo.checkout_at(
1107                Some(&checkout_opts),
1108                (*td).as_raw_fd(),
1109                rootpath,
1110                &base,
1111                cancellable,
1112            )
1113            .context("Checking out base commit")?;
1114        }
1115
1116        // Layer all subsequent commits
1117        checkout_opts.process_whiteouts = true;
1118        for commit in layer_commits {
1119            tracing::debug!("Unpacking {commit}");
1120            repo.checkout_at(
1121                Some(&checkout_opts),
1122                (*td).as_raw_fd(),
1123                rootpath,
1124                &commit,
1125                cancellable,
1126            )
1127            .with_context(|| format!("Checking out layer {commit}"))?;
1128        }
1129
1130        let root_dir = td.open_dir(rootpath)?;
1131
1132        let modifier =
1133            ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::empty(), None);
1134        modifier.set_devino_cache(&devino);
1135        // If we have derived layers, then we need to handle the case where
1136        // the derived layers include custom policy. Just relabel everything
1137        // in this case.
1138        if have_derived_layers {
1139            let sepolicy = ostree::SePolicy::new_at(root_dir.as_raw_fd(), cancellable)?;
1140            tracing::debug!("labeling from merged tree");
1141            modifier.set_sepolicy(Some(&sepolicy));
1142        } else if let Some(base) = base_commit.as_ref() {
1143            tracing::debug!("labeling from base tree");
1144            // TODO: We can likely drop this; we know all labels should be pre-computed.
1145            modifier.set_sepolicy_from_commit(repo, &base, cancellable)?;
1146        } else {
1147            panic!("Unexpected state: no derived layers and no base")
1148        }
1149
1150        cleanup_root(&root_dir)?;
1151
1152        let mt = ostree::MutableTree::new();
1153        repo.write_dfd_to_mtree(
1154            (*td).as_raw_fd(),
1155            rootpath,
1156            &mt,
1157            Some(&modifier),
1158            cancellable,
1159        )
1160        .context("Writing merged filesystem to mtree")?;
1161
1162        let merged_root = repo
1163            .write_mtree(&mt, cancellable)
1164            .context("Writing mtree")?;
1165        let merged_root = merged_root.downcast::<ostree::RepoFile>().unwrap();
1166        // The merge has the base commit as a parent, if it exists. See
1167        // https://github.com/ostreedev/ostree/pull/3523
1168        let parent = base_commit.as_deref();
1169        let merged_commit = repo
1170            .write_commit_with_time(
1171                parent,
1172                None,
1173                None,
1174                Some(&metadata),
1175                &merged_root,
1176                timestamp,
1177                cancellable,
1178            )
1179            .context("Writing commit")?;
1180        if !no_imgref {
1181            repo.transaction_set_ref(None, ostree_ref, Some(merged_commit.as_str()));
1182        }
1183        txn.commit(cancellable)?;
1184
1185        if !disable_gc {
1186            let n: u32 = gc_image_layers_impl(repo, cancellable)?;
1187            tracing::debug!("pruned {n} layers");
1188        }
1189
1190        // Here we re-query state just to run through the same code path,
1191        // though it'd be cheaper to synthesize it from the data we already have.
1192        let state = query_image_commit(repo, &merged_commit)?;
1193        Ok(state)
1194    }
1195
1196    /// Import a layered container image.
1197    ///
1198    /// If enabled, this will also prune unused container image layers.
1199    #[context("Importing")]
1200    pub async fn import(
1201        mut self,
1202        mut import: Box<PreparedImport>,
1203    ) -> Result<Box<LayeredImageState>> {
1204        if let Some(status) = import.format_layer_status() {
1205            system_repo_journal_print(&self.repo, libsystemd::logging::Priority::Info, &status);
1206        }
1207        // First download all layers for the base image (if necessary) - we need the SELinux policy
1208        // there to label all following layers.
1209        self.unencapsulate_base(&mut import, false, true).await?;
1210        let des_layers = self.proxy.get_layer_info(&import.proxy_img).await?;
1211        let proxy = self.proxy;
1212        let target_imgref = self.target_imgref.as_ref().unwrap_or(&self.imgref);
1213        let base_commit = import
1214            .ostree_commit_layer
1215            .as_ref()
1216            .map(|c| c.commit.clone().unwrap());
1217
1218        let root_is_transient = if let Some(base) = base_commit.as_ref() {
1219            let rootf = self.repo.read_commit(&base, gio::Cancellable::NONE)?.0;
1220            let rootf = rootf.downcast_ref::<ostree::RepoFile>().unwrap();
1221            crate::ostree_prepareroot::overlayfs_root_enabled(rootf)?
1222        } else {
1223            // For generic images we assume they're using composefs
1224            true
1225        };
1226        tracing::debug!("Base rootfs is transient: {root_is_transient}");
1227
1228        let ostree_ref = ref_for_image(&target_imgref.imgref)?;
1229
1230        let mut layer_commits = Vec::new();
1231        let mut layer_filtered_content: Option<MetaFilteredData> = None;
1232        let have_derived_layers = !import.layers.is_empty();
1233        tracing::debug!("Processing layers: {}", import.layers.len());
1234        for layer in import.layers {
1235            if let Some(c) = layer.commit {
1236                tracing::debug!("Reusing fetched commit {}", c);
1237                layer_commits.push(c.to_string());
1238            } else {
1239                if let Some(p) = self.layer_progress.as_ref() {
1240                    p.send(ImportProgress::DerivedLayerStarted(layer.layer.clone()))
1241                        .await?;
1242                }
1243                let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
1244                    &proxy,
1245                    &import.proxy_img,
1246                    &import.manifest,
1247                    &layer.layer,
1248                    self.layer_byte_progress.as_ref(),
1249                    des_layers.as_ref(),
1250                    self.imgref.imgref.transport,
1251                )
1252                .await?;
1253                // An important aspect of this is that we SELinux label the derived layers using
1254                // the base policy.
1255                let opts = crate::tar::WriteTarOptions {
1256                    base: base_commit.clone(),
1257                    selinux: true,
1258                    allow_nonusr: root_is_transient,
1259                    retain_var: self.ostree_v2024_3,
1260                };
1261                let r = crate::tar::write_tar(
1262                    &self.repo,
1263                    blob,
1264                    media_type,
1265                    layer.ostree_ref.as_str(),
1266                    Some(opts),
1267                );
1268                let r = super::unencapsulate::join_fetch(r, driver)
1269                    .await
1270                    .with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?;
1271                tracing::debug!("Imported layer: {}", r.commit.as_str());
1272                layer_commits.push(r.commit);
1273                let filtered_owned = HashMap::from_iter(r.filtered.clone());
1274                if let Some((filtered, n_rest)) = bootc_utils::collect_until(
1275                    r.filtered.iter(),
1276                    const { NonZeroUsize::new(5).unwrap() },
1277                ) {
1278                    let mut msg = String::new();
1279                    for (path, n) in filtered {
1280                        write!(msg, "{path}: {n} ").unwrap();
1281                    }
1282                    if n_rest > 0 {
1283                        write!(msg, "...and {n_rest} more").unwrap();
1284                    }
1285                    tracing::debug!("Found filtered toplevels: {msg}");
1286                    layer_filtered_content
1287                        .get_or_insert_default()
1288                        .insert(layer.layer.digest().to_string(), filtered_owned);
1289                } else {
1290                    tracing::debug!("No filtered content");
1291                }
1292                if let Some(p) = self.layer_progress.as_ref() {
1293                    p.send(ImportProgress::DerivedLayerCompleted(layer.layer.clone()))
1294                        .await?;
1295                }
1296            }
1297        }
1298
1299        // TODO change the imageproxy API to ensure this happens automatically when
1300        // the image reference is dropped
1301        proxy.close_image(&import.proxy_img).await?;
1302
1303        // We're done with the proxy, make sure it didn't have any errors.
1304        proxy.finalize().await?;
1305        tracing::debug!("finalized proxy");
1306
1307        // Disconnect progress notifiers to signal we're done with fetching.
1308        let _ = self.layer_byte_progress.take();
1309        let _ = self.layer_progress.take();
1310
1311        let mut metadata = BTreeMap::new();
1312        metadata.insert(
1313            META_MANIFEST_DIGEST,
1314            import.manifest_digest.to_string().to_variant(),
1315        );
1316        metadata.insert(
1317            META_MANIFEST,
1318            import.manifest.to_canon_json_string()?.to_variant(),
1319        );
1320        metadata.insert(
1321            META_CONFIG,
1322            import.config.to_canon_json_string()?.to_variant(),
1323        );
1324        metadata.insert(
1325            "ostree.importer.version",
1326            env!("CARGO_PKG_VERSION").to_variant(),
1327        );
1328        let metadata = metadata.to_variant();
1329
1330        let timestamp = timestamp_of_manifest_or_config(&import.manifest, &import.config)
1331            .unwrap_or_else(|| chrono::offset::Utc::now().timestamp() as u64);
1332        // Destructure to transfer ownership to thread
1333        let repo = self.repo;
1334        let mut state = crate::tokio_util::spawn_blocking_cancellable_flatten(
1335            move |cancellable| -> Result<Box<LayeredImageState>> {
1336                Self::write_merge_commit_impl(
1337                    &repo,
1338                    base_commit.as_deref(),
1339                    &layer_commits,
1340                    have_derived_layers,
1341                    metadata,
1342                    timestamp,
1343                    &ostree_ref,
1344                    self.no_imgref,
1345                    self.disable_gc,
1346                    Some(cancellable),
1347                )
1348            },
1349        )
1350        .await?;
1351        // We can at least avoid re-verifying the base commit.
1352        state.verify_text = import.verify_text;
1353        state.filtered_files = layer_filtered_content;
1354        Ok(state)
1355    }
1356}
1357
1358/// List all images stored
1359pub fn list_images(repo: &ostree::Repo) -> Result<Vec<String>> {
1360    let cancellable = gio::Cancellable::NONE;
1361    let refs = repo.list_refs_ext(
1362        Some(IMAGE_PREFIX),
1363        ostree::RepoListRefsExtFlags::empty(),
1364        cancellable,
1365    )?;
1366    refs.keys()
1367        .map(|imgname| refescape::unprefix_unescape_ref(IMAGE_PREFIX, imgname))
1368        .collect()
1369}
1370
1371/// Attempt to query metadata for a pulled image; if it is corrupted,
1372/// the error is printed to stderr and None is returned.
1373fn try_query_image(
1374    repo: &ostree::Repo,
1375    imgref: &ImageReference,
1376) -> Result<Option<Box<LayeredImageState>>> {
1377    let ostree_ref = &ref_for_image(imgref)?;
1378    if let Some(merge_rev) = repo.resolve_rev(ostree_ref, true)? {
1379        match query_image_commit(repo, merge_rev.as_str()) {
1380            Ok(r) => Ok(Some(r)),
1381            Err(e) => {
1382                eprintln!("error: failed to query image commit: {e}");
1383                Ok(None)
1384            }
1385        }
1386    } else {
1387        Ok(None)
1388    }
1389}
1390
1391/// Query metadata for a pulled image.
1392#[context("Querying image {imgref}")]
1393pub fn query_image(
1394    repo: &ostree::Repo,
1395    imgref: &ImageReference,
1396) -> Result<Option<Box<LayeredImageState>>> {
1397    let ostree_ref = &ref_for_image(imgref)?;
1398    let merge_rev = repo.resolve_rev(ostree_ref, true)?;
1399    merge_rev
1400        .map(|r| query_image_commit(repo, r.as_str()))
1401        .transpose()
1402}
1403
1404/// Given detached commit metadata, parse the data that we serialized for a pending update (if any).
1405fn parse_cached_update(meta: &glib::VariantDict) -> Result<Option<CachedImageUpdate>> {
1406    // Try to retrieve the manifest digest key from the commit detached metadata.
1407    let manifest_digest =
1408        if let Some(d) = meta.lookup::<String>(ImageImporter::CACHED_KEY_MANIFEST_DIGEST)? {
1409            d
1410        } else {
1411            // It's possible that something *else* wrote detached metadata, but without
1412            // our key; gracefully handle that.
1413            return Ok(None);
1414        };
1415    let manifest_digest = Digest::from_str(&manifest_digest)?;
1416    // If we found the cached manifest digest key, then we must have the manifest and config;
1417    // otherwise that's an error.
1418    let manifest = meta.lookup_value(ImageImporter::CACHED_KEY_MANIFEST, None);
1419    let manifest: oci_image::ImageManifest = manifest
1420        .as_ref()
1421        .and_then(|v| v.str())
1422        .map(serde_json::from_str)
1423        .transpose()?
1424        .ok_or_else(|| {
1425            anyhow!(
1426                "Expected cached manifest {}",
1427                ImageImporter::CACHED_KEY_MANIFEST
1428            )
1429        })?;
1430    let config = meta.lookup_value(ImageImporter::CACHED_KEY_CONFIG, None);
1431    let config: oci_image::ImageConfiguration = config
1432        .as_ref()
1433        .and_then(|v| v.str())
1434        .map(serde_json::from_str)
1435        .transpose()?
1436        .ok_or_else(|| {
1437            anyhow!(
1438                "Expected cached manifest {}",
1439                ImageImporter::CACHED_KEY_CONFIG
1440            )
1441        })?;
1442    Ok(Some(CachedImageUpdate {
1443        manifest,
1444        config,
1445        manifest_digest,
1446    }))
1447}
1448
1449/// Remove any cached
1450#[context("Clearing cached update {imgref}")]
1451pub fn clear_cached_update(repo: &ostree::Repo, imgref: &ImageReference) -> Result<()> {
1452    let cancellable = gio::Cancellable::NONE;
1453    let ostree_ref = ref_for_image(imgref)?;
1454    let rev = repo.require_rev(&ostree_ref)?;
1455    let Some(commitmeta) = repo.read_commit_detached_metadata(&rev, cancellable)? else {
1456        return Ok(());
1457    };
1458
1459    // SAFETY: We know this is an a{sv}
1460    let mut commitmeta: BTreeMap<String, glib::Variant> =
1461        BTreeMap::from_variant(&commitmeta).unwrap();
1462    let mut changed = false;
1463    for key in [
1464        ImageImporter::CACHED_KEY_CONFIG,
1465        ImageImporter::CACHED_KEY_MANIFEST,
1466        ImageImporter::CACHED_KEY_MANIFEST_DIGEST,
1467    ] {
1468        if commitmeta.remove(key).is_some() {
1469            changed = true;
1470        }
1471    }
1472    if !changed {
1473        return Ok(());
1474    }
1475    let commitmeta = glib::Variant::from(commitmeta);
1476    repo.write_commit_detached_metadata(&rev, Some(&commitmeta), cancellable)?;
1477    Ok(())
1478}
1479
1480/// Query metadata for a pulled image via an OSTree commit digest.
1481/// The digest must refer to a pulled container image's merge commit.
1482pub fn query_image_commit(repo: &ostree::Repo, commit: &str) -> Result<Box<LayeredImageState>> {
1483    let merge_commit = commit.to_string();
1484    let merge_commit_obj = repo.load_commit(commit)?.0;
1485    let commit_meta = &merge_commit_obj.child_value(0);
1486    let commit_meta = &ostree::glib::VariantDict::new(Some(commit_meta));
1487    let (manifest, manifest_digest) = manifest_data_from_commitmeta(commit_meta)?;
1488    let configuration = image_config_from_commitmeta(commit_meta)?;
1489    let mut layers = manifest.layers().iter().cloned();
1490    // We require a base layer.
1491    let base_layer = layers.next().ok_or_else(|| anyhow!("No layers found"))?;
1492    let base_layer = query_layer(repo, base_layer)?;
1493    let ostree_ref = base_layer.ostree_ref.as_str();
1494    let base_commit = base_layer
1495        .commit
1496        .ok_or_else(|| anyhow!("Missing base image ref {ostree_ref}"))?;
1497
1498    let detached_commitmeta =
1499        repo.read_commit_detached_metadata(&merge_commit, gio::Cancellable::NONE)?;
1500    let detached_commitmeta = detached_commitmeta
1501        .as_ref()
1502        .map(|v| glib::VariantDict::new(Some(v)));
1503    let cached_update = detached_commitmeta
1504        .as_ref()
1505        .map(parse_cached_update)
1506        .transpose()?
1507        .flatten();
1508    let state = Box::new(LayeredImageState {
1509        base_commit,
1510        merge_commit,
1511        manifest_digest,
1512        manifest,
1513        configuration,
1514        cached_update,
1515        // we can't cross-reference with a remote here
1516        verify_text: None,
1517        filtered_files: None,
1518    });
1519    tracing::debug!("Wrote merge commit {}", state.merge_commit);
1520    Ok(state)
1521}
1522
1523fn manifest_for_image(repo: &ostree::Repo, imgref: &ImageReference) -> Result<ImageManifest> {
1524    let ostree_ref = ref_for_image(imgref)?;
1525    let rev = repo.require_rev(&ostree_ref)?;
1526    let (commit_obj, _) = repo.load_commit(rev.as_str())?;
1527    let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0)));
1528    Ok(manifest_data_from_commitmeta(commit_meta)?.0)
1529}
1530
1531/// Copy a downloaded image from one repository to another, while also
1532/// optionally changing the image reference type.
1533#[context("Copying image")]
1534pub async fn copy(
1535    src_repo: &ostree::Repo,
1536    src_imgref: &ImageReference,
1537    dest_repo: &ostree::Repo,
1538    dest_imgref: &ImageReference,
1539) -> Result<()> {
1540    let src_ostree_ref = ref_for_image(src_imgref)?;
1541    let src_commit = src_repo.require_rev(&src_ostree_ref)?;
1542    let manifest = manifest_for_image(src_repo, src_imgref)?;
1543    // Create a task to copy each layer, plus the final ref
1544    let layer_refs = manifest
1545        .layers()
1546        .iter()
1547        .map(ref_for_layer)
1548        .chain(std::iter::once(Ok(src_commit.to_string())));
1549    for ostree_ref in layer_refs {
1550        let ostree_ref = ostree_ref?;
1551        let src_repo = src_repo.clone();
1552        let dest_repo = dest_repo.clone();
1553        crate::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> {
1554            let cancellable = Some(cancellable);
1555            let srcfd = &format!("file:///proc/self/fd/{}", src_repo.dfd());
1556            let flags = ostree::RepoPullFlags::MIRROR;
1557            let opts = glib::VariantDict::new(None);
1558            let refs = [ostree_ref.as_str()];
1559            // Some older archives may have bindings, we don't need to verify them.
1560            opts.insert("disable-verify-bindings", true);
1561            opts.insert("refs", &refs[..]);
1562            opts.insert("flags", flags.bits() as i32);
1563            let options = opts.to_variant();
1564            dest_repo.pull_with_options(srcfd, &options, None, cancellable)?;
1565            Ok(())
1566        })
1567        .await?;
1568    }
1569
1570    let dest_ostree_ref = ref_for_image(dest_imgref)?;
1571    dest_repo.set_ref_immediate(
1572        None,
1573        &dest_ostree_ref,
1574        Some(&src_commit),
1575        gio::Cancellable::NONE,
1576    )?;
1577
1578    Ok(())
1579}
1580
1581/// Options controlling commit export into OCI
1582#[derive(Clone, Debug, Default)]
1583#[non_exhaustive]
1584pub struct ExportToOCIOpts {
1585    /// If true, do not perform gzip compression of the tar layers.
1586    pub skip_compression: bool,
1587    /// Path to Docker-formatted authentication file.
1588    pub authfile: Option<std::path::PathBuf>,
1589    /// Output progress to stdout
1590    pub progress_to_stdout: bool,
1591}
1592
1593/// The way we store "chunk" layers in ostree is by writing a commit
1594/// whose filenames are their own object identifier. This function parses
1595/// what is written by the `ImporterMode::ObjectSet` logic, turning
1596/// it back into a "chunked" structure that is used by the export code.
1597fn chunking_from_layer_committed(
1598    repo: &ostree::Repo,
1599    l: &Descriptor,
1600    chunking: &mut chunking::Chunking,
1601) -> Result<()> {
1602    let mut chunk = Chunk::default();
1603    let layer_ref = &ref_for_layer(l)?;
1604    let root = repo.read_commit(layer_ref, gio::Cancellable::NONE)?.0;
1605    let e = root.enumerate_children(
1606        "standard::name,standard::size",
1607        gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS,
1608        gio::Cancellable::NONE,
1609    )?;
1610    for child in e.clone() {
1611        let child = &child?;
1612        // The name here should be a valid checksum
1613        let name = child.name();
1614        // SAFETY: ostree doesn't give us non-UTF8 filenames
1615        let name = Utf8Path::from_path(&name).unwrap();
1616        ostree::validate_checksum_string(name.as_str())?;
1617        chunking.remainder.move_obj(&mut chunk, name.as_str());
1618    }
1619    chunking.chunks.push(chunk);
1620    Ok(())
1621}
1622
1623/// Export an imported container image to a target OCI directory.
1624#[context("Copying image")]
1625pub(crate) fn export_to_oci(
1626    repo: &ostree::Repo,
1627    imgref: &ImageReference,
1628    dest_oci: &Dir,
1629    tag: Option<&str>,
1630    opts: ExportToOCIOpts,
1631) -> Result<Descriptor> {
1632    let srcinfo = query_image(repo, imgref)?.ok_or_else(|| anyhow!("No such image"))?;
1633    let (commit_layer, component_layers, remaining_layers) =
1634        parse_manifest_layout(&srcinfo.manifest, &srcinfo.configuration)?;
1635
1636    // Unfortunately today we can't guarantee we reserialize the same tar stream
1637    // or compression, so we'll need to generate a new copy of the manifest and config
1638    // with the layers reset.
1639    let mut new_manifest = srcinfo.manifest.clone();
1640    new_manifest.layers_mut().clear();
1641    let mut new_config = srcinfo.configuration.clone();
1642    if let Some(history) = new_config.history_mut() {
1643        history.clear();
1644    }
1645    new_config.rootfs_mut().diff_ids_mut().clear();
1646
1647    let opts = ExportOpts {
1648        skip_compression: opts.skip_compression,
1649        authfile: opts.authfile,
1650        ..Default::default()
1651    };
1652
1653    let mut labels = HashMap::new();
1654
1655    let mut dest_oci = ocidir::OciDir::ensure(dest_oci.try_clone()?)?;
1656
1657    let commit_chunk_ref = commit_layer
1658        .as_ref()
1659        .map(|l| ref_for_layer(l))
1660        .transpose()?;
1661    let commit_chunk_rev = commit_chunk_ref
1662        .as_ref()
1663        .map(|r| repo.require_rev(&r))
1664        .transpose()?;
1665    if let Some(commit_chunk_rev) = commit_chunk_rev {
1666        let mut chunking = chunking::Chunking::new(repo, &commit_chunk_rev)?;
1667        for layer in component_layers {
1668            chunking_from_layer_committed(repo, layer, &mut chunking)?;
1669        }
1670
1671        // Given the object chunking information we recomputed from what
1672        // we found on disk, re-serialize to layers (tarballs).
1673        export_chunked(
1674            repo,
1675            &srcinfo.base_commit,
1676            &mut dest_oci,
1677            &mut new_manifest,
1678            &mut new_config,
1679            &mut labels,
1680            chunking,
1681            &opts,
1682            "",
1683        )?;
1684    }
1685
1686    // Now, handle the non-ostree layers.
1687    let compression = opts.skip_compression.then_some(Compression::none());
1688    for (i, layer) in remaining_layers.iter().enumerate() {
1689        let layer_ref = &ref_for_layer(layer)?;
1690        let mut target_blob = dest_oci.create_gzip_layer(compression)?;
1691        // We accepted these images as raw (non-ostree) so export them the same way
1692        let export_opts = crate::tar::ExportOptions { raw: true };
1693        crate::tar::export_commit(
1694            repo,
1695            layer_ref.as_str(),
1696            &mut target_blob,
1697            Some(export_opts),
1698        )?;
1699        let layer = target_blob.complete()?;
1700        let previous_annotations = srcinfo
1701            .manifest
1702            .layers()
1703            .get(i)
1704            .and_then(|l| l.annotations().as_ref())
1705            .cloned();
1706        let history = srcinfo.configuration.history().as_ref();
1707        let history_entry = history.and_then(|v| v.get(i));
1708        let previous_description = history_entry
1709            .clone()
1710            .and_then(|h| h.comment().as_deref())
1711            .unwrap_or_default();
1712
1713        let previous_created = history_entry
1714            .and_then(|h| h.created().as_deref())
1715            .and_then(bootc_utils::try_deserialize_timestamp)
1716            .unwrap_or_default();
1717
1718        dest_oci.push_layer_full(
1719            &mut new_manifest,
1720            &mut new_config,
1721            layer,
1722            previous_annotations,
1723            previous_description,
1724            previous_created,
1725        )
1726    }
1727
1728    let new_config = dest_oci.write_config(new_config)?;
1729    new_manifest.set_config(new_config);
1730
1731    Ok(dest_oci.insert_manifest(new_manifest, tag, oci_image::Platform::default())?)
1732}
1733
1734/// Given a container image reference which is stored in `repo`, export it to the
1735/// target image location.
1736#[context("Export")]
1737pub async fn export(
1738    repo: &ostree::Repo,
1739    src_imgref: &ImageReference,
1740    dest_imgref: &ImageReference,
1741    opts: Option<ExportToOCIOpts>,
1742) -> Result<oci_image::Digest> {
1743    let opts = opts.unwrap_or_default();
1744    let target_oci = dest_imgref.transport == Transport::OciDir;
1745    let tempdir = if !target_oci {
1746        let vartmp = cap_std::fs::Dir::open_ambient_dir("/var/tmp", cap_std::ambient_authority())?;
1747        let td = cap_std_ext::cap_tempfile::TempDir::new_in(&vartmp)?;
1748        // Always skip compression when making a temporary copy
1749        let opts = ExportToOCIOpts {
1750            skip_compression: true,
1751            progress_to_stdout: opts.progress_to_stdout,
1752            ..Default::default()
1753        };
1754        export_to_oci(repo, src_imgref, &td, None, opts)?;
1755        td
1756    } else {
1757        let (path, tag) = parse_oci_path_and_tag(dest_imgref.name.as_str());
1758        tracing::debug!("using OCI path={path} tag={tag:?}");
1759        let path = Dir::open_ambient_dir(path, cap_std::ambient_authority())
1760            .with_context(|| format!("Opening {path}"))?;
1761        let descriptor = export_to_oci(repo, src_imgref, &path, tag, opts)?;
1762        return Ok(descriptor.digest().clone());
1763    };
1764    // Pass the temporary oci directory as the current working directory for the skopeo process
1765    let target_fd = 3i32;
1766    let tempoci = ImageReference {
1767        transport: Transport::OciDir,
1768        name: format!("/proc/self/fd/{target_fd}"),
1769    };
1770    let authfile = opts.authfile.as_deref();
1771    skopeo::copy(
1772        &tempoci,
1773        dest_imgref,
1774        authfile,
1775        Some((std::sync::Arc::new(tempdir.try_clone()?.into()), target_fd)),
1776        opts.progress_to_stdout,
1777    )
1778    .await
1779}
1780
1781/// Iterate over deployment commits, returning the manifests from
1782/// commits which point to a container image.
1783#[context("Listing deployment manifests")]
1784fn list_container_deployment_manifests(
1785    repo: &ostree::Repo,
1786    cancellable: Option<&gio::Cancellable>,
1787) -> Result<Vec<ImageManifest>> {
1788    // Gather all refs which start with ostree/0/ or ostree/1/ or rpmostree/base/
1789    // and create a set of the commits which they reference.
1790    let commits = OSTREE_BASE_DEPLOYMENT_REFS
1791        .iter()
1792        .chain(RPMOSTREE_BASE_REFS)
1793        .chain(std::iter::once(&BASE_IMAGE_PREFIX))
1794        .try_fold(
1795            std::collections::HashSet::new(),
1796            |mut acc, &p| -> Result<_> {
1797                let refs = repo.list_refs_ext(
1798                    Some(p),
1799                    ostree::RepoListRefsExtFlags::empty(),
1800                    cancellable,
1801                )?;
1802                for (_, v) in refs {
1803                    acc.insert(v);
1804                }
1805                Ok(acc)
1806            },
1807        )?;
1808    // Loop over the commits - if they refer to a container image, add that to our return value.
1809    let mut r = Vec::new();
1810    for commit in commits {
1811        let commit_obj = repo.load_commit(&commit)?.0;
1812        let commit_meta = &glib::VariantDict::new(Some(&commit_obj.child_value(0)));
1813        if commit_meta
1814            .lookup::<String>(META_MANIFEST_DIGEST)?
1815            .is_some()
1816        {
1817            tracing::trace!("Commit {commit} is a container image");
1818            let manifest = manifest_data_from_commitmeta(commit_meta)?.0;
1819            r.push(manifest);
1820        }
1821    }
1822    Ok(r)
1823}
1824
1825/// Garbage collect unused image layer references.
1826///
1827/// This function assumes no transaction is active on the repository.
1828/// The underlying objects are *not* pruned; that requires a separate invocation
1829/// of [`ostree::Repo::prune`].
1830pub fn gc_image_layers(repo: &ostree::Repo) -> Result<u32> {
1831    gc_image_layers_impl(repo, gio::Cancellable::NONE)
1832}
1833
1834#[context("Pruning image layers")]
1835fn gc_image_layers_impl(
1836    repo: &ostree::Repo,
1837    cancellable: Option<&gio::Cancellable>,
1838) -> Result<u32> {
1839    let all_images = list_images(repo)?;
1840    let deployment_commits = list_container_deployment_manifests(repo, cancellable)?;
1841    let all_manifests = all_images
1842        .into_iter()
1843        .map(|img| {
1844            ImageReference::try_from(img.as_str()).and_then(|ir| manifest_for_image(repo, &ir))
1845        })
1846        .chain(deployment_commits.into_iter().map(Ok))
1847        .collect::<Result<Vec<_>>>()?;
1848    tracing::debug!("Images found: {}", all_manifests.len());
1849    let mut referenced_layers = BTreeSet::new();
1850    for m in all_manifests.iter() {
1851        for layer in m.layers() {
1852            referenced_layers.insert(layer.digest().to_string());
1853        }
1854    }
1855    tracing::debug!("Referenced layers: {}", referenced_layers.len());
1856    let found_layers = repo
1857        .list_refs_ext(
1858            Some(LAYER_PREFIX),
1859            ostree::RepoListRefsExtFlags::empty(),
1860            cancellable,
1861        )?
1862        .into_iter()
1863        .map(|v| v.0);
1864    tracing::debug!("Found layers: {}", found_layers.len());
1865    let mut pruned = 0u32;
1866    for layer_ref in found_layers {
1867        let layer_digest = refescape::unprefix_unescape_ref(LAYER_PREFIX, &layer_ref)?;
1868        if referenced_layers.remove(layer_digest.as_str()) {
1869            continue;
1870        }
1871        pruned += 1;
1872        tracing::debug!("Pruning: {}", layer_ref.as_str());
1873        repo.set_ref_immediate(None, layer_ref.as_str(), None, cancellable)?;
1874    }
1875
1876    Ok(pruned)
1877}
1878
1879#[cfg(feature = "internal-testing-api")]
1880/// Return how many container blobs (layers) are stored
1881pub fn count_layer_references(repo: &ostree::Repo) -> Result<u32> {
1882    let cancellable = gio::Cancellable::NONE;
1883    let n = repo
1884        .list_refs_ext(
1885            Some(LAYER_PREFIX),
1886            ostree::RepoListRefsExtFlags::empty(),
1887            cancellable,
1888        )?
1889        .len();
1890    Ok(n as u32)
1891}
1892
1893/// Generate a suitable warning message from given list of filtered files, if any.
1894pub fn image_filtered_content_warning(
1895    filtered_files: &Option<MetaFilteredData>,
1896) -> Result<Option<String>> {
1897    use std::fmt::Write;
1898
1899    let r = filtered_files.as_ref().map(|v| {
1900        let mut filtered = BTreeMap::<&String, u32>::new();
1901        for paths in v.values() {
1902            for (k, v) in paths {
1903                let e = filtered.entry(k).or_default();
1904                *e += v;
1905            }
1906        }
1907        let mut buf = "Image contains non-ostree compatible file paths:".to_string();
1908        for (k, v) in filtered {
1909            write!(buf, " {k}: {v}").unwrap();
1910        }
1911        buf
1912    });
1913    Ok(r)
1914}
1915
1916/// Remove the specified image reference.  If the image is already
1917/// not present, this function will successfully perform no operation.
1918///
1919/// This function assumes no transaction is active on the repository.
1920/// The underlying layers are *not* pruned; that requires a separate invocation
1921/// of [`gc_image_layers`].
1922#[context("Pruning {img}")]
1923pub fn remove_image(repo: &ostree::Repo, img: &ImageReference) -> Result<bool> {
1924    let ostree_ref = &ref_for_image(img)?;
1925    let found = repo.resolve_rev(ostree_ref, true)?.is_some();
1926    // Note this API is already idempotent, but we might as well avoid another
1927    // trip into ostree.
1928    if found {
1929        repo.set_ref_immediate(None, ostree_ref, None, gio::Cancellable::NONE)?;
1930    }
1931    Ok(found)
1932}
1933
1934/// Remove the specified image references.  If an image is not found, further
1935/// images will be removed, but an error will be returned.
1936///
1937/// This function assumes no transaction is active on the repository.
1938/// The underlying layers are *not* pruned; that requires a separate invocation
1939/// of [`gc_image_layers`].
1940pub fn remove_images<'a>(
1941    repo: &ostree::Repo,
1942    imgs: impl IntoIterator<Item = &'a ImageReference>,
1943) -> Result<()> {
1944    let mut missing = Vec::new();
1945    for img in imgs.into_iter() {
1946        let found = remove_image(repo, img)?;
1947        if !found {
1948            missing.push(img);
1949        }
1950    }
1951    if !missing.is_empty() {
1952        let missing = missing.into_iter().fold("".to_string(), |mut a, v| {
1953            a.push_str(&v.to_string());
1954            a
1955        });
1956        return Err(anyhow::anyhow!("Missing images: {missing}"));
1957    }
1958    Ok(())
1959}
1960
1961#[derive(Debug, Default)]
1962struct CompareState {
1963    verified: BTreeSet<Utf8PathBuf>,
1964    inode_corrupted: BTreeSet<Utf8PathBuf>,
1965    unknown_corrupted: BTreeSet<Utf8PathBuf>,
1966}
1967
1968impl CompareState {
1969    fn is_ok(&self) -> bool {
1970        self.inode_corrupted.is_empty() && self.unknown_corrupted.is_empty()
1971    }
1972}
1973
1974fn compare_file_info(src: &gio::FileInfo, target: &gio::FileInfo) -> bool {
1975    if src.file_type() != target.file_type() {
1976        return false;
1977    }
1978    if src.size() != target.size() {
1979        return false;
1980    }
1981    for attr in ["unix::uid", "unix::gid", "unix::mode"] {
1982        if src.attribute_uint32(attr) != target.attribute_uint32(attr) {
1983            return false;
1984        }
1985    }
1986    true
1987}
1988
1989#[context("Querying object inode")]
1990fn inode_of_object(repo: &ostree::Repo, checksum: &str) -> Result<u64> {
1991    let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
1992    let (prefix, suffix) = checksum.split_at(2);
1993    let objpath = format!("objects/{prefix}/{suffix}.file");
1994    let metadata = repodir.symlink_metadata(objpath)?;
1995    Ok(metadata.ino())
1996}
1997
1998fn compare_commit_trees(
1999    repo: &ostree::Repo,
2000    root: &Utf8Path,
2001    target: &ostree::RepoFile,
2002    expected: &ostree::RepoFile,
2003    exact: bool,
2004    colliding_inodes: &BTreeSet<u64>,
2005    state: &mut CompareState,
2006) -> Result<()> {
2007    let cancellable = gio::Cancellable::NONE;
2008    let queryattrs = "standard::name,standard::type";
2009    let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS;
2010    let expected_iter = expected.enumerate_children(queryattrs, queryflags, cancellable)?;
2011
2012    while let Some(expected_info) = expected_iter.next_file(cancellable)? {
2013        let expected_child = expected_iter.child(&expected_info);
2014        let name = expected_info.name();
2015        let name = name.to_str().expect("UTF-8 ostree name");
2016        let path = Utf8PathBuf::from(format!("{root}{name}"));
2017        let target_child = target.child(name);
2018        let target_info = crate::diff::query_info_optional(&target_child, queryattrs, queryflags)
2019            .context("querying optional to")?;
2020        let is_dir = matches!(expected_info.file_type(), gio::FileType::Directory);
2021        if let Some(target_info) = target_info {
2022            let to_child = target_child
2023                .downcast::<ostree::RepoFile>()
2024                .expect("downcast");
2025            to_child.ensure_resolved()?;
2026            let from_child = expected_child
2027                .downcast::<ostree::RepoFile>()
2028                .expect("downcast");
2029            from_child.ensure_resolved()?;
2030
2031            if is_dir {
2032                let from_contents_checksum = from_child.tree_get_contents_checksum();
2033                let to_contents_checksum = to_child.tree_get_contents_checksum();
2034                if from_contents_checksum != to_contents_checksum {
2035                    let subpath = Utf8PathBuf::from(format!("{path}/"));
2036                    compare_commit_trees(
2037                        repo,
2038                        &subpath,
2039                        &from_child,
2040                        &to_child,
2041                        exact,
2042                        colliding_inodes,
2043                        state,
2044                    )?;
2045                }
2046            } else {
2047                let from_checksum = from_child.checksum();
2048                let to_checksum = to_child.checksum();
2049                let matches = if exact {
2050                    from_checksum == to_checksum
2051                } else {
2052                    compare_file_info(&target_info, &expected_info)
2053                };
2054                if !matches {
2055                    let from_inode = inode_of_object(repo, &from_checksum)?;
2056                    let to_inode = inode_of_object(repo, &to_checksum)?;
2057                    if colliding_inodes.contains(&from_inode)
2058                        || colliding_inodes.contains(&to_inode)
2059                    {
2060                        state.inode_corrupted.insert(path);
2061                    } else {
2062                        state.unknown_corrupted.insert(path);
2063                    }
2064                } else {
2065                    state.verified.insert(path);
2066                }
2067            }
2068        } else {
2069            eprintln!("Missing {path}");
2070            state.unknown_corrupted.insert(path);
2071        }
2072    }
2073    Ok(())
2074}
2075
2076#[context("Verifying container image state")]
2077pub(crate) fn verify_container_image(
2078    sysroot: &SysrootLock,
2079    imgref: &ImageReference,
2080    state: &LayeredImageState,
2081    colliding_inodes: &BTreeSet<u64>,
2082    verbose: bool,
2083) -> Result<bool> {
2084    let cancellable = gio::Cancellable::NONE;
2085    let repo = &sysroot.repo();
2086    let merge_commit = state.merge_commit.as_str();
2087    let merge_commit_root = repo.read_commit(merge_commit, gio::Cancellable::NONE)?.0;
2088    let merge_commit_root = merge_commit_root
2089        .downcast::<ostree::RepoFile>()
2090        .expect("downcast");
2091    merge_commit_root.ensure_resolved()?;
2092
2093    let (commit_layer, _component_layers, remaining_layers) =
2094        parse_manifest_layout(&state.manifest, &state.configuration)?;
2095
2096    let mut comparison_state = CompareState::default();
2097
2098    let query = |l: &Descriptor| query_layer(repo, l.clone());
2099
2100    let base_tree = repo
2101        .read_commit(&state.base_commit, cancellable)?
2102        .0
2103        .downcast::<ostree::RepoFile>()
2104        .expect("downcast");
2105    if let Some(commit_layer) = commit_layer {
2106        println!(
2107            "Verifying with base ostree layer {}",
2108            ref_for_layer(commit_layer)?
2109        );
2110    }
2111    compare_commit_trees(
2112        repo,
2113        "/".into(),
2114        &merge_commit_root,
2115        &base_tree,
2116        true,
2117        colliding_inodes,
2118        &mut comparison_state,
2119    )?;
2120
2121    let remaining_layers = remaining_layers
2122        .into_iter()
2123        .map(query)
2124        .collect::<Result<Vec<_>>>()?;
2125
2126    println!("Image has {} derived layers", remaining_layers.len());
2127
2128    for layer in remaining_layers.iter().rev() {
2129        let layer_ref = layer.ostree_ref.as_str();
2130        let layer_commit = layer
2131            .commit
2132            .as_deref()
2133            .ok_or_else(|| anyhow!("Missing layer {layer_ref}"))?;
2134        let layer_tree = repo
2135            .read_commit(layer_commit, cancellable)?
2136            .0
2137            .downcast::<ostree::RepoFile>()
2138            .expect("downcast");
2139        compare_commit_trees(
2140            repo,
2141            "/".into(),
2142            &merge_commit_root,
2143            &layer_tree,
2144            false,
2145            colliding_inodes,
2146            &mut comparison_state,
2147        )?;
2148    }
2149
2150    let n_verified = comparison_state.verified.len();
2151    if comparison_state.is_ok() {
2152        println!("OK image {imgref} (verified={n_verified})");
2153        println!();
2154    } else {
2155        let n_inode = comparison_state.inode_corrupted.len();
2156        let n_other = comparison_state.unknown_corrupted.len();
2157        eprintln!("warning: Found corrupted merge commit");
2158        eprintln!("  inode clashes: {n_inode}");
2159        eprintln!("  unknown:       {n_other}");
2160        eprintln!("  ok:            {n_verified}");
2161        if verbose {
2162            eprintln!("Mismatches:");
2163            for path in comparison_state.inode_corrupted {
2164                eprintln!("  inode: {path}");
2165            }
2166            for path in comparison_state.unknown_corrupted {
2167                eprintln!("  other: {path}");
2168            }
2169        }
2170        eprintln!();
2171        return Ok(false);
2172    }
2173
2174    Ok(true)
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179    use cap_std_ext::cap_tempfile;
2180    use oci_image::{DescriptorBuilder, MediaType, Sha256Digest};
2181
2182    use super::*;
2183
2184    #[test]
2185    fn test_ref_for_descriptor() {
2186        let d = DescriptorBuilder::default()
2187            .size(42u64)
2188            .media_type(MediaType::ImageManifest)
2189            .digest(
2190                Sha256Digest::from_str(
2191                    "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
2192                )
2193                .unwrap(),
2194            )
2195            .build()
2196            .unwrap();
2197        assert_eq!(
2198            ref_for_layer(&d).unwrap(),
2199            "ostree/container/blob/sha256_3A_2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
2200        );
2201    }
2202
2203    #[test]
2204    fn test_cleanup_root() -> Result<()> {
2205        let td = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
2206        let usretc = "usr/etc";
2207        cleanup_root(&td).unwrap();
2208        td.create_dir_all(usretc)?;
2209        let usretc = &td.open_dir(usretc)?;
2210        usretc.write("hostname", b"hostname")?;
2211        cleanup_root(&td).unwrap();
2212        assert!(usretc.try_exists("hostname")?);
2213        usretc.write("hostname", b"")?;
2214        cleanup_root(&td).unwrap();
2215        assert!(!td.try_exists("hostname")?);
2216
2217        usretc.symlink_contents("../run/systemd/stub-resolv.conf", "resolv.conf")?;
2218        cleanup_root(&td).unwrap();
2219        assert!(usretc.symlink_metadata("resolv.conf")?.is_symlink());
2220        usretc.remove_file("resolv.conf")?;
2221        usretc.write("resolv.conf", b"")?;
2222        cleanup_root(&td).unwrap();
2223        assert!(!usretc.try_exists("resolv.conf")?);
2224
2225        Ok(())
2226    }
2227}