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::sync::Arc;
7
8use anyhow::Result;
9
10use composefs::fsverity::FsVerityHashValue;
11use composefs::repository::Repository;
12
13use crate::OciDigest;
14
15/// Generate a bootable EROFS image from a pulled OCI manifest (idempotent).
16#[cfg(feature = "boot")]
17pub fn generate_boot_image<ObjectID: FsVerityHashValue>(
18    repo: &Arc<Repository<ObjectID>>,
19    manifest_digest: &OciDigest,
20) -> Result<ObjectID> {
21    if let Some(existing) = boot_image(repo, manifest_digest)? {
22        return Ok(existing);
23    }
24
25    let erofs_id = crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None)?
26        .expect("container image should produce boot EROFS");
27
28    Ok(erofs_id)
29}
30
31/// Returns the boot EROFS image verity, if one exists.
32pub fn boot_image<ObjectID: FsVerityHashValue>(
33    repo: &Repository<ObjectID>,
34    manifest_digest: &OciDigest,
35) -> Result<Option<ObjectID>> {
36    crate::composefs_boot_erofs_for_manifest(repo, manifest_digest, None, repo.erofs_version())
37}
38
39/// Remove the bootable EROFS image reference (idempotent).
40///
41/// The EROFS image itself is garbage-collected on the next `repo.gc()`.
42pub fn remove_boot_image<ObjectID: FsVerityHashValue>(
43    repo: &Arc<Repository<ObjectID>>,
44    manifest_digest: &OciDigest,
45) -> Result<()> {
46    let img = crate::oci_image::OciImage::open(repo, manifest_digest, None)?;
47
48    if !img.is_container_image() {
49        anyhow::bail!("not a container image");
50    }
51
52    if img.boot_image_ref(repo.erofs_version()).is_none() {
53        return Ok(());
54    }
55
56    // Read original config JSON to preserve its exact bytes
57    let config_json = img.read_config_json(repo)?;
58
59    let (_config_digest, new_config_verity) = crate::write_config_raw(
60        repo,
61        &config_json,
62        img.layer_refs().clone(),
63        img.image_ref_v2(), // preserve existing V2 image ref
64        img.image_ref_v1(), // preserve existing V1 image ref
65        None,               // no boot image (V2)
66        None,               // no boot image (V1)
67    )?;
68
69    let manifest_json = img.read_manifest_json(repo)?;
70    let layer_verities: Vec<_> = img
71        .layer_refs()
72        .iter()
73        .map(|(k, v)| (k.clone(), v.clone()))
74        .collect();
75
76    crate::oci_image::rewrite_manifest(
77        repo,
78        &manifest_json,
79        manifest_digest,
80        &new_config_verity,
81        &layer_verities,
82        None,
83    )?;
84
85    Ok(())
86}
87
88#[cfg(all(test, feature = "boot"))]
89mod test {
90    use super::*;
91    use composefs::fsverity::Sha256HashValue;
92    use composefs::test::TestRepo;
93    use composefs_boot::bootloader::get_boot_resources;
94
95    use crate::oci_image::OciImage;
96    use crate::test_util;
97
98    #[tokio::test]
99    async fn test_boot_image_none_before_generate() {
100        let test_repo = TestRepo::<Sha256HashValue>::new();
101        let repo = &test_repo.repo;
102
103        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
104
105        let result = boot_image(repo, &img.manifest_digest).unwrap();
106        assert!(result.is_none(), "no boot image should exist yet");
107    }
108
109    #[tokio::test]
110    async fn test_generate_boot_image() {
111        let test_repo = TestRepo::<Sha256HashValue>::new();
112        let repo = &test_repo.repo;
113
114        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
115
116        let image_verity = generate_boot_image(repo, &img.manifest_digest).unwrap();
117
118        let found = boot_image(repo, &img.manifest_digest).unwrap();
119        assert_eq!(found, Some(image_verity.clone()));
120
121        // Open by tag since manifest was rewritten
122        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
123        assert_eq!(
124            oci.boot_image_ref(repo.erofs_version()),
125            Some(&image_verity)
126        );
127
128        let plain_image = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap();
129        let plain_verity = plain_image.compute_image_id(repo.erofs_version());
130        assert_ne!(
131            image_verity, plain_verity,
132            "boot-transformed image should differ from non-transformed image"
133        );
134    }
135
136    #[tokio::test]
137    async fn test_generate_boot_image_idempotent() {
138        let test_repo = TestRepo::<Sha256HashValue>::new();
139        let repo = &test_repo.repo;
140
141        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
142
143        let v1 = generate_boot_image(repo, &img.manifest_digest).unwrap();
144        let v2 = generate_boot_image(repo, &img.manifest_digest).unwrap();
145        assert_eq!(v1, v2);
146    }
147
148    #[tokio::test]
149    async fn test_remove_boot_image() {
150        let test_repo = TestRepo::<Sha256HashValue>::new();
151        let repo = &test_repo.repo;
152
153        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
154
155        generate_boot_image(repo, &img.manifest_digest).unwrap();
156        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_some());
157
158        remove_boot_image(repo, &img.manifest_digest).unwrap();
159        assert!(
160            boot_image(repo, &img.manifest_digest).unwrap().is_none(),
161            "boot image should be gone after remove"
162        );
163
164        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
165        assert!(oci.is_container_image());
166
167        let gc = repo.gc(&[]).unwrap();
168        assert_eq!(
169            gc.images_pruned, 1,
170            "exactly the EROFS image should be pruned"
171        );
172    }
173
174    #[tokio::test]
175    async fn test_remove_boot_image_idempotent() {
176        let test_repo = TestRepo::<Sha256HashValue>::new();
177        let repo = &test_repo.repo;
178
179        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
180
181        remove_boot_image(repo, &img.manifest_digest).unwrap();
182
183        generate_boot_image(repo, &img.manifest_digest).unwrap();
184        remove_boot_image(repo, &img.manifest_digest).unwrap();
185        remove_boot_image(repo, &img.manifest_digest).unwrap();
186
187        assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none());
188    }
189
190    #[tokio::test]
191    async fn test_boot_image_gc_preserves_when_tagged() {
192        let test_repo = TestRepo::<Sha256HashValue>::new();
193        let repo = &test_repo.repo;
194
195        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
196
197        let image_verity = generate_boot_image(repo, &img.manifest_digest).unwrap();
198
199        let gc = repo.gc(&[]).unwrap();
200        assert_eq!(gc.images_pruned, 0);
201        assert_eq!(gc.streams_pruned, 0);
202
203        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
204        assert_eq!(
205            oci.boot_image_ref(repo.erofs_version()),
206            Some(&image_verity)
207        );
208    }
209
210    #[tokio::test]
211    async fn test_boot_image_gc_collects_after_untag() {
212        let test_repo = TestRepo::<Sha256HashValue>::new();
213        let repo = &test_repo.repo;
214
215        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
216
217        generate_boot_image(repo, &img.manifest_digest).unwrap();
218
219        crate::oci_image::untag_image(repo, "myapp:v1").unwrap();
220
221        let gc = repo.gc(&[]).unwrap();
222        assert!(gc.objects_removed > 0);
223        assert_eq!(gc.images_pruned, 1);
224        assert!(gc.streams_pruned > 0);
225
226        let gc2 = repo.gc(&[]).unwrap();
227        assert_eq!(gc2.objects_removed, 0);
228        assert_eq!(gc2.images_pruned, 0);
229        assert_eq!(gc2.streams_pruned, 0);
230    }
231
232    #[tokio::test]
233    async fn test_remove_boot_image_then_gc_preserves_oci() {
234        let test_repo = TestRepo::<Sha256HashValue>::new();
235        let repo = &test_repo.repo;
236
237        let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await;
238
239        generate_boot_image(repo, &img.manifest_digest).unwrap();
240
241        remove_boot_image(repo, &img.manifest_digest).unwrap();
242        let gc = repo.gc(&[]).unwrap();
243        assert_eq!(gc.images_pruned, 1);
244
245        let oci = OciImage::open_ref(repo, "myapp:v1").unwrap();
246        assert!(oci.is_container_image());
247        assert!(oci.boot_image_ref(repo.erofs_version()).is_none());
248    }
249
250    /// Boot EROFS differs from plain EROFS and contains the expected boot entries.
251    #[tokio::test]
252    async fn test_boot_content() {
253        for tag in ["myapp:v1", "uki:v1"] {
254            let test_repo = TestRepo::<Sha256HashValue>::new();
255            let repo = &test_repo.repo;
256
257            let img = test_util::create_bootable_image(repo, Some(tag), 1).await;
258
259            let boot_verity = generate_boot_image(repo, &img.manifest_digest).unwrap();
260
261            let fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap();
262            let boot_entries = get_boot_resources(&fs, repo).unwrap();
263            assert_eq!(boot_entries.len(), 2, "tag={tag}");
264            assert!(
265                boot_entries.iter().any(|e| matches!(
266                    e,
267                    composefs_boot::bootloader::BootEntry::UsrLibModulesVmLinuz(_)
268                )),
269                "tag={tag}: expected vmlinuz entry"
270            );
271            assert!(
272                boot_entries
273                    .iter()
274                    .any(|e| matches!(e, composefs_boot::bootloader::BootEntry::Type2(_))),
275                "tag={tag}: expected Type2 entry"
276            );
277
278            let plain_fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap();
279            let plain_verity = plain_fs.commit_image(repo, None).unwrap();
280            assert_ne!(boot_verity, plain_verity, "tag={tag}");
281        }
282    }
283}