Skip to content

Initial reimplementation of composefs-c#225

Draft
cgwalters wants to merge 10 commits into
composefs:mainfrom
cgwalters:composefs-c-compat
Draft

Initial reimplementation of composefs-c#225
cgwalters wants to merge 10 commits into
composefs:mainfrom
cgwalters:composefs-c-compat

Conversation

@cgwalters
Copy link
Copy Markdown
Collaborator

Basically starting on composefs/composefs#423

3 key goals:

  • Compatible CLI interfaces
  • Compatible EROFS output format (this is a big deal!)
  • Next: Compatible C shared library (ugly and messy)

Assisted-by: OpenCode (Claude Sonnet 4)

@cgwalters
Copy link
Copy Markdown
Collaborator Author

There's definitely some sub-tasks to this and pieces that we need to break out. One that I'm realizing is that the dumpfile format is hardcoded to sha256-12. I guess we can just auto-detect from length (like we're doing in other places) but the more I think about this the more I feel we need to formalize it (as is argued in #224 )

So how about a magic comment in the dumpfile like

# format: sha512-12

or so?

@cgwalters
Copy link
Copy Markdown
Collaborator Author

Let's make the format layout a choice to avoid breaking sealed UKIs as is today

@cgwalters cgwalters force-pushed the composefs-c-compat branch 3 times, most recently from 8a5c48d to 9cb1923 Compare March 11, 2026 01:41
@cgwalters cgwalters force-pushed the composefs-c-compat branch 4 times, most recently from 6eda766 to dc1fed7 Compare March 17, 2026 15:31
@cgwalters cgwalters force-pushed the composefs-c-compat branch from dc1fed7 to 9a845fa Compare March 17, 2026 23:10
@cgwalters cgwalters force-pushed the composefs-c-compat branch from 9a845fa to 9823c67 Compare March 31, 2026 13:49
@cgwalters cgwalters force-pushed the composefs-c-compat branch 4 times, most recently from a8d6802 to 25cbbb1 Compare May 3, 2026 21:40
Copilot AI mentioned this pull request May 6, 2026
8 tasks
@cgwalters cgwalters force-pushed the composefs-c-compat branch 2 times, most recently from 895ccd1 to 8eeec80 Compare May 11, 2026 11:59
@cgwalters
Copy link
Copy Markdown
Collaborator Author

bootc-dev/bootc#1812 is related to this - we need to fix how we generate the EROFS. The problem in a nutshell is that https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9ed50b8231e37b1ae863f5dec8153b98d9f389b4 fixed a legitimate bug, but it also made the kernel parser less strict than it needed to be to fix the bug; we now accept symlinks that cross a block boundary, whereas before we didn't.

@cgwalters cgwalters force-pushed the composefs-c-compat branch 2 times, most recently from ad51b62 to 4081268 Compare May 18, 2026 18:28
@cgwalters cgwalters marked this pull request as ready for review May 18, 2026 19:00
@cgwalters cgwalters force-pushed the composefs-c-compat branch 2 times, most recently from ae23696 to 5dab7f6 Compare May 19, 2026 16:42
@cgwalters cgwalters marked this pull request as draft May 19, 2026 17:51
@cgwalters
Copy link
Copy Markdown
Collaborator Author

cgwalters commented May 19, 2026

OK, this is obviously a giant PR, but I think it's getting closer into shape - the much extended proptest found lots of corner cases and we now have pretty robust parity with composefs-c as far as the V1 (original) EROFS layout.

That said, when I was thinking about applying this to bootc I realized we have basically an upgrade compatibility hazard - bootc container ukify has to match what the client generates, so we need to make it an opt-in to generate v1. And that has a cascading set of design implications.

I'll attach some LLM-assisted info on this.

Details # Design: Dual-format EROFS generation (V1 + V2) with bootc UKI integration

🤖 Assisted-by: OpenCode (Claude Sonnet 4.6)

Background

composefs-rs added V1 EROFS format support (compatible with C mkcomposefs/composefs-info
1.0.8) alongside the existing V2 (composefs-rs native) format. The meta.json erofs_version
field and cfs_erofs_version ro_compat flag allow repositories to signal their preferred format
to tools.

The problem this design addresses: bootc uses composefs for sealed UKIs, where the
composefs=<digest> kernel argument is baked into the UKI at signing time. Because V1 and V2
EROFS images of the same content have different fs-verity digests, a sealed UKI tied to a V2
digest will fail to boot if only a V1 image is present (and vice versa). We need a principled
way to:

  • Default new repositories to V1-only (for C tooling compatibility).
  • Let bootc opt into generating both V1 and V2 for every image, so existing sealed V2 UKIs
    continue to boot while new UKIs can use V1.
  • Signal which EROFS format a UKI expects via the kernel command line, so the initramfs can
    find the right image.

meta.json changes

A new ro_compat feature flag "v1_erofs" is introduced alongside the existing
"cfs_erofs_version".

{
  "version": 1,
  "algorithm": "fsverity-sha256-12",
  "erofs_version": "V1",
  "features": {
    "compatible": [],
    "read-only-compatible": ["cfs_erofs_version", "v1_erofs"],
    "incompatible": []
  }
}

Semantics of "v1_erofs" in ro_compat:

  • Present (default for all new repos): only V1 EROFS images are generated. Old tools that
    do not recognize this flag open the repository read-only, which is correct — a repo in
    dual-format mode should not be written by tools that don't understand both formats.
  • Absent: both V1 and V2 EROFS images are generated for every image commit. This is the
    mode bootc uses.

Relationship to "cfs_erofs_version": that flag tells old C tools "the repo's default image
format is V1". "v1_erofs" tells new composefs-rs tools "generate only V1, not both". They
are complementary and can coexist.

known_features::RO_COMPAT gains "v1_erofs" so the unknown-flag check does not reject repos
that have it.

FormatSet type

A new type expressing which EROFS format versions to generate for an image:

// In crates/composefs/src/erofs/format.rs (or repository.rs)
pub struct FormatSet(/* bitset over FormatVersion */);

impl FormatSet {
    pub const V1_ONLY: FormatSet = /* {V1} */;
    pub const BOTH: FormatSet = /* {V1, V2} */;

    pub fn contains(&self, v: FormatVersion) -> bool { ... }
}

The repository's default FormatSet is derived at open time from meta.json:

  • "v1_erofs" present in ro_compat → FormatSet::V1_ONLY
  • "v1_erofs" absent → FormatSet::BOTH

Exposed as Repository::default_format_set() -> FormatSet.

RepositoryConfig changes

RepositoryConfig gains a field controlling whether "v1_erofs" is written at init time:

pub struct RepositoryConfig {
    pub algorithm: Algorithm,
    pub erofs_version: FormatVersion,
    pub erofs_formats: FormatSet,  // new; default FormatSet::V1_ONLY
    insecure: bool,
}

init_path writes "v1_erofs" into ro_compat when erofs_formats == FormatSet::V1_ONLY,
and omits it when erofs_formats == FormatSet::BOTH.

Image generation API

FileSystem::commit_image currently generates a single EROFS image using the repo's
erofs_version. It is supplemented by a new method:

impl<ObjectID: FsVerityHashValue> FileSystem {
    /// Generate EROFS images for each version in `formats`, store them in `repo`,
    /// and return a map from FormatVersion to the stored object ID.
    pub fn commit_images(
        &self,
        repo: &Repository<ObjectID>,
        ref_name: Option<&str>,
        formats: FormatSet,
    ) -> Result<HashMap<FormatVersion, ObjectID>>;
}

The existing commit_image becomes a thin wrapper calling commit_images with
FormatSet::from(repo.erofs_version()) (a single-version set), for callers that only need one
format and do not need to be format-set-aware.

generate_boot_image in composefs-oci gains a FormatSet parameter. When not specified by
the caller, it defaults to repo.default_format_set().

Splitstream named refs

Four constants in composefs-oci/src/lib.rs; the existing two are unchanged:

pub const IMAGE_REF_KEY: &str = "composefs.image";          // V2, existing
pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1";    // V1, new
pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot";      // V2, existing
pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1"; // V1, new

When FormatSet includes V1, the V1 digest is stored under the .v1 keys. When it includes
V2, the existing keys are populated. When both, all four are written. GC follows all populated
named refs, so both EROFS objects remain alive for as long as the config splitstream is
reachable from a tag.

Kernel argument scheme

Two karg names; the initramfs handles both:

Karg Format Notes
composefs=<digest> V2 Existing; no change to meaning. All currently sealed UKIs use this.
composefs.digest=<digest> V1 New; used by bootc container ukify --format=1.

ComposefsCmdline in composefs-boot/src/cmdline.rs becomes an enum:

pub enum ComposefsCmdline {
    /// Legacy V2 karg: `composefs=<digest>`
    V2 { digest: Hex },
    /// V1 karg: `composefs.digest=<digest>`
    V1 { digest: Hex },
}

Both variants are generated and parsed by composefs-boot. Since bootc maintains its own copy
of the initramfs karg parsing in bootc/crates/initramfs/src/lib.rs, the same logic must be
duplicated there. Both files carry a comment noting they must be kept in sync; the long-term
remedy is for bootc to consume composefs-boot directly rather than duplicating.

The initramfs logic at boot time:

  1. Check for composefs.digest= → look up V1 image in composefs/images/<digest>, mount it.
  2. Fall back to composefs= → look up V2 image, mount it (existing behaviour).

cfsctl init CLI

cfsctl init gains an --erofs option:

--erofs=v1    (default) Generate only V1 EROFS. Sets "v1_erofs" in ro_compat.
--erofs=dual            Generate both V1 and V2 EROFS. Omits "v1_erofs" from ro_compat.

Idempotency: if the repo already exists and the erofs_formats setting differs, an error is
returned (same behaviour as a mismatched algorithm).

bootc integration

bootc initializes its composefs repository with:

RepositoryConfig {
    algorithm: Algorithm::SHA512,
    erofs_formats: FormatSet::BOTH,  // no "v1_erofs" in ro_compat
    ..Default::default()
}

This causes every image pull to generate both V1 and V2 EROFS objects automatically.

bootc container ukify gains a --format option:

--format=1    Embed composefs.digest=<v1-digest> in the UKI cmdline.
--format=2    (default for now) Embed composefs=<v2-digest> in the UKI cmdline.

The plan is to flip the default to --format=1 once the new initramfs logic ships broadly.

Migration

Existing bootc repos (initialized before this change, with V2 default and no v1_erofs
flag): because bootc will now pass erofs_formats: FormatSet::BOTH at init time, and init_path
is idempotent, the repo's meta.json is updated to reflect BOTH on next open. default_format_set()
then returns BOTH. On the next image pull, both formats are generated. Existing sealed V2 UKIs
continue to boot via the existing composefs=<v2-digest> karg and V2 image. No manual migration
step needed.

Existing plain cfsctl repos (V1 default, v1_erofs present): remain V1-only unless the
user re-runs cfsctl init --erofs=dual. Upgrading from V1-only to dual is allowed by init_path;
downgrading from dual back to V1-only returns an error (would silently stop generating V2 images
that existing sealed UKIs may depend on).

Files affected

composefs-rs

File Change
crates/composefs/src/erofs/format.rs Add FormatSet type
crates/composefs/src/repository.rs known_features::V1_EROFS, RepositoryConfig::erofs_formats, Repository::default_format_set(), commit_images()
crates/composefs-oci/src/lib.rs IMAGE_REF_KEY_V1, BOOT_IMAGE_REF_KEY_V1; update create_filesystem and generate_boot_image
crates/composefs-oci/src/boot.rs generate_boot_image gains FormatSet param
crates/composefs-boot/src/cmdline.rs ComposefsCmdline enum; parse/generate both karg names
crates/composefs-ctl/src/lib.rs `cfsctl init --erofs=v1
doc/repository.md Document v1_erofs feature flag and FormatSet semantics

bootc (separate repo)

File Change
crates/lib/src/store/mod.rs Init repo with FormatSet::BOTH
crates/lib/src/ukify.rs `--format=1
crates/lib/src/bootc_composefs/status.rs Parse composefs.digest= alongside composefs=
crates/initramfs/src/lib.rs Handle both karg names (duplicated from composefs-boot)

@cgwalters cgwalters force-pushed the composefs-c-compat branch 2 times, most recently from 67a3785 to ec1e4ce Compare May 20, 2026 19:38
cgwalters added 9 commits May 20, 2026 16:36
Add set_write_concurrency() to Repository for overriding the default
parallelism. Add read_filesystem_with_semaphore() as a public entry
point that accepts an explicit Semaphore, and refactor the internal
read_filesystem_impl() to centralize semaphore selection.

Prep for wiring up --threads in mkcomposefs.

Assisted-by: OpenCode (Claude Sonnet 4.6)
Signed-off-by: Colin Walters <walters@verbum.org>
The patch recipe referenced crates/cfsctl which was never a valid path;
the crate has always been named composefs-ctl. Also relax the clean-tree
check to allow untracked files (only committed changes need to match the
pinned revision).

Assisted-by: OpenCode (Claude Sonnet 4.6)
Signed-off-by: Colin Walters <walters@verbum.org>
Signed-off-by: Colin Walters <walters@verbum.org>
import_oci_layout() was opening the layout directory before calling
ensure_writable(), so pulling into a read-only repo produced a misleading
ENOENT error instead of a clear 'not writable' message. Move the write
check to the top of the function, matching the existing skopeo pull path.

Fixes privileged_pull_readonly_repo integration test.

Signed-off-by: Colin Walters <walters@verbum.org>
For compatibility with the C composefs, we need to support writing
directly to a flat XX/DIGEST path, without a leading `objects/`.

Assisted-by: OpenCode (Claude Sonnet 4.6)
Signed-off-by: Colin Walters <walters@verbum.org>
The script hardcoded /usr/share/edk2/ovmf/OVMF_CODE.fd which is only
present on Fedora. Probe a list of common paths (Ubuntu's ovmf package
uses /usr/share/ovmf/OVMF.fd, Arch uses /usr/share/edk2/x64/OVMF.4m.fd)
so the script works across distros without manual adjustment.

Also add -machine q35, required on newer QEMU builds (e.g. RHEL10/CentOS
Stream 10) where the default pc-i440fx machine type doesn't pair well with
OVMF for EFI boot.

Assisted-by: OpenCode (claude-sonnet-4-6@default)
Signed-off-by: Colin Walters <walters@verbum.org>
The combined OVMF.qemuvars.fd with -bios hangs indefinitely on RHEL10/
CentOS Stream 10 QEMU (qemu-kvm 9.x).  Use the split OVMF_CODE.fd +
OVMF_VARS.fd files with -drive if=pflash and -machine q35 instead, which
works correctly.  Fall back to -bios with the combined image on distros
that only ship the combined file (Ubuntu, Arch).

Updated both testthing.py (which drives the example integration tests)
and the fix-verity helper script (which runs the in-VM verity fixup pass).
A temporary copy of OVMF_VARS.fd is made so UEFI can write to it without
modifying the original system file.

Assisted-by: OpenCode (claude-sonnet-4-6@default)
Signed-off-by: Colin Walters <walters@verbum.org>
composefs-setup-root validates that the repo's meta.json has
fs-verity enabled before trusting the repo. The dracut hook was
only enabling verity on the content objects, so setup-root would
see the repo as insecure and refuse to proceed.

Switch the working directory to /sysroot/composefs (instead of
the objects subdirectory) so we can enable verity on meta.json
in addition to all the content objects. Also quote the loop
variable and use the full relative path for clarity.

Assisted-by: OpenCode (claude-sonnet-4-6@default)
Signed-off-by: Colin Walters <walters@verbum.org>
The 30s default is tight on slower hardware (e.g. CentOS Stream 10
with OVMF pflash init overhead) — the VM boots successfully but just
barely misses the window. 60s gives enough headroom while still being
short enough to catch genuinely broken VMs. CI on Ubuntu with KVM
acceleration boots well under 30s so the extra budget costs nothing.

Assisted-by: OpenCode (claude-sonnet-4-6@default)
Signed-off-by: Colin Walters <walters@verbum.org>
@cgwalters cgwalters force-pushed the composefs-c-compat branch 2 times, most recently from 6c48658 to 03b9a32 Compare May 20, 2026 20:37
@cgwalters cgwalters added this to the 0.5 milestone May 20, 2026
Adds support for generating V1-format EROFS images alongside the existing
V2 ("composefs") format. V1 EROFS is the on-disk format used by the C
composefs implementation, enabling interoperability between the Rust and C
stacks.

Eventually, the idea is we deprecate the C implementation and replace
it with this.

It turns out that the EROFS filesystems we were generating can't
be mounted by RHEL9 era kernels. So that's another reason to fix this.

However: we can't change the EROFS layout we output by default, because
current sealed UKIs basically require compatibility.

So: Let's thread through the concept of versioning here.

While we're doing this, the idea is that for sealed UKIs, we will
distinguish "container image wants v1 format" by detecting a new
`composefs.cmdline=` karg.

Otherwise, the repository now defaults to v1 for new repos. Otherwise,
it defaults to generating both versions. Existing repositories can turn
off the V2 format as well.

Assisted-by: OpenCode (Claude Sonnet 4.6)
Signed-off-by: Colin Walters <walters@verbum.org>
@cgwalters cgwalters force-pushed the composefs-c-compat branch from 03b9a32 to 6234700 Compare May 20, 2026 21:38
@cgwalters cgwalters marked this pull request as ready for review May 20, 2026 21:45
@cgwalters cgwalters marked this pull request as draft May 20, 2026 22:04
@cgwalters
Copy link
Copy Markdown
Collaborator Author

OK, passing CI now, though re-reviewing I see a few more things to fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant