composefs-oci 0.9.0

Use OCI container images with composefs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
//! Boot image management for OCI containers.
//!
//! A bootable EROFS image is a derived artifact from an OCI container image
//! that filters out some components (such as the UKI) to avoid circular references.

use std::collections::HashMap;
use std::sync::Arc;

use anyhow::Result;

use composefs::erofs::format::FormatVersion;
use composefs::fsverity::FsVerityHashValue;
#[cfg(feature = "boot")]
use composefs::generic_tree::OciTransformOptions;
use composefs::generic_tree::XattrFiltering;
use composefs::repository::Repository;

use crate::OciDigest;
#[cfg(feature = "boot")]
use crate::oci_image::OciImage;

/// Generate a bootable EROFS image from a pulled OCI manifest (idempotent),
/// with configurable OCI transform options (currently just the xattr
/// filtering mode; see [`OciTransformOptions`]).
///
/// Each xattr filtering mode is cached independently under its own named
/// ref (see [`boot_image_for_mode`]): requesting a different mode for the
/// same manifest does not evict or overwrite the boot image cached for
/// another mode.
#[cfg(feature = "boot")]
pub fn generate_boot_image<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    manifest_digest: &OciDigest,
    options: &OciTransformOptions,
) -> Result<ObjectID> {
    if let Some(existing) = boot_image_for_mode(repo, manifest_digest, options.xattrs)? {
        return Ok(existing);
    }

    let (erofs_id, _) =
        crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None, options, false)?
            .expect("container image should produce boot EROFS");

    Ok(erofs_id)
}

/// Exactly the same as [`generate_boot_image`], but also returns the untransformed
/// filesystem created from splitstreams
#[cfg(feature = "boot")]
pub fn generate_boot_image_get_fs<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    manifest_digest: &OciDigest,
    options: &OciTransformOptions,
) -> Result<(ObjectID, Option<composefs::tree::FileSystem<ObjectID>>)> {
    if let Some(existing) = boot_image_for_mode(repo, manifest_digest, options.xattrs)? {
        return Ok((existing, None));
    }

    let (erofs_id, untransformed_fs) =
        crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None, options, true)?
            .expect("container image should produce boot EROFS");

    Ok((erofs_id, untransformed_fs))
}

/// Result of [`find_matching_boot_image`].
#[derive(Debug, Clone)]
pub enum BootImageMatch<ObjectID> {
    /// A (mode, format version) combination produced the expected digest.
    Found {
        /// The xattr filtering mode that produced `digest`.
        mode: XattrFiltering,
        /// The EROFS format version that produced `digest`.
        version: FormatVersion,
        /// The resulting boot image digest, equal to the `expected`
        /// argument passed to [`find_matching_boot_image`].
        digest: ObjectID,
    },
    /// No combination matched. Contains the number of (mode, version)
    /// combinations that were tried, for use in error messages.
    NotFound(usize),
}

/// Try every combination of [`XattrFiltering`] mode and EROFS
/// [`FormatVersion`] (generating, or cache-hitting, the boot image for each)
/// until one produces a boot image whose digest matches `expected`.
/// Short-circuits on the first match.
///
/// This is intended for callers that have an expected boot image digest
/// (e.g. parsed from a UKI's embedded kernel cmdline) and need to determine
/// which mode and format version were used to originally produce it,
/// without hard-coding knowledge of which modes or versions exist. This can
/// happen when a boot image is regenerated by a different composefs-rs
/// version than the one that originally produced it, since both the default
/// xattr filtering mode and the default EROFS format version have changed
/// over time — and a repository's [`FormatConfig`](composefs::erofs::format::FormatConfig)
/// is fixed for the lifetime of the repository, so it may never spontaneously
/// generate the format version a UKI actually needs.
///
/// Rebuilding the filesystem tree is the expensive part of this search, so
/// modes are tried outermost (one tree build per mode) and, for each mode,
/// every relevant format version is tried against that single tree: first
/// via cheap cached named-ref lookups, then (if none hit) by computing the
/// image ID directly for whichever versions weren't already covered by the
/// cache.
///
/// On success, returns the matching mode, version and digest (the digest is
/// always equal to `expected`, but is included for caller convenience); if
/// the match was found by direct computation rather than a cache hit, the
/// recovered image is also committed to the repository and cached under its
/// named ref, so subsequent lookups are cheap. On failure, returns the
/// number of (mode, version) combinations that were attempted, so the
/// caller can build an error message like "tried N combinations, none
/// matched".
#[cfg(feature = "boot")]
pub fn find_matching_boot_image<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    manifest_digest: &OciDigest,
    expected: &ObjectID,
) -> Result<BootImageMatch<ObjectID>> {
    let mut tried = 0;

    for &mode in XattrFiltering::VARIANTS {
        match try_mode(repo, manifest_digest, mode, expected)? {
            ModeResult::Found { version, digest } => {
                return Ok(BootImageMatch::Found {
                    mode,
                    version,
                    digest,
                });
            }
            ModeResult::NotFound(attempts) => tried += attempts,
        }
    }

    Ok(BootImageMatch::NotFound(tried))
}

