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;
#[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)
}
#[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))
}
#[derive(Debug, Clone)]
pub enum BootImageMatch<ObjectID> {
Found {
mode: XattrFiltering,
version: FormatVersion,
digest: ObjectID,
},
NotFound(usize),
}
#[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))
}
#[cfg(feature = "boot")]
enum ModeResult<ObjectID> {
Found {
version: FormatVersion,
digest: ObjectID,
},
NotFound(usize),
}
#[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();
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() {
return Ok(ModeResult::NotFound(tried));
}
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))
}
#[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)?;
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(())
}
pub fn boot_image<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
manifest_digest: &OciDigest,
) -> Result<Option<ObjectID>> {
boot_image_for_mode(repo, manifest_digest, XattrFiltering::AllowlistOnly)
}
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,
)
}
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(());
}
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(), img.image_ref_v1(), &HashMap::new(), )?;
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()));
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);
}
#[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"
);
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())
);
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()
);
}
#[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);
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)
);
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());
}
#[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
));
}
#[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
));
}
#[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()
);
}
#[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;
let v2_id =
generate_boot_image(&repo, &img.manifest_digest, &OciTransformOptions::default())
.unwrap();
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
));
let oci_after = OciImage::open(&repo, &img.manifest_digest, None).unwrap();
assert_eq!(oci_after.boot_image_ref_v1(), Some(&v1_id));
}
#[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}");
}
}
}