Skip to main content

bootc_lib/bootc_composefs/
repo.rs

1//! Composefs repository lifecycle and OCI pull paths.
2//!
3//! This module owns how OCI images get into the composefs object store.
4//! There are two pull paths, selected by the `use_unified` flag:
5//!
6//! ## Direct pull (`use_unified = false`)
7//!
8//! `pull_composefs_direct` fetches from the source transport (registry, OCI
9//! dir, etc.) straight into the composefs repo via `composefs_oci::pull` with
10//! default options. No containers-storage involvement.
11//!
12//! ## Unified pull (`use_unified = true`)
13//!
14//! `pull_composefs_unified` is the two-stage path that populates all three
15//! stores (see [`crate::store`] for the architecture overview):
16//!
17//! **Stage 1** — Pull into bootc-owned containers-storage via
18//! `CStorage::pull_with_progress` (or `pull_from_host_storage` if the image
19//! already exists in the default podman store, saving a network round-trip).
20//!
21//! **Stage 2** — `composefs_oci::pull` with `LocalFetchOpt::ZeroCopy` and
22//! `storage_root` pointing at the containers-storage directory. composefs-ctl
23//! walks the overlay `diff/` directories and FICLONEs each file into the
24//! composefs object store keyed by its SHA-512 fsverity digest. On a
25//! reflink-capable filesystem this is near-instantaneous and consumes no
26//! additional disk space.
27//!
28//! The caller provides `storage_path` as an absolute filesystem path string
29//! (not a `Dir` fd) because composefs-ctl passes it to a child skopeo process.
30//! It is derived from the physical root fd via `/proc/self/fd/{fd}` readlink.
31//!
32//! ## Entry points
33//!
34//! - [`pull_composefs_repo`] — upgrade/switch on a composefs-booted system.
35//! - [`initialize_composefs_repository`] — `bootc install` with the composefs
36//!   backend.
37
38use fn_error_context::context;
39use std::sync::Arc;
40
41use anyhow::{Context, Result};
42
43use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
44use composefs::repository::{EnsureStatus, RepositoryConfig};
45use composefs::tree::FileSystem;
46use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources};
47use composefs_ctl::composefs;
48use composefs_ctl::composefs_boot;
49use composefs_ctl::composefs_oci;
50use composefs_oci::{
51    LocalFetchOpt, PullOptions, PullResult,
52    image::create_filesystem as create_composefs_filesystem, tag_image,
53};
54
55use ostree_ext::containers_image_proxy;
56
57use cap_std_ext::{
58    cap_std::{ambient_authority, fs::Dir},
59    cap_tempfile,
60    dirext::CapStdExtDirExt,
61};
62use rustix::fs::{FlockOperation, flock};
63
64use crate::bootc_composefs::boot::{
65    ExpectedBootImageIds, ensure_correct_composefs_digest, expected_boot_image_ids,
66    print_uki_dumpfile_diff_on_mismatch,
67};
68use crate::bootc_composefs::progress;
69use crate::composefs_consts::BOOTC_TAG_PREFIX;
70use crate::install::{RootSetup, State};
71use crate::lsm;
72use crate::podstorage::CStorage;
73use crate::progress_jsonl::ProgressWriter;
74
75/// Create a composefs OCI tag name for the given manifest digest.
76///
77/// Returns a tag like `localhost/bootc-sha256:abc...` which acts as a GC root
78/// in the composefs repository, keeping the manifest, config, and all layer
79/// splitstreams alive.
80pub(crate) fn bootc_tag_for_manifest(manifest_digest: &str) -> String {
81    format!("{BOOTC_TAG_PREFIX}{manifest_digest}")
82}
83
84pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result<crate::store::ComposefsRepository> {
85    crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs")
86        .context("Failed to open composefs repository")
87}
88
89/// Build the repository configuration used by every composefs initialization
90/// path.  Keeping the fs-verity policy and the dual EROFS format policy here
91/// prevents preflight initialization from silently creating a different
92/// repository than the real pull path will use.
93pub(crate) fn composefs_repository_config(allow_missing_fsverity: bool) -> RepositoryConfig {
94    let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
95    if allow_missing_fsverity {
96        config = config.set_insecure();
97    }
98    crate::store::set_dual_erofs_formats(&mut config);
99    config
100}
101
102/// Open an existing repository without changing its metadata, or create a new
103/// one with bootc's current dual-format configuration.
104fn ensure_composefs_repository(
105    rootfs_dir: &Dir,
106    config: RepositoryConfig,
107) -> Result<(crate::store::ComposefsRepository, bool)> {
108    let (repo, status) =
109        crate::store::ComposefsRepository::ensure_path(rootfs_dir, "composefs", config)
110            .context("Failed to initialize composefs repository")?;
111    Ok((repo, status == EnsureStatus::Created))
112}
113
114pub(crate) struct InitializedComposefs {
115    pub(crate) repo: Arc<crate::store::ComposefsRepository>,
116    pub(crate) created: bool,
117    pub(crate) repository_insecure: bool,
118}
119
120pub(crate) fn final_repository_policy(
121    image_fsverity_policy: Option<bool>,
122    requested_relaxed: bool,
123) -> bool {
124    image_fsverity_policy == Some(true) || (image_fsverity_policy.is_none() && requested_relaxed)
125}
126
127/// Enforce the durable repository policy after inspecting the imported image.
128/// Existing metadata is never rewritten: an explicit relaxation only applies
129/// to the session that will install the boot artifacts.
130pub(crate) fn validate_repository_policy(
131    repository_insecure: bool,
132    requested_relaxed: bool,
133    allow_missing_verity_explicit: bool,
134) -> Result<()> {
135    if repository_insecure && !requested_relaxed {
136        anyhow::bail!(
137            "Existing composefs repository is insecure, but this install requires fs-verity; refusing to continue"
138        );
139    }
140    if !repository_insecure && requested_relaxed && !allow_missing_verity_explicit {
141        anyhow::bail!(
142            "Initial insecure boot artifact conflicts with the existing strict composefs repository; explicitly pass --allow-missing-verity to permit this session"
143        );
144    }
145    Ok(())
146}
147
148/// Replace only the metadata of a repository created by this install.  The
149/// replacement is generated by composefs-rs itself, so format and feature
150/// settings cannot drift from the normal initialization path.
151pub(crate) fn finalize_fresh_repository_policy(
152    rootfs: &Dir,
153    initial_config: RepositoryConfig,
154) -> Result<()> {
155    let repo_dir = rootfs.open_dir(crate::store::COMPOSEFS)?;
156    let temp_root =
157        cap_tempfile::TempDir::new_in(rootfs).context("Creating temporary metadata directory")?;
158    let relaxed = initial_config.set_insecure();
159    let (temporary_repo, _) =
160        crate::store::ComposefsRepository::init_path(&*temp_root, ".", relaxed)?;
161    let replacement_metadata = temporary_repo.metadata().clone();
162    drop(temporary_repo);
163    let replacement_bytes = serde_json::to_vec(&replacement_metadata)?;
164
165    // Repository handles use LOCK_SH.  They must all be gone before this
166    // short exclusive critical section, otherwise flock would self-deadlock.
167    let lock = repo_dir
168        .reopen_as_ownedfd()
169        .context("Opening composefs repository lock")?;
170    flock(&lock, FlockOperation::LockExclusive).context("Taking composefs repository lock")?;
171    let target_metadata: composefs::repository::RepoMetadata =
172        serde_json::from_reader(repo_dir.open("meta.json")?)?;
173    anyhow::ensure!(
174        target_metadata == replacement_metadata,
175        "composefs metadata generated for policy replacement does not match the repository"
176    );
177
178    repo_dir
179        .atomic_replace_with("meta.json", |writer| -> Result<()> {
180            use std::io::Write;
181
182            writer.write_all(&replacement_bytes)?;
183            writer.flush()?;
184            writer.get_ref().as_file().sync_all()?;
185            Ok(())
186        })
187        .context("Atomically replacing composefs metadata")?;
188    Ok(())
189}
190
191pub(crate) fn tag_pulled_image(
192    rootfs_dir: &Dir,
193    pull_result: &PullResult<Sha512HashValue>,
194) -> Result<()> {
195    let repo = crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs")?;
196    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
197    tag_image(&repo, &pull_result.manifest_digest, &tag)
198        .context("Tagging pulled image as bootc GC root")?;
199    Ok(())
200}
201
202pub(crate) fn initialize_composefs_repository(
203    state: &State,
204    root_setup: &RootSetup,
205    allow_missing_fsverity: bool,
206) -> Result<InitializedComposefs> {
207    const COMPOSEFS_REPO_INIT_JOURNAL_ID: &str = "5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9";
208
209    let rootfs_dir = &root_setup.physical_root;
210    let image_name = &state.source.imageref.name;
211    let transport = &state.source.imageref.transport;
212
213    tracing::info!(
214        message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID,
215        bootc.operation = "repository_init",
216        bootc.source_image = %image_name,
217        bootc.transport = %transport,
218        bootc.allow_missing_fsverity = allow_missing_fsverity,
219        "Initializing composefs repository for image {}:{}",
220        transport,
221        image_name
222    );
223
224    crate::store::ensure_composefs_dir(rootfs_dir)?;
225
226    let config = composefs_repository_config(allow_missing_fsverity);
227    let (mut repo, created) = ensure_composefs_repository(rootfs_dir, config)?;
228    let repository_insecure = repo.is_insecure();
229    // `set_insecure()` is an explicit per-handle relaxation.  It must also be
230    // applied when ensure_path opened an existing strict repository; ensure_path
231    // correctly derives the durable policy from meta.json and does not rewrite
232    // it merely because this session permits missing fs-verity.
233    if allow_missing_fsverity && (created || repository_insecure) {
234        repo.set_insecure()?;
235    }
236
237    let repo = Arc::new(repo);
238
239    Ok(InitializedComposefs {
240        repo,
241        created,
242        repository_insecure,
243    })
244}
245
246/// Pull or import an install image into an initialized composefs repository.
247pub(crate) async fn pull_install_composefs_repository(
248    state: &State,
249    root_setup: &RootSetup,
250    repo: &Arc<crate::store::ComposefsRepository>,
251    use_unified: bool,
252    fetch_imgref: Option<&containers_image_proxy::ImageReference>,
253) -> Result<PullResult<Sha512HashValue>> {
254    let rootfs_dir = &root_setup.physical_root;
255    let original_imgref: containers_image_proxy::ImageReference = state
256        .source
257        .imageref
258        .to_string()
259        .as_str()
260        .try_into()
261        .context("Parsing source image reference")?;
262    let imgref = fetch_imgref.unwrap_or(&original_imgref);
263
264    // Ensure the compatibility symlink ostree/bootc -> ../composefs/bootc
265    // exists.  This is needed for LBI and (when unified storage is enabled)
266    // for containers-storage under composefs/bootc/storage.  The existing
267    // /usr/lib/bootc/storage symlink and all runtime code using
268    // ostree/bootc/storage depend on this link.
269    crate::store::ensure_composefs_bootc_link(rootfs_dir)?;
270
271    let pull_result = if use_unified {
272        // Unified path: first into containers-storage on the target
273        // rootfs, then cstor zero-copy into composefs. This ensures the image
274        // is available for `podman run` from first boot.
275        let sepolicy = state.load_policy()?;
276        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
277        let imgstore = CStorage::create(rootfs_dir, &run, sepolicy.as_ref())?;
278        let storage_path = root_setup.physical_root_path.join(CStorage::subpath());
279
280        // `bootc install` does not yet plumb `--quiet`/`--progress-fd` down to
281        // this path; use defaults for now (terminal progress still renders).
282        let r = pull_composefs_unified(
283            &imgstore,
284            storage_path.as_str(),
285            &repo,
286            &imgref,
287            false,
288            ProgressWriter::default(),
289        )
290        .await?;
291
292        // SELinux-label the containers-storage now that all pulls are done.
293        imgstore
294            .ensure_labeled()
295            .context("SELinux labeling of containers-storage")?;
296        r
297    } else {
298        // Direct path: pull directly into composefs via skopeo, without
299        // containers-storage as intermediary.
300        pull_composefs_direct(&repo, &imgref, false, ProgressWriter::default()).await?
301    };
302
303    Ok(pull_result)
304}
305
306/// Result of pulling a composefs repository, including the OCI manifest digest
307/// needed to reconstruct image metadata from the local composefs repo.
308pub(crate) struct PullRepoResult {
309    pub(crate) repo: crate::store::ComposefsRepository,
310    pub(crate) entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
311    pub(crate) id: Sha512HashValue,
312    pub(crate) boot_ids: ExpectedBootImageIds,
313    /// The OCI manifest content digest (e.g. "sha256:abc...")
314    pub(crate) manifest_digest: String,
315    /// The untransformed OCI filesystem (still has /boot, /sysroot, etc.)
316    pub(crate) fs: FileSystem<Sha512HashValue>,
317}
318
319/// The boot-facing artifacts reconstructed from a pulled OCI image.
320pub(crate) struct BootImage {
321    pub(crate) id: Sha512HashValue,
322    pub(crate) boot_ids: ExpectedBootImageIds,
323    pub(crate) fs: FileSystem<Sha512HashValue>,
324    pub(crate) entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
325}
326
327/// Generate the boot image, reconstruct the untransformed filesystem for boot
328/// entry discovery, and reconcile the generated digest with a UKI, if present.
329///
330/// Keeping this sequence next to the pull paths is important: install and
331/// update must not independently generate images or recover a different digest.
332pub(crate) fn prepare_boot_image(
333    repo: &Arc<crate::store::ComposefsRepository>,
334    pull_result: &PullResult<Sha512HashValue>,
335) -> Result<BootImage> {
336    let generated_id = composefs_oci::generate_boot_image(
337        repo,
338        &pull_result.manifest_digest,
339        &composefs_oci::OciTransformOptions::default(),
340    )
341    .context("Generating bootable EROFS image")?;
342
343    let fs = create_composefs_filesystem(
344        &**repo,
345        &pull_result.config_digest,
346        Some(&pull_result.config_verity),
347        &composefs_oci::OciTransformOptions::default(),
348    )
349    .context("Creating composefs filesystem for boot entry discovery")?;
350    let entries =
351        get_boot_resources(&fs, &**repo).context("Extracting boot entries from OCI image")?;
352    let oci_img =
353        composefs_oci::oci_image::OciImage::open(repo, &pull_result.manifest_digest, None)
354            .context("Opening OCI image to read boot image refs")?;
355    let expected_ids = expected_boot_image_ids(
356        oci_img.boot_image_ref_v1().cloned(),
357        oci_img.boot_image_ref_v2().cloned(),
358        &generated_id,
359        repo.erofs_version(),
360    );
361    let recovered = print_uki_dumpfile_diff_on_mismatch(
362        ensure_correct_composefs_digest(
363            repo,
364            &pull_result.manifest_digest,
365            generated_id,
366            expected_ids,
367            &entries,
368        ),
369        repo,
370        &fs,
371    )?;
372
373    Ok(BootImage {
374        id: recovered.id,
375        boot_ids: recovered.expected_ids,
376        fs,
377        entries,
378    })
379}
380
381/// Inspect the image's fs-verity policy without generating or recovering a boot
382/// image. Boot image generation is deferred until the durable repository policy
383/// has been finalized and the normal boot setup path runs.
384pub(crate) fn inspect_image_fsverity_policy(
385    repo: &Arc<crate::store::ComposefsRepository>,
386    pull_result: &PullResult<Sha512HashValue>,
387) -> Result<Option<bool>> {
388    let fs = create_composefs_filesystem(
389        &**repo,
390        &pull_result.config_digest,
391        Some(&pull_result.config_verity),
392        &composefs_oci::OciTransformOptions::default(),
393    )
394    .context("Creating composefs filesystem for image fs-verity policy inspection")?;
395    let entries = get_boot_resources(&fs, &**repo).context("Extracting boot entries")?;
396    crate::bootc_composefs::boot::image_fsverity_policy(repo, &entries)
397}
398
399/// Pull an image directly into the composefs repository via skopeo.
400///
401/// This is the default path: the image is fetched directly from the source
402/// transport (registry, oci directory, etc.) into the composefs repo without
403/// going through containers-storage first.
404async fn pull_composefs_direct(
405    repo: &Arc<crate::store::ComposefsRepository>,
406    imgref: &containers_image_proxy::ImageReference,
407    quiet: bool,
408    prog: ProgressWriter,
409) -> Result<PullResult<Sha512HashValue>> {
410    let imgref_str = imgref.to_string();
411    tracing::info!("Direct pull: fetching {imgref_str} into composefs repository");
412
413    let mut config = crate::deploy::new_proxy_config();
414    ostree_ext::container::merge_default_container_proxy_opts(&mut config)?;
415
416    let (reporter, prog_task) = progress::spawn(quiet, prog);
417
418    let pull_result = composefs_oci::pull(
419        repo,
420        &imgref_str,
421        None,
422        PullOptions {
423            img_proxy_config: Some(config),
424            progress: Some(reporter),
425            ..Default::default()
426        },
427    )
428    .await;
429
430    // Awaiting the progress task after the pull future completes ensures the
431    // reporter's `Arc` (and hence the channel it feeds) has been dropped, so
432    // the background task drains its queue and exits rather than hanging.
433    prog_task
434        .await
435        .context("Composefs progress task panicked")?;
436
437    pull_result.context("Pulling image into composefs repository")
438}
439
440/// Pull an image via unified storage: first into bootc-owned containers-storage,
441/// then from there into the composefs repository via cstor (zero-copy
442/// reflink/hardlink).
443///
444/// The caller provides:
445/// - `imgstore`: the bootc-owned `CStorage` instance (may be on an arbitrary
446///   mount point during install, or under `/sysroot` during upgrade)
447/// - `storage_path`: the absolute filesystem path to that containers-storage
448///   directory, so cstor and skopeo can find it (e.g.
449///   `/mnt/sysroot/ostree/bootc/storage` during install, or
450///   `/sysroot/ostree/bootc/storage` during upgrade)
451///
452/// This ensures the image is available in containers-storage for `podman run`
453/// while also populating the composefs repo for booting.
454async fn pull_composefs_unified(
455    imgstore: &CStorage,
456    storage_path: &str,
457    repo: &Arc<crate::store::ComposefsRepository>,
458    imgref: &containers_image_proxy::ImageReference,
459    quiet: bool,
460    prog: ProgressWriter,
461) -> Result<PullResult<Sha512HashValue>> {
462    let image = &imgref.name;
463
464    // Stage 1: get the image into bootc-owned containers-storage.
465    if imgref.transport == containers_image_proxy::Transport::ContainerStorage {
466        // The image is in the default containers-storage (/var/lib/containers/storage).
467        // Copy it into bootc-owned storage.
468        tracing::info!("Unified pull: copying {image} from host containers-storage");
469        imgstore
470            .pull_from_host_storage(image)
471            .await
472            .context("Copying image from host containers-storage into bootc storage")?;
473    } else {
474        // For registry (docker://), oci:, docker-daemon:, etc. — pull
475        // via the native podman API with streaming progress display.
476        let pull_ref = imgref.to_string();
477        tracing::info!("Unified pull: fetching {pull_ref} into containers-storage");
478        imgstore
479            .pull_with_progress(&pull_ref)
480            .await
481            .context("Pulling image into bootc containers-storage")?;
482    }
483
484    // Stage 2: import full OCI structure (layers + config + manifest) from
485    // containers-storage into composefs via cstor (zero-copy reflink/hardlink).
486    let cstor_imgref_str = format!("containers-storage:{image}");
487    tracing::info!("Unified pull: importing from {cstor_imgref_str} (zero-copy)");
488
489    let storage = std::path::Path::new(storage_path);
490    let (reporter, prog_task) = progress::spawn(quiet, prog);
491    let pull_opts = PullOptions {
492        // The image is already in bootc-owned containers-storage at this point
493        // (placed there by Stage 1 of the unified pull). Use ZeroCopy so we
494        // actually import via reflink/hardlink and fail loudly if that isn't
495        // possible — a plain copy fallback here would mean Stage 1 and Stage 2
496        // are on different filesystems or the storage root is wrong.
497        local_fetch: LocalFetchOpt::ZeroCopy,
498        storage_root: Some(storage),
499        progress: Some(reporter),
500        ..Default::default()
501    };
502    let pull_result = composefs_oci::pull(repo, &cstor_imgref_str, None, pull_opts).await;
503
504    prog_task
505        .await
506        .context("Composefs progress task panicked")?;
507
508    let pull_result = pull_result.context("Importing from containers-storage into composefs")?;
509
510    Ok(pull_result)
511}
512
513/// Pulls an image into a composefs repository at /sysroot.
514///
515/// When `use_unified` is true, the image is first pulled into bootc-owned
516/// containers-storage (so it's available for `podman run`), then imported
517/// from there into the composefs repo via zero-copy reflinks.
518///
519/// When `use_unified` is false (the default), the image is pulled directly
520/// into the composefs repo via skopeo.
521///
522/// With a `delta`, the content is read from the delta file instead and no
523/// network access happens at all; `spec_imgref` then only names what the
524/// resulting deployment tracks. composefs-ctl recognises the delta artifact
525/// from the layout's manifest and reconstructs the changed layers against the
526/// source image in the repository, failing if that image is absent.
527///
528/// Checks for boot entries in the image and returns them.
529#[context("Pulling composefs repository")]
530pub(crate) async fn pull_composefs_repo(
531    spec_imgref: &crate::spec::ImageReference,
532    delta: Option<&crate::delta::Delta>,
533    allow_missing_fsverity: bool,
534    use_unified: bool,
535    quiet: bool,
536    prog: ProgressWriter,
537) -> Result<PullRepoResult> {
538    const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8";
539
540    let imgref = match delta {
541        Some(delta) => {
542            delta.validate_image_reference(spec_imgref)?;
543            crate::delta::reject_unified_storage(delta, use_unified)?;
544            delta.pull_ref()?
545        }
546        None => spec_imgref.to_image_proxy_ref()?,
547    };
548
549    tracing::info!(
550        message_id = COMPOSEFS_PULL_JOURNAL_ID,
551        bootc.operation = "pull",
552        bootc.source_image = &spec_imgref.image,
553        bootc.transport = %imgref.transport,
554        bootc.allow_missing_fsverity = allow_missing_fsverity,
555        bootc.unified_storage = use_unified,
556        bootc.delta = delta.map(|d| d.path.as_str()),
557        "Pulling composefs image {imgref}",
558    );
559
560    let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?;
561
562    let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?;
563    if allow_missing_fsverity {
564        repo.set_insecure()?;
565    }
566
567    let repo = Arc::new(repo);
568
569    // Upgrade any old-format OCI images before pulling.  Old bootc
570    // (composefs-rs ≤ 2203e8f) did not add IMAGE_REF_KEY to config
571    // splitstreams, so the new GC's tag-based stream walk cannot reach
572    // their layer objects.  upgrade_repo() rewrites those config
573    // splitstreams in place before we add a new deployment, ensuring all
574    // existing deployments are GC-safe.  It is idempotent and fast when
575    // images are already in the current format.
576    let upgrade_result =
577        composefs_oci::upgrade_repo(&repo).context("Upgrading old-format OCI images")?;
578    if upgrade_result.upgraded > 0 {
579        tracing::info!(
580            "Upgraded {} old-format OCI image(s) to current format",
581            upgrade_result.upgraded
582        );
583    }
584
585    let pull_result = if use_unified {
586        // Create bootc-owned containers-storage on the rootfs.
587        // Load SELinux policy from the running system so newly pulled layers
588        // get the correct container_var_lib_t labels.
589        let root = Dir::open_ambient_dir("/", ambient_authority())?;
590        let sepolicy = lsm::new_sepolicy_at(&root)?;
591        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
592        let imgstore = CStorage::create(&rootfs_dir, &run, sepolicy.as_ref())?;
593        let storage_path = format!("/sysroot/{}", CStorage::subpath());
594
595        pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref, quiet, prog).await?
596    } else {
597        pull_composefs_direct(&repo, &imgref, quiet, prog).await?
598    };
599
600    // Tag the manifest as a bootc-owned GC root.
601    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
602    tag_image(&*repo, &pull_result.manifest_digest, &tag)
603        .context("Tagging pulled image as bootc GC root")?;
604
605    tracing::info!(
606        message_id = COMPOSEFS_PULL_JOURNAL_ID,
607        bootc.operation = "pull",
608        bootc.manifest_digest = %pull_result.manifest_digest,
609        bootc.manifest_verity = pull_result.manifest_verity.to_hex(),
610        bootc.config_digest = %pull_result.config_digest,
611        bootc.config_verity = pull_result.config_verity.to_hex(),
612        bootc.tag = tag,
613        "Pulled image into composefs repository",
614    );
615
616    let BootImage {
617        id,
618        boot_ids,
619        fs,
620        entries,
621    } = prepare_boot_image(&repo, &pull_result)?;
622
623    // Unwrap the Arc to get the owned repo back.
624    let mut repo = Arc::try_unwrap(repo).map_err(|_| {
625        anyhow::anyhow!("BUG: Arc<Repository> still has other references after pull completed")
626    })?;
627    if allow_missing_fsverity {
628        repo.set_insecure()?;
629    }
630
631    Ok(PullRepoResult {
632        repo,
633        entries,
634        id,
635        boot_ids,
636        manifest_digest: pull_result.manifest_digest.to_string(),
637        fs,
638    })
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use std::os::unix::fs::MetadataExt;
645
646    #[test]
647    fn test_bootc_tag_for_manifest() {
648        let digest = "sha256:abc123def456";
649        let tag = bootc_tag_for_manifest(digest);
650        assert_eq!(tag, "localhost/bootc-sha256:abc123def456");
651        assert!(tag.starts_with(BOOTC_TAG_PREFIX));
652    }
653
654    #[test]
655    fn test_repository_init_preserves_requested_verity_policy() {
656        for allow_missing in [true, false] {
657            let tempdir = tempfile::tempdir().unwrap();
658            let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap();
659            let result = crate::store::ComposefsRepository::init_path(
660                &root,
661                ".",
662                composefs_repository_config(allow_missing),
663            );
664
665            match result {
666                Ok((repo, _)) => assert_eq!(repo.is_insecure(), allow_missing),
667                Err(error) if !allow_missing => {
668                    // A host without fs-verity support must fail strict
669                    // initialization rather than silently creating insecure
670                    // metadata.
671                    let message = format!("{error:#}");
672                    assert!(
673                        message.to_ascii_lowercase().contains("verity"),
674                        "strict initialization failed for an unrelated reason: {message}"
675                    );
676                }
677                Err(error) => panic!("insecure initialization failed: {error:#}"),
678            }
679        }
680    }
681
682    #[test]
683    fn test_existing_repository_policy_is_one_way() {
684        let tempdir = tempfile::tempdir().unwrap();
685        let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap();
686        let (strict_repo, _) = match crate::store::ComposefsRepository::init_path(
687            &root,
688            ".",
689            composefs_repository_config(false),
690        ) {
691            Ok(result) => result,
692            Err(error) if format!("{error:#}").to_ascii_lowercase().contains("verity") => {
693                // The host test filesystem may not support fs-verity.  The
694                // strict fresh-install behavior is covered by the same init
695                // probe above and by the VM test environment.
696                return;
697            }
698            Err(error) => panic!("strict initialization failed unexpectedly: {error:#}"),
699        };
700        assert!(!strict_repo.is_insecure());
701
702        let metadata_before = std::fs::read(tempdir.path().join("meta.json")).unwrap();
703        let metadata_inode = std::fs::metadata(tempdir.path().join("meta.json"))
704            .unwrap()
705            .ino();
706        let (relaxed_repo, _) = crate::store::ComposefsRepository::init_path(
707            &root,
708            ".",
709            composefs_repository_config(true),
710        )
711        .unwrap();
712        assert!(!relaxed_repo.is_insecure());
713        assert_eq!(
714            metadata_before,
715            std::fs::read(tempdir.path().join("meta.json")).unwrap()
716        );
717        assert_eq!(
718            metadata_inode,
719            std::fs::metadata(tempdir.path().join("meta.json"))
720                .unwrap()
721                .ino()
722        );
723        assert!(validate_repository_policy(false, true, true).is_ok());
724
725        let insecure_tempdir = tempfile::tempdir().unwrap();
726        let insecure_root =
727            Dir::open_ambient_dir(insecure_tempdir.path(), ambient_authority()).unwrap();
728        let (insecure_repo, _) = crate::store::ComposefsRepository::init_path(
729            &insecure_root,
730            ".",
731            composefs_repository_config(true),
732        )
733        .unwrap();
734        assert!(insecure_repo.is_insecure());
735        assert!(validate_repository_policy(true, false, false).is_err());
736    }
737
738    #[test]
739    fn test_ensure_existing_v2_repository_preserves_metadata() {
740        let tempdir = tempfile::tempdir().unwrap();
741        let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap();
742        crate::store::ensure_composefs_dir(&root).unwrap();
743        let mut v2_config =
744            RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure();
745        v2_config.erofs_formats = composefs::erofs::format::FormatConfig::single(
746            composefs::erofs::format::FormatVersion::V2,
747        );
748        let (repo, _) =
749            crate::store::ComposefsRepository::init_path(&root, crate::store::COMPOSEFS, v2_config)
750                .unwrap();
751        let repo_path = tempdir.path().join(crate::store::COMPOSEFS);
752        let metadata_path = repo_path.join("meta.json");
753        let metadata_before = std::fs::read(&metadata_path).unwrap();
754        let object_path = repo_path.join("objects/sentinel");
755        std::fs::create_dir(object_path.parent().unwrap()).unwrap();
756        std::fs::write(&object_path, b"retained").unwrap();
757        drop(repo);
758
759        let (reopened, created) =
760            ensure_composefs_repository(&root, composefs_repository_config(true)).unwrap();
761        assert!(!created);
762        assert!(reopened.is_insecure());
763        assert_eq!(
764            reopened.erofs_version(),
765            composefs::erofs::format::FormatVersion::V2
766        );
767        assert_eq!(metadata_before, std::fs::read(metadata_path).unwrap());
768        assert_eq!(std::fs::read(object_path).unwrap(), b"retained");
769    }
770
771    #[test]
772    fn test_final_repository_policy_matrix() {
773        for (image_fsverity_policy, requested_relaxed, expected) in [
774            (Some(false), false, false),
775            (Some(false), true, false),
776            (Some(true), false, true),
777            (Some(true), true, true),
778            (None, false, false),
779            (None, true, true),
780        ] {
781            assert_eq!(
782                final_repository_policy(image_fsverity_policy, requested_relaxed),
783                expected
784            );
785        }
786    }
787
788    #[test]
789    fn test_existing_repository_policy_matrix() {
790        for (insecure, image_fsverity_policy, requested, explicit, expected) in [
791            (false, Some(false), false, false, true),
792            (false, Some(true), true, false, false),
793            (false, Some(true), true, true, true),
794            (false, None, false, false, true),
795            (false, None, true, false, false),
796            (false, None, true, true, true),
797            (true, Some(false), false, false, false),
798            (true, Some(true), true, false, true),
799            (true, None, false, false, false),
800            (true, None, true, false, true),
801        ] {
802            let requested_relaxed = final_repository_policy(image_fsverity_policy, requested);
803            assert_eq!(
804                validate_repository_policy(insecure, requested_relaxed, explicit).is_ok(),
805                expected,
806                "insecure={insecure} image_fsverity_policy={image_fsverity_policy:?} requested={requested} explicit={explicit}"
807            );
808        }
809    }
810
811    #[test]
812    fn test_fresh_policy_replacement_preserves_objects() {
813        let tempdir = tempfile::tempdir().unwrap();
814        let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap();
815        crate::store::ensure_composefs_dir(&root).unwrap();
816        let (repo, _) = match crate::store::ComposefsRepository::init_path(
817            &root,
818            crate::store::COMPOSEFS,
819            composefs_repository_config(false),
820        ) {
821            Ok(repo) => repo,
822            Err(error) if format!("{error:#}").to_ascii_lowercase().contains("verity") => return,
823            Err(error) => panic!("strict repository initialization failed: {error:#}"),
824        };
825        let metadata_path = tempdir
826            .path()
827            .join(crate::store::COMPOSEFS)
828            .join("meta.json");
829        let metadata_inode = std::fs::metadata(&metadata_path).unwrap().ino();
830        let object_path = tempdir
831            .path()
832            .join(crate::store::COMPOSEFS)
833            .join("objects/sentinel");
834        // composefs-rs creates objects/ lazily, so it may not exist yet.
835        std::fs::create_dir_all(object_path.parent().unwrap()).unwrap();
836        std::fs::write(&object_path, b"retained").unwrap();
837        drop(repo);
838
839        finalize_fresh_repository_policy(&root, composefs_repository_config(false)).unwrap();
840
841        let reopened =
842            crate::store::ComposefsRepository::open_path(&root, crate::store::COMPOSEFS).unwrap();
843        assert!(reopened.is_insecure());
844        assert_ne!(
845            metadata_inode,
846            std::fs::metadata(&metadata_path).unwrap().ino()
847        );
848        assert_eq!(std::fs::read(object_path).unwrap(), b"retained");
849    }
850
851    #[test]
852    fn test_fresh_policy_replacement_refuses_feature_mismatch() {
853        let tempdir = tempfile::tempdir().unwrap();
854        let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap();
855        crate::store::ensure_composefs_dir(&root).unwrap();
856        let (repo, _) = crate::store::ComposefsRepository::init_path(
857            &root,
858            crate::store::COMPOSEFS,
859            composefs_repository_config(true),
860        )
861        .unwrap();
862        drop(repo);
863
864        let metadata_path = tempdir
865            .path()
866            .join(crate::store::COMPOSEFS)
867            .join("meta.json");
868        let mut metadata: composefs::repository::RepoMetadata =
869            serde_json::from_slice(&std::fs::read(&metadata_path).unwrap()).unwrap();
870        metadata
871            .features
872            .compatible
873            .push("review-only-feature".to_string());
874        std::fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
875        let metadata_inode = std::fs::metadata(&metadata_path).unwrap().ino();
876
877        let error = finalize_fresh_repository_policy(&root, composefs_repository_config(false))
878            .unwrap_err();
879        assert!(format!("{error:#}").contains("metadata"));
880        assert_eq!(
881            metadata_inode,
882            std::fs::metadata(&metadata_path).unwrap().ino()
883        );
884    }
885}