/// Result of trying a single [`XattrFiltering`] mode against every relevant
/// [`FormatVersion`], for [`find_matching_boot_image`].
#[cfg(feature = "boot")]
enum ModeResult<ObjectID> {
    Found {
        version: FormatVersion,
        digest: ObjectID,
    },
    /// The number of (mode, version) combinations that were tried for this
    /// mode.
    NotFound(usize),
}

/// Tries every [`FormatVersion`] in [`FormatVersion::BOOT_VERSIONS`] for a
/// single `mode`, against `expected`. Checks cheap cached named refs first;
/// only builds the filesystem tree (once) if no cached ref matches.
#[cfg(feature = "boot")]
fn try_mode<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    manifest_digest: &OciDigest,
    mode: XattrFiltering,
    expected: &ObjectID,
) -> Result<ModeResult<ObjectID>> {
    use composefs_boot::BootOps;

    let img = OciImage::open(repo, manifest_digest, None)?;

    let mut tried = 0;
    let mut cached_versions = Vec::new();

    // Cheap path: check every already-cached boot image ref for this mode
    // before doing any expensive tree-building work.
    for version in FormatVersion::BOOT_VERSIONS {
        if let Some(digest) = img.boot_image_ref_for_mode(version, mode) {
            if digest == expected {
                return Ok(ModeResult::Found {
                    version,
                    digest: digest.clone(),
                });
            }
            tried += 1;
            cached_versions.push(version);
        }
    }

    if cached_versions.len() == FormatVersion::BOOT_VERSIONS.len() {
        // Every relevant version was already cached and none matched; no
        // point building the tree just to recompute nothing.
        return Ok(ModeResult::NotFound(tried));
    }

    // No cached ref matched: build the filesystem tree once for this mode
    // (the expensive part) and check every format version not already
    // covered by the cache above, mirroring `ensure_oci_composefs_erofs_boot`.
    let options = OciTransformOptions { xattrs: mode };
    let mut fs = crate::image::create_filesystem(
        repo,
        img.config_digest(),
        Some(img.config_verity()),
        &options,
    )?;
    fs.transform_for_boot(repo)?;

    for version in FormatVersion::BOOT_VERSIONS {
        if cached_versions.contains(&version) {
            continue;
        }
        let (image_data, digest) = fs.compute_image_bytes(version);
        if &digest == expected {
            persist_recovered_boot_image(repo, &img, manifest_digest, mode, version, &image_data)?;
            return Ok(ModeResult::Found { version, digest });
        }
        tried += 1;
    }

    Ok(ModeResult::NotFound(tried))
}

/// Persists a boot image digest recovered by [`try_mode`] via direct
/// computation (rather than a cache hit): writes the already-computed
/// `image_data` and adds its named ref to the OCI config, so the next
/// lookup for this (mode, version) combination is a cache hit.
///
/// Takes the raw EROFS bytes rather than the filesystem tree they were
/// built from: `try_mode` already generated and hashed this exact image
/// (via `FileSystem::compute_image_bytes`) while searching for a match, so
/// writing those same bytes here avoids running `mkfs_erofs` a second time.
#[cfg(feature = "boot")]
fn persist_recovered_boot_image<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    img: &OciImage<ObjectID>,
    manifest_digest: &OciDigest,
    mode: XattrFiltering,
    version: FormatVersion,
    image_data: &[u8],
) -> Result<()> {
    let digest = repo.write_image(None, image_data)?;

    // Read original config JSON to preserve its exact bytes.
    let config_json = img.read_config_json(repo)?;

    let mut boot_images = img.boot_image_refs().clone();
    boot_images.insert(
        crate::boot_image_ref_key(version, mode)
            .into_owned()
            .into_boxed_str(),
        digest,
    );

    let (_config_digest, new_config_verity) = crate::write_config_raw(
        repo,
        &config_json,
        img.layer_refs().clone(),
        img.image_ref_v2(),
        img.image_ref_v1(),
        &boot_images,
    )?;

    let manifest_json = img.read_manifest_json(repo)?;
    let layer_verities: Vec<_> = img
        .layer_refs()
        .iter()
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();

    crate::oci_image::rewrite_manifest(
        repo,
        &manifest_json,
        manifest_digest,
        &new_config_verity,
        &layer_verities,
        None,
    )?;

    Ok(())
}

