Skip to main content

composefs_oci/
boot.rs

1//! Boot image management for OCI containers.
2//!
3//! A bootable EROFS image is a derived artifact from an OCI container image
4//! that filters out some components (such as the UKI) to avoid circular references.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use anyhow::Result;
10
11use composefs::erofs::format::FormatVersion;
12use composefs::fsverity::FsVerityHashValue;
13#[cfg(feature = "boot")]
14use composefs::generic_tree::OciTransformOptions;
15use composefs::generic_tree::XattrFiltering;
16use composefs::repository::Repository;
17
18use crate::OciDigest;
19#[cfg(feature = "boot")]
20use crate::oci_image::OciImage;
21
22/// Generate a bootable EROFS image from a pulled OCI manifest (idempotent),
23/// with configurable OCI transform options (currently just the xattr
24/// filtering mode; see [`OciTransformOptions`]).
25///
26/// Each xattr filtering mode is cached independently under its own named
27/// ref (see [`boot_image_for_mode`]): requesting a different mode for the
28/// same manifest does not evict or overwrite the boot image cached for
29/// another mode.
30#[cfg(feature = "boot")]
31pub fn generate_boot_image<ObjectID: FsVerityHashValue>(
32    repo: &Arc<Repository<ObjectID>>,
33    manifest_digest: &OciDigest,
34    options: &OciTransformOptions,
35) -> Result<ObjectID> {
36    if let Some(existing) = boot_image_for_mode(repo, manifest_digest, options.xattrs)? {
37        return Ok(existing);
38    }
39
40    let (erofs_id, _) =
41        crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None, options, false)?
42            .expect("container image should produce boot EROFS");
43
44    Ok(erofs_id)
45}
46
47/// Exactly the same as [`generate_boot_image`], but also returns the untransformed
48/// filesystem created from splitstreams
49#[cfg(feature = "boot")]
50pub fn generate_boot_image_get_fs<ObjectID: FsVerityHashValue>(
51    repo: &Arc<Repository<ObjectID>>,
52    manifest_digest: &OciDigest,
53    options: &OciTransformOptions,
54) -> Result<(ObjectID, Option<composefs::tree::FileSystem<ObjectID>>)> {
55    if let Some(existing) = boot_image_for_mode(repo, manifest_digest, options.xattrs)? {
56        return Ok((existing, None));
57    }
58
59    let (erofs_id, untransformed_fs) =
60        crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None, options, true)?
61            .expect("container image should produce boot EROFS");
62
63    Ok((erofs_id, untransformed_fs))
64}
65
66/// Result of [`find_matching_boot_image`].
67#[derive(Debug, Clone)]
68pub enum BootImageMatch<ObjectID> {
69    /// A (mode, format version) combination produced the expected digest.
70    Found {
71        /// The xattr filtering mode that produced `digest`.
72        mode: XattrFiltering,
73        /// The EROFS format version that produced `digest`.
74        version: FormatVersion,
75        /// The resulting boot image digest, equal to the `expected`
76        /// argument passed to [`find_matching_boot_image`].
77        digest: ObjectID,
78    },
79    /// No combination matched. Contains the number of (mode, version)
80    /// combinations that were tried, for use in error messages.
81    NotFound(usize),
82}
83
84/// Try every combination of [`XattrFiltering`] mode and EROFS
85/// [`FormatVersion`] (generating, or cache-hitting, the boot image for each)
86/// until one produces a boot image whose digest matches `expected`.
87/// Short-circuits on the first match.
88///
89/// This is intended for callers that have an expected boot image digest
90/// (e.g. parsed from a UKI's embedded kernel cmdline) and need to determine
91/// which mode and format version were used to originally produce it,
92/// without hard-coding knowledge of which modes or versions exist. This can
93/// happen when a boot image is regenerated by a different composefs-rs
94/// version than the one that originally produced it, since both the default
95/// xattr filtering mode and the default EROFS format version have changed
96/// over time — and a repository's [`FormatConfig`](composefs::erofs::format::FormatConfig)
97/// is fixed for the lifetime of the repository, so it may never spontaneously
98/// generate the format version a UKI actually needs.
99///
100/// Rebuilding the filesystem tree is the expensive part of this search, so
101/// modes are tried outermost (one tree build per mode) and, for each mode,
102/// every relevant format version is tried against that single tree: first
103/// via cheap cached named-ref lookups, then (if none hit) by computing the
104/// image ID directly for whichever versions weren't already covered by the
105/// cache.
106///
107/// On success, returns the matching mode, version and digest (the digest is
108/// always equal to `expected`, but is included for caller convenience); if
109/// the match was found by direct computation rather than a cache hit, the
110/// recovered image is also committed to the repository and cached under its
111/// named ref, so subsequent lookups are cheap. On failure, returns the
112/// number of (mode, version) combinations that were attempted, so the
113/// caller can build an error message like "tried N combinations, none
114/// matched".
115#[cfg(feature = "boot")]
116pub fn find_matching_boot_image<ObjectID: FsVerityHashValue>(
117    repo: &Arc<Repository<ObjectID>>,
118    manifest_digest: &OciDigest,
119    expected: &ObjectID,
120) -> Result<BootImageMatch<ObjectID>> {
121    let mut tried = 0;
122
123    for &mode in XattrFiltering::VARIANTS {
124        match try_mode(repo, manifest_digest, mode, expected)? {
125            ModeResult::Found { version, digest } => {
126                return Ok(BootImageMatch::Found {
127                    mode,
128                    version,
129                    digest,
130                });
131            }
132            ModeResult::NotFound(attempts) => tried += attempts,
133        }
134    }
135
136    Ok(BootImageMatch::NotFound(tried))
137}
138
139/// Result of trying a single [`XattrFiltering`] mode against every relevant
140/// [`FormatVersion`], for [`find_matching_boot_image`].
141#[cfg(feature = "boot")]
142enum ModeResult<ObjectID> {
143    Found {
144        version: FormatVersion,
145        digest: ObjectID,
146    },
147    /// The number of (mode, version) combinations that were tried for this
148    /// mode.
149    NotFound(usize),
150}
151
152/// Tries every [`FormatVersion`] in [`FormatVersion::BOOT_VERSIONS`] for a
153/// single `mode`, against `expected`. Checks cheap cached named refs first;
154/// only builds the filesystem tree (once) if no cached ref matches.
155#[cfg(feature = "boot")]
156fn try_mode<ObjectID: FsVerityHashValue>(
157    repo: &Arc<Repository<ObjectID>>,
158    manifest_digest: &OciDigest,
159    mode: XattrFiltering,
160    expected: &ObjectID,
161) -> Result<ModeResult<ObjectID>> {
162    use composefs_boot::BootOps;
163
164    let img = OciImage::open(repo, manifest_digest, None)?;
165
166    let mut tried = 0;
167    let mut cached_versions = Vec::new();
168
169    // Cheap path: check every already-cached boot image ref for this mode
170    // before doing any expensive tree-building work.
171    for version in FormatVersion::BOOT_VERSIONS {
172        if let Some(digest) = img.boot_image_ref_for_mode(version, mode) {
173            if digest == expected {
174                return Ok(ModeResult::Found {
175                    version,
176                    digest: digest.clone(),
177                });
178            }
179            tried += 1;
180            cached_versions.push(version);
181        }
182    }
183
184    if cached_versions.len() == FormatVersion::BOOT_VERSIONS.len() {
185        // Every relevant version was already cached and none matched; no
186        // point building the tree just to recompute nothing.
187        return Ok(ModeResult::NotFound(tried));
188    }
189
190    // No cached ref matched: build the filesystem tree once for this mode
191    // (the expensive part) and check every format version not already
192    // covered by the cache above, mirroring `ensure_oci_composefs_erofs_boot`.
193    let options = OciTransformOptions { xattrs: mode };
194    let mut fs = crate::image::create_filesystem(
195        repo,
196        img.config_digest(),
197        Some(img.config_verity()),
198        &options,
199    )?;
200    fs.transform_for_boot(repo)?;
201
202    for version in FormatVersion::BOOT_VERSIONS {
203        if cached_versions.contains(&version) {
204            continue;
205        }
206        let (image_data, digest) = fs.compute_image_bytes(version);
207        if &digest == expected {
208            persist_recovered_boot_image(repo, &img, manifest_digest, mode, version, &image_data)?;
209            return Ok(ModeResult::Found { version, digest });
210        }
211        tried += 1;
212    }
213
214    Ok(ModeResult::NotFound(tried))
215}
216
217/// Persists a boot image digest recovered by [`try_mode`] via direct
218/// computation (rather than a cache hit): writes the already-computed
219/// `image_data` and adds its named ref to the OCI config, so the next
220/// lookup for this (mode, version) combination is a cache hit.
221///
222/// Takes the raw EROFS bytes rather than the filesystem tree they were
223/// built from: `try_mode` already generated and hashed this exact image
224/// (via `FileSystem::compute_image_bytes`) while searching for a match, so
225/// writing those same bytes here avoids running `mkfs_erofs` a second time.
226#[cfg(feature = "boot")]
227fn persist_recovered_boot_image<ObjectID: FsVerityHashValue>(
228    repo: &Arc<Repository<ObjectID>>,
229    img: &OciImage<ObjectID>,
230    manifest_digest: &OciDigest,
231    mode: XattrFiltering,
232    version: FormatVersion,
233    image_data: &[u8],
234) -> Result<()> {
235    let digest = repo.write_image(None, image_data)?;
236
237    // Read original config JSON to preserve its exact bytes.
238    let config_json = img.read_config_json(repo)?;
239
240    let mut boot_images = img.boot_image_refs().clone();
241    boot_images.insert(
242        crate::boot_image_ref_key(version, mode)
243            .into_owned()
244            .into_boxed_str(),
245        digest,
246    );
247
248    let (_config_digest, new_config_verity) = crate::write_config_raw(
249        repo,
250        &config_json,
251        img.layer_refs().clone(),
252        img.image_ref_v2(),
253        img.image_ref_v1(),
254        &boot_images,
255    )?;
256
257    let manifest_json = img.read_manifest_json(repo)?;
258    let layer_verities: Vec<_> = img
259        .layer_refs()
260        .iter()
261        .map(|(k, v)| (k.clone(), v.clone()))
262        .collect();
263
264    crate::oci_image::rewrite_manifest(
265        repo,
266        &manifest_json,
267        manifest_digest,
268        &new_config_verity,
269        &layer_verities,
270        None,
271    )?;
272
273    Ok(())
274}
275
276/// Returns the boot EROFS image verity built with the default
277/// ([`XattrFiltering::AllowlistOnly`]) xattr filtering mode, if one exists.
278pub fn boot_image<ObjectID: FsVerityHashValue>(
279    repo: &Repository<ObjectID>,
280    manifest_digest: &OciDigest,
281) -> Result<Option<ObjectID>> {
282    boot_image_for_mode(repo, manifest_digest, XattrFiltering::AllowlistOnly)
283}
284
285/// Returns the boot EROFS image verity built with the given xattr filtering
286/// `mode`, if one exists.
287pub fn boot_image_for_mode<ObjectID: FsVerityHashValue>(
288    repo: &Repository<ObjectID>,
289    manifest_digest: &OciDigest,
290    mode: XattrFiltering,
291) -> Result<Option<ObjectID>> {
292    crate::composefs_boot_erofs_for_manifest(
293        repo,
294        manifest_digest,
295        None,
296        repo.erofs_version(),
297        mode,
298    )
299}
300
301/// Remove all bootable EROFS image references (idempotent), across every
302/// xattr filtering mode that has been cached for this manifest.
303///
304/// The EROFS images themselves are garbage-collected on the next `repo.gc()`.
305pub fn remove_boot_image<ObjectID: FsVerityHashValue>(
306    repo: &Arc<Repository<ObjectID>>,
307    manifest_digest: &OciDigest,
308) -> Result<()> {
309    let img = crate::oci_image::OciImage::open(repo, manifest_digest, None)?;
310
311    if !img.is_container_image() {
312        anyhow::bail!("not a container image");
313    }
314
315    if img.boot_image_refs().is_empty() {
316        return Ok(());
317    }
318
319    // Read original config JSON to preserve its exact bytes
320    let config_json = img.read_config_json(repo)?;
321
322    let (_config_digest, new_config_verity) = crate::write_config_raw(
323        repo,
324        &config_json,
325        img.layer_refs().clone(),
326        img.image_ref_v2(), // preserve existing V2 image ref
327        img.image_ref_v1(), // preserve existing V1 image ref
328        &HashMap::new(),    // drop all boot images, every mode
329    )?;
330
331    let manifest_json = img.read_manifest_json(repo)?;
332    let layer_verities: Vec<_> = img
333        .layer_refs()
334        .iter()
335        .map(|(k, v)| (k.clone(), v.clone()))
336        .collect();
337
338    crate::oci_image::rewrite_manifest(
339        repo,
340        &manifest_json,
341        manifest_digest,
342        &new_config_verity,
343        &layer_verities,
344        None,
345    )?;
346
347    Ok(())
348}
349
350#[cfg(all(test, feature = "boot"))]
351mod test {
352    use super::*;
353    use composefs::fsverity::Sha256HashValue;
354    use composefs::test::TestRepo;
355    use composefs_boot::bootloader::get_boot_resources;
356
357    use crate::oci_image::OciImage;
358    use crate::test_util;
359
360    #[tokio::test]
361    async fn test_boot_image_none_before_generate() {
362        let test_repo = TestRepo::<Sha256HashValue>::new();
363        let repo = &test_repo.repo;
364
365        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
366
367        let result = boot_image(repo, &img.manifest_digest).unwrap();
368        assert!(result.is_none(), "no boot image should exist yet");
369    }
370
371    #[tokio::test]
372    async fn test_generate_boot_image() {
373        let test_repo = TestRepo::<Sha256HashValue>::new();
374        let repo = &test_repo.repo;
375
376        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
377
378        let image_verity =
379            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
380                .unwrap();
381
382        let found = boot_image(repo, &img.manifest_digest).unwrap();
383        assert_eq!(found, Some(image_verity.clone()));
384
385        // Open by tag since manifest was rewritten
386        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
387        assert_eq!(
388            oci.boot_image_ref(repo.erofs_version()),
389            Some(&image_verity)
390        );
391
392        let plain_image = crate::image::create_filesystem(
393            repo,
394            &img.config_digest,
395            None,
396            &OciTransformOptions::default(),
397        )
398        .unwrap();
399        let plain_verity = plain_image.compute_image_id(repo.erofs_version());
400        assert_ne!(
401            image_verity, plain_verity,
402            "boot-transformed image should differ from non-transformed image"
403        );
404    }
405
406    #[tokio::test]
407    async fn test_generate_boot_image_idempotent() {
408        let test_repo = TestRepo::<Sha256HashValue>::new();
409        let repo = &test_repo.repo;
410
411        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
412
413        let v1 = generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
414            .unwrap();
415        let v2 = generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
416            .unwrap();
417        assert_eq!(v1, v2);
418    }
419
420    /// Each xattr filtering mode is cached under its own named ref: generating
421    /// `KeepUserXattrs` produces a distinct image from the default
422    /// `AllowlistOnly` mode (when a `user.*` xattr is present), and neither
423    /// mode evicts or overwrites the other's cache entry.
424    #[tokio::test]
425    async fn test_generate_boot_image_modes_cache_independently() {
426        use test_util::{KernelVersion, OsImage};
427
428        let test_repo = TestRepo::<Sha256HashValue>::new();
429        let repo = &test_repo.repo;
430
431        let img = OsImage::bootable(KernelVersion::V1)
432            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
433            .build_oci(repo, Some("myapp:v1"))
434            .await;
435
436        let allowlist_id = generate_boot_image(
437            repo,
438            &img.manifest_digest,
439            &OciTransformOptions {
440                xattrs: XattrFiltering::AllowlistOnly,
441            },
442        )
443        .unwrap();
444
445        let keep_user_id = generate_boot_image(
446            repo,
447            &img.manifest_digest,
448            &OciTransformOptions {
449                xattrs: XattrFiltering::KeepUserXattrs,
450            },
451        )
452        .unwrap();
453
454        assert_ne!(
455            allowlist_id, keep_user_id,
456            "KeepUserXattrs should produce a distinct image from AllowlistOnly \
457             when a user.* xattr is present"
458        );
459
460        // Both modes remain independently cached: neither generation call
461        // above evicted the other mode's entry.
462        assert_eq!(
463            boot_image(repo, &img.manifest_digest).unwrap(),
464            Some(allowlist_id.clone()),
465            "AllowlistOnly cache entry should be unaffected by the KeepUserXattrs call"
466        );
467        assert_eq!(
468            boot_image_for_mode(repo, &img.manifest_digest, XattrFiltering::KeepUserXattrs)
469                .unwrap(),
470            Some(keep_user_id.clone())
471        );
472
473        // Re-generating either mode is a cache hit, returning the same image.
474        let allowlist_cached =
475            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
476                .unwrap();
477        assert_eq!(allowlist_cached, allowlist_id);
478        let keep_user_cached = generate_boot_image(
479            repo,
480            &img.manifest_digest,
481            &OciTransformOptions {
482                xattrs: XattrFiltering::KeepUserXattrs,
483            },
484        )
485        .unwrap();
486        assert_eq!(keep_user_cached, keep_user_id);
487    }
488
489    #[tokio::test]
490    async fn test_remove_boot_image() {
491        let test_repo = TestRepo::<Sha256HashValue>::new();
492        let repo = &test_repo.repo;
493
494        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
495
496        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
497        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_some());
498
499        remove_boot_image(repo, &img.manifest_digest).unwrap();
500        assert!(
501            boot_image(repo, &img.manifest_digest).unwrap().is_none(),
502            "boot image should be gone after remove"
503        );
504
505        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
506        assert!(oci.is_container_image());
507
508        let gc = repo.gc(&[]).unwrap();
509        assert_eq!(
510            gc.images_pruned, 1,
511            "exactly the EROFS image should be pruned"
512        );
513    }
514
515    #[tokio::test]
516    async fn test_remove_boot_image_idempotent() {
517        let test_repo = TestRepo::<Sha256HashValue>::new();
518        let repo = &test_repo.repo;
519
520        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
521
522        remove_boot_image(repo, &img.manifest_digest).unwrap();
523
524        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
525        remove_boot_image(repo, &img.manifest_digest).unwrap();
526        remove_boot_image(repo, &img.manifest_digest).unwrap();
527
528        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none());
529    }
530
531    #[tokio::test]
532    async fn test_remove_boot_image_clears_all_modes() {
533        let test_repo = TestRepo::<Sha256HashValue>::new();
534        let repo = &test_repo.repo;
535
536        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
537
538        generate_boot_image(
539            repo,
540            &img.manifest_digest,
541            &OciTransformOptions {
542                xattrs: XattrFiltering::AllowlistOnly,
543            },
544        )
545        .unwrap();
546        generate_boot_image(
547            repo,
548            &img.manifest_digest,
549            &OciTransformOptions {
550                xattrs: XattrFiltering::KeepUserXattrs,
551            },
552        )
553        .unwrap();
554
555        remove_boot_image(repo, &img.manifest_digest).unwrap();
556
557        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none());
558        assert!(
559            boot_image_for_mode(repo, &img.manifest_digest, XattrFiltering::KeepUserXattrs)
560                .unwrap()
561                .is_none()
562        );
563    }
564
565    /// When both xattr filtering modes are cached simultaneously for a
566    /// tagged manifest, GC must keep both EROFS images alive; once untagged,
567    /// GC must collect both.
568    #[tokio::test]
569    async fn test_boot_image_gc_handles_both_modes_simultaneously() {
570        use test_util::{KernelVersion, OsImage};
571
572        let test_repo = TestRepo::<Sha256HashValue>::new();
573        let repo = &test_repo.repo;
574
575        let img = OsImage::bootable(KernelVersion::V1)
576            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
577            .build_oci(repo, Some("myapp:v1"))
578            .await;
579
580        let allowlist_id = generate_boot_image(
581            repo,
582            &img.manifest_digest,
583            &OciTransformOptions {
584                xattrs: XattrFiltering::AllowlistOnly,
585            },
586        )
587        .unwrap();
588        let keep_user_id = generate_boot_image(
589            repo,
590            &img.manifest_digest,
591            &OciTransformOptions {
592                xattrs: XattrFiltering::KeepUserXattrs,
593            },
594        )
595        .unwrap();
596        assert_ne!(allowlist_id, keep_user_id);
597
598        // Both modes' images must survive GC while tagged.
599        let gc = repo.gc(&[]).unwrap();
600        assert_eq!(gc.images_pruned, 0);
601        assert_eq!(gc.streams_pruned, 0);
602
603        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
604        assert_eq!(
605            oci.boot_image_ref(repo.erofs_version()),
606            Some(&allowlist_id)
607        );
608        assert_eq!(
609            oci.boot_image_ref_for_mode(repo.erofs_version(), XattrFiltering::KeepUserXattrs),
610            Some(&keep_user_id)
611        );
612
613        // Once untagged, GC must collect both EROFS images.
614        crate::oci_image::untag_image(repo, "myapp:v1").unwrap();
615        let gc = repo.gc(&[]).unwrap();
616        assert_eq!(
617            gc.images_pruned, 2,
618            "both the AllowlistOnly and KeepUserXattrs boot images should be pruned"
619        );
620    }
621
622    #[tokio::test]
623    async fn test_boot_image_gc_preserves_when_tagged() {
624        let test_repo = TestRepo::<Sha256HashValue>::new();
625        let repo = &test_repo.repo;
626
627        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
628
629        let image_verity =
630            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
631                .unwrap();
632
633        let gc = repo.gc(&[]).unwrap();
634        assert_eq!(gc.images_pruned, 0);
635        assert_eq!(gc.streams_pruned, 0);
636
637        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
638        assert_eq!(
639            oci.boot_image_ref(repo.erofs_version()),
640            Some(&image_verity)
641        );
642    }
643
644    #[tokio::test]
645    async fn test_boot_image_gc_collects_after_untag() {
646        let test_repo = TestRepo::<Sha256HashValue>::new();
647        let repo = &test_repo.repo;
648
649        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
650
651        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
652
653        crate::oci_image::untag_image(repo, "myapp:v1").unwrap();
654
655        let gc = repo.gc(&[]).unwrap();
656        assert!(gc.objects_removed > 0);
657        assert_eq!(gc.images_pruned, 1);
658        assert!(gc.streams_pruned > 0);
659
660        let gc2 = repo.gc(&[]).unwrap();
661        assert_eq!(gc2.objects_removed, 0);
662        assert_eq!(gc2.images_pruned, 0);
663        assert_eq!(gc2.streams_pruned, 0);
664    }
665
666    #[tokio::test]
667    async fn test_remove_boot_image_then_gc_preserves_oci() {
668        let test_repo = TestRepo::<Sha256HashValue>::new();
669        let repo = &test_repo.repo;
670
671        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
672
673        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
674
675        remove_boot_image(repo, &img.manifest_digest).unwrap();
676        let gc = repo.gc(&[]).unwrap();
677        assert_eq!(gc.images_pruned, 1);
678
679        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
680        assert!(oci.is_container_image());
681        assert!(oci.boot_image_ref(repo.erofs_version()).is_none());
682    }
683
684    /// [`find_matching_boot_image`] finds the default ([`XattrFiltering::AllowlistOnly`])
685    /// mode when `expected` is the digest that mode produces, without
686    /// needing to try any other mode.
687    #[tokio::test]
688    async fn test_find_matching_boot_image_default_mode() {
689        let test_repo = TestRepo::<Sha256HashValue>::new();
690        let repo = &test_repo.repo;
691
692        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
693
694        let allowlist_id = generate_boot_image(
695            repo,
696            &img.manifest_digest,
697            &OciTransformOptions {
698                xattrs: XattrFiltering::AllowlistOnly,
699            },
700        )
701        .unwrap();
702
703        let result = find_matching_boot_image(repo, &img.manifest_digest, &allowlist_id).unwrap();
704        assert!(matches!(
705            result,
706            BootImageMatch::Found {
707                mode: XattrFiltering::AllowlistOnly,
708                version: FormatVersion::V1,
709                digest,
710            } if digest == allowlist_id
711        ));
712    }
713
714    /// [`find_matching_boot_image`] finds [`XattrFiltering::KeepUserXattrs`]
715    /// when `expected` is the digest that mode produces, proving it actually
716    /// tries every mode rather than stopping after the default.
717    #[tokio::test]
718    async fn test_find_matching_boot_image_non_default_mode() {
719        use test_util::{KernelVersion, OsImage};
720
721        let test_repo = TestRepo::<Sha256HashValue>::new();
722        let repo = &test_repo.repo;
723
724        let img = OsImage::bootable(KernelVersion::V1)
725            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
726            .build_oci(repo, Some("myapp:v1"))
727            .await;
728
729        let keep_user_id = generate_boot_image(
730            repo,
731            &img.manifest_digest,
732            &OciTransformOptions {
733                xattrs: XattrFiltering::KeepUserXattrs,
734            },
735        )
736        .unwrap();
737
738        let result = find_matching_boot_image(repo, &img.manifest_digest, &keep_user_id).unwrap();
739        assert!(matches!(
740            result,
741            BootImageMatch::Found {
742                mode: XattrFiltering::KeepUserXattrs,
743                version: FormatVersion::V1,
744                digest,
745            } if digest == keep_user_id
746        ));
747    }
748
749    /// [`find_matching_boot_image`] returns `NotFound` with the total
750    /// number of (mode, version) combinations attempted when `expected`
751    /// matches nothing.
752    ///
753    /// No boot image has been generated for this manifest yet, so there are
754    /// no cached refs to hit: every mode falls through to building the tree
755    /// and computing both [`FormatVersion::BOOT_VERSIONS`] directly, giving
756    /// `XattrFiltering::VARIANTS.len() * FormatVersion::BOOT_VERSIONS.len()`
757    /// attempts in total.
758    #[tokio::test]
759    async fn test_find_matching_boot_image_not_found() {
760        let test_repo = TestRepo::<Sha256HashValue>::new();
761        let repo = &test_repo.repo;
762
763        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
764        let bogus = Sha256HashValue::from_hex("ff".repeat(32)).unwrap();
765
766        let result = find_matching_boot_image(repo, &img.manifest_digest, &bogus).unwrap();
767        let BootImageMatch::NotFound(tried) = result else {
768            panic!("expected NotFound, got a match");
769        };
770        assert_eq!(
771            tried,
772            XattrFiltering::VARIANTS.len() * FormatVersion::BOOT_VERSIONS.len()
773        );
774    }
775
776    /// [`find_matching_boot_image`] recovers a boot image digest for a
777    /// [`FormatVersion`] the repository is not configured to ever generate.
778    ///
779    /// This is the motivating scenario for the format-version search: a
780    /// repository created by an older composefs-rs/bootc build may be
781    /// permanently locked to a single `FormatConfig` (immutable once
782    /// `meta.json` is written), so a digest embedded in a UKI for a
783    /// *different* format version can never be found via the normal
784    /// cached-ref path, no matter how many times the boot image is
785    /// regenerated through it. Only building the tree once and computing
786    /// the other version's digest directly can recover it.
787    #[tokio::test]
788    async fn test_find_matching_boot_image_non_default_version() {
789        use composefs::erofs::format::FormatConfig;
790        use composefs::repository::RepositoryConfig;
791        use composefs_boot::BootOps;
792        use rustix::fs::CWD;
793
794        let dir = composefs::test::tempdir();
795        let repo_path = dir.path().join("repo");
796        let mut config = RepositoryConfig::new(Sha256HashValue::ALGORITHM).set_insecure();
797        config.erofs_formats = FormatConfig::single(FormatVersion::V2);
798        let (repo, _) = Repository::init_path(CWD, &repo_path, config).unwrap();
799        let repo = Arc::new(repo);
800
801        let img = test_util::create_bootable_image(&repo, Some("myapp:v1"), 1).await;
802
803        // This repo is permanently locked to V2 -- simulating an old
804        // repository whose `FormatConfig` predates a newer default.
805        let v2_id =
806            generate_boot_image(&repo, &img.manifest_digest, &OciTransformOptions::default())
807                .unwrap();
808
809        // What a *different*, V1-defaulting build would have embedded in a
810        // UKI for the same content -- computed directly, not through this
811        // repo's (fixed, V2-only) commit path.
812        let mut fs = crate::image::create_filesystem(
813            &repo,
814            &img.config_digest,
815            None,
816            &OciTransformOptions::default(),
817        )
818        .unwrap();
819        fs.transform_for_boot(&repo).unwrap();
820        let v1_id = fs.compute_image_id(FormatVersion::V1);
821        assert_ne!(v1_id, v2_id);
822
823        let oci = OciImage::open(&repo, &img.manifest_digest, None).unwrap();
824        assert!(
825            oci.boot_image_ref_v1().is_none(),
826            "V1 ref should not exist yet in this V2-only repo"
827        );
828
829        let result = find_matching_boot_image(&repo, &img.manifest_digest, &v1_id).unwrap();
830        assert!(matches!(
831            result,
832            BootImageMatch::Found {
833                mode: XattrFiltering::AllowlistOnly,
834                version: FormatVersion::V1,
835                digest,
836            } if digest == v1_id
837        ));
838
839        // The recovered image must now be persisted and cached for next time.
840        let oci_after = OciImage::open(&repo, &img.manifest_digest, None).unwrap();
841        assert_eq!(oci_after.boot_image_ref_v1(), Some(&v1_id));
842    }
843
844    /// Boot EROFS differs from plain EROFS and contains the expected boot entries.
845    #[tokio::test]
846    async fn test_boot_content() {
847        for tag in ["myapp:v1", "uki:v1"] {
848            let test_repo = TestRepo::<Sha256HashValue>::new();
849            let repo = &test_repo.repo;
850
851            let img = test_util::create_bootable_image(repo, Some(tag), 1).await;
852
853            let boot_verity =
854                generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
855                    .unwrap();
856
857            let fs = crate::image::create_filesystem(
858                repo,
859                &img.config_digest,
860                None,
861                &OciTransformOptions::default(),
862            )
863            .unwrap();
864            let boot_entries = get_boot_resources(&fs, repo).unwrap();
865            assert_eq!(boot_entries.len(), 2, "tag={tag}");
866            assert!(
867                boot_entries.iter().any(|e| matches!(
868                    e,
869                    composefs_boot::bootloader::BootEntry::UsrLibModulesVmLinuz(_)
870                )),
871                "tag={tag}: expected vmlinuz entry"
872            );
873            assert!(
874                boot_entries
875                    .iter()
876                    .any(|e| matches!(e, composefs_boot::bootloader::BootEntry::Type2(_))),
877                "tag={tag}: expected Type2 entry"
878            );
879
880            let plain_fs = crate::image::create_filesystem(
881                repo,
882                &img.config_digest,
883                None,
884                &OciTransformOptions::default(),
885            )
886            .unwrap();
887            let plain_verity = plain_fs.commit_image(repo, None).unwrap();
888            assert_ne!(boot_verity, plain_verity, "tag={tag}");
889        }
890    }
891}