/// Returns the boot EROFS image verity built with the default
/// ([`XattrFiltering::AllowlistOnly`]) xattr filtering mode, if one exists.
pub fn boot_image<ObjectID: FsVerityHashValue>(
    repo: &Repository<ObjectID>,
    manifest_digest: &OciDigest,
) -> Result<Option<ObjectID>> {
    boot_image_for_mode(repo, manifest_digest, XattrFiltering::AllowlistOnly)
}

/// Returns the boot EROFS image verity built with the given xattr filtering
/// `mode`, if one exists.
pub fn boot_image_for_mode<ObjectID: FsVerityHashValue>(
    repo: &Repository<ObjectID>,
    manifest_digest: &OciDigest,
    mode: XattrFiltering,
) -> Result<Option<ObjectID>> {
    crate::composefs_boot_erofs_for_manifest(
        repo,
        manifest_digest,
        None,
        repo.erofs_version(),
        mode,
    )
}

/// Remove all bootable EROFS image references (idempotent), across every
/// xattr filtering mode that has been cached for this manifest.
///
/// The EROFS images themselves are garbage-collected on the next `repo.gc()`.
pub fn remove_boot_image<ObjectID: FsVerityHashValue>(
    repo: &Arc<Repository<ObjectID>>,
    manifest_digest: &OciDigest,
) -> Result<()> {
    let img = crate::oci_image::OciImage::open(repo, manifest_digest, None)?;

    if !img.is_container_image() {
        anyhow::bail!("not a container image");
    }

    if img.boot_image_refs().is_empty() {
        return Ok(());
    }

    // Read original config JSON to preserve its exact bytes
    let config_json = img.read_config_json(repo)?;

    let (_config_digest, new_config_verity) = crate::write_config_raw(
        repo,
        &config_json,
        img.layer_refs().clone(),
        img.image_ref_v2(), // preserve existing V2 image ref
        img.image_ref_v1(), // preserve existing V1 image ref
        &HashMap::new(),    // drop all boot images, every mode
    )?;

    let manifest_json = img.read_manifest_json(repo)?;
    let layer_verities: Vec<_> = img
        .layer_refs()
        .iter()
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();

    crate::oci_image::rewrite_manifest(
        repo,
        &manifest_json,
        manifest_digest,
        &new_config_verity,
        &layer_verities,
        None,
    )?;

    Ok(())
}

#[cfg(all(test, feature = "boot"))]
mod test {
    use super::*;
    use composefs::fsverity::Sha256HashValue;
    use composefs::test::TestRepo;
    use composefs_boot::bootloader::get_boot_resources;

    use crate::oci_image::OciImage;
    use crate::test_util;

    #[tokio::test]
    async fn test_boot_image_none_before_generate() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        let result = boot_image(repo, &img.manifest_digest).unwrap();
        assert!(result.is_none(), "no boot image should exist yet");
    }

    #[tokio::test]
    async fn test_generate_boot_image() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        let image_verity =
            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
                .unwrap();

        let found = boot_image(repo, &img.manifest_digest).unwrap();
        assert_eq!(found, Some(image_verity.clone()));

        // Open by tag since manifest was rewritten
        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
        assert_eq!(
            oci.boot_image_ref(repo.erofs_version()),
            Some(&image_verity)
        );

        let plain_image = crate::image::create_filesystem(
            repo,
            &img.config_digest,
            None,
            &OciTransformOptions::default(),
        )
        .unwrap();
        let plain_verity = plain_image.compute_image_id(repo.erofs_version());
        assert_ne!(
            image_verity, plain_verity,
            "boot-transformed image should differ from non-transformed image"
        );
    }

    #[tokio::test]
    async fn test_generate_boot_image_idempotent() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        let v1 = generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
            .unwrap();
        let v2 = generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
            .unwrap();
        assert_eq!(v1, v2);
    }

    /// Each xattr filtering mode is cached under its own named ref: generating
    /// `KeepUserXattrs` produces a distinct image from the default
    /// `AllowlistOnly` mode (when a `user.*` xattr is present), and neither
    /// mode evicts or overwrites the other's cache entry.
    #[tokio::test]
    async fn test_generate_boot_image_modes_cache_independently() {
        use test_util::{KernelVersion, OsImage};

        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = OsImage::bootable(KernelVersion::V1)
            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
            .build_oci(repo, Some("myapp:v1"))
            .await;

        let allowlist_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::AllowlistOnly,
            },
        )
        .unwrap();

        let keep_user_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::KeepUserXattrs,
            },
        )
        .unwrap();

        assert_ne!(
            allowlist_id, keep_user_id,
            "KeepUserXattrs should produce a distinct image from AllowlistOnly \
             when a user.* xattr is present"
        );

        // Both modes remain independently cached: neither generation call
        // above evicted the other mode's entry.
        assert_eq!(
            boot_image(repo, &img.manifest_digest).unwrap(),
            Some(allowlist_id.clone()),
            "AllowlistOnly cache entry should be unaffected by the KeepUserXattrs call"
        );
        assert_eq!(
            boot_image_for_mode(repo, &img.manifest_digest, XattrFiltering::KeepUserXattrs)
                .unwrap(),
            Some(keep_user_id.clone())
        );

        // Re-generating either mode is a cache hit, returning the same image.
        let allowlist_cached =
            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
                .unwrap();
        assert_eq!(allowlist_cached, allowlist_id);
        let keep_user_cached = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::KeepUserXattrs,
            },
        )
        .unwrap();
        assert_eq!(keep_user_cached, keep_user_id);
    }

    #[tokio::test]
    async fn test_remove_boot_image() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_some());

        remove_boot_image(repo, &img.manifest_digest).unwrap();
        assert!(
            boot_image(repo, &img.manifest_digest).unwrap().is_none(),
            "boot image should be gone after remove"
        );

        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
        assert!(oci.is_container_image());

        let gc = repo.gc(&[]).unwrap();
        assert_eq!(
            gc.images_pruned, 1,
            "exactly the EROFS image should be pruned"
        );
    }

    #[tokio::test]
    async fn test_remove_boot_image_idempotent() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        remove_boot_image(repo, &img.manifest_digest).unwrap();

        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();
        remove_boot_image(repo, &img.manifest_digest).unwrap();
        remove_boot_image(repo, &img.manifest_digest).unwrap();

        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none());
    }

    #[tokio::test]
    async fn test_remove_boot_image_clears_all_modes() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::AllowlistOnly,
            },
        )
        .unwrap();
        generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::KeepUserXattrs,
            },
        )
        .unwrap();

        remove_boot_image(repo, &img.manifest_digest).unwrap();

        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none());
        assert!(
            boot_image_for_mode(repo, &img.manifest_digest, XattrFiltering::KeepUserXattrs)
                .unwrap()
                .is_none()
        );
    }

    /// When both xattr filtering modes are cached simultaneously for a
    /// tagged manifest, GC must keep both EROFS images alive; once untagged,
    /// GC must collect both.
    #[tokio::test]
    async fn test_boot_image_gc_handles_both_modes_simultaneously() {
        use test_util::{KernelVersion, OsImage};

        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = OsImage::bootable(KernelVersion::V1)
            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
            .build_oci(repo, Some("myapp:v1"))
            .await;

        let allowlist_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::AllowlistOnly,
            },
        )
        .unwrap();
        let keep_user_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::KeepUserXattrs,
            },
        )
        .unwrap();
        assert_ne!(allowlist_id, keep_user_id);

        // Both modes' images must survive GC while tagged.
        let gc = repo.gc(&[]).unwrap();
        assert_eq!(gc.images_pruned, 0);
        assert_eq!(gc.streams_pruned, 0);

        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
        assert_eq!(
            oci.boot_image_ref(repo.erofs_version()),
            Some(&allowlist_id)
        );
        assert_eq!(
            oci.boot_image_ref_for_mode(repo.erofs_version(), XattrFiltering::KeepUserXattrs),
            Some(&keep_user_id)
        );

        // Once untagged, GC must collect both EROFS images.
        crate::oci_image::untag_image(repo, "myapp:v1").unwrap();
        let gc = repo.gc(&[]).unwrap();
        assert_eq!(
            gc.images_pruned, 2,
            "both the AllowlistOnly and KeepUserXattrs boot images should be pruned"
        );
    }

    #[tokio::test]
    async fn test_boot_image_gc_preserves_when_tagged() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        let image_verity =
            generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
                .unwrap();

        let gc = repo.gc(&[]).unwrap();
        assert_eq!(gc.images_pruned, 0);
        assert_eq!(gc.streams_pruned, 0);

        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
        assert_eq!(
            oci.boot_image_ref(repo.erofs_version()),
            Some(&image_verity)
        );
    }

    #[tokio::test]
    async fn test_boot_image_gc_collects_after_untag() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();

        crate::oci_image::untag_image(repo, "myapp:v1").unwrap();

        let gc = repo.gc(&[]).unwrap();
        assert!(gc.objects_removed > 0);
        assert_eq!(gc.images_pruned, 1);
        assert!(gc.streams_pruned > 0);

        let gc2 = repo.gc(&[]).unwrap();
        assert_eq!(gc2.objects_removed, 0);
        assert_eq!(gc2.images_pruned, 0);
        assert_eq!(gc2.streams_pruned, 0);
    }

    #[tokio::test]
    async fn test_remove_boot_image_then_gc_preserves_oci() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default()).unwrap();

        remove_boot_image(repo, &img.manifest_digest).unwrap();
        let gc = repo.gc(&[]).unwrap();
        assert_eq!(gc.images_pruned, 1);

        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
        assert!(oci.is_container_image());
        assert!(oci.boot_image_ref(repo.erofs_version()).is_none());
    }

    /// [`find_matching_boot_image`] finds the default ([`XattrFiltering::AllowlistOnly`])
    /// mode when `expected` is the digest that mode produces, without
    /// needing to try any other mode.
    #[tokio::test]
    async fn test_find_matching_boot_image_default_mode() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;

        let allowlist_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::AllowlistOnly,
            },
        )
        .unwrap();

        let result = find_matching_boot_image(repo, &img.manifest_digest, &allowlist_id).unwrap();
        assert!(matches!(
            result,
            BootImageMatch::Found {
                mode: XattrFiltering::AllowlistOnly,
                version: FormatVersion::V1,
                digest,
            } if digest == allowlist_id
        ));
    }

    /// [`find_matching_boot_image`] finds [`XattrFiltering::KeepUserXattrs`]
    /// when `expected` is the digest that mode produces, proving it actually
    /// tries every mode rather than stopping after the default.
    #[tokio::test]
    async fn test_find_matching_boot_image_non_default_mode() {
        use test_util::{KernelVersion, OsImage};

        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = OsImage::bootable(KernelVersion::V1)
            .with_layer("/usr/lib/testfile 5 100644 1 0 0 0 0.0 - hello - user.testattr=hi")
            .build_oci(repo, Some("myapp:v1"))
            .await;

        let keep_user_id = generate_boot_image(
            repo,
            &img.manifest_digest,
            &OciTransformOptions {
                xattrs: XattrFiltering::KeepUserXattrs,
            },
        )
        .unwrap();

        let result = find_matching_boot_image(repo, &img.manifest_digest, &keep_user_id).unwrap();
        assert!(matches!(
            result,
            BootImageMatch::Found {
                mode: XattrFiltering::KeepUserXattrs,
                version: FormatVersion::V1,
                digest,
            } if digest == keep_user_id
        ));
    }

    /// [`find_matching_boot_image`] returns `NotFound` with the total
    /// number of (mode, version) combinations attempted when `expected`
    /// matches nothing.
    ///
    /// No boot image has been generated for this manifest yet, so there are
    /// no cached refs to hit: every mode falls through to building the tree
    /// and computing both [`FormatVersion::BOOT_VERSIONS`] directly, giving
    /// `XattrFiltering::VARIANTS.len() * FormatVersion::BOOT_VERSIONS.len()`
    /// attempts in total.
    #[tokio::test]
    async fn test_find_matching_boot_image_not_found() {
        let test_repo = TestRepo::<Sha256HashValue>::new();
        let repo = &test_repo.repo;

        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
        let bogus = Sha256HashValue::from_hex("ff".repeat(32)).unwrap();

        let result = find_matching_boot_image(repo, &img.manifest_digest, &bogus).unwrap();
        let BootImageMatch::NotFound(tried) = result else {
            panic!("expected NotFound, got a match");
        };
        assert_eq!(
            tried,
            XattrFiltering::VARIANTS.len() * FormatVersion::BOOT_VERSIONS.len()
        );
    }

    /// [`find_matching_boot_image`] recovers a boot image digest for a
    /// [`FormatVersion`] the repository is not configured to ever generate.
    ///
    /// This is the motivating scenario for the format-version search: a
    /// repository created by an older composefs-rs/bootc build may be
    /// permanently locked to a single `FormatConfig` (immutable once
    /// `meta.json` is written), so a digest embedded in a UKI for a
    /// *different* format version can never be found via the normal
    /// cached-ref path, no matter how many times the boot image is
    /// regenerated through it. Only building the tree once and computing
    /// the other version's digest directly can recover it.
    #[tokio::test]
    async fn test_find_matching_boot_image_non_default_version() {
        use composefs::erofs::format::FormatConfig;
        use composefs::repository::RepositoryConfig;
        use composefs_boot::BootOps;
        use rustix::fs::CWD;

        let dir = composefs::test::tempdir();
        let repo_path = dir.path().join("repo");
        let mut config = RepositoryConfig::new(Sha256HashValue::ALGORITHM).set_insecure();
        config.erofs_formats = FormatConfig::single(FormatVersion::V2);
        let (repo, _) = Repository::init_path(CWD, &repo_path, config).unwrap();
        let repo = Arc::new(repo);

        let img = test_util::create_bootable_image(&repo, Some("myapp:v1"), 1).await;

        // This repo is permanently locked to V2 -- simulating an old
        // repository whose `FormatConfig` predates a newer default.
        let v2_id =
            generate_boot_image(&repo, &img.manifest_digest, &OciTransformOptions::default())
                .unwrap();

        // What a *different*, V1-defaulting build would have embedded in a
        // UKI for the same content -- computed directly, not through this
        // repo's (fixed, V2-only) commit path.
        let mut fs = crate::image::create_filesystem(
            &repo,
            &img.config_digest,
            None,
            &OciTransformOptions::default(),
        )
        .unwrap();
        fs.transform_for_boot(&repo).unwrap();
        let v1_id = fs.compute_image_id(FormatVersion::V1);
        assert_ne!(v1_id, v2_id);

        let oci = OciImage::open(&repo, &img.manifest_digest, None).unwrap();
        assert!(
            oci.boot_image_ref_v1().is_none(),
            "V1 ref should not exist yet in this V2-only repo"
        );

        let result = find_matching_boot_image(&repo, &img.manifest_digest, &v1_id).unwrap();
        assert!(matches!(
            result,
            BootImageMatch::Found {
                mode: XattrFiltering::AllowlistOnly,
                version: FormatVersion::V1,
                digest,
            } if digest == v1_id
        ));

        // The recovered image must now be persisted and cached for next time.
        let oci_after = OciImage::open(&repo, &img.manifest_digest, None).unwrap();
        assert_eq!(oci_after.boot_image_ref_v1(), Some(&v1_id));
    }

    /// Boot EROFS differs from plain EROFS and contains the expected boot entries.
    #[tokio::test]
    async fn test_boot_content() {
        for tag in ["myapp:v1", "uki:v1"] {
            let test_repo = TestRepo::<Sha256HashValue>::new();
            let repo = &test_repo.repo;

            let img = test_util::create_bootable_image(repo, Some(tag), 1).await;

            let boot_verity =
                generate_boot_image(repo, &img.manifest_digest, &OciTransformOptions::default())
                    .unwrap();

            let fs = crate::image::create_filesystem(
                repo,
                &img.config_digest,
                None,
                &OciTransformOptions::default(),
            )
            .unwrap();
            let boot_entries = get_boot_resources(&fs, repo).unwrap();
            assert_eq!(boot_entries.len(), 2, "tag={tag}");
            assert!(
                boot_entries.iter().any(|e| matches!(
                    e,
                    composefs_boot::bootloader::BootEntry::UsrLibModulesVmLinuz(_)
                )),
                "tag={tag}: expected vmlinuz entry"
            );
            assert!(
                boot_entries
                    .iter()
                    .any(|e| matches!(e, composefs_boot::bootloader::BootEntry::Type2(_))),
                "tag={tag}: expected Type2 entry"
            );

            let plain_fs = crate::image::create_filesystem(
                repo,
                &img.config_digest,
                None,
                &OciTransformOptions::default(),
            )
            .unwrap();
            let plain_verity = plain_fs.commit_image(repo, None).unwrap();
            assert_ne!(boot_verity, plain_verity, "tag={tag}");
        }
    }
}