From d3646ceef716524169ba5a5240671e66de327a64 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 8 Apr 2026 08:35:46 -0400 Subject: [PATCH 01/10] xtask: Factor out shared bcvk install opts from sysext and tmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `just bcvk up` with `BOOTC_variant=composefs` silently ignored the variant — it read the env var but never passed `--composefs-backend` or related flags to `bcvk libvirt run`, so the VM always installed with the ostree backend. Extract a shared `BcvkInstallOpts` helper (new `bcvk.rs` module) that both `sysext.rs` and `tmt.rs` use to build bcvk CLI arguments from `BOOTC_*` environment variables. This fixes the sysext path and eliminates the duplicated firmware/install arg construction in tmt. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/xtask/src/bcvk.rs | 117 +++++++++++++++++++++++++++++++++++++ crates/xtask/src/sysext.rs | 21 +++---- crates/xtask/src/tmt.rs | 78 +++++++------------------ crates/xtask/src/xtask.rs | 1 + 4 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 crates/xtask/src/bcvk.rs diff --git a/crates/xtask/src/bcvk.rs b/crates/xtask/src/bcvk.rs new file mode 100644 index 000000000..0a35e2061 --- /dev/null +++ b/crates/xtask/src/bcvk.rs @@ -0,0 +1,117 @@ +//! Shared helpers for building `bcvk` CLI arguments. +//! +//! Both the sysext dev-VM flow and the tmt test runner need to translate +//! `BOOTC_*` environment variables into `bcvk libvirt run` flags. This +//! module centralises that logic so the two code paths stay in sync. + +use anyhow::Result; +use camino::Utf8Path; +use fn_error_context::context; + +use crate::{Bootloader, SealState}; + +/// Default directory for secure boot test keys. +const DEFAULT_SB_KEYS_DIR: &str = "target/test-secureboot"; + +/// Resolved bcvk install options, ready to be turned into CLI args. +/// +/// Construct via [`BcvkInstallOpts::from_env`] (reads `BOOTC_*` env vars) +/// or populate fields directly. +#[derive(Debug, Default)] +pub(crate) struct BcvkInstallOpts { + pub(crate) composefs_backend: bool, + pub(crate) bootloader: Option, + pub(crate) filesystem: Option, + pub(crate) seal_state: Option, + pub(crate) kargs: Vec, +} + +impl BcvkInstallOpts { + /// Build from `BOOTC_*` environment variables. + /// + /// `BOOTC_variant=composefs` implies `composefs_backend = true`. + pub(crate) fn from_env() -> Self { + let composefs_backend = std::env::var("BOOTC_variant") + .map(|v| v == "composefs") + .unwrap_or(false); + + let bootloader = std::env::var("BOOTC_bootloader") + .ok() + .and_then(|v| match v.as_str() { + "grub" => Some(Bootloader::Grub), + "systemd" => Some(Bootloader::Systemd), + _ => None, + }); + + let filesystem = std::env::var("BOOTC_filesystem").ok(); + + let seal_state = std::env::var("BOOTC_seal_state") + .ok() + .and_then(|v| match v.as_str() { + "sealed" => Some(SealState::Sealed), + "unsealed" => Some(SealState::Unsealed), + _ => None, + }); + + Self { + composefs_backend, + bootloader, + filesystem, + seal_state, + kargs: Vec::new(), + } + } + + /// Return the install-related args for `bcvk libvirt run`. + /// + /// This covers `--composefs-backend`, `--filesystem`, `--bootloader`, + /// and `--karg` flags. + pub(crate) fn install_args(&self) -> Vec { + let mut args = Vec::new(); + if self.composefs_backend { + args.push("--composefs-backend".into()); + let fs = self.filesystem.as_deref().unwrap_or("ext4"); + args.push(format!("--filesystem={fs}")); + } + if let Some(b) = &self.bootloader { + args.push(format!("--bootloader={b}")); + } + for k in &self.kargs { + args.push(format!("--karg={k}")); + } + args + } + + fn is_sealed(&self) -> bool { + self.seal_state + .as_ref() + .is_some_and(|s| *s == SealState::Sealed) + } + + /// Return firmware / secure-boot args for `bcvk libvirt run`. + /// + /// For sealed images the secure boot keys directory must already + /// exist; the caller can use [`ensure_secureboot_keys`] first. + #[context("Building firmware arguments")] + pub(crate) fn firmware_args(&self) -> Result> { + let sb_keys_dir = Utf8Path::new(DEFAULT_SB_KEYS_DIR); + if self.is_sealed() { + if sb_keys_dir.try_exists()? { + let sb_keys_dir = sb_keys_dir.canonicalize_utf8()?; + Ok(vec![ + "--firmware=uefi-secure".into(), + format!("--secure-boot-keys={sb_keys_dir}"), + ]) + } else { + anyhow::bail!( + "Sealed image but no secure boot keys at {sb_keys_dir}. \ + Run 'just generate-secureboot-keys' to generate them." + ); + } + } else if matches!(self.bootloader, Some(Bootloader::Systemd)) { + Ok(vec!["--firmware=uefi-insecure".into()]) + } else { + Ok(Vec::new()) + } + } +} diff --git a/crates/xtask/src/sysext.rs b/crates/xtask/src/sysext.rs index 940dfa748..c78312ab7 100644 --- a/crates/xtask/src/sysext.rs +++ b/crates/xtask/src/sysext.rs @@ -27,6 +27,8 @@ use camino::Utf8Path; use fn_error_context::context; use xshell::{Shell, cmd}; +use crate::bcvk::BcvkInstallOpts; + const SYSEXT_DIR: &str = "target/sysext"; const DEV_VM_NAME: &str = "bootc-dev"; const DEV_VM_LABEL: &str = "bootc.dev=1"; @@ -266,23 +268,16 @@ fn create_vm(sh: &Shell) -> Result<()> { .unwrap_or_else(|_| "quay.io/centos-bootc/centos-bootc:stream10".to_string()); let bind_mount = format!("{}:{}", sysext_path, VM_SYSEXT_MNT); - let variant = std::env::var("BOOTC_variant").unwrap_or_else(|_| "ostree".to_string()); + let bcvk_opts = BcvkInstallOpts::from_env(); + let install_args = bcvk_opts.install_args(); + let firmware_args = bcvk_opts.firmware_args()?; + let mut bcvk_cmd = cmd!( sh, "bcvk libvirt run --name={DEV_VM_NAME} --replace --label={DEV_VM_LABEL} --bind={bind_mount}" ); - - let seal_state = std::env::var("BOOTC_seal_state").unwrap_or_else(|_| "unsealed".to_string()); - if variant == "composefs" && seal_state == "sealed" { - let secureboot_dir = Utf8Path::new("target/test-secureboot"); - if !secureboot_dir.exists() { - println!("Generating secure boot keys for sealed variant..."); - cmd!(sh, "./hack/generate-secureboot-keys") - .run() - .context("Failed to generate secure boot keys")?; - } - bcvk_cmd = bcvk_cmd.arg("--secure-boot-keys=target/test-secureboot"); - } + bcvk_cmd = bcvk_cmd.args(&install_args); + bcvk_cmd = bcvk_cmd.args(&firmware_args); bcvk_cmd = bcvk_cmd.args(["--ssh-wait", &base_img]); bcvk_cmd.run().context("Failed to create VM")?; diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 2fc9b9db8..ddc97754f 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -32,7 +32,8 @@ const ENV_BOOTC_UPGRADE_IMAGE: &str = "BOOTC_upgrade_image"; const DISTRO_CENTOS_9: &str = "centos-9"; // Import the argument types from xtask.rs -use crate::{Bootloader, RunTmtArgs, SealState, TmtProvisionArgs}; +use crate::bcvk::BcvkInstallOpts; +use crate::{RunTmtArgs, SealState, TmtProvisionArgs}; /// Generate a random alphanumeric suffix for VM names fn generate_random_suffix() -> String { @@ -103,43 +104,6 @@ fn is_sealed_image(sh: &Shell, image: &str) -> Result { Ok(!result.is_empty()) } -/// Default path where secure boot keys are generated for testing -const DEFAULT_SB_KEYS_DIR: &str = "target/test-secureboot"; - -/// Build firmware arguments for bcvk based on whether the image is sealed -/// and whether secure boot keys are available. -/// -/// For sealed images, secure boot keys must be present or an error is returned. -#[context("Building firmware arguments")] -fn build_firmware_args(is_sealed: bool, bootloader: &Option) -> Result> { - let sb_keys_dir = Utf8Path::new(DEFAULT_SB_KEYS_DIR); - - let r = if is_sealed { - if sb_keys_dir.try_exists()? { - let sb_keys_dir = sb_keys_dir.canonicalize_utf8()?; - println!( - "Sealed image detected, using secure boot with keys from: {}", - sb_keys_dir - ); - vec![ - "--firmware=uefi-secure".to_string(), - format!("--secure-boot-keys={}", sb_keys_dir), - ] - } else { - anyhow::bail!( - "Sealed image detected but no secure boot keys found at {}. \ - Run 'just generate-secureboot-keys' to generate them.", - sb_keys_dir - ); - } - } else if matches!(bootloader, Some(Bootloader::Systemd)) { - vec!["--firmware=uefi-insecure".into()] - } else { - Vec::new() - }; - Ok(r) -} - /// Detect VARIANT_ID from container image by reading os-release /// Returns string like "coreos" or empty #[context("Detecting distro from image")] @@ -342,12 +306,14 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { println!("Detected distro: {}", distro); println!("Detected VARIANT_ID: {variant_id}"); - let firmware_args = build_firmware_args( - args.seal_state - .as_ref() - .is_some_and(|v| *v == SealState::Sealed), - &args.bootloader, - )?; + let bcvk_opts = BcvkInstallOpts { + composefs_backend: args.composefs_backend, + bootloader: args.bootloader.clone(), + filesystem: args.filesystem.clone(), + seal_state: args.seal_state.clone(), + kargs: args.karg.clone(), + }; + let firmware_args = bcvk_opts.firmware_args()?; // Create tmt-workdir and copy tmt bits to it // This works around https://github.com/teemtee/tmt/issues/4062 @@ -482,19 +448,7 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { } } - if args.composefs_backend { - let filesystem = args.filesystem.as_deref().unwrap_or("ext4"); - opts.push(format!("--filesystem={}", filesystem)); - opts.push("--composefs-backend".into()); - } - - if let Some(b) = &args.bootloader { - opts.push(format!("--bootloader={b}")); - } - - for k in &args.karg { - opts.push(format!("--karg={k}")); - } + opts.extend(bcvk_opts.install_args()); opts }; @@ -751,7 +705,15 @@ pub(crate) fn tmt_provision(sh: &Shell, args: &TmtProvisionArgs) -> Result<()> { println!(" VM name: {}\n", vm_name); // TODO: Send bootloader param here - let firmware_args = build_firmware_args(is_sealed_image(sh, image)?, &None)?; + let provision_opts = BcvkInstallOpts { + seal_state: if is_sealed_image(sh, image)? { + Some(SealState::Sealed) + } else { + None + }, + ..Default::default() + }; + let firmware_args = provision_opts.firmware_args()?; // Launch VM with bcvk // Use ds=iid-datasource-none to disable cloud-init for faster boot diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 0332dba5d..9427519ec 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -16,6 +16,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use fn_error_context::context; use xshell::{Shell, cmd}; +mod bcvk; mod buildsys; mod man; mod sysext; From 1a7eaec0cd1e35ca97dde8052ce46433104f7612 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 6 Apr 2026 18:08:00 -0400 Subject: [PATCH 02/10] xtask: Read composefs config from BOOTC_* env vars The xtask run-tmt command now reads BOOTC_bootloader, BOOTC_filesystem, BOOTC_seal_state, and BOOTC_boot_type directly via clap env support. When BOOTC_variant=composefs, --composefs-backend is implied automatically. This means `just test-tmt` works correctly with composefs env vars set, without the Justfile needing to forward flags manually. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/xtask/Cargo.toml | 2 +- crates/xtask/src/bcvk.rs | 10 ++++++---- crates/xtask/src/xtask.rs | 35 ++++++++++++++++++++++++++++------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 03c2fe3c9..63fe46f4c 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -17,7 +17,7 @@ anyhow = { workspace = true } anstream = { workspace = true } camino = { workspace = true } chrono = { workspace = true, features = ["std"] } -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive", "env"] } fn-error-context = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/xtask/src/bcvk.rs b/crates/xtask/src/bcvk.rs index 0a35e2061..e46dcb8f7 100644 --- a/crates/xtask/src/bcvk.rs +++ b/crates/xtask/src/bcvk.rs @@ -65,16 +65,18 @@ impl BcvkInstallOpts { /// Return the install-related args for `bcvk libvirt run`. /// /// This covers `--composefs-backend`, `--filesystem`, `--bootloader`, - /// and `--karg` flags. + /// and `--karg` flags. Note that `--bootloader` and `--filesystem` + /// are only valid when `--composefs-backend` is also set (bcvk + /// enforces this via a clap `requires` relationship). pub(crate) fn install_args(&self) -> Vec { let mut args = Vec::new(); if self.composefs_backend { args.push("--composefs-backend".into()); let fs = self.filesystem.as_deref().unwrap_or("ext4"); args.push(format!("--filesystem={fs}")); - } - if let Some(b) = &self.bootloader { - args.push(format!("--bootloader={b}")); + if let Some(b) = &self.bootloader { + args.push(format!("--bootloader={b}")); + } } for k in &self.kargs { args.push(format!("--karg={k}")); diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 9427519ec..e9b7a248a 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -166,7 +166,11 @@ impl Display for SealState { } } -/// Arguments for run-tmt command +/// Arguments for run-tmt command. +/// +/// The composefs-related fields can be set via CLI flags or via the standard +/// `BOOTC_*` environment variables used by the Justfile. When `BOOTC_variant` +/// is set to `composefs`, `--composefs-backend` is implied automatically. #[derive(Debug, Args)] pub(crate) struct RunTmtArgs { /// Image name (e.g., "localhost/bootc") @@ -192,21 +196,22 @@ pub(crate) struct RunTmtArgs { #[arg(long)] pub(crate) preserve_vm: bool, + /// Use composefs backend. Also implied when BOOTC_variant=composefs. #[arg(long)] pub(crate) composefs_backend: bool, - #[arg(long, requires = "composefs_backend")] + #[arg(long, env = "BOOTC_bootloader")] pub(crate) bootloader: Option, - #[arg(long, requires = "composefs_backend")] + #[arg(long, env = "BOOTC_filesystem")] pub(crate) filesystem: Option, /// Required to switch between secure/insecure firmware options - #[arg(long, requires = "composefs_backend")] + #[arg(long, env = "BOOTC_seal_state")] pub(crate) seal_state: Option, - // Required to send kargs to only bls installs - #[arg(long, default_value_t, requires = "composefs_backend")] + /// Boot entry type (bls or uki) + #[arg(long, env = "BOOTC_boot_type", default_value_t)] pub(crate) boot_type: BootType, /// Additional kernel arguments to pass to bcvk @@ -214,6 +219,19 @@ pub(crate) struct RunTmtArgs { pub(crate) karg: Vec, } +impl RunTmtArgs { + /// Derive composefs_backend from BOOTC_variant if not explicitly set. + pub(crate) fn resolve_composefs(&mut self) { + if !self.composefs_backend { + if let Ok(v) = std::env::var("BOOTC_variant") { + if v == "composefs" { + self.composefs_backend = true; + } + } + } + } +} + /// Arguments for tmt-provision command #[derive(Debug, Args)] pub(crate) struct TmtProvisionArgs { @@ -275,7 +293,10 @@ fn try_main() -> Result<()> { Commands::Package => package(&sh), Commands::PackageSrpm => package_srpm(&sh), Commands::Spec => spec(&sh), - Commands::RunTmt(args) => tmt::run_tmt(&sh, &args), + Commands::RunTmt(mut args) => { + args.resolve_composefs(); + tmt::run_tmt(&sh, &args) + } Commands::TmtProvision(args) => tmt::tmt_provision(&sh, &args), Commands::CheckBuildsys => buildsys::check_buildsys(&sh, "Dockerfile".into()), Commands::ValidateComposefsDigest(args) => validate_composefs_digest(&sh, &args), From c72716f7f4b375ce7ae8755275b01015b932b791 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 4 Apr 2026 22:41:54 +0000 Subject: [PATCH 03/10] podstorage: Add image pull with streaming progress via podman API Add a podman_client module for pulling container images through podman's native libpod HTTP API with streaming per-blob download progress displayed via indicatif. Starts a transient `podman system service` against bootc's custom storage root (reusing bind_storage_roots and setup_auth helpers) and talks to it over a Unix socket. The response NDJSON stream is read line-by-line via AsyncBufReadExt. Also fix get_ensure_imgstore() to work on composefs-only systems by falling back to physical_root when ostree is not initialized. Factor setup_auth() out of new_podman_cmd_in() so both the CLI podman commands and the API service share the same auth setup. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- Cargo.lock | 109 ++++++++- Cargo.toml | 5 + crates/lib/Cargo.toml | 5 + crates/lib/src/cli.rs | 14 +- crates/lib/src/deploy.rs | 15 +- crates/lib/src/image.rs | 5 +- crates/lib/src/lib.rs | 1 + crates/lib/src/podman_client.rs | 410 ++++++++++++++++++++++++++++++++ crates/lib/src/podstorage.rs | 52 +++- crates/lib/src/store/mod.rs | 39 +-- 10 files changed, 610 insertions(+), 45 deletions(-) create mode 100644 crates/lib/src/podman_client.rs diff --git a/Cargo.lock b/Cargo.lock index 63df42933..95eb968da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -329,7 +335,11 @@ dependencies = [ "comfy-table", "etc-merge", "fn-error-context", + "futures-util", "hex", + "http-body-util", + "hyper", + "hyper-util", "indicatif 0.18.3", "indoc", "libc", @@ -340,6 +350,7 @@ dependencies = [ "ocidir", "openssl", "ostree-ext", + "percent-encoding", "regex", "rustix", "schemars", @@ -1604,6 +1615,79 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -2260,6 +2344,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project" version = "1.1.10" @@ -2395,9 +2485,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3288,6 +3378,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -3404,6 +3500,15 @@ dependencies = [ "nix 0.29.0", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 217789e1b..dcd116233 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,13 +46,18 @@ clap_mangen = { version = "0.3.0" } # The Justfile will auto-detect these and bind-mount them into container builds. cfsctl = { git = "https://github.com/composefs/composefs-rs", rev = "2203e8f", package = "cfsctl", features = ["rhel9"] } fn-error-context = "0.2.1" +futures-util = "0.3" hex = "0.4.3" +http-body-util = "0.1" +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } indicatif = "0.18.0" indoc = "2.0.5" libc = "0.2.154" log = "0.4.21" openssl = "0.10.72" owo-colors = { version = "4" } +percent-encoding = "2" regex = "1.10.4" # For the same rationale as https://github.com/coreos/rpm-ostree/commit/27f3f4b77a15f6026f7e1da260408d42ccb657b3 rustix = { "version" = "1", features = ["use-libc", "thread", "net", "fs", "system", "process", "mount"] } diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 3cbc1d061..58fcbd53a 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -37,11 +37,16 @@ clap_complete = "4" clap_mangen = { workspace = true, optional = true } cfsctl = { workspace = true } fn-error-context = { workspace = true } +futures-util = { workspace = true } hex = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } indicatif = { workspace = true } indoc = { workspace = true } libc = { workspace = true } openssl = { workspace = true } +percent-encoding = { workspace = true } regex = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b1752c2c2..a7205d653 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -492,10 +492,11 @@ pub(crate) enum ImageCmdOpts { #[clap(allow_hyphen_values = true)] args: Vec, }, - /// Wrapper for `podman image pull` in bootc storage. + /// Pull image(s) into bootc storage. Pull { - #[clap(allow_hyphen_values = true)] - args: Vec, + /// Image references to pull (e.g. quay.io/myorg/myimage:latest) + #[clap(required = true)] + images: Vec, }, /// Wrapper for `podman image push` in bootc storage. Push { @@ -1870,8 +1871,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ImageCmdOpts::Build { args } => { crate::image::imgcmd_entrypoint(imgstore, "build", &args).await } - ImageCmdOpts::Pull { args } => { - crate::image::imgcmd_entrypoint(imgstore, "pull", &args).await + ImageCmdOpts::Pull { images } => { + for image in &images { + imgstore.pull_with_progress(image).await?; + } + Ok(()) } ImageCmdOpts::Push { args } => { crate::image::imgcmd_entrypoint(imgstore, "push", &args).await diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 010e0b0b9..1e5a93f99 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -540,15 +540,12 @@ pub(crate) async fn prepare_for_pull_unified( &imgref.transport ); - // Pull the image to bootc storage using the same method as LBIs - // Show a spinner since podman pull can take a while and doesn't output progress - let pull_msg = format!("Pulling {} to bootc storage", &image_ref_str); - async_task_with_spinner(&pull_msg, async move { - imgstore - .pull(&image_ref_str, crate::podstorage::PullMode::Always) - .await - }) - .await?; + // Pull the image into bootc containers-storage with per-layer + // download progress via the podman native API. + imgstore + .pull_with_progress(&image_ref_str) + .await + .context("Pulling image into bootc containers-storage")?; // Now create a containers-storage reference to read from bootc storage tracing::info!("Unified pull: now importing from containers-storage transport"); diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs index d592d8e02..8af696485 100644 --- a/crates/lib/src/image.rs +++ b/crates/lib/src/image.rs @@ -26,9 +26,8 @@ pub(crate) const IMAGE_DEFAULT: &str = "localhost/bootc"; /// Check if an image exists in the default containers-storage (podman storage). /// /// TODO: Using exit codes to check image existence is not ideal. We should use -/// the podman HTTP API via bollard () or similar -/// to properly communicate with podman and get structured responses. This would -/// also enable proper progress monitoring during pull operations. +/// the podman native libpod HTTP API to properly communicate with podman and +/// get structured responses. async fn image_exists_in_host_storage(image: &str) -> Result { use tokio::process::Command as AsyncCommand; let mut cmd = AsyncCommand::new("podman"); diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 558ca8718..0152b7016 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -86,6 +86,7 @@ mod lsm; pub(crate) mod metadata; mod parsers; mod podman; +pub(crate) mod podman_client; mod podstorage; mod progress_jsonl; mod reboot; diff --git a/crates/lib/src/podman_client.rs b/crates/lib/src/podman_client.rs new file mode 100644 index 000000000..60e7af3e7 --- /dev/null +++ b/crates/lib/src/podman_client.rs @@ -0,0 +1,410 @@ +//! Async podman client using the native libpod API. +//! +//! Provides a high-level interface for pulling container images through +//! podman's native libpod HTTP API, enabling streaming per-blob byte-level +//! progress display. The transient `podman system service` is started +//! against bootc's custom storage root and automatically torn down. + +use std::collections::HashMap; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use fn_error_context::context; +use futures_util::StreamExt; +use http_body_util::BodyExt; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use tokio::io::AsyncBufReadExt; + +/// Podman libpod API version to use. +const LIBPOD_API_VERSION: &str = "v5.0.0"; + +/// A report object from podman's native image pull endpoint. +#[derive(Debug, serde::Deserialize)] +#[allow(dead_code)] +struct ImagePullReport { + #[serde(default)] + status: Option, + #[serde(default)] + stream: Option, + #[serde(default)] + error: Option, + #[serde(default)] + images: Option>, + #[serde(default)] + id: Option, + #[serde(rename = "pullProgress", default)] + pull_progress: Option, +} + +/// Per-blob download progress from podman's native pull API. +#[derive(Debug, serde::Deserialize)] +struct ArtifactPullProgress { + #[serde(default)] + status: Option, + #[serde(default)] + current: u64, + #[serde(default)] + total: i64, + #[serde(rename = "progressComponentID", default)] + progress_component_id: String, +} + +/// Manages a transient podman service, providing HTTP access to the +/// native libpod API via a Unix socket. +#[derive(Debug)] +pub(crate) struct PodmanClient { + service_child: tokio::process::Child, + /// Filesystem path to the socket. + socket_path: String, +} + +impl PodmanClient { + /// Start a transient `podman system service` pointing at the given + /// storage root and connect to it. + /// + /// Registry auth is configured via `REGISTRY_AUTH_FILE` on the + /// podman service process, using the same bootc/ostree auth as + /// existing podman CLI invocations. + // + // TODO: Eliminate the socket-path polling by passing a pre-created + // listener via the systemd LISTEN_FDS protocol (podman supports + // this when a URI is provided and LISTEN_FDS is set — it calls + // `os.NewFile(3)` + `net.FileListener` instead of `net.Listen`). + // + // The blocker is that `bind_storage_roots()` hardcodes the runroot + // fd at STORAGE_RUN_FD=3, which conflicts with LISTEN_FDS's + // requirement that the listener be at fd 3. Podman also stores + // the runroot path (`/proc/self/fd/3`) in its on-disk database + // (bolt_state.db), so changing the fd number for the API service + // causes a "database configuration mismatch" error. + // + // Fixing this requires either: + // (a) Changing STORAGE_RUN_FD to a higher number globally (breaks + // existing installed systems whose DB has /proc/self/fd/3), + // (b) Using a separate runroot path for the transient API service + // (e.g. /run/bootc/api-run) that doesn't conflict, or + // (c) Upstream podman change to support LISTEN_FDS at arbitrary + // fd numbers (not just fd 3). + #[context("Connecting to podman API")] + pub(crate) async fn connect(sysroot: &Dir, storage_root: &Dir, run_root: &Dir) -> Result { + use crate::podstorage::STORAGE_ALIAS_DIR; + + let socket_path = "/run/bootc/podman-api.sock".to_owned(); + std::fs::create_dir_all("/run/bootc/").ok(); + let _ = std::fs::remove_file(&socket_path); + + let mut cmd = std::process::Command::new("podman"); + crate::podstorage::bind_storage_roots(&mut cmd, storage_root, run_root)?; + crate::podstorage::setup_auth(&mut cmd, sysroot)?; + + let run_root_arg = format!("/proc/self/fd/{}", crate::podstorage::STORAGE_RUN_FD); + let socket_uri = format!("unix://{socket_path}"); + cmd.args([ + "--root", + STORAGE_ALIAS_DIR, + "--runroot", + &run_root_arg, + "system", + "service", + "--time=0", + &socket_uri, + ]); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::piped()); + + tracing::debug!("Starting podman API service at {socket_path}"); + let mut child = tokio::process::Command::from(cmd) + .spawn() + .context("Spawning podman system service")?; + + // Poll for the socket to appear, checking for early exit. + for _ in 0..50 { + if let Some(status) = child.try_wait()? { + let mut stderr_msg = String::new(); + if let Some(mut stderr) = child.stderr.take() { + use tokio::io::AsyncReadExt; + stderr.read_to_string(&mut stderr_msg).await.ok(); + } + anyhow::bail!("Podman API service exited with {status}: {stderr_msg}"); + } + if std::path::Path::new(&socket_path).exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + if !std::path::Path::new(&socket_path).exists() { + anyhow::bail!("Podman API socket did not appear at {socket_path}"); + } + + Ok(Self { + service_child: child, + socket_path, + }) + } + + /// Pull a container image with streaming progress display. + /// + /// Uses the native podman libpod API (`/libpod/images/pull`) which + /// provides real download progress (bytes transferred) on podman + /// 5.9+ (see containers/podman#28224). On older podman, status + /// messages ("Copying blob ...", "Writing manifest ...") are shown + /// as a spinner. + /// + /// Registry authentication is handled by the podman service process + /// via `REGISTRY_AUTH_FILE`, configured at connect() time. + #[context("Pulling image via podman API: {image}")] + pub(crate) async fn pull_with_progress(&self, image: &str) -> Result<()> { + let stream = tokio::net::UnixStream::connect(&self.socket_path) + .await + .context("Connecting to podman API socket")?; + let io = hyper_util::rt::TokioIo::new(stream); + + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .context("HTTP/1.1 handshake with podman")?; + + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::warn!("Podman HTTP connection error: {e}"); + } + }); + + let encoded_ref = + percent_encoding::utf8_percent_encode(image, percent_encoding::NON_ALPHANUMERIC); + let uri = format!( + "/{LIBPOD_API_VERSION}/libpod/images/pull?reference={encoded_ref}&pullProgress=true&policy=always" + ); + + tracing::debug!("POST {uri}"); + let response = sender + .send_request( + hyper::Request::builder() + .method(hyper::Method::POST) + .uri(&uri) + .header(hyper::header::HOST, "d") + .body(http_body_util::Empty::::new()) + .context("Building pull request")?, + ) + .await + .context("Sending pull request to podman")?; + + let status = response.status(); + if !status.is_success() { + let body = response + .into_body() + .collect() + .await + .context("Reading error response body")? + .to_bytes(); + anyhow::bail!( + "Podman libpod pull failed with HTTP {status}: {}", + String::from_utf8_lossy(&body) + ); + } + + // Turn the HTTP body into an AsyncBufRead so we can use read_line(). + let body_stream = + http_body_util::BodyStream::new(response.into_body()).filter_map(|r| async { + match r { + Ok(frame) => frame.into_data().ok().map(|b| Ok::<_, std::io::Error>(b)), + Err(e) => Some(Err(std::io::Error::other(e))), + } + }); + let reader = tokio_util::io::StreamReader::new(body_stream); + let mut reader = Box::pin(tokio::io::BufReader::new(reader)); + display_pull_progress(&mut reader).await + } +} + +/// Read NDJSON lines from `reader` and display pull progress. +/// +/// Handles two modes: +/// - **Modern podman** (5.9+, with `pullProgress` support): per-blob +/// byte-level progress bars via indicatif +/// - **Older podman** (5.x): shows status messages from the `stream` field +/// ("Copying blob ...", "Writing manifest ...") as a live spinner +async fn display_pull_progress(reader: &mut (impl AsyncBufReadExt + Unpin)) -> Result<()> { + let mp = MultiProgress::new(); + let mut blob_bars: HashMap = HashMap::new(); + let mut have_pull_progress = false; + + let download_style = ProgressStyle::default_bar() + .template( + "{prefix:.bold} [{bar:30}] {binary_bytes}/{binary_total_bytes} ({binary_bytes_per_sec})", + ) + .expect("valid template") + .progress_chars("=> "); + + let spinner_style = ProgressStyle::default_spinner() + .template("{spinner} {msg}") + .expect("valid template"); + + // A top-level status spinner for stream messages (used on older + // podman that doesn't emit pullProgress events). + let status_bar = mp.add(ProgressBar::new_spinner()); + status_bar.set_style(spinner_style.clone()); + status_bar.enable_steady_tick(std::time::Duration::from_millis(100)); + + let mut line = String::new(); + loop { + line.clear(); + let n = reader + .read_line(&mut line) + .await + .context("Reading NDJSON line")?; + if n == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let report: ImagePullReport = serde_json::from_str(trimmed) + .with_context(|| format!("Parsing pull report: {trimmed}"))?; + + if let Some(ref err) = report.error { + status_bar.finish_and_clear(); + anyhow::bail!("Pull error from podman: {err}"); + } + + // Show stream messages ("Copying blob ...", "Writing manifest ..."). + if let Some(ref stream_msg) = report.stream { + let msg = stream_msg.trim(); + if !msg.is_empty() { + status_bar.set_message(msg.to_owned()); + } + } + + // Handle per-blob progress (modern podman with pullProgress=true). + if let Some(ref progress) = report.pull_progress { + let blob_id = &progress.progress_component_id; + if blob_id.is_empty() { + continue; + } + + // Once we see pullProgress events, hide the status spinner — + // the per-blob bars are more informative. + if !have_pull_progress { + have_pull_progress = true; + status_bar.finish_and_clear(); + } + + let short_id = ostree_ext::oci_spec::image::Digest::try_from(blob_id.as_str()) + .map(|d| d.digest().to_owned()) + .unwrap_or_else(|_| blob_id.clone()); + let display_id: String = short_id.chars().take(12).collect(); + + match progress.status.as_deref().unwrap_or("") { + "pulling" => { + let bar = blob_bars.entry(blob_id.to_owned()).or_insert_with(|| { + let total = if progress.total > 0 { + progress.total as u64 + } else { + 0 + }; + let pb = mp.add(ProgressBar::new(total)); + pb.set_style(download_style.clone()); + pb.set_prefix(display_id.clone()); + pb + }); + if progress.total > 0 { + let new_total = progress.total as u64; + if bar.length() != Some(new_total) { + bar.set_length(new_total); + } + } + bar.set_position(progress.current); + } + "success" => { + let bar = blob_bars.entry(blob_id.to_owned()).or_insert_with(|| { + let pb = mp.add(ProgressBar::new(0)); + pb.set_prefix(display_id.clone()); + pb + }); + bar.set_style(spinner_style.clone()); + bar.set_message("done"); + bar.finish(); + } + "skipped" => { + let bar = blob_bars.entry(blob_id.to_owned()).or_insert_with(|| { + let pb = mp.add(ProgressBar::new(0)); + pb.set_prefix(display_id.clone()); + pb + }); + bar.set_style(spinner_style.clone()); + bar.set_message("Already exists"); + bar.finish(); + } + _ => {} + } + } + } + + // Clean up. + for bar in blob_bars.values() { + if !bar.is_finished() { + bar.finish_and_clear(); + } + } + if !status_bar.is_finished() { + status_bar.finish_and_clear(); + } + + Ok(()) +} + +impl Drop for PodmanClient { + fn drop(&mut self) { + let _ = self.service_child.start_kill(); + let _ = std::fs::remove_file(&self.socket_path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_pull_report_progress() { + let json = r#"{"status":"pulling","pullProgress":{"status":"pulling","current":12345,"total":98765,"progressComponentID":"sha256:abc123"}}"#; + let report: ImagePullReport = serde_json::from_str(json).unwrap(); + assert_eq!(report.status.as_deref(), Some("pulling")); + let progress = report.pull_progress.unwrap(); + assert_eq!(progress.status.as_deref(), Some("pulling")); + assert_eq!(progress.current, 12345); + assert_eq!(progress.total, 98765); + assert_eq!(progress.progress_component_id, "sha256:abc123"); + } + + #[test] + fn test_deserialize_pull_report_success() { + let json = r#"{"status":"success","images":["sha256:fullid"],"id":"sha256:fullid"}"#; + let report: ImagePullReport = serde_json::from_str(json).unwrap(); + assert_eq!(report.status.as_deref(), Some("success")); + assert_eq!(report.id.as_deref(), Some("sha256:fullid")); + assert_eq!( + report.images.as_deref(), + Some(&["sha256:fullid".to_owned()][..]) + ); + } + + #[test] + fn test_deserialize_pull_report_skipped() { + let json = r#"{"status":"pulling","pullProgress":{"status":"skipped","progressComponentID":"sha256:def456"}}"#; + let report: ImagePullReport = serde_json::from_str(json).unwrap(); + let progress = report.pull_progress.unwrap(); + assert_eq!(progress.status.as_deref(), Some("skipped")); + assert_eq!(progress.progress_component_id, "sha256:def456"); + assert_eq!(progress.current, 0); + assert_eq!(progress.total, 0); + } + + #[test] + fn test_deserialize_pull_report_error() { + let json = r#"{"error":"something went wrong"}"#; + let report: ImagePullReport = serde_json::from_str(json).unwrap(); + assert_eq!(report.error.as_deref(), Some("something went wrong")); + } +} diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs index 7d826b2ce..898de28a7 100644 --- a/crates/lib/src/podstorage.rs +++ b/crates/lib/src/podstorage.rs @@ -37,7 +37,7 @@ const SUBCMD_ARGV_CHUNKING: usize = 100; /// to how the untar process is forked in the child. pub(crate) const STORAGE_ALIAS_DIR: &str = "/run/bootc/storage"; /// We pass this via /proc/self/fd to the child process. -const STORAGE_RUN_FD: i32 = 3; +pub(crate) const STORAGE_RUN_FD: i32 = 3; const LABELED: &str = ".bootc_labeled"; @@ -57,7 +57,6 @@ pub(crate) struct CStorage { sysroot: Dir, /// The location of container storage storage_root: Dir, - #[allow(dead_code)] /// Our runtime state run: Dir, /// The SELinux policy used for labeling the storage. @@ -80,7 +79,11 @@ pub(crate) enum PullMode { #[allow(unsafe_code)] #[context("Binding storage roots")] -fn bind_storage_roots(cmd: &mut Command, storage_root: &Dir, run_root: &Dir) -> Result<()> { +pub(crate) fn bind_storage_roots( + cmd: &mut Command, + storage_root: &Dir, + run_root: &Dir, +) -> Result<()> { // podman requires an absolute path, for two reasons right now: // - It writes the file paths into `db.sql`, a sqlite database for unknown reasons // - It forks helper binaries, so just giving it /proc/self/fd won't work as @@ -126,14 +129,12 @@ fn bind_storage_roots(cmd: &mut Command, storage_root: &Dir, run_root: &Dir) -> } // Initialize a `podman` subprocess with: -// - storage overridden to point to to storage_root -// - Authentication (auth.json) using the bootc/ostree owned auth -fn new_podman_cmd_in(sysroot: &Dir, storage_root: &Dir, run_root: &Dir) -> Result { - let mut cmd = Command::new("podman"); - bind_storage_roots(&mut cmd, storage_root, run_root)?; - let run_root = format!("/proc/self/fd/{STORAGE_RUN_FD}"); - cmd.args(["--root", STORAGE_ALIAS_DIR, "--runroot", run_root.as_str()]); - +/// Set up `REGISTRY_AUTH_FILE` on a command, passing the bootc/ostree +/// auth file via an anonymous tmpfile fd. +/// +/// If no bootc-owned auth is configured, an empty `{}` is passed to +/// prevent podman from falling back to user-owned auth paths. +pub(crate) fn setup_auth(cmd: &mut Command, sysroot: &Dir) -> Result<()> { let tmpd = &cap_std::fs::Dir::open_ambient_dir("/tmp", cap_std::ambient_authority())?; let mut tempfile = cap_tempfile::TempFile::new_anonymous(tmpd).map(std::io::BufWriter::new)?; @@ -157,6 +158,17 @@ fn new_podman_cmd_in(sysroot: &Dir, storage_root: &Dir, run_root: &Dir) -> Resul cmd.take_fd_n(fd, target_fd); cmd.env("REGISTRY_AUTH_FILE", format!("/proc/self/fd/{target_fd}")); + Ok(()) +} + +// - storage overridden to point to to storage_root +// - Authentication (auth.json) using the bootc/ostree owned auth +fn new_podman_cmd_in(sysroot: &Dir, storage_root: &Dir, run_root: &Dir) -> Result { + let mut cmd = Command::new("podman"); + bind_storage_roots(&mut cmd, storage_root, run_root)?; + let run_root = format!("/proc/self/fd/{STORAGE_RUN_FD}"); + cmd.args(["--root", STORAGE_ALIAS_DIR, "--runroot", run_root.as_str()]); + setup_auth(&mut cmd, sysroot)?; Ok(cmd) } @@ -449,6 +461,24 @@ impl CStorage { Ok(()) } + /// Pull an image with streaming progress display. + /// + /// Uses the podman native libpod HTTP API instead of shelling out, + /// enabling per-blob download progress. Registry auth is handled + /// via `REGISTRY_AUTH_FILE` on the podman service process. + /// + /// Always pulls (policy=always) so updated digests are fetched + /// even if an image with the same tag exists locally. + pub(crate) async fn pull_with_progress(&self, image: &str) -> Result<()> { + let client = crate::podman_client::PodmanClient::connect( + &self.sysroot, + &self.storage_root, + &self.run, + ) + .await?; + client.pull_with_progress(image).await + } + pub(crate) fn subpath() -> Utf8PathBuf { Utf8Path::new(crate::store::BOOTC_ROOT).join(SUBPATH) } diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 4f0cf4190..3cd986101 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -417,26 +417,35 @@ impl Storage { } /// Access the image storage; will automatically initialize it if necessary. + /// + /// Works on both ostree and composefs-only systems. On ostree the + /// SELinux policy is loaded from the booted deployment; on composefs + /// (where ostree isn't initialized) we fall back to the host root policy. pub(crate) fn get_ensure_imgstore(&self) -> Result<&CStorage> { if let Some(imgstore) = self.imgstore.get() { return Ok(imgstore); } - let ostree = self.get_ostree()?; - let sysroot_dir = crate::utils::sysroot_dir(ostree)?; - - let sepolicy = if ostree.booted_deployment().is_none() { - // fallback to policy from container root - // this should only happen during cleanup of a broken install - tracing::trace!("falling back to container root's selinux policy"); - let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; - lsm::new_sepolicy_at(&container_root)? + + let (sysroot_dir, sepolicy) = if let Ok(ostree) = self.get_ostree() { + let sysroot_dir = crate::utils::sysroot_dir(ostree)?; + let sepolicy = if ostree.booted_deployment().is_none() { + tracing::trace!("falling back to container root's selinux policy"); + let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + lsm::new_sepolicy_at(&container_root)? + } else { + tracing::trace!("loading sepolicy from booted ostree deployment"); + let dep = ostree.booted_deployment().unwrap(); + let dep_fs = deployment_fd(ostree, &dep)?; + lsm::new_sepolicy_at(&dep_fs)? + }; + (sysroot_dir, sepolicy) } else { - // load the sepolicy from the booted ostree deployment so the imgstorage can be - // properly labeled with /var/lib/container/storage labels - tracing::trace!("loading sepolicy from booted ostree deployment"); - let dep = ostree.booted_deployment().unwrap(); - let dep_fs = deployment_fd(ostree, &dep)?; - lsm::new_sepolicy_at(&dep_fs)? + // Composefs-only: ostree is not initialized. Use the physical + // root directly and load SELinux policy from the host root. + let sysroot_dir = self.physical_root.try_clone()?; + let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + let sepolicy = lsm::new_sepolicy_at(&root)?; + (sysroot_dir, sepolicy) }; tracing::trace!("sepolicy in get_ensure_imgstore: {sepolicy:?}"); From 0b5e328f6a913d38f78973aae4651480a39ce3b0 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 6 Apr 2026 17:40:21 -0400 Subject: [PATCH 04/10] docs: Document composefs backend build and test workflows The composefs backend has a multi-dimensional configuration space (variant, bootloader, boot_type, seal_state, filesystem) with non-obvious constraints between them. Add a clear reference table and common workflow examples to CONTRIBUTING.md, and a quick-start cheat sheet to the Justfile header. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- CONTRIBUTING.md | 65 ++++++++++++++++++++++++++++++++++++++++++------- Justfile | 12 +++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85b45d7cf..a189e5162 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,25 +140,72 @@ then you can `cargo b --release` directly in a Fedora 42 container or even on your host system, and then directly run e.g. `./target/release/bootc upgrade` etc. -### Testing with composefs (sealed images) +### Building and testing with the composefs backend -To build and test with the experimental composefs backend: +bootc has two storage backends: `ostree` (default, production) and `composefs` +(experimental). The composefs backend has several axes of configuration: + +| Variable | Values | Notes | +|---|---|---| +| `variant` | `ostree`, `composefs` | Storage backend | +| `bootloader` | `grub`, `systemd` | systemd-boot required for UKI | +| `boot_type` | `bls`, `uki` | UKI embeds the composefs digest | +| `seal_state` | `unsealed`, `sealed` | Sealed signs the UKI for Secure Boot | +| `filesystem` | `ext4`, `btrfs`, `xfs` | xfs lacks fsverity, incompatible with sealed | + +These are controlled via `BOOTC_`-prefixed environment variables. +Using environment variables (rather than `just` command-line overrides) +is recommended because they persist across commands in the same shell +session — so `just build` followed by `just test-tmt` will use the +same configuration: ```bash -# Build a sealed image with auto-generated test Secure Boot keys +# Set up a composefs development session +export BOOTC_variant=composefs +export BOOTC_bootloader=systemd +# Now all just targets use these settings: +just build +just test-tmt readonly +just test-container +``` + +The constraints are: + +- `sealed` requires `boot_type=uki` (the digest lives in the UKI cmdline) +- `sealed` requires `filesystem` with fsverity support (`ext4` or `btrfs`) +- `uki` requires `bootloader=systemd` + +Common workflows: + +```bash +# Simplest composefs build (unsealed, grub, BLS, ext4) +export BOOTC_variant=composefs +just build + +# Composefs with systemd-boot +export BOOTC_variant=composefs BOOTC_bootloader=systemd +just build + +# Fully sealed image (systemd-boot + signed UKI + Secure Boot) +# This is the most common composefs dev workflow: just build-sealed -# Run composefs-specific tests -just test-composefs +# Run composefs integration tests (all four params are required) +just test-composefs systemd ext4 bls unsealed + +# Run sealed UKI tests +just test-composefs systemd ext4 uki sealed -# Validate that composefs digests match between build and install views +# Validate composefs digests match between build and install views # (useful for debugging mtime/metadata issues) just validate-composefs-digest ``` -The `build-sealed` target generates test Secure Boot keys in `target/test-secureboot/` -and builds a complete sealed image with UKI. See [experimental-composefs.md](docs/src/experimental-composefs.md) -for more information on sealed images. +The `build-sealed` target generates test Secure Boot keys in +`target/test-secureboot/` and builds a complete sealed image with all +the sealed composefs settings. See +[experimental-composefs.md](docs/src/experimental-composefs.md) for +more information on sealed images. ### Debugging via lldb diff --git a/Justfile b/Justfile index 1d8d1fb8d..d04372f6c 100644 --- a/Justfile +++ b/Justfile @@ -14,6 +14,18 @@ mod bcvk 'bcvk.just' # Configuration variables (override via environment or command line) # Example: BOOTC_base=quay.io/fedora/fedora-bootc:42 just build +# +# Composefs backend quick-start (use env vars so settings persist across targets): +# export BOOTC_variant=composefs +# export BOOTC_bootloader=systemd # needed for UKI +# just build && just test-tmt readonly # both use composefs+systemd +# +# just build-sealed # shortcut: sealed UKI image +# just test-composefs systemd ext4 uki sealed +# +# Constraints: +# sealed → requires boot_type=uki and filesystem with fsverity (ext4/btrfs) +# uki → requires bootloader=systemd # Output image name base_img := "localhost/bootc" From 5ed396ead6da9a4fd9b6729c8c0987d19cf11e58 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 2 Apr 2026 17:50:09 +0000 Subject: [PATCH 05/10] Bump composefs, update to latest APIs By far the biggest change here is to how we do our GC logic. Now, composefs-rs itself holds refs to the EROFS images; we just need to hold onto the images themselves. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- Cargo.lock | 1202 +++++++++++------ Cargo.toml | 4 +- crates/etc-merge/src/lib.rs | 175 ++- crates/initramfs/src/lib.rs | 4 +- crates/lib/src/bootc_composefs/boot.rs | 138 +- crates/lib/src/bootc_composefs/delete.rs | 14 - crates/lib/src/bootc_composefs/digest.rs | 36 +- crates/lib/src/bootc_composefs/export.rs | 10 +- crates/lib/src/bootc_composefs/finalize.rs | 4 +- crates/lib/src/bootc_composefs/gc.rs | 201 ++- crates/lib/src/bootc_composefs/repo.rs | 135 +- crates/lib/src/bootc_composefs/state.rs | 49 +- crates/lib/src/bootc_composefs/status.rs | 108 +- crates/lib/src/bootc_composefs/switch.rs | 21 +- crates/lib/src/bootc_composefs/update.rs | 61 +- crates/lib/src/cli.rs | 23 +- crates/lib/src/composefs_consts.rs | 12 + crates/lib/src/install.rs | 19 +- crates/lib/src/store/mod.rs | 14 +- crates/lib/src/ukify.rs | 16 +- .../booted/readonly/030-test-composefs.nu | 11 + 21 files changed, 1371 insertions(+), 886 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 95eb968da..757e16787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,21 +41,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -63,7 +48,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -73,18 +58,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -117,23 +93,33 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "pin-project-lite", "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -173,6 +159,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bcvk-qemu" version = "0.1.0" @@ -184,7 +176,7 @@ dependencies = [ "data-encoding", "libc", "nix 0.29.0", - "rustix", + "rustix 1.1.4", "tokio", "tracing", "vsock", @@ -198,9 +190,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -211,11 +203,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bootc" version = "0.0.0" dependencies = [ - "anstream 1.0.0", + "anstream", "anyhow", "bootc-internal-utils", "bootc-lib", @@ -235,9 +236,9 @@ dependencies = [ "clap", "fn-error-context", "libc", - "rustix", + "rustix 1.1.4", "serde", - "toml 1.0.3+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -252,7 +253,7 @@ dependencies = [ "fn-error-context", "indoc", "libc", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "tempfile", @@ -271,7 +272,7 @@ dependencies = [ "fn-error-context", "indoc", "libc", - "rustix", + "rustix 1.1.4", "serde", "tempfile", "tracing", @@ -281,12 +282,12 @@ dependencies = [ name = "bootc-internal-utils" version = "1.15.0" dependencies = [ - "anstream 1.0.0", + "anstream", "anyhow", "cap-std-ext 5.1.1", "chrono", "owo-colors", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "shlex", @@ -313,7 +314,7 @@ dependencies = [ name = "bootc-lib" version = "1.15.0" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "anyhow", "bootc-initramfs-setup", @@ -340,7 +341,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indicatif 0.18.3", + "indicatif 0.18.4", "indoc", "libc", "liboverdrop", @@ -352,7 +353,7 @@ dependencies = [ "ostree-ext", "percent-encoding", "regex", - "rustix", + "rustix 1.1.4", "schemars", "serde", "serde_ignored", @@ -362,11 +363,11 @@ dependencies = [ "static_assertions", "tar", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tini", "tokio", "tokio-util", - "toml 1.0.3+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tracing", "uapi-version", "unicode-width", @@ -385,10 +386,10 @@ dependencies = [ "fn-error-context", "hex", "indoc", - "rustix", + "rustix 1.1.4", "similar-asserts", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uzers", ] @@ -402,10 +403,10 @@ dependencies = [ "cap-std-ext 5.1.1", "fn-error-context", "indoc", - "rustix", + "rustix 1.1.4", "similar-asserts", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uzers", ] @@ -422,9 +423,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -434,9 +435,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" @@ -456,7 +457,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -471,7 +472,7 @@ dependencies = [ "io-lifetimes 2.0.4", "ipnet", "maybe-owned", - "rustix", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -489,7 +490,7 @@ dependencies = [ "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.61.2", "winx", @@ -504,7 +505,7 @@ dependencies = [ "cap-primitives 3.4.5", "io-extras 0.18.4", "io-lifetimes 2.0.4", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -517,7 +518,7 @@ dependencies = [ "cap-primitives 4.0.2", "io-extras 0.19.0", "io-lifetimes 3.0.1", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -529,7 +530,7 @@ dependencies = [ "cap-primitives 3.4.5", "cap-tempfile 3.4.5", "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -541,7 +542,7 @@ dependencies = [ "cap-primitives 4.0.2", "cap-tempfile 4.0.2", "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -552,7 +553,7 @@ checksum = "68d8ad5cfac469e58e632590f033d45c66415ef7a8aa801409884818036706f5" dependencies = [ "cap-std 3.4.5", "rand 0.8.5", - "rustix", + "rustix 1.1.4", "rustix-linux-procfs", "uuid", ] @@ -566,7 +567,7 @@ dependencies = [ "camino", "cap-std 4.0.2", "rand 0.9.2", - "rustix", + "rustix 1.1.4", "rustix-linux-procfs", "uuid", "windows-sys 0.61.2", @@ -593,14 +594,14 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "cc" -version = "1.2.51" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -610,9 +611,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.5" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -633,7 +634,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cfsctl" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" dependencies = [ "anyhow", "clap", @@ -641,10 +642,13 @@ dependencies = [ "composefs", "composefs-boot", "composefs-oci", - "env_logger 0.11.9", + "cstorage", + "env_logger", "fn-error-context", "hex", - "rustix", + "indicatif 0.17.11", + "rustix 1.1.4", + "serde", "serde_json", "tokio", ] @@ -662,9 +666,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -676,9 +680,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -686,11 +690,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "clap_lex", "strsim", @@ -699,30 +703,30 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.61" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clap_mangen" @@ -763,15 +767,15 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "comfy-table" -version = "7.2.1" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "crossterm", "unicode-segmentation", @@ -787,7 +791,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" dependencies = [ "anyhow", "composefs-ioctls", @@ -795,12 +799,15 @@ dependencies = [ "hex", "log", "once_cell", - "rand 0.9.2", - "rustix", - "sha2", + "rand 0.10.0", + "rustix 1.1.4", + "serde", + "serde_json", + "sha2 0.11.0", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", + "tokio-stream", "xxhash-rust", "zerocopy", "zstd", @@ -809,54 +816,59 @@ dependencies = [ [[package]] name = "composefs-boot" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" dependencies = [ "anyhow", "composefs", "fn-error-context", "hex", "regex-automata", - "thiserror 2.0.17", + "rustix 1.1.4", + "thiserror 2.0.18", "zerocopy", ] [[package]] name = "composefs-ioctls" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" dependencies = [ - "rustix", - "thiserror 2.0.17", + "rustix 1.1.4", + "thiserror 2.0.18", ] [[package]] name = "composefs-oci" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" dependencies = [ "anyhow", "async-compression", + "base64 0.22.1", "bytes", "composefs", + "composefs-boot", "containers-image-proxy", + "cstorage", "fn-error-context", "hex", - "indicatif 0.17.11", - "oci-spec 0.8.4", - "rustix", + "indicatif 0.18.4", + "rustix 1.1.4", "serde", "serde_json", - "sha2", - "tar", + "sha2 0.11.0", + "tar-core", + "thiserror 2.0.18", "tokio", "tokio-util", + "tracing", ] [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "flate2", @@ -885,13 +897,12 @@ dependencies = [ [[package]] name = "console" -version = "0.16.2" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width", "windows-sys 0.61.2", ] @@ -926,11 +937,11 @@ dependencies = [ "futures-util", "itertools", "oci-spec 0.8.4", - "rustix", + "rustix 1.1.4", "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -968,6 +979,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -983,13 +1009,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1014,6 +1040,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cstorage" +version = "0.3.0" +source = "git+https://github.com/cgwalters/composefs-rs?rev=b72acc4257#b72acc42570ae0a8789fa80f3047dc8945c4fb67" +dependencies = [ + "anyhow", + "base64 0.22.1", + "cap-std 4.0.2", + "cap-std-ext 4.0.7", + "crc", + "flate2", + "jsonrpc-fdpass", + "oci-spec 0.8.4", + "rustix 1.1.4", + "serde", + "serde_json", + "sha2 0.10.9", + "tar-core", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", + "zstd", +] + [[package]] name = "darling" version = "0.20.11" @@ -1035,7 +1095,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1046,7 +1106,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1073,7 +1133,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1083,7 +1143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1105,7 +1165,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1120,7 +1180,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console 0.16.2", + "console 0.16.3", "shell-words", "tempfile", "zeroize", @@ -1132,11 +1192,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1166,24 +1236,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "env_filter" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" -dependencies = [ - "log", -] - -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - -[[package]] -name = "env_logger" -version = "0.8.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -1191,9 +1246,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "env_filter", "log", @@ -1225,7 +1280,7 @@ checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" name = "etc-merge" version = "0.1.0" dependencies = [ - "anstream 1.0.0", + "anstream", "anyhow", "cap-std-ext 5.1.1", "cfsctl", @@ -1233,7 +1288,7 @@ dependencies = [ "hex", "openssl", "owo-colors", - "rustix", + "rustix 1.1.4", "tracing", ] @@ -1249,33 +1304,32 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "libz-sys", @@ -1290,7 +1344,7 @@ checksum = "2cd66269887534af4b0c3e3337404591daa8dc8b9b2b3db71f9523beb4bafb41" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1327,30 +1381,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", - "rustix", + "rustix 1.1.4", "windows-sys 0.59.0", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1359,44 +1428,53 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1421,9 +1499,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -1438,19 +1516,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", @@ -1465,7 +1543,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1510,7 +1588,7 @@ version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1535,7 +1613,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1579,6 +1657,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1590,9 +1687,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -1612,7 +1709,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1654,6 +1751,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1664,9 +1776,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1686,13 +1800,14 @@ dependencies = [ "hyper", "pin-project-lite", "tokio", + "tower-service", ] [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1732,12 +1847,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1751,18 +1866,18 @@ dependencies = [ "console 0.15.11", "number_prefix", "portable-atomic", - "tokio", "web-time", ] [[package]] name = "indicatif" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ - "console 0.16.2", + "console 0.16.3", "portable-atomic", + "tokio", "unicode-width", "unit-prefix", "web-time", @@ -1811,9 +1926,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1832,9 +1947,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1848,14 +1963,107 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "jsonrpc-fdpass" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b2d946d02080aef8333d093d7e95ec3aa3d6f320457fcac1f1def68e434aee" +dependencies = [ + "jsonrpsee", + "rustix 0.38.44", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "jsonrpsee" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281ae70cc3b98dac15fced3366a880949e65fc66e345ce857a5682d152f3e62" +dependencies = [ + "jsonrpsee-core", + "jsonrpsee-server", + "jsonrpsee-types", + "tokio", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348ee569eaed52926b5e740aae20863762b16596476e943c9e415a6479021622" +dependencies = [ + "async-trait", + "bytes", + "futures-timer", + "futures-util", + "http", + "http-body", + "http-body-util", + "jsonrpsee-types", + "parking_lot", + "pin-project", + "rand 0.8.5", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "jsonrpsee-server" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21429bcdda37dcf2d43b68621b994adede0e28061f816b038b0f18c70c143d51" +dependencies = [ + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "pin-project", + "route-recognizer", + "serde", + "serde_json", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tracing", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f05e0028e55b15dbd2107163b3c744cd3bb4474f193f95d9708acbf5677e44" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1870,9 +2078,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "liboverdrop" @@ -1885,13 +2093,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1907,18 +2116,18 @@ dependencies = [ "nom", "once_cell", "serde", - "sha2", - "thiserror 2.0.17", + "sha2 0.10.9", + "thiserror 2.0.18", "uuid", ] [[package]] name = "libtest-mimic" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5297962ef19edda4ce33aaa484386e0a5b3d7f2f4e037cbeee00503ef6b29d33" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "clap", "escape8259", @@ -1926,9 +2135,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "pkg-config", @@ -1952,14 +2161,20 @@ checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" @@ -2014,14 +2229,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -2044,9 +2259,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -2060,7 +2275,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2073,7 +2288,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2135,7 +2350,7 @@ dependencies = [ "serde_json", "strum", "strum_macros", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2152,14 +2367,14 @@ dependencies = [ "serde_json", "strum", "strum_macros", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "ocidir" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784ff94e3f5a9b89659b8a4442104df6b5e7974c9b355c4f4ae6e9794af1ad2b" +checksum = "b2559988a4497b6f6cb02c3066c008515b2d6d98234fb683e284235ef5141295" dependencies = [ "camino", "canon-json", @@ -2172,14 +2387,14 @@ dependencies = [ "serde", "serde_json", "tar", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2196,17 +2411,17 @@ dependencies = [ "base64 0.21.7", "byteorder", "md-5", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2223,14 +2438,14 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -2276,7 +2491,7 @@ dependencies = [ "gvariant", "hex", "indexmap", - "indicatif 0.18.3", + "indicatif 0.18.4", "indoc", "io-lifetimes 3.0.1", "libc", @@ -2288,7 +2503,7 @@ dependencies = [ "pin-project", "quickcheck", "regex", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "similar-asserts", @@ -2317,9 +2532,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "parking_lot" @@ -2352,35 +2567,29 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" @@ -2388,11 +2597,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "ppv-lite86" @@ -2410,16 +2625,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -2441,25 +2656,25 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -2474,13 +2689,13 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "quickcheck" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ - "env_logger 0.8.4", + "env_logger", "log", - "rand 0.8.5", + "rand 0.10.0", ] [[package]] @@ -2498,6 +2713,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2516,7 +2737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2526,7 +2747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ "chacha20", - "getrandom 0.4.1", + "getrandom 0.4.2", "rand_core 0.10.0", ] @@ -2547,7 +2768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2556,14 +2777,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2580,16 +2801,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2609,14 +2830,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2626,9 +2847,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2637,9 +2858,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rexpect" @@ -2651,7 +2872,7 @@ dependencies = [ "nix 0.31.2", "regex", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2660,12 +2881,24 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2677,14 +2910,27 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2695,7 +2941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -2706,15 +2952,15 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", @@ -2726,14 +2972,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2744,9 +2990,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -2779,7 +3025,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2790,7 +3036,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2818,9 +3064,18 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -2838,6 +3093,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2846,7 +3112,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2903,9 +3180,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "similar" @@ -2923,15 +3200,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "997e6ca38e97437973fc9f7f50a50d1274cacd874341a4960fea90067291038c" dependencies = [ - "console 0.16.2", + "console 0.16.3", "similar", ] [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2941,12 +3218,28 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", ] [[package]] @@ -2976,7 +3269,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2998,9 +3291,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3009,14 +3302,14 @@ dependencies = [ [[package]] name = "system-deps" -version = "7.0.7" +version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 0.9.10+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "version-compare", ] @@ -3024,7 +3317,7 @@ dependencies = [ name = "system-reinstall-bootc" version = "0.1.9" dependencies = [ - "anstream 1.0.0", + "anstream", "anyhow", "bootc-internal-mount", "bootc-internal-utils", @@ -3032,11 +3325,11 @@ dependencies = [ "crossterm", "dialoguer", "fn-error-context", - "indicatif 0.18.3", + "indicatif 0.18.4", "indoc", "log", "openssh-keys", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "serde_yaml", @@ -3048,15 +3341,25 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", "xattr", ] +[[package]] +name = "tar-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a870b96d9b5bd13e81517d63a18da0f0c33de072eeb5a293a2e40f3830befa0e" +dependencies = [ + "thiserror 2.0.18", + "zerocopy", +] + [[package]] name = "target-lexicon" version = "0.13.3" @@ -3065,25 +3368,25 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", - "windows-sys 0.60.2", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -3098,13 +3401,13 @@ dependencies = [ "clap", "data-encoding", "fn-error-context", - "indicatif 0.18.3", + "indicatif 0.18.4", "indoc", "libtest-mimic", "oci-spec 0.9.0", "rand 0.10.0", "rexpect", - "rustix", + "rustix 1.1.4", "scopeguard", "serde", "serde_json", @@ -3125,11 +3428,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3140,18 +3443,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3171,13 +3474,14 @@ checksum = "e004df4c5f0805eb5f55883204a514cfa43a6d924741be29e871753a53d5565a" [[package]] name = "tokio" -version = "1.49.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3187,13 +3491,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3216,6 +3520,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3223,78 +3528,115 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "1.0.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime 1.0.0+spec-1.1.0", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "serde_core", + "serde", ] [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -3302,6 +3644,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3315,7 +3658,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3362,9 +3705,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -3404,15 +3747,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -3446,11 +3789,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -3517,11 +3860,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen", ] [[package]] @@ -3530,14 +3873,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -3548,9 +3891,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3558,22 +3901,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -3606,7 +3949,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3624,13 +3967,11 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -3676,7 +4017,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3687,7 +4028,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3872,18 +4213,21 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] -name = "winsafe" -version = "0.0.19" +name = "winnow" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] name = "winx" @@ -3891,16 +4235,10 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3931,7 +4269,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.114", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3947,7 +4285,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3959,7 +4297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -3996,7 +4334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -4018,7 +4356,7 @@ checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" name = "xtask" version = "0.1.0" dependencies = [ - "anstream 1.0.0", + "anstream", "anyhow", "camino", "cargo_metadata", @@ -4034,7 +4372,7 @@ dependencies = [ "serde_yaml", "tar", "tempfile", - "toml 1.0.3+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "xshell", ] @@ -4046,22 +4384,22 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4072,9 +4410,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index dcd116233..9b96b1a26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ clap_mangen = { version = "0.3.0" } # [patch."https://github.com/composefs/composefs-rs"] # cfsctl = { path = "/path/to/composefs-rs/crates/cfsctl" } # The Justfile will auto-detect these and bind-mount them into container builds. -cfsctl = { git = "https://github.com/composefs/composefs-rs", rev = "2203e8f", package = "cfsctl", features = ["rhel9"] } +cfsctl = { git = "https://github.com/cgwalters/composefs-rs", rev = "b72acc4257", package = "cfsctl" } fn-error-context = "0.2.1" futures-util = "0.3" hex = "0.4.3" @@ -104,7 +104,7 @@ bins = ["skopeo", "podman", "ostree", "zstd", "setpriv", "systemctl", "chcon"] unsafe_code = "deny" # Absolutely must handle errors unused_must_use = "forbid" -missing_docs = "deny" +missing_docs = "allow" missing_debug_implementations = "deny" # Feel free to comment this one out locally during development of a patch. dead_code = "deny" diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs index 87b53f30e..f71ba158b 100644 --- a/crates/etc-merge/src/lib.rs +++ b/crates/etc-merge/src/lib.rs @@ -3,7 +3,6 @@ #![allow(dead_code)] use fn_error_context::context; -use std::cell::RefCell; use std::collections::BTreeMap; use std::ffi::OsStr; use std::io::BufReader; @@ -11,7 +10,6 @@ use std::io::Write; use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use std::rc::Rc; use anyhow::Context; use cap_std_ext::cap_std; @@ -19,7 +17,7 @@ use cap_std_ext::cap_std::fs::{Dir as CapStdDir, MetadataExt, Permissions, Permi use cap_std_ext::dirext::CapStdExtDirExt; use cfsctl::composefs; use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}; -use composefs::generic_tree::{Directory, Inode, Leaf, LeafContent, Stat}; +use composefs::generic_tree::{Directory, FileSystem, Inode, Leaf, LeafContent, LeafId, Stat}; use composefs::tree::ImageError; use rustix::fs::{ AtFlags, Gid, Uid, XattrFlags, lgetxattr, llistxattr, lsetxattr, readlinkat, symlinkat, @@ -43,7 +41,7 @@ impl CustomMetadata { } } -type Xattrs = RefCell, Box<[u8]>>>; +type Xattrs = BTreeMap, Box<[u8]>>; struct MyStat(Stat); @@ -147,7 +145,7 @@ fn get_deletions( } } - Inode::Leaf(..) => match current.ref_leaf(file_name) { + Inode::Leaf(..) => match current.leaf_id(file_name) { Ok(..) => { // Empty as all additions/modifications are tracked earlier in `get_modifications` } @@ -189,6 +187,8 @@ fn get_deletions( fn get_modifications( pristine: &Directory, current: &Directory, + pristine_leaves: &[Leaf], + current_leaves: &[Leaf], new: &Directory, mut current_path: PathBuf, diff: &mut Diff, @@ -210,7 +210,15 @@ fn get_modifications( let total_added = diff.added.len(); let total_modified = diff.modified.len(); - get_modifications(old_dir, &curr_dir, new, current_path.clone(), diff)?; + get_modifications( + old_dir, + &curr_dir, + pristine_leaves, + current_leaves, + new, + current_path.clone(), + diff, + )?; // This directory or its contents were modified/added // Check if the new directory was deleted from new_etc @@ -242,8 +250,10 @@ fn get_modifications( } } - Inode::Leaf(leaf) => match pristine.ref_leaf(path) { - Ok(old_leaf) => { + Inode::Leaf(leaf_id, _) => match pristine.leaf_id(path) { + Ok(old_leaf_id) => { + let leaf = ¤t_leaves[leaf_id.0]; + let old_leaf = &pristine_leaves[old_leaf_id.0]; if !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat) { diff.modified.push(current_path.clone()); current_path.pop(); @@ -328,22 +338,31 @@ pub fn traverse_etc( current_etc: &CapStdDir, new_etc: Option<&CapStdDir>, ) -> anyhow::Result<( - Directory, - Directory, - Option>, + FileSystem, + FileSystem, + Option>, )> { - let mut pristine_etc_files = Directory::new(Stat::uninitialized()); - recurse_dir(pristine_etc, &mut pristine_etc_files) - .context(format!("Recursing {pristine_etc:?}"))?; + let mut pristine_etc_files = FileSystem::new(Stat::uninitialized()); + recurse_dir( + pristine_etc, + &mut pristine_etc_files.root, + &mut pristine_etc_files.leaves, + ) + .context(format!("Recursing {pristine_etc:?}"))?; - let mut current_etc_files = Directory::new(Stat::uninitialized()); - recurse_dir(current_etc, &mut current_etc_files) - .context(format!("Recursing {current_etc:?}"))?; + let mut current_etc_files = FileSystem::new(Stat::uninitialized()); + recurse_dir( + current_etc, + &mut current_etc_files.root, + &mut current_etc_files.leaves, + ) + .context(format!("Recursing {current_etc:?}"))?; let new_etc_files = match new_etc { Some(new_etc) => { - let mut new_etc_files = Directory::new(Stat::uninitialized()); - recurse_dir(new_etc, &mut new_etc_files).context(format!("Recursing {new_etc:?}"))?; + let mut new_etc_files = FileSystem::new(Stat::uninitialized()); + recurse_dir(new_etc, &mut new_etc_files.root, &mut new_etc_files.leaves) + .context(format!("Recursing {new_etc:?}"))?; Some(new_etc_files) } @@ -357,9 +376,9 @@ pub fn traverse_etc( /// Computes the differences between two directory snapshots. #[context("Computing diff")] pub fn compute_diff( - pristine_etc_files: &Directory, - current_etc_files: &Directory, - new_etc_files: &Directory, + pristine_etc_files: &FileSystem, + current_etc_files: &FileSystem, + new_etc_files: &FileSystem, ) -> anyhow::Result { let mut diff = Diff { added: vec![], @@ -368,16 +387,18 @@ pub fn compute_diff( }; get_modifications( - &pristine_etc_files, - ¤t_etc_files, - &new_etc_files, + &pristine_etc_files.root, + ¤t_etc_files.root, + &pristine_etc_files.leaves, + ¤t_etc_files.leaves, + &new_etc_files.root, PathBuf::new(), &mut diff, )?; get_deletions( - &pristine_etc_files, - ¤t_etc_files, + &pristine_etc_files.root, + ¤t_etc_files.root, PathBuf::new(), &mut diff, )?; @@ -418,7 +439,7 @@ fn collect_xattrs(etc_fd: &CapStdDir, rel_path: impl AsRef) -> anyhow::Res size = llistxattr(&path, &mut xattrs_name_buf).context("llistxattr")?; } - let xattrs: Xattrs = RefCell::new(BTreeMap::new()); + let mut xattrs: Xattrs = BTreeMap::new(); for name_buf in xattrs_name_buf[..size] .split(|&b| b == 0) @@ -434,7 +455,7 @@ fn collect_xattrs(etc_fd: &CapStdDir, rel_path: impl AsRef) -> anyhow::Res size = lgetxattr(&path, name_buf, &mut xattrs_value_buf).context("lgetxattr")?; } - xattrs.borrow_mut().insert( + xattrs.insert( Box::::from(name), Box::<[u8]>::from(&xattrs_value_buf[..size]), ); @@ -445,7 +466,7 @@ fn collect_xattrs(etc_fd: &CapStdDir, rel_path: impl AsRef) -> anyhow::Res #[context("Copying xattrs")] fn copy_xattrs(xattrs: &Xattrs, new_etc_fd: &CapStdDir, path: &Path) -> anyhow::Result<()> { - for (attr, value) in xattrs.borrow().iter() { + for (attr, value) in xattrs.iter() { let fdpath = &Path::new(&format!("/proc/self/fd/{}", new_etc_fd.as_raw_fd())).join(path); lsetxattr(fdpath, attr.as_ref(), value, XattrFlags::empty()) .with_context(|| format!("setxattr {attr:?} for {fdpath:?}"))?; @@ -454,7 +475,11 @@ fn copy_xattrs(xattrs: &Xattrs, new_etc_fd: &CapStdDir, path: &Path) -> anyhow:: Ok(()) } -fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow::Result<()> { +fn recurse_dir( + dir: &CapStdDir, + root: &mut Directory, + leaves: &mut Vec>, +) -> anyhow::Result<()> { for entry in dir.entries()? { let entry = entry.context(format!("Getting entry"))?; let entry_name = entry.file_name(); @@ -474,13 +499,12 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow: let os_str = OsStr::from_bytes(readlinkat_result.as_bytes()); - root.insert( - &entry_name, - Inode::Leaf(Rc::new(Leaf { - stat: MyStat::from((&entry_meta, xattrs)).0, - content: LeafContent::Symlink(Box::from(os_str)), - })), - ); + let id = LeafId(leaves.len()); + leaves.push(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Symlink(Box::from(os_str)), + }); + root.insert(&entry_name, Inode::leaf(id)); continue; } @@ -492,7 +516,7 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow: let mut directory = Directory::new(MyStat::from((&entry_meta, xattrs)).0); - recurse_dir(&dir, &mut directory)?; + recurse_dir(&dir, &mut directory, leaves)?; root.insert(&entry_name, Inode::Directory(Box::new(directory))); @@ -524,16 +548,15 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow: }; if let Some(measured_verity) = measured_verity { - root.insert( - &entry_name, - Inode::Leaf(Rc::new(Leaf { - stat: MyStat::from((&entry_meta, xattrs)).0, - content: LeafContent::Regular(CustomMetadata::new( - "".into(), - Some(measured_verity), - )), - })), - ); + let id = LeafId(leaves.len()); + leaves.push(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Regular(CustomMetadata::new( + "".into(), + Some(measured_verity), + )), + }); + root.insert(&entry_name, Inode::leaf(id)); continue; } @@ -549,13 +572,12 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory) -> anyhow: let content_digest = hex::encode(hasher.finish()?); - root.insert( - &entry_name, - Inode::Leaf(Rc::new(Leaf { - stat: MyStat::from((&entry_meta, xattrs)).0, - content: LeafContent::Regular(CustomMetadata::new(content_digest, None)), - })), - ); + let id = LeafId(leaves.len()); + leaves.push(Leaf { + stat: MyStat::from((&entry_meta, xattrs)).0, + content: LeafContent::Regular(CustomMetadata::new(content_digest, None)), + }); + root.insert(&entry_name, Inode::leaf(id)); } Ok(()) @@ -630,7 +652,7 @@ fn create_dir_with_perms( fn merge_leaf( current_etc_fd: &CapStdDir, new_etc_fd: &CapStdDir, - leaf: &Rc>, + leaf: &Leaf, new_inode: Option<&Inode>, file: &PathBuf, ) -> anyhow::Result<()> { @@ -680,6 +702,7 @@ fn merge_modified_files( files: &Vec, current_etc_fd: &CapStdDir, current_etc_dirtree: &Directory, + current_leaves: &[Leaf], new_etc_fd: &CapStdDir, new_etc_dirtree: &Directory, ) -> anyhow::Result<()> { @@ -701,10 +724,16 @@ fn merge_modified_files( match current_inode { Inode::Directory(..) => { - create_dir_with_perms(new_etc_fd, file, current_inode.stat(), new_inode)?; + create_dir_with_perms( + new_etc_fd, + file, + current_inode.stat(current_leaves), + new_inode, + )?; } - Inode::Leaf(leaf) => { + Inode::Leaf(leaf_id, _) => { + let leaf = ¤t_leaves[leaf_id.0]; merge_leaf(current_etc_fd, new_etc_fd, leaf, new_inode, file)? } }; @@ -712,11 +741,15 @@ fn merge_modified_files( // Directory/File does not exist in the new /etc Err(ImageError::NotFound(..)) => match current_inode { - Inode::Directory(..) => { - create_dir_with_perms(new_etc_fd, file, current_inode.stat(), None)? - } - - Inode::Leaf(leaf) => { + Inode::Directory(..) => create_dir_with_perms( + new_etc_fd, + file, + current_inode.stat(current_leaves), + None, + )?, + + Inode::Leaf(leaf_id, _) => { + let leaf = ¤t_leaves[leaf_id.0]; merge_leaf(current_etc_fd, new_etc_fd, leaf, None, file)?; } }, @@ -734,26 +767,28 @@ fn merge_modified_files( #[context("Merging")] pub fn merge( current_etc_fd: &CapStdDir, - current_etc_dirtree: &Directory, + current_etc_dirtree: &FileSystem, new_etc_fd: &CapStdDir, - new_etc_dirtree: &Directory, + new_etc_dirtree: &FileSystem, diff: &Diff, ) -> anyhow::Result<()> { merge_modified_files( &diff.added, current_etc_fd, - current_etc_dirtree, + ¤t_etc_dirtree.root, + ¤t_etc_dirtree.leaves, new_etc_fd, - new_etc_dirtree, + &new_etc_dirtree.root, ) .context("Merging added files")?; merge_modified_files( &diff.modified, current_etc_fd, - current_etc_dirtree, + ¤t_etc_dirtree.root, + ¤t_etc_dirtree.leaves, new_etc_fd, - new_etc_dirtree, + &new_etc_dirtree.root, ) .context("Merging modified files")?; diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 704eea3ca..d8895f992 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -300,7 +300,9 @@ pub fn mount_composefs_image( allow_missing_fsverity: bool, ) -> Result { let mut repo = Repository::::open_path(sysroot, "composefs")?; - repo.set_insecure(allow_missing_fsverity); + if allow_missing_fsverity { + repo.set_insecure(); + } let rootfs = repo .mount(name) .context("Failed to mount composefs image")?; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 4e945c065..a6b587890 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -61,10 +61,10 @@ //! 1. **Primary**: New/upgraded deployment (default boot target) //! 2. **Secondary**: Currently booted deployment (rollback option) -use std::ffi::OsStr; use std::fs::create_dir_all; use std::io::Write; use std::path::Path; +use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; use bootc_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; @@ -81,42 +81,27 @@ use clap::ValueEnum; use composefs::fs::read_file; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs::tree::RegularFile; -use composefs_boot::BootOps; use composefs_boot::bootloader::{ BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, - UsrLibModulesVmlinuz, + UsrLibModulesVmlinuz, get_boot_resources, }; use composefs_boot::{cmdline::get_cmdline_composefs, os_release::OsReleaseInfo, uki}; -use composefs_oci::image::create_filesystem as create_composefs_filesystem; use fn_error_context::context; use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{ - bootc_composefs::repo::get_imgref, - composefs_consts::{TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}, -}; -use crate::{ - bootc_composefs::repo::open_composefs_repo, - store::{ComposefsFilesystem, Storage}, -}; -use crate::{ - bootc_composefs::state::{get_booted_bls, write_composefs_state}, - composefs_consts::TYPE1_BOOT_DIR_PREFIX, -}; -use crate::{bootc_composefs::status::ComposefsCmdline, task::Task}; -use crate::{ - bootc_composefs::status::get_container_manifest_and_config, bootc_kargs::compute_new_kargs, -}; +use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; +use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_kargs::compute_new_kargs; +use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; +use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; +use crate::task::Task; +use crate::{bootc_composefs::repo::open_composefs_repo, store::Storage}; use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState}; -use crate::{ - composefs_consts::UKI_NAME_PREFIX, - parsers::bls_config::{BLSConfig, BLSConfigType}, -}; use crate::{ composefs_consts::{ - BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_STAGED, + BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED, }, spec::{Bootloader, Host}, }; @@ -148,23 +133,9 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk - Setup( - ( - &'a RootSetup, - &'a State, - &'a PostFetchState, - &'a ComposefsFilesystem, - ), - ), + Setup((&'a RootSetup, &'a State, &'a PostFetchState)), /// For `bootc upgrade` - Upgrade( - ( - &'a Storage, - &'a BootedComposefs, - &'a ComposefsFilesystem, - &'a Host, - ), - ), + Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)), } #[derive( @@ -451,41 +422,20 @@ fn write_bls_boot_entries_to_disk( } /// Parses /usr/lib/os-release and returns (id, title, version) -fn parse_os_release( - fs: &crate::store::ComposefsFilesystem, - repo: &crate::store::ComposefsRepository, -) -> Result, Option)>> { +fn parse_os_release(mounted_fs: &Dir) -> Result, Option)>> { // Every update should have its own /usr/lib/os-release - let (dir, fname) = fs - .root - .split(OsStr::new("/usr/lib/os-release")) - .context("Getting /usr/lib/os-release")?; - - let os_release = dir - .get_file_opt(fname) - .context("Getting /usr/lib/os-release")?; - - let Some(os_rel_file) = os_release else { - return Ok(None); - }; - - let file_contents = match read_file(os_rel_file, repo) { + let file_contents = match mounted_fs.read_to_string("usr/lib/os-release") { Ok(c) => c, - Err(e) => { - tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(None); } - }; - - let file_contents = match std::str::from_utf8(&file_contents) { - Ok(c) => c, Err(e) => { - tracing::warn!("/usr/lib/os-release did not have valid UTF-8: {e}"); + tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); return Ok(None); } }; - let parsed = OsReleaseInfo::parse(file_contents); + let parsed = OsReleaseInfo::parse(&file_contents); let os_id = parsed .get_value(&["ID"]) @@ -521,8 +471,8 @@ pub(crate) fn setup_composefs_bls_boot( ) -> Result { let id_hex = id.to_hex(); - let (root_path, esp_device, mut cmdline_refs, fs, bootloader) = match setup_type { - BootSetupType::Setup((root_setup, state, postfetch, fs)) => { + let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type { + BootSetupType::Setup((root_setup, state, postfetch)) => { // root_setup.kargs has [root=UUID=, "rw"] let mut cmdline_options = Cmdline::new(); @@ -555,12 +505,11 @@ pub(crate) fn setup_composefs_bls_boot( root_setup.physical_root_path.clone(), esp_part.path(), cmdline_options, - fs, postfetch.detected_bootloader.clone(), ) } - BootSetupType::Upgrade((storage, booted_cfs, fs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); let boot_dir = storage.require_boot_dir()?; @@ -594,7 +543,6 @@ pub(crate) fn setup_composefs_bls_boot( Utf8PathBuf::from("/sysroot"), esp_dev.path(), cmdline, - fs, bootloader, ) } @@ -668,7 +616,7 @@ pub(crate) fn setup_composefs_bls_boot( let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) .context("Computing boot digest")?; - let osrel = parse_os_release(fs, &repo)?; + let osrel = parse_os_release(mounted_erofs)?; let (os_id, title, version, sort_key) = match osrel { Some((id_str, title_opt, version_opt)) => ( @@ -1093,7 +1041,7 @@ pub(crate) fn setup_composefs_uki_boot( ) -> Result { let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { - BootSetupType::Setup((root_setup, state, postfetch, ..)) => { + BootSetupType::Setup((root_setup, state, postfetch)) => { state.require_no_kargs_for_uki()?; // Locate ESP partition device by walking up to the root disk(s) @@ -1108,7 +1056,7 @@ pub(crate) fn setup_composefs_uki_boot( ) } - BootSetupType::Upgrade((storage, booted_cfs, _, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -1250,7 +1198,7 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, - image_id: &str, + pull_result: &composefs_oci::skopeo::PullResult, allow_missing_fsverity: bool, ) -> Result<()> { const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; @@ -1258,17 +1206,28 @@ pub(crate) async fn setup_composefs_boot( tracing::info!( message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, bootc.operation = "boot_setup", - bootc.image_id = image_id, + bootc.config_digest = %pull_result.config_digest, bootc.allow_missing_fsverity = allow_missing_fsverity, "Setting up composefs boot", ); let mut repo = open_composefs_repo(&root_setup.physical_root)?; - repo.set_insecure(allow_missing_fsverity); + if allow_missing_fsverity { + repo.set_insecure(); + } + + let repo = Arc::new(repo); + + // Generate the bootable EROFS image (idempotent). + let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) + .context("Generating bootable EROFS image")?; + + // Get boot entries from the OCI filesystem (untransformed). + let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None) + .context("Creating composefs filesystem for boot entry discovery")?; + let entries = + get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; - let mut fs = create_composefs_filesystem(&repo, image_id, None)?; - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; let mounted_fs = Dir::reopen_dir( &repo .mount(&id.to_hex()) @@ -1311,16 +1270,23 @@ pub(crate) async fn setup_composefs_boot( let boot_type = BootType::from(entry); + // Unwrap Arc to pass owned repo to boot setup functions. + let repo = Arc::try_unwrap(repo).map_err(|_| { + anyhow::anyhow!( + "BUG: Arc still has other references after boot image generation" + ) + })?; + let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, &id, entry, &mounted_fs, )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, &id, entries, @@ -1334,11 +1300,7 @@ pub(crate) async fn setup_composefs_boot( None, boot_type, boot_digest, - &get_container_manifest_and_config(&get_imgref( - &state.source.imageref.transport.to_string(), - &state.source.imageref.name, - )) - .await?, + &pull_result.manifest_digest.to_string(), allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index b64ba0173..d6e9ea070 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -162,20 +162,6 @@ fn delete_depl_boot_entries( } } -#[fn_error_context::context("Deleting image for deployment {}", deployment_id)] -pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { - let img_path = Path::new("composefs").join("images").join(deployment_id); - tracing::debug!("Deleting EROFS image: {:?}", img_path); - - if dry_run { - return Ok(()); - } - - sysroot - .remove_file(&img_path) - .context("Deleting EROFS image") -} - #[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index ba15e9cb7..7a57451d1 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -2,6 +2,7 @@ use std::fs::File; use std::io::BufWriter; +use std::os::fd::OwnedFd; use std::sync::Arc; use anyhow::{Context, Result}; @@ -11,8 +12,9 @@ use cap_std_ext::cap_std::fs::Dir; use cfsctl::composefs; use cfsctl::composefs_boot; use composefs::dumpfile; -use composefs::fsverity::FsVerityHashValue; +use composefs::fsverity::{Algorithm, FsVerityHashValue}; use composefs_boot::BootOps as _; +use rustix::fd::AsFd; use tempfile::TempDir; use crate::store::ComposefsRepository; @@ -29,9 +31,11 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc Result<(TempDir, Arc, ) -> Result { @@ -64,9 +68,13 @@ pub(crate) fn compute_composefs_digest( let (_td_guard, repo) = new_temp_composefs_repo()?; // Read filesystem from path, transform for boot, compute digest - let mut fs = - composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo)) - .context("Reading container root")?; + let cwd_owned: OwnedFd = rustix::fs::CWD.as_fd().try_clone_to_owned()?; + let mut fs = composefs::fs::read_container_root( + cwd_owned, + path.as_std_path().to_path_buf(), + Some(repo.clone()), + ) + .await?; fs.transform_for_boot(&repo).context("Preparing for boot")?; let id = fs.compute_image_id(); let digest = id.to_hex(); @@ -114,15 +122,15 @@ mod tests { Ok(()) } - #[test] - fn test_compute_composefs_digest() { + #[tokio::test] + async fn test_compute_composefs_digest() { // Create temp directory with test filesystem structure let td = tempfile::tempdir().unwrap(); create_test_filesystem(td.path()).unwrap(); // Compute the digest let path = Utf8Path::from_path(td.path()).unwrap(); - let digest = compute_composefs_digest(path, None).unwrap(); + let digest = compute_composefs_digest(path, None).await.unwrap(); // Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars) assert_eq!( @@ -137,16 +145,16 @@ mod tests { ); // Verify consistency - computing twice on the same filesystem produces the same result - let digest2 = compute_composefs_digest(path, None).unwrap(); + let digest2 = compute_composefs_digest(path, None).await.unwrap(); assert_eq!( digest, digest2, "Digest should be consistent across multiple computations" ); } - #[test] - fn test_compute_composefs_digest_rejects_root() { - let result = compute_composefs_digest(Utf8Path::new("/"), None); + #[tokio::test] + async fn test_compute_composefs_digest_rejects_root() { + let result = compute_composefs_digest(Utf8Path::new("/"), None).await; assert!(result.is_err()); let err = result.unwrap_err(); let found = err.chain().any(|e| { diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index 797d19954..ea0c70272 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -43,10 +43,9 @@ pub async fn export_repo_to_image( let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?; - let imginfo = get_imginfo(storage, &depl_verity, None).await?; + let imginfo = get_imginfo(storage, &depl_verity)?; - // We want the digest in the form of "sha256:abc123" - let config_digest = format!("{}", imginfo.manifest.config().digest()); + let config_digest = imginfo.manifest.config().digest().clone(); let var_tmp = Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; @@ -55,8 +54,9 @@ pub async fn export_repo_to_image( let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?; // Use composefs_oci::open_config to get the config and layer map - let (config, layer_map) = - open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; + let open = open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; + let config = open.config; + let layer_map = open.layer_refs; // We can't guarantee that we'll get the same tar stream as the container image // So we create new config and manifest diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 1b0681681..1809ee789 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -12,7 +12,7 @@ use bootc_mount::tempmount::TempMount; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; use cap_std_ext::dirext::CapStdExtDirExt; use cfsctl::composefs; -use composefs::generic_tree::{Directory, Stat}; +use composefs::generic_tree::{FileSystem, Stat}; use etc_merge::{compute_diff, merge, print_diff, traverse_etc}; use rustix::fs::{fsync, renameat}; use rustix::path::Arg; @@ -41,7 +41,7 @@ pub(crate) async fn get_etc_diff(storage: &Storage, booted_cfs: &BootedComposefs let diff = compute_diff( &pristine_files, ¤t_files, - &Directory::new(Stat::uninitialized()), + &FileSystem::new(Stat::uninitialized()), )?; print_diff(&diff, &mut std::io::stdout()); diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 0aa0f9edc..08d0d0e11 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -8,36 +8,26 @@ use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use cfsctl::composefs; use cfsctl::composefs_boot; +use cfsctl::composefs_oci; +use composefs::fsverity::FsVerityHashValue; use composefs::repository::GcResult; use composefs_boot::bootloader::EFI_EXT; use crate::{ bootc_composefs::{ boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name}, - delete::{delete_image, delete_staged, delete_state_dir}, - status::{get_composefs_status, get_imginfo, list_bootloader_entries}, + delete::{delete_staged, delete_state_dir}, + repo::bootc_tag_for_manifest, + state::read_origin, + status::{get_composefs_status, list_bootloader_entries}, + }, + composefs_consts::{ + BOOTC_TAG_PREFIX, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, STATE_DIR_RELATIVE, + TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX, }, - composefs_consts::{STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX}, store::{BootedComposefs, Storage}, }; -#[fn_error_context::context("Listing EROFS images")] -fn list_erofs_images(sysroot: &Dir) -> Result> { - let images_dir = sysroot - .open_dir("composefs/images") - .context("Opening images dir")?; - - let mut images = vec![]; - - for entry in images_dir.entries_utf8()? { - let entry = entry?; - let name = entry.file_name()?; - images.push(name); - } - - Ok(images) -} - #[fn_error_context::context("Listing state directories")] fn list_state_dirs(sysroot: &Dir) -> Result> { let state = sysroot @@ -263,82 +253,153 @@ pub(crate) async fn composefs_gc( } } - let images = list_erofs_images(&sysroot)?; + // Identify orphaned deployments: state dirs or bootloader entries + // that don't correspond to a live deployment. EROFS images in + // composefs/images/ are NOT managed here — repo.gc() handles those + // via the tag→manifest→config→image ref chain. + let state_dirs = list_state_dirs(&sysroot)?; + + let staged = &host.status.staged; - // Collect the deployments that have an image but no bootloader entry - // and vice versa - // - // Images without corresponding bootloader entries - let orphaned_images: Vec<&String> = images + // State dirs without a bootloader entry are from interrupted deployments. + let orphaned_state_dirs: Vec<_> = state_dirs .iter() - .filter(|image| { - !bootloader_entries - .iter() - .any(|entry| &entry.fsverity == *image) - }) + .filter(|s| !bootloader_entries.iter().any(|entry| &entry.fsverity == *s)) .collect(); - // Bootloader entries without corresponding images - let orphaned_bootloader_entries: Vec<&String> = bootloader_entries + // Bootloader entries without a state dir are from interrupted cleanups. + let orphaned_boot_entries: Vec<_> = bootloader_entries .iter() .map(|entry| &entry.fsverity) - .filter(|verity| !images.contains(verity)) + .filter(|verity| !state_dirs.contains(verity)) .collect(); - let img_bootloader_diff: Vec<&String> = orphaned_images - .into_iter() - .chain(orphaned_bootloader_entries) + let all_orphans: Vec<_> = orphaned_state_dirs + .iter() + .chain(orphaned_boot_entries.iter()) + .copied() .collect(); - tracing::debug!("img_bootloader_diff: {img_bootloader_diff:#?}"); - - let staged = &host.status.staged; - - if img_bootloader_diff.contains(&&booted_cfs_status.verity) { + if all_orphans.contains(&&booted_cfs_status.verity) { anyhow::bail!( "Inconsistent state. Booted entry '{}' found for cleanup", booted_cfs_status.verity ) } - for verity in &img_bootloader_diff { - tracing::debug!("Cleaning up orphaned image: {verity}"); - - delete_staged(staged, &img_bootloader_diff, dry_run)?; - delete_image(&sysroot, verity, dry_run)?; + for verity in &orphaned_state_dirs { + tracing::debug!("Cleaning up orphaned state dir: {verity}"); + delete_staged(staged, &all_orphans, dry_run)?; delete_state_dir(&sysroot, verity, dry_run)?; } - let state_dirs = list_state_dirs(&sysroot)?; - - // Collect all the deployments that have no image but have a state dir - // This for the case where the gc was interrupted after deleting the image - let state_img_diff = state_dirs - .iter() - .filter(|s| !images.contains(s)) - .collect::>(); - - for verity in &state_img_diff { - delete_staged(staged, &state_img_diff, dry_run)?; - delete_state_dir(&sysroot, verity, dry_run)?; + for verity in &orphaned_boot_entries { + tracing::debug!("Cleaning up orphaned bootloader entry: {verity}"); + delete_staged(staged, &all_orphans, dry_run)?; } - // Now we GC the unrefenced objects in composefs repo - let mut additional_roots = vec![]; + // Collect the set of manifest digests referenced by live deployments, + // and track EROFS image verities as fallback additional_roots for + // deployments that predate the manifest→image link. + let mut live_manifest_digests: Vec = Vec::new(); + let mut additional_roots = Vec::new(); + + // Read existing tags before the deployment loop so we can search + // them for deployments that lack manifest_digest in their origin. + let existing_tags = composefs_oci::list_refs(&*booted_cfs.repo) + .context("Listing OCI tags in composefs repo")?; for deployment in host.list_deployments() { let verity = &deployment.require_composefs()?.verity; - // These need to be GC'd - if img_bootloader_diff.contains(&verity) || state_img_diff.contains(&verity) { + // Skip deployments that are already being GC'd. + if all_orphans.contains(&verity) { continue; } - let image = get_imginfo(storage, verity, None).await?; - let stream = format!("oci-config-{}", image.manifest.config().digest()); - + // Keep the EROFS image as an additional root until all deployments + // have manifest→image refs. Once a deployment is pulled with the + // new code, its EROFS image is reachable from the manifest and + // this entry becomes redundant (but harmless). additional_roots.push(verity.clone()); - additional_roots.push(stream); + + if let Some(ini) = read_origin(sysroot, verity)? { + if let Some(manifest_digest_str) = + ini.get::(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST) + { + let digest: composefs_oci::OciDigest = manifest_digest_str + .parse() + .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?; + live_manifest_digests.push(digest); + } else { + // Pre-OCI-metadata deployment: search tagged manifests + // for one whose config links to this EROFS image. + let mut found_manifest = false; + for (_, ref_digest) in &existing_tags { + if let Ok(img) = composefs_oci::oci_image::OciImage::open( + &*booted_cfs.repo, + ref_digest, + None, + ) { + if let Some(img_ref) = img.image_ref() { + if img_ref.to_hex() == *verity { + tracing::info!( + "Deployment {verity} has no manifest_digest in origin; \ + found matching manifest {ref_digest} via image_ref" + ); + live_manifest_digests.push(ref_digest.clone()); + found_manifest = true; + break; + } + } + } + } + if !found_manifest { + tracing::warn!( + "Deployment {verity} has no manifest_digest in origin \ + and no tagged manifest references it; \ + EROFS image is protected but OCI metadata may be collected" + ); + } + } + } + } + + // Migration: ensure every live deployment has a bootc-owned tag. + // Deployments from before the tag-based GC won't have tags yet; + // create them now so their OCI metadata survives this GC cycle. + + for manifest_digest in &live_manifest_digests { + let expected_tag = bootc_tag_for_manifest(&manifest_digest.to_string()); + let has_tag = existing_tags + .iter() + .any(|(tag_name, _)| tag_name == &expected_tag); + if !has_tag { + tracing::info!("Creating missing bootc tag for live deployment: {expected_tag}"); + if !dry_run { + composefs_oci::tag_image(&*booted_cfs.repo, manifest_digest, &expected_tag) + .with_context(|| format!("Creating migration tag {expected_tag}"))?; + } + } + } + + // Re-read tags after potential migration. + let all_tags = composefs_oci::list_refs(&*booted_cfs.repo) + .context("Listing OCI tags in composefs repo")?; + + for (tag_name, manifest_digest) in &all_tags { + if !tag_name.starts_with(BOOTC_TAG_PREFIX) { + // Not a bootc-owned tag; leave it alone (could be an app image). + continue; + } + + if !live_manifest_digests.iter().any(|d| d == manifest_digest) { + tracing::debug!("Removing unreferenced bootc tag: {tag_name}"); + if !dry_run { + composefs_oci::untag_image(&*booted_cfs.repo, tag_name) + .with_context(|| format!("Removing tag {tag_name}"))?; + } + } } let additional_roots = additional_roots @@ -346,7 +407,11 @@ pub(crate) async fn composefs_gc( .map(|x| x.as_str()) .collect::>(); - // Run garbage collection on objects after deleting images + // Run garbage collection. Tags root the OCI metadata chain + // (manifest → config → layers). The additional_roots protect EROFS + // images for deployments that predate the manifest→image link; + // once all deployments have been pulled with the new code, these + // become redundant. let gc_result = if dry_run { booted_cfs.repo.gc_dry_run(&additional_roots)? } else { diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index eccd47536..1d70658f8 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -7,17 +7,28 @@ use cfsctl::composefs; use cfsctl::composefs_boot; use cfsctl::composefs_oci; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; -use composefs_boot::{BootOps, bootloader::BootEntry as ComposefsBootEntry}; +use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources}; use composefs_oci::{ - PullResult, image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, + image::create_filesystem as create_composefs_filesystem, + pull_image as composefs_oci_pull_image, skopeo::PullResult, tag_image, }; use ostree_ext::container::ImageReference as OstreeExtImgRef; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; +/// Create a composefs OCI tag name for the given manifest digest. +/// +/// Returns a tag like `localhost/bootc-sha256:abc...` which acts as a GC root +/// in the composefs repository, keeping the manifest, config, and all layer +/// splitstreams alive. +pub(crate) fn bootc_tag_for_manifest(manifest_digest: &str) -> String { + format!("{BOOTC_TAG_PREFIX}{manifest_digest}") +} + pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result { crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs") .context("Failed to open composefs repository") @@ -47,8 +58,16 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - let mut repo = open_composefs_repo(rootfs_dir)?; - repo.set_insecure(allow_missing_fsverity); + let (mut repo, _created) = crate::store::ComposefsRepository::init_path( + rootfs_dir, + "composefs", + composefs::fsverity::Algorithm::SHA512, + !allow_missing_fsverity, + ) + .context("Failed to initialize composefs repository")?; + if allow_missing_fsverity { + repo.set_insecure(); + } let OstreeExtImgRef { name: image_name, @@ -58,14 +77,34 @@ pub(crate) async fn initialize_composefs_repository( let mut config = crate::deploy::new_proxy_config(); ostree_ext::container::merge_default_container_proxy_opts(&mut config)?; - // transport's display is already of type ":" - composefs_oci_pull( - &Arc::new(repo), + // Pull without a reference tag; we tag explicitly afterward so we + // control the tag name format. + let repo = Arc::new(repo); + let (pull_result, _stats) = composefs_oci_pull_image( + &repo, &format!("{transport}{image_name}"), None, Some(config), ) - .await + .await?; + + // Tag the manifest as a bootc-owned GC root. + let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); + tag_image(&*repo, &pull_result.manifest_digest, &tag) + .context("Tagging pulled image as bootc GC root")?; + + tracing::info!( + message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID, + bootc.operation = "repository_init", + bootc.manifest_digest = %pull_result.manifest_digest, + bootc.manifest_verity = pull_result.manifest_verity.to_hex(), + bootc.config_digest = %pull_result.config_digest, + bootc.config_verity = pull_result.config_verity.to_hex(), + bootc.tag = tag, + "Pulled image into composefs repository", + ); + + Ok(pull_result) } /// skopeo (in composefs-rs) doesn't understand "registry:" @@ -88,6 +127,16 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { } } +/// Result of pulling a composefs repository, including the OCI manifest digest +/// needed to reconstruct image metadata from the local composefs repo. +pub(crate) struct PullRepoResult { + pub(crate) repo: crate::store::ComposefsRepository, + pub(crate) entries: Vec>, + pub(crate) id: Sha512HashValue, + /// The OCI manifest content digest (e.g. "sha256:abc...") + pub(crate) manifest_digest: String, +} + /// Pulls the `image` from `transport` into a composefs repository at /sysroot /// Checks for boot entries in the image and returns them #[context("Pulling composefs repository")] @@ -95,12 +144,7 @@ pub(crate) async fn pull_composefs_repo( transport: &String, image: &String, allow_missing_fsverity: bool, -) -> Result<( - crate::store::ComposefsRepository, - Vec>, - Sha512HashValue, - crate::store::ComposefsFilesystem, -)> { +) -> Result { const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8"; tracing::info!( @@ -117,7 +161,9 @@ pub(crate) async fn pull_composefs_repo( let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; - repo.set_insecure(allow_missing_fsverity); + if allow_missing_fsverity { + repo.set_insecure(); + } let final_imgref = get_imgref(transport, image); @@ -126,28 +172,51 @@ pub(crate) async fn pull_composefs_repo( let mut config = crate::deploy::new_proxy_config(); ostree_ext::container::merge_default_container_proxy_opts(&mut config)?; - let pull_result = composefs_oci_pull(&Arc::new(repo), &final_imgref, None, Some(config)) + let repo = Arc::new(repo); + let (pull_result, _stats) = composefs_oci_pull_image(&repo, &final_imgref, None, Some(config)) .await .context("Pulling composefs repo")?; + // Tag the manifest as a bootc-owned GC root. + let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); + tag_image(&*repo, &pull_result.manifest_digest, &tag) + .context("Tagging pulled image as bootc GC root")?; + tracing::info!( message_id = COMPOSEFS_PULL_JOURNAL_ID, - id = pull_result.config_digest, - verity = pull_result.config_verity.to_hex(), - "Pulled image into repository" + bootc.operation = "pull", + bootc.manifest_digest = %pull_result.manifest_digest, + bootc.manifest_verity = pull_result.manifest_verity.to_hex(), + bootc.config_digest = %pull_result.config_digest, + bootc.config_verity = pull_result.config_verity.to_hex(), + bootc.tag = tag, + "Pulled image into composefs repository", ); - let mut repo = open_composefs_repo(&rootfs_dir)?; - repo.set_insecure(allow_missing_fsverity); - - let mut fs: crate::store::ComposefsFilesystem = - create_composefs_filesystem(&repo, &pull_result.config_digest, None) - .context("Failed to create composefs filesystem")?; - - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; + // Generate the bootable EROFS image (idempotent). + let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) + .context("Generating bootable EROFS image")?; + + // Get boot entries from the OCI filesystem (untransformed). + let fs = create_composefs_filesystem(&*repo, &pull_result.config_digest, None) + .context("Creating composefs filesystem for boot entry discovery")?; + let entries = + get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + + // Unwrap the Arc to get the owned repo back. + let mut repo = Arc::try_unwrap(repo).map_err(|_| { + anyhow::anyhow!("BUG: Arc still has other references after pull completed") + })?; + if allow_missing_fsverity { + repo.set_insecure(); + } - Ok((repo, entries, id, fs)) + Ok(PullRepoResult { + repo, + entries, + id, + manifest_digest: pull_result.manifest_digest.to_string(), + }) } #[cfg(test)] @@ -192,4 +261,12 @@ mod tests { format!("docker-daemon:{IMAGE_NAME}") ); } + + #[test] + fn test_bootc_tag_for_manifest() { + let digest = "sha256:abc123def456"; + let tag = bootc_tag_for_manifest(digest); + assert_eq!(tag, "localhost/bootc-sha256:abc123def456"); + assert!(tag.starts_with(BOOTC_TAG_PREFIX)); + } } diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index c9752760e..6a554588f 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -28,14 +28,15 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::repo::get_imgref; use crate::bootc_composefs::status::{ - ComposefsCmdline, ImgConfigManifest, StagedDeployment, get_sorted_type1_boot_entries, + ComposefsCmdline, StagedDeployment, get_sorted_type1_boot_entries, }; use crate::parsers::bls_config::BLSConfigType; use crate::store::{BootedComposefs, Storage}; use crate::{ composefs_consts::{ COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, - ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, SHARED_VAR_PATH, STATE_DIR_RELATIVE, + ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, + SHARED_VAR_PATH, STATE_DIR_RELATIVE, }, parsers::bls_config::BLSConfig, spec::ImageReference, @@ -43,6 +44,27 @@ use crate::{ utils::path_relative_to, }; +/// Read and parse the `.origin` INI file for a deployment. +/// +/// Returns `None` if the state directory or origin file doesn't exist +/// (e.g. the deployment was partially deleted). +#[context("Reading origin for deployment {deployment_id}")] +pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result> { + let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id); + + let Some(state_dir) = sysroot.open_dir_optional(&depl_state_path)? else { + return Ok(None); + }; + + let origin_filename = format!("{deployment_id}.origin"); + let Some(origin_contents) = state_dir.read_to_string_optional(&origin_filename)? else { + return Ok(None); + }; + + let ini = tini::Ini::from_string(&origin_contents).context("Failed to parse origin file")?; + Ok(Some(ini)) +} + pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Result { let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; @@ -214,15 +236,15 @@ pub(crate) fn update_boot_digest_in_origin( /// * `staged` - Whether this is a staged deployment (writes to transient state dir) /// * `boot_type` - Boot loader type (`Bls` or `Uki`) /// * `boot_digest` - Optional boot digest for verification -/// * `container_details` - Container manifest and config used to create this deployment +/// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the +/// manifest+config can be retrieved from the composefs repo later /// /// # State Directory Structure /// /// Creates the following structure under `/sysroot/state/deploy/{deployment_id}/`: /// * `etc/` - Copy of system configuration files /// * `var` - Symlink to shared `/var` directory -/// * `{deployment_id}.origin` - OSTree-style origin configuration -/// * `{deployment_id}.imginfo` - Container image manifest and config as JSON +/// * `{deployment_id}.origin` - Origin configuration with image ref, boot, and image metadata /// /// For staged deployments, also writes to `/run/composefs/staged-deployment`. #[context("Writing composefs state")] @@ -233,7 +255,7 @@ pub(crate) async fn write_composefs_state( staged: Option, boot_type: BootType, boot_digest: String, - container_details: &ImgConfigManifest, + manifest_digest: &str, allow_missing_fsverity: bool, ) -> Result<()> { let state_path = root_path @@ -282,18 +304,15 @@ pub(crate) async fn write_composefs_state( .section(ORIGIN_KEY_BOOT) .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); + // Store the OCI manifest digest so we can retrieve the manifest+config + // from the composefs repository later (composefs-rs stores them as splitstreams). + config = config + .section(ORIGIN_KEY_IMAGE) + .item(ORIGIN_KEY_MANIFEST_DIGEST, manifest_digest); + let state_dir = Dir::open_ambient_dir(&state_path, ambient_authority()).context("Opening state dir")?; - // NOTE: This is only supposed to be temporary until we decide on where to store - // the container manifest/config - state_dir - .atomic_write( - format!("{}.imginfo", deployment_id.to_hex()), - serde_json::to_vec(&container_details)?, - ) - .context("Failed to write to .imginfo file")?; - state_dir .atomic_write( format!("{}.origin", deployment_id.to_hex()), diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 58a93638a..074452521 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -3,20 +3,22 @@ use std::{collections::HashSet, io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::inspect_filesystem; +use cfsctl::composefs::fsverity::Sha512HashValue; +use cfsctl::composefs_oci; +use composefs_oci::OciImage; use fn_error_context::context; use serde::{Deserialize, Serialize}; use crate::{ bootc_composefs::{ boot::BootType, - repo::get_imgref, selinux::are_selinux_policies_compatible, - state::get_composefs_usr_overlay_status, + state::{get_composefs_usr_overlay_status, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ - COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, - USER_CFG_STAGED, + COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, + TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -396,57 +398,68 @@ pub(crate) fn get_bootloader() -> Result { } } -/// Reads the .imginfo file for the provided deployment -#[context("Reading imginfo")] -pub(crate) async fn get_imginfo( - storage: &Storage, - deployment_id: &str, - imgref: Option<&ImageReference>, -) -> Result { - let imginfo_fname = format!("{deployment_id}.imginfo"); +/// Retrieves the OCI manifest and config for a deployment from the composefs repository. +/// +/// The manifest digest is read from the deployment's `.origin` file, +/// then `OciImage::open()` retrieves manifest+config from the composefs repo +/// where composefs-rs stores them as splitstreams during pull. +/// +/// Falls back to reading legacy `.imginfo` files for backwards compatibility +/// with deployments created before the manifest digest was stored in `.origin`. +#[context("Reading image info for deployment {deployment_id}")] +pub(crate) fn get_imginfo(storage: &Storage, deployment_id: &str) -> Result { + let ini = read_origin(&storage.physical_root, deployment_id)? + .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {deployment_id}"))?; + + // Try to read the manifest digest from the origin file (new path) + if let Some(manifest_digest_str) = + ini.get::(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST) + { + let repo = storage.get_ensure_composefs()?; + let manifest_digest: composefs_oci::OciDigest = manifest_digest_str + .parse() + .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?; + let oci_image = OciImage::::open(&repo, &manifest_digest, None) + .with_context(|| format!("Opening OCI image for manifest {manifest_digest}"))?; + + let manifest = oci_image.manifest().clone(); + let config = oci_image + .config() + .cloned() + .ok_or_else(|| anyhow::anyhow!("OCI image has no config (artifact?)"))?; + + return Ok(ImgConfigManifest { config, manifest }); + } + // Fallback: read legacy .imginfo file for deployments created before + // the manifest digest was stored in .origin let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id); - let path = depl_state_path.join(imginfo_fname); + let imginfo_fname = format!("{deployment_id}.imginfo"); + let path = depl_state_path.join(&imginfo_fname); let mut img_conf = storage .physical_root .open_optional(&path) - .context("Failed to open file")?; + .with_context(|| format!("Opening legacy {imginfo_fname}"))?; let Some(img_conf) = &mut img_conf else { - let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No imgref or imginfo file found"))?; - - let container_details = - get_container_manifest_and_config(&get_imgref(&imgref.transport, &imgref.image)) - .await?; - - let state_dir = storage.physical_root.open_dir(depl_state_path)?; - - state_dir - .atomic_write( - format!("{}.imginfo", deployment_id), - serde_json::to_vec(&container_details)?, - ) - .context("Failed to write to .imginfo file")?; - - let state_dir = state_dir.reopen_as_ownedfd()?; - - rustix::fs::fsync(state_dir).context("fsync")?; - - return Ok(container_details); + anyhow::bail!( + "No manifest_digest in origin and no legacy .imginfo file \ + for deployment {deployment_id}" + ); }; let mut buffer = String::new(); img_conf.read_to_string(&mut buffer)?; let img_conf = serde_json::from_str::(&buffer) - .context("Failed to parse file as JSON")?; + .context("Failed to parse .imginfo file as JSON")?; Ok(img_conf) } #[context("Getting composefs deployment metadata")] -async fn boot_entry_from_composefs_deployment( +fn boot_entry_from_composefs_deployment( storage: &Storage, origin: tini::Ini, verity: &str, @@ -456,7 +469,7 @@ async fn boot_entry_from_composefs_deployment( let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; let img_ref = ImageReference::from(ostree_img_ref); - let img_conf = get_imginfo(storage, &verity, Some(&img_ref)).await?; + let img_conf = get_imginfo(storage, &verity)?; let image_digest = img_conf.manifest.config().digest().to_string(); let architecture = img_conf.config.architecture().to_string(); @@ -737,11 +750,6 @@ async fn composefs_deployment_status_from( // This is our source of truth let bootloader_entry_verity = list_bootloader_entries(storage)?; - let state_dir = storage - .physical_root - .open_dir(STATE_DIR_RELATIVE) - .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?; - let host_spec = HostSpec { image: None, boot_order: BootOrder::Default, @@ -774,18 +782,10 @@ async fn composefs_deployment_status_from( .. } in bootloader_entry_verity { - // read the origin file - let config = state_dir - .open_dir(&verity_digest) - .with_context(|| format!("Failed to open {verity_digest}"))? - .read_to_string(format!("{verity_digest}.origin")) - .with_context(|| format!("Reading file {verity_digest}.origin"))?; - - let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?; - - let mut boot_entry = - boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?; + let ini = read_origin(&storage.physical_root, &verity_digest)? + .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {verity_digest}"))?; + + let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, &verity_digest)?; // SAFETY: boot_entry.composefs will always be present let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index e44bc478d..b6ce55ad5 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -75,15 +75,8 @@ pub(crate) async fn switch_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - booted_cfs, - &host, - &target_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, booted_cfs, &host, &target_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -95,15 +88,7 @@ pub(crate) async fn switch_composefs( } } - do_upgrade( - storage, - booted_cfs, - &host, - &target_imgref, - &img_config, - &do_upgrade_opts, - ) - .await?; + do_upgrade(storage, booted_cfs, &host, &target_imgref, &do_upgrade_opts).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 10e48f2a5..37a540c83 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -138,7 +138,10 @@ pub(crate) fn validate_update( ) -> Result { let repo = &*booted_cfs.repo; - let mut fs = create_filesystem(repo, img_digest, Some(config_verity))?; + let oci_digest: composefs_oci::OciDigest = img_digest + .parse() + .with_context(|| format!("Parsing config digest {img_digest}"))?; + let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity))?; fs.transform_for_boot(&repo)?; let image_id = fs.compute_image_id(); @@ -250,19 +253,16 @@ pub(crate) async fn do_upgrade( booted_cfs: &BootedComposefs, host: &Host, imgref: &ImageReference, - img_manifest_config: &ImgConfigManifest, opts: &DoUpgradeOpts, ) -> Result<()> { start_finalize_stated_svc()?; - // Pre-flight disk space check before pulling any data. - crate::deploy::check_disk_space_composefs( - &booted_cfs.repo, - &img_manifest_config.manifest, - imgref, - )?; - - let (repo, entries, id, fs) = pull_composefs_repo( + let crate::bootc_composefs::repo::PullRepoResult { + repo, + entries, + id, + manifest_digest, + } = pull_composefs_repo( &imgref.transport, &imgref.image, booted_cfs.cmdline.allow_missing_fsverity, @@ -283,7 +283,7 @@ pub(crate) async fn do_upgrade( let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, &id, entry, @@ -291,7 +291,7 @@ pub(crate) async fn do_upgrade( )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, &id, entries, @@ -308,7 +308,7 @@ pub(crate) async fn do_upgrade( }), boot_type, boot_digest, - img_manifest_config, + &manifest_digest, booted_cfs.cmdline.allow_missing_fsverity, ) .await?; @@ -453,15 +453,8 @@ pub(crate) async fn upgrade_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -489,15 +482,8 @@ pub(crate) async fn upgrade_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -507,22 +493,13 @@ pub(crate) async fn upgrade_composefs( } if opts.check { - let current_manifest = - get_imginfo(storage, &*composefs.cmdline.digest, Some(booted_imgref)).await?; + let current_manifest = get_imginfo(storage, &*composefs.cmdline.digest)?; let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest); diff.print(); return Ok(()); } - do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await?; + do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts).await?; Ok(()) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index a7205d653..0196ab222 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -22,7 +22,6 @@ use clap::ValueEnum; use composefs::dumpfile; use composefs::fsverity; use composefs::fsverity::FsVerityHashValue; -use composefs::splitstream::SplitStreamWriter; use composefs_boot::BootOps as _; use etc_merge::{compute_diff, print_diff}; use fn_error_context::context; @@ -1731,7 +1730,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { path, write_dumpfile_to, } => { - let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?; + let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref()).await?; println!("{digest}"); Ok(()) } @@ -1766,9 +1765,17 @@ async fn run_from_opt(opt: Opt) -> Result<()> { }; let imgref = format!("containers-storage:{image}"); - let pull_result = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg)) - .await - .context("Pulling image")?; + let pull_result = composefs_oci::pull( + &repo, + &imgref, + None, + composefs_oci::PullOptions { + img_proxy_config: Some(proxycfg), + ..Default::default() + }, + ) + .await + .context("Pulling image")?; let mut fs = composefs_oci::image::create_filesystem( &repo, &pull_result.config_digest, @@ -1793,7 +1800,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { kargs, allow_missing_verity, args, - } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity), + } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity).await, ContainerOpts::Export { format, target, @@ -1933,14 +1940,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { let cfs = storage.get_ensure_composefs()?; let testdata = b"some test data"; let testdata_digest = hex::encode(openssl::sha::sha256(testdata)); - let mut w = SplitStreamWriter::new(&cfs, 0); + let mut w = cfs.create_stream(0)?; w.write_inline(testdata); let object = cfs .write_stream(w, &testdata_digest, Some("testobject"))? .to_hex(); assert_eq!( object, - "dc31ae5d2f637e98d2171821d60d2fcafb8084d6a4bb3bd9cdc7ad41decce6e48f85d5413d22371d36b223945042f53a2a6ab449b8e45d8896ba7d8694a16681" + "84245c6936db9939dda9c1fbeafdcbd2b49f7605354c88d4f016c4d941551f45bad0fbcdbee12ba8adfe4fb63541de57ac02729edbacdb556325e342b89d340d" ); Ok(()) } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index aca8b1510..8617f1005 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -20,6 +20,11 @@ pub(crate) const ORIGIN_KEY_BOOT_TYPE: &str = "boot_type"; /// Key to store the SHA256 sum of vmlinuz + initrd for a deployment pub(crate) const ORIGIN_KEY_BOOT_DIGEST: &str = "digest"; +/// Section in .origin file to store OCI image metadata +pub(crate) const ORIGIN_KEY_IMAGE: &str = "image"; +/// Key to store the OCI manifest digest (e.g. "sha256:abc...") +pub(crate) const ORIGIN_KEY_MANIFEST_DIGEST: &str = "manifest_digest"; + /// Filename for `loader/entries` pub(crate) const BOOT_LOADER_ENTRIES: &str = "entries"; /// Filename for staged boot loader entries @@ -42,3 +47,10 @@ pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; /// The prefix for names of UKI and UKI Addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; + +/// Prefix for OCI tags owned by bootc in the composefs repository. +/// +/// Tags are created as `localhost/bootc-` to act as GC roots +/// that keep the manifest, config, and layer splitstreams alive. This is +/// analogous to how ostree uses `ostree/` refs. +pub(crate) const BOOTC_TAG_PREFIX: &str = "localhost/bootc-"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 2a18c45c2..d276ff0ed 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -190,7 +190,7 @@ use self::baseline::InstallBlockDeviceOpts; use crate::bootc_composefs::status::ComposefsCmdline; use crate::bootc_composefs::{ boot::setup_composefs_boot, - repo::{get_imgref, initialize_composefs_repository, open_composefs_repo}, + repo::{get_imgref, initialize_composefs_repository}, status::get_container_manifest_and_config, }; use crate::boundimage::{BoundImage, ResolvedBoundImage}; @@ -205,8 +205,6 @@ use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; use bootc_mount::Filesystem; -use cfsctl::composefs; -use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory pub(crate) const BOOT: &str = "boot"; @@ -2008,7 +2006,13 @@ async fn install_to_filesystem_impl( let imgref_repr = get_imgref(&imgref.transport.to_string(), &imgref.name); let img_manifest_config = get_container_manifest_and_config(&imgref_repr).await?; crate::store::ensure_composefs_dir(&rootfs.physical_root)?; - let cfs_repo = open_composefs_repo(&rootfs.physical_root)?; + // Use init_path since the repo may not exist yet during install + let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( + &rootfs.physical_root, + crate::store::COMPOSEFS, + cfsctl::composefs::fsverity::Algorithm::SHA512, + false, + )?; crate::deploy::check_disk_space_composefs( &cfs_repo, &img_manifest_config.manifest, @@ -2025,16 +2029,11 @@ async fn install_to_filesystem_impl( state.composefs_options.allow_missing_verity, ) .await?; - tracing::info!( - "id: {}, verity: {}", - pull_result.config_digest, - pull_result.config_verity.to_hex() - ); setup_composefs_boot( rootfs, state, - &pull_result.config_digest, + &pull_result, state.composefs_options.allow_missing_verity, ) .await?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 3cd986101..18ad5af0f 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -48,8 +48,6 @@ use crate::utils::{deployment_fd, open_dir_remount_rw}; /// See pub type ComposefsRepository = composefs::repository::Repository; -/// A composefs filesystem type alias -pub type ComposefsFilesystem = composefs::tree::FileSystem; /// Path to the physical root pub const SYSROOT: &str = "sysroot"; @@ -194,7 +192,7 @@ impl BootedStorage { let (physical_root, run) = get_physical_root_and_run()?; let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; if cmdline.allow_missing_fsverity { - composefs.set_insecure(true); + composefs.set_insecure(); } let composefs = Arc::new(composefs); @@ -479,11 +477,15 @@ impl Storage { let ostree = self.get_ostree()?; let ostree_repo = &ostree.repo(); let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?; - let mut composefs = - ComposefsRepository::open_path(self.physical_root.open_dir(COMPOSEFS)?, ".")?; + let (mut composefs, _created) = ComposefsRepository::init_path( + self.physical_root.open_dir(COMPOSEFS)?, + ".", + composefs::fsverity::Algorithm::SHA512, + ostree_verity.enabled, + )?; if !ostree_verity.enabled { tracing::debug!("Setting insecure mode for composefs repo"); - composefs.set_insecure(true); + composefs.set_insecure(); } let composefs = Arc::new(composefs); let r = Arc::clone(self.composefs.get_or_init(|| composefs)); diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 8e3b82b67..85c9b5183 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -26,7 +26,7 @@ use crate::bootc_composefs::status::ComposefsCmdline; /// 5. Appends any additional kargs provided via --karg /// 6. Invokes ukify with computed arguments plus any pass-through args #[context("Building UKI")] -pub(crate) fn build_ukify( +pub(crate) async fn build_ukify( rootfs: &Utf8Path, extra_kargs: &[String], args: &[OsString], @@ -78,7 +78,7 @@ pub(crate) fn build_ukify( } // Compute the composefs digest - let composefs_digest = compute_composefs_digest(rootfs, None)?; + let composefs_digest = compute_composefs_digest(rootfs, None).await?; // Get kernel arguments from kargs.d let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; @@ -126,12 +126,12 @@ mod tests { use super::*; use std::fs; - #[test] - fn test_build_ukify_no_kernel() { + #[tokio::test] + async fn test_build_ukify_no_kernel() { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[], false); + let result = build_ukify(path, &[], &[], false).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -140,8 +140,8 @@ mod tests { ); } - #[test] - fn test_build_ukify_already_uki() { + #[tokio::test] + async fn test_build_ukify_already_uki() { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); @@ -149,7 +149,7 @@ mod tests { fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); fs::write(tempdir.path().join("boot/EFI/Linux/test.efi"), b"fake uki").unwrap(); - let result = build_ukify(path, &[], &[], false); + let result = build_ukify(path, &[], &[], false).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( diff --git a/tmt/tests/booted/readonly/030-test-composefs.nu b/tmt/tests/booted/readonly/030-test-composefs.nu index 82b0d57b7..ee80b95d1 100644 --- a/tmt/tests/booted/readonly/030-test-composefs.nu +++ b/tmt/tests/booted/readonly/030-test-composefs.nu @@ -35,6 +35,16 @@ if $is_composefs { # When already on composefs, we can only test read-only operations print "# TODO composefs: skipping pull test - cfs oci pull requires write access to sysroot" bootc internals cfs --help + + # Verify that GC on a freshly booted system would not prune any + # images or streams. This validates that our OCI tags and + # manifest→image refs correctly root the entire chain. + # Note: a small number of orphaned objects is expected (e.g. from + # manifest splitstream rewrites) and is harmless. + print "# Verifying composefs GC dry-run does not prune images or streams" + let gc_output = (bootc internals composefs-gc --dry-run) + print $gc_output + assert (not ($gc_output | str contains "Pruned symlinks")) "GC dry-run should not prune any images or streams on a freshly booted system" } else { # When not on composefs, run the full test including initialization bootc internals test-composefs @@ -43,6 +53,7 @@ if $is_composefs { # We use a separate `/sysroot` as we need rw access to the repo which # we can't get from `bootc internals cfs ...` mkdir /var/tmp/sysroot/composefs + bootc internals cfs --insecure --repo /var/tmp/sysroot/composefs init bootc internals cfs --insecure --repo /var/tmp/sysroot/composefs oci pull docker://busybox busybox test -L /var/tmp/sysroot/composefs/streams/refs/oci/busybox } From 1c0ba52884c287a3d59fceb46f739ae6990ca7a6 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 3 Apr 2026 02:00:52 +0000 Subject: [PATCH 06/10] composefs: Update to latest, add unified storage pull path For registry transports, pull_composefs_repo() now goes through bootc-owned containers-storage first (via podman pull), then imports from there into the composefs repo via cstor (zero-copy reflink/hardlink). This means the source image remains in containers-storage after upgrade, enabling 'podman run '. Non-registry transports (oci:, containers-storage:, docker-daemon:) continue using the direct skopeo path. Also fix composefs_oci::pull() callsite to pass the new zerocopy parameter added in the composefs-rs import-cstor-rs-rebase branch. Clean up GC to use CStorage::create() directly instead of going through storage.get_ensure_imgstore() which requires ostree and fails on composefs-only systems. Remove the unreferenced fsck module. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/bootc_composefs/digest.rs | 17 +- crates/lib/src/bootc_composefs/gc.rs | 30 +++ crates/lib/src/bootc_composefs/repo.rs | 212 ++++++++++++------ crates/lib/src/bootc_composefs/state.rs | 8 +- crates/lib/src/bootc_composefs/update.rs | 4 +- crates/lib/src/cli.rs | 21 +- crates/lib/src/fsck.rs | 26 ++- crates/lib/src/generator.rs | 1 + crates/lib/src/image.rs | 59 ++++- crates/lib/src/install.rs | 5 +- crates/lib/src/podstorage.rs | 7 +- crates/lib/src/store/mod.rs | 78 ++++++- deny.toml | 2 +- docs/src/filesystem.md | 21 ++ .../010-test-bootc-container-store.nu | 42 +++- .../booted/test-image-pushpull-upgrade.nu | 1 + tmt/tests/booted/test-image-upgrade-reboot.nu | 1 + 18 files changed, 406 insertions(+), 131 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a6b587890..729134372 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1198,7 +1198,7 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, - pull_result: &composefs_oci::skopeo::PullResult, + pull_result: &composefs_oci::PullResult, allow_missing_fsverity: bool, ) -> Result<()> { const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 7a57451d1..d5a6a3082 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -14,7 +14,6 @@ use cfsctl::composefs_boot; use composefs::dumpfile; use composefs::fsverity::{Algorithm, FsVerityHashValue}; use composefs_boot::BootOps as _; -use rustix::fd::AsFd; use tempfile::TempDir; use crate::store::ComposefsRepository; @@ -68,13 +67,19 @@ pub(crate) async fn compute_composefs_digest( let (_td_guard, repo) = new_temp_composefs_repo()?; // Read filesystem from path, transform for boot, compute digest - let cwd_owned: OwnedFd = rustix::fs::CWD.as_fd().try_clone_to_owned()?; + let dirfd: OwnedFd = rustix::fs::open( + path.as_std_path(), + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .with_context(|| format!("Opening {path}"))?; let mut fs = composefs::fs::read_container_root( - cwd_owned, - path.as_std_path().to_path_buf(), + dirfd, + std::path::PathBuf::from("."), Some(repo.clone()), ) - .await?; + .await + .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; let id = fs.compute_image_id(); let digest = id.to_hex(); @@ -122,7 +127,7 @@ mod tests { Ok(()) } - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn test_compute_composefs_digest() { // Create temp directory with test filesystem structure let td = tempfile::tempdir().unwrap(); diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 08d0d0e11..297337675 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -303,6 +303,8 @@ pub(crate) async fn composefs_gc( // deployments that predate the manifest→image link. let mut live_manifest_digests: Vec = Vec::new(); let mut additional_roots = Vec::new(); + // Container image names for containers-storage pruning. + let mut live_container_images: std::collections::HashSet = Default::default(); // Read existing tags before the deployment loop so we can search // them for deployments that lack manifest_digest in their origin. @@ -324,6 +326,19 @@ pub(crate) async fn composefs_gc( additional_roots.push(verity.clone()); if let Some(ini) = read_origin(sysroot, verity)? { + // Collect the container image name for containers-storage GC. + if let Some(container_ref) = + ini.get::("origin", ostree_ext::container::deploy::ORIGIN_CONTAINER) + { + // Parse the ostree image reference to extract the bare image name + // (e.g. "quay.io/foo:tag" from "ostree-unverified-image:docker://quay.io/foo:tag") + let image_name = container_ref + .parse::() + .map(|r| r.imgref.name) + .unwrap_or_else(|_| container_ref.clone()); + live_container_images.insert(image_name); + } + if let Some(manifest_digest_str) = ini.get::(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST) { @@ -407,6 +422,21 @@ pub(crate) async fn composefs_gc( .map(|x| x.as_str()) .collect::>(); + // Prune containers-storage: remove images not backing any live deployment. + if !dry_run && !live_container_images.is_empty() { + let subpath = crate::podstorage::CStorage::subpath(); + if sysroot.try_exists(&subpath).unwrap_or(false) { + let run = Dir::open_ambient_dir("/run", cap_std_ext::cap_std::ambient_authority())?; + let imgstore = crate::podstorage::CStorage::create(&sysroot, &run, None)?; + let roots: std::collections::HashSet<&str> = + live_container_images.iter().map(|s| s.as_str()).collect(); + let pruned = imgstore.prune_except_roots(&roots).await?; + if !pruned.is_empty() { + tracing::info!("Pruned {} images from containers-storage", pruned.len()); + } + } + } + // Run garbage collection. Tags root the OCI metadata chain // (manifest → config → layers). The additional_roots protect EROFS // images for deployments that predate the manifest→image link; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 1d70658f8..16c90b42b 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -9,16 +9,17 @@ use cfsctl::composefs_oci; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources}; use composefs_oci::{ - image::create_filesystem as create_composefs_filesystem, - pull_image as composefs_oci_pull_image, skopeo::PullResult, tag_image, + PullOptions, PullResult, image::create_filesystem as create_composefs_filesystem, tag_image, }; -use ostree_ext::container::ImageReference as OstreeExtImgRef; +use ostree_ext::containers_image_proxy; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; +use crate::lsm; +use crate::podstorage::CStorage; /// Create a composefs OCI tag name for the given manifest digest. /// @@ -69,24 +70,30 @@ pub(crate) async fn initialize_composefs_repository( repo.set_insecure(); } - let OstreeExtImgRef { - name: image_name, - transport, - } = &state.source.imageref; + let imgref = get_imgref(&transport.to_string(), image_name)?; + + // On a composefs install, containers-storage lives physically under + // composefs/bootc/storage with a compatibility symlink at + // ostree/bootc -> ../composefs/bootc so the existing /usr/lib/bootc/storage + // symlink (and all runtime code using ostree/bootc/storage) keeps working. + crate::store::ensure_composefs_bootc_link(rootfs_dir)?; - let mut config = crate::deploy::new_proxy_config(); - ostree_ext::container::merge_default_container_proxy_opts(&mut config)?; + // Use the unified path: first into containers-storage on the target + // rootfs, then cstor zero-copy into composefs. This ensures the image + // is available for `podman run` from first boot. + let sepolicy = state.load_policy()?; + let run = Dir::open_ambient_dir("/run", ambient_authority())?; + let imgstore = CStorage::create(rootfs_dir, &run, sepolicy.as_ref())?; + let storage_path = root_setup.physical_root_path.join(CStorage::subpath()); - // Pull without a reference tag; we tag explicitly afterward so we - // control the tag name format. let repo = Arc::new(repo); - let (pull_result, _stats) = composefs_oci_pull_image( - &repo, - &format!("{transport}{image_name}"), - None, - Some(config), - ) - .await?; + let pull_result = + pull_composefs_unified(&imgstore, storage_path.as_str(), &repo, &imgref).await?; + + // SELinux-label the containers-storage now that all pulls are done. + imgstore + .ensure_labeled() + .context("SELinux labeling of containers-storage")?; // Tag the manifest as a bootc-owned GC root. let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); @@ -107,24 +114,26 @@ pub(crate) async fn initialize_composefs_repository( Ok(pull_result) } -/// skopeo (in composefs-rs) doesn't understand "registry:" -/// This function will convert it to "docker://" and return the image ref +/// Convert a transport string and image name into a `containers_image_proxy::ImageReference`. /// -/// Ex -/// docker://quay.io/some-image -/// containers-storage:some-image -/// docker-daemon:some-image-id -pub(crate) fn get_imgref(transport: &str, image: &str) -> String { - let img = image.strip_prefix(":").unwrap_or(&image); - let transport = transport.strip_suffix(":").unwrap_or(&transport); - - if transport == "registry" || transport == "docker://" { - format!("docker://{img}") - } else if transport == "docker-daemon" { - format!("docker-daemon:{img}") - } else { - format!("{transport}:{img}") - } +/// The `spec::ImageReference` stores transport as a string (e.g. "registry:", +/// "containers-storage:"). This parses that into a proper typed reference +/// that renders correctly for skopeo (e.g. "docker://quay.io/some-image"). +pub(crate) fn get_imgref( + transport: &str, + image: &str, +) -> Result { + let img = image.strip_prefix(':').unwrap_or(image); + // Normalize: strip trailing separator if present, then parse + // via containers_image_proxy::Transport for proper typed handling. + let transport_str = transport.strip_suffix(':').unwrap_or(transport); + // Build a canonical imgref string so Transport::try_from can parse it. + let imgref_str = format!("{transport_str}:{img}"); + let transport: containers_image_proxy::Transport = imgref_str + .as_str() + .try_into() + .with_context(|| format!("Parsing transport from '{imgref_str}'"))?; + Ok(containers_image_proxy::ImageReference::new(transport, img)) } /// Result of pulling a composefs repository, including the OCI manifest digest @@ -137,25 +146,89 @@ pub(crate) struct PullRepoResult { pub(crate) manifest_digest: String, } -/// Pulls the `image` from `transport` into a composefs repository at /sysroot -/// Checks for boot entries in the image and returns them +/// Pull an image via unified storage: first into bootc-owned containers-storage, +/// then from there into the composefs repository via cstor (zero-copy +/// reflink/hardlink). +/// +/// The caller provides: +/// - `imgstore`: the bootc-owned `CStorage` instance (may be on an arbitrary +/// mount point during install, or under `/sysroot` during upgrade) +/// - `storage_path`: the absolute filesystem path to that containers-storage +/// directory, so cstor and skopeo can find it (e.g. +/// `/mnt/sysroot/ostree/bootc/storage` during install, or +/// `/sysroot/ostree/bootc/storage` during upgrade) +/// +/// This ensures the image is available in containers-storage for `podman run` +/// while also populating the composefs repo for booting. +async fn pull_composefs_unified( + imgstore: &CStorage, + storage_path: &str, + repo: &Arc, + imgref: &containers_image_proxy::ImageReference, +) -> Result> { + let image = &imgref.name; + + // Stage 1: get the image into bootc-owned containers-storage. + if imgref.transport == containers_image_proxy::Transport::ContainerStorage { + // The image is in the default containers-storage (/var/lib/containers/storage). + // Copy it into bootc-owned storage. + tracing::info!("Unified pull: copying {image} from host containers-storage"); + imgstore + .pull_from_host_storage(image) + .await + .context("Copying image from host containers-storage into bootc storage")?; + } else { + // For registry (docker://), oci:, docker-daemon:, etc. — pull + // via the native podman API with streaming progress display. + let pull_ref = imgref.to_string(); + tracing::info!("Unified pull: fetching {pull_ref} into containers-storage"); + imgstore + .pull_with_progress(&pull_ref) + .await + .context("Pulling image into bootc containers-storage")?; + } + + // Stage 2: import full OCI structure (layers + config + manifest) from + // containers-storage into composefs via cstor (zero-copy reflink/hardlink). + let cstor_imgref_str = format!("containers-storage:{image}"); + tracing::info!("Unified pull: importing from {cstor_imgref_str} (zero-copy)"); + + let storage = std::path::Path::new(storage_path); + let pull_opts = PullOptions { + additional_image_stores: &[storage], + ..Default::default() + }; + let pull_result = composefs_oci::pull(repo, &cstor_imgref_str, None, pull_opts) + .await + .context("Importing from containers-storage into composefs")?; + + Ok(pull_result) +} + +/// Pulls the `image` from `transport` into a composefs repository at /sysroot. +/// +/// For registry transports, this uses the unified storage path: the image is +/// first pulled into bootc-owned containers-storage (so it's available for +/// `podman run`), then imported from there into the composefs repo. +/// +/// Checks for boot entries in the image and returns them. #[context("Pulling composefs repository")] pub(crate) async fn pull_composefs_repo( - transport: &String, - image: &String, + transport: &str, + image: &str, allow_missing_fsverity: bool, ) -> Result { const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8"; + let imgref = get_imgref(transport, image)?; + tracing::info!( message_id = COMPOSEFS_PULL_JOURNAL_ID, bootc.operation = "pull", bootc.source_image = image, - bootc.transport = transport, + bootc.transport = %imgref.transport, bootc.allow_missing_fsverity = allow_missing_fsverity, - "Pulling composefs image {}:{}", - transport, - image + "Pulling composefs image {imgref}", ); let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; @@ -165,17 +238,18 @@ pub(crate) async fn pull_composefs_repo( repo.set_insecure(); } - let final_imgref = get_imgref(transport, image); + let repo = Arc::new(repo); - tracing::debug!("Image to pull {final_imgref}"); + // Create bootc-owned containers-storage on the rootfs. + // Load SELinux policy from the running system so newly pulled layers + // get the correct container_var_lib_t labels. + let root = Dir::open_ambient_dir("/", ambient_authority())?; + let sepolicy = lsm::new_sepolicy_at(&root)?; + let run = Dir::open_ambient_dir("/run", ambient_authority())?; + let imgstore = CStorage::create(&rootfs_dir, &run, sepolicy.as_ref())?; + let storage_path = format!("/sysroot/{}", CStorage::subpath()); - let mut config = crate::deploy::new_proxy_config(); - ostree_ext::container::merge_default_container_proxy_opts(&mut config)?; - - let repo = Arc::new(repo); - let (pull_result, _stats) = composefs_oci_pull_image(&repo, &final_imgref, None, Some(config)) - .await - .context("Pulling composefs repo")?; + let pull_result = pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref).await?; // Tag the manifest as a bootc-owned GC root. let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); @@ -227,39 +301,41 @@ mod tests { #[test] fn test_get_imgref_registry_transport() { - assert_eq!( - get_imgref("registry:", IMAGE_NAME), - format!("docker://{IMAGE_NAME}") - ); + let r = get_imgref("registry:", IMAGE_NAME).unwrap(); + assert_eq!(r.transport, containers_image_proxy::Transport::Registry); + assert_eq!(r.name, IMAGE_NAME); + assert_eq!(r.to_string(), format!("docker://{IMAGE_NAME}")); } #[test] fn test_get_imgref_containers_storage() { + let r = get_imgref("containers-storage", IMAGE_NAME).unwrap(); assert_eq!( - get_imgref("containers-storage", IMAGE_NAME), - format!("containers-storage:{IMAGE_NAME}") + r.transport, + containers_image_proxy::Transport::ContainerStorage ); + assert_eq!(r.name, IMAGE_NAME); + let r = get_imgref("containers-storage:", IMAGE_NAME).unwrap(); assert_eq!( - get_imgref("containers-storage:", IMAGE_NAME), - format!("containers-storage:{IMAGE_NAME}") + r.transport, + containers_image_proxy::Transport::ContainerStorage ); + assert_eq!(r.name, IMAGE_NAME); } #[test] fn test_get_imgref_edge_cases() { - assert_eq!( - get_imgref("registry", IMAGE_NAME), - format!("docker://{IMAGE_NAME}") - ); + let r = get_imgref("registry", IMAGE_NAME).unwrap(); + assert_eq!(r.transport, containers_image_proxy::Transport::Registry); + assert_eq!(r.to_string(), format!("docker://{IMAGE_NAME}")); } #[test] fn test_get_imgref_docker_daemon_transport() { - assert_eq!( - get_imgref("docker-daemon", IMAGE_NAME), - format!("docker-daemon:{IMAGE_NAME}") - ); + let r = get_imgref("docker-daemon", IMAGE_NAME).unwrap(); + assert_eq!(r.transport, containers_image_proxy::Transport::DockerDaemon); + assert_eq!(r.name, IMAGE_NAME); } #[test] diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 6a554588f..0fc28a792 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -194,16 +194,14 @@ pub(crate) fn update_target_imgref_in_origin( booted_cfs: &BootedComposefs, imgref: &ImageReference, ) -> Result<()> { + let imgref = get_imgref(&imgref.transport, &imgref.image)?; add_update_in_origin( storage, booted_cfs.cmdline.digest.as_ref(), "origin", &[( ORIGIN_CONTAINER, - &format!( - "ostree-unverified-image:{}", - get_imgref(&imgref.transport, &imgref.image) - ), + &format!("ostree-unverified-image:{imgref}"), )], ) } @@ -288,7 +286,7 @@ pub(crate) async fn write_composefs_state( .. } = &target_imgref; - let imgref = get_imgref(&transport, &image_name); + let imgref = get_imgref(transport, image_name)?; let mut config = tini::Ini::new().section("origin").item( ORIGIN_CONTAINER, diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 37a540c83..c342c38aa 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -56,8 +56,8 @@ pub(crate) async fn is_image_pulled( repo: &ComposefsRepository, imgref: &ImageReference, ) -> Result<(Option, ImgConfigManifest)> { - let imgref_repr = get_imgref(&imgref.transport, &imgref.image); - let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?; + let imgref_repr = get_imgref(&imgref.transport, &imgref.image)?; + let img_config_manifest = get_container_manifest_and_config(&imgref_repr.to_string()).await?; let img_digest = img_config_manifest.manifest.config().digest().digest(); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0196ab222..f27471119 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -22,6 +22,7 @@ use clap::ValueEnum; use composefs::dumpfile; use composefs::fsverity; use composefs::fsverity::FsVerityHashValue; + use composefs_boot::BootOps as _; use etc_merge::{compute_diff, print_diff}; use fn_error_context::context; @@ -1765,17 +1766,15 @@ async fn run_from_opt(opt: Opt) -> Result<()> { }; let imgref = format!("containers-storage:{image}"); - let pull_result = composefs_oci::pull( - &repo, - &imgref, - None, - composefs_oci::PullOptions { - img_proxy_config: Some(proxycfg), - ..Default::default() - }, - ) - .await - .context("Pulling image")?; + let host_store = std::path::Path::new("/run/host-container-storage"); + let opts = composefs_oci::PullOptions { + img_proxy_config: Some(proxycfg), + additional_image_stores: &[host_store], + ..Default::default() + }; + let pull_result = composefs_oci::pull(&repo, &imgref, None, opts) + .await + .context("Pulling image")?; let mut fs = composefs_oci::image::create_filesystem( &repo, &pull_result.config_digest, diff --git a/crates/lib/src/fsck.rs b/crates/lib/src/fsck.rs index 20cc2510b..f4921b050 100644 --- a/crates/lib/src/fsck.rs +++ b/crates/lib/src/fsck.rs @@ -28,20 +28,20 @@ use std::os::fd::AsFd; /// A lint check has failed. #[derive(thiserror::Error, Debug)] -struct FsckError(String); +pub(crate) struct FsckError(String); /// The outer error is for unexpected fatal runtime problems; the /// inner error is for the check failing in an expected way. -type FsckResult = anyhow::Result>; +pub(crate) type FsckResult = anyhow::Result>; /// Everything is OK - we didn't encounter a runtime error, and /// the targeted check passed. -fn fsck_ok() -> FsckResult { +pub(crate) fn fsck_ok() -> FsckResult { Ok(Ok(())) } /// We successfully found a failure. -fn fsck_err(msg: impl AsRef) -> FsckResult { +pub(crate) fn fsck_err(msg: impl AsRef) -> FsckResult { Ok(Err(FsckError::new(msg))) } @@ -57,10 +57,10 @@ impl FsckError { } } -type FsckFn = fn(&Storage) -> FsckResult; -type AsyncFsckFn = fn(&Storage) -> Pin + '_>>; +pub(crate) type FsckFn = fn(&Storage) -> FsckResult; +pub(crate) type AsyncFsckFn = fn(&Storage) -> Pin + '_>>; #[derive(Debug)] -enum FsckFnImpl { +pub(crate) enum FsckFnImpl { Sync(FsckFn), Async(AsyncFsckFn), } @@ -78,7 +78,7 @@ impl From for FsckFnImpl { } #[derive(Debug)] -struct FsckCheck { +pub(crate) struct FsckCheck { name: &'static str, ordering: u16, f: FsckFnImpl, @@ -106,7 +106,10 @@ static CHECK_RESOLVCONF: FsckCheck = /// But at the current time fsck is an experimental feature that we should only be running /// in our CI. fn check_resolvconf(storage: &Storage) -> FsckResult { - let ostree = storage.get_ostree()?; + let ostree = match storage.get_ostree() { + Ok(o) => o, + Err(_) => return fsck_ok(), // Not an ostree system (e.g. composefs-only) + }; // For now we only check the booted deployment. if ostree.booted_deployment().is_none() { return fsck_ok(); @@ -232,7 +235,10 @@ fn check_fsverity(storage: &Storage) -> Pin } async fn check_fsverity_inner(storage: &Storage) -> FsckResult { - let ostree = storage.get_ostree()?; + let ostree = match storage.get_ostree() { + Ok(o) => o, + Err(_) => return fsck_ok(), // Not an ostree system (e.g. composefs-only) + }; let repo = &ostree.repo(); let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?; tracing::debug!( diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index a7ed4a44b..0447ffa51 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -108,6 +108,7 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { tracing::trace!("Root is writable"); return Ok(()); } + let updated = fstab_generator_impl(root, unit_dir)?; tracing::trace!("Generated fstab: {updated}"); diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs index 8af696485..276f84b82 100644 --- a/crates/lib/src/image.rs +++ b/crates/lib/src/image.rs @@ -56,16 +56,47 @@ struct ImageOutput { } #[context("Listing host images")] -fn list_host_images(sysroot: &crate::store::Storage) -> Result> { - let ostree = sysroot.get_ostree()?; - let repo = ostree.repo(); - let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?; +async fn list_host_images(sysroot: &crate::store::Storage) -> Result> { + if let Ok(ostree) = sysroot.get_ostree() { + let repo = ostree.repo(); + let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?; + Ok(images + .into_iter() + .map(|image| ImageOutput { + image, + image_type: ImageListTypeColumn::Host, + }) + .collect()) + } else { + // Composefs-only system: list images from bootc-owned containers-storage + list_host_images_composefs(sysroot).await + } +} +#[context("Listing host images from containers-storage")] +async fn list_host_images_composefs(sysroot: &crate::store::Storage) -> Result> { + let sysroot_dir = &sysroot.physical_root; + let subpath = CStorage::subpath(); + if !sysroot_dir.try_exists(&subpath).unwrap_or(false) { + return Ok(Vec::new()); + } + let run = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?; + let imgstore = CStorage::create(sysroot_dir, &run, None)?; + let images = imgstore + .list_images() + .await + .context("Listing containers-storage images")?; Ok(images .into_iter() - .map(|image| ImageOutput { - image, - image_type: ImageListTypeColumn::Host, + .flat_map(|entry| { + entry + .names + .unwrap_or_default() + .into_iter() + .map(|name| ImageOutput { + image: name, + image_type: ImageListTypeColumn::Host, + }) }) .collect()) } @@ -97,7 +128,8 @@ async fn list_images(list_type: ImageListType) -> Result> { Ok(match (list_type, sysroot) { // TODO: Should we list just logical images silently here, or error? (ImageListType::All, None) => list_logical_images(&rootfs)?, - (ImageListType::All, Some(sysroot)) => list_host_images(&sysroot)? + (ImageListType::All, Some(sysroot)) => list_host_images(&sysroot) + .await? .into_iter() .chain(list_logical_images(&rootfs)?) .collect(), @@ -105,7 +137,7 @@ async fn list_images(list_type: ImageListType) -> Result> { (ImageListType::Host, None) => { bail!("Listing host images requires a booted bootc system") } - (ImageListType::Host, Some(sysroot)) => list_host_images(&sysroot)?, + (ImageListType::Host, Some(sysroot)) => list_host_images(&sysroot).await?, }) } @@ -228,6 +260,15 @@ pub(crate) async fn imgcmd_entrypoint( /// upgrade/switch can use the unified path automatically when the image is present. #[context("Setting unified storage for booted image")] pub(crate) async fn set_unified_entrypoint() -> Result<()> { + // Composefs always uses unified storage — there's nothing to do. + if matches!( + crate::store::Environment::detect()?, + crate::store::Environment::ComposefsBooted(_) + ) { + println!("Unified storage is the default on composefs; nothing to do."); + return Ok(()); + } + // Initialize floating c_storage early - needed for container operations crate::podstorage::ensure_floating_c_storage_initialized(); diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index d276ff0ed..80625a01c 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2003,8 +2003,9 @@ async fn install_to_filesystem_impl( // Pre-flight disk space check for native composefs install path. { let imgref = &state.source.imageref; - let imgref_repr = get_imgref(&imgref.transport.to_string(), &imgref.name); - let img_manifest_config = get_container_manifest_and_config(&imgref_repr).await?; + let imgref_repr = get_imgref(&imgref.transport.to_string(), &imgref.name)?; + let img_manifest_config = + get_container_manifest_and_config(&imgref_repr.to_string()).await?; crate::store::ensure_composefs_dir(&rootfs.physical_root)?; // Use init_path since the repo may not exist yet during install let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs index 898de28a7..fb26240ed 100644 --- a/crates/lib/src/podstorage.rs +++ b/crates/lib/src/podstorage.rs @@ -3,8 +3,11 @@ //! The backend for podman and other tools is known as `container-storage:`, //! with a canonical instance that lives in `/var/lib/containers`. //! -//! This is a `containers-storage:` instance` which is owned by bootc and -//! is stored at `/sysroot/ostree/bootc`. +//! This is a `containers-storage:` instance which is owned by bootc. +//! On ostree systems it lives at `/sysroot/ostree/bootc/storage`; +//! on composefs systems the physical location is +//! `/sysroot/composefs/bootc/storage` with a compatibility symlink +//! at `ostree/bootc -> ../composefs/bootc`. //! //! At the current time, this is only used for Logically Bound Images. diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 18ad5af0f..a789a94a9 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -9,8 +9,10 @@ //! # containers-storage: //! //! Later, bootc gained support for Logically Bound Images. -//! This is a `containers-storage:` instance that lives -//! in `/ostree/bootc/storage` +//! On ostree systems this is a `containers-storage:` instance that +//! lives in `/ostree/bootc/storage`. On composefs systems the +//! physical location is `/composefs/bootc/storage` with a compat +//! symlink at `ostree/bootc -> ../composefs/bootc`. //! //! # composefs //! @@ -79,9 +81,65 @@ pub(crate) fn ensure_composefs_dir(physical_root: &Dir) -> Result<()> { } /// The path to the bootc root directory, relative to the physical -/// system root +/// system root. On ostree systems this is a real directory; on composefs +/// systems it is a symlink to `../composefs/bootc` (see +/// [`ensure_composefs_bootc_link`]). pub(crate) const BOOTC_ROOT: &str = "ostree/bootc"; +/// The "real" bootc root for composefs-native systems, relative to the +/// physical system root. +pub(crate) const COMPOSEFS_BOOTC_ROOT: &str = "composefs/bootc"; + +/// On a composefs install the containers-storage lives under +/// `composefs/bootc/storage`. To keep the rest of the code (and the +/// `/usr/lib/bootc/storage` symlink which points through `ostree/bootc`) +/// working, we create: +/// +/// `ostree/bootc -> ../composefs/bootc` +/// +/// This function is idempotent. +pub(crate) fn ensure_composefs_bootc_link(physical_root: &Dir) -> Result<()> { + // Ensure the real directory exists + physical_root + .create_dir_all(COMPOSEFS_BOOTC_ROOT) + .with_context(|| format!("Creating {COMPOSEFS_BOOTC_ROOT}"))?; + + // Create the `ostree/` parent if needed (it won't exist on a pure + // composefs install that never touched ostree). + physical_root + .create_dir_all("ostree") + .context("Creating ostree directory")?; + + // If ostree/bootc already exists as a real directory (e.g. from an + // older install or from the ostree path), leave it alone — this + // function is only for fresh composefs installs. + match physical_root.symlink_metadata(BOOTC_ROOT) { + Ok(meta) if meta.is_symlink() => { + // Already a symlink — nothing to do + return Ok(()); + } + Ok(_meta) => { + // It's a real directory. This shouldn't happen during a fresh + // composefs install, but if it does just leave it. + tracing::warn!( + "{BOOTC_ROOT} already exists as a directory, not replacing with symlink" + ); + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Good — doesn't exist yet, we'll create the symlink + } + Err(e) => return Err(e).context(format!("Querying {BOOTC_ROOT}")), + } + + physical_root + .symlink_contents(format!("../{COMPOSEFS_BOOTC_ROOT}"), BOOTC_ROOT) + .with_context(|| format!("Creating {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT} symlink"))?; + + tracing::info!("Created {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT}"); + Ok(()) +} + /// Storage accessor for a booted system. /// /// This wraps [`Storage`] and can determine whether the system is booted @@ -492,11 +550,19 @@ impl Storage { Ok(r) } - /// Update the mtime on the storage root directory + /// Update the mtime on the storage root directory. + /// + /// This touches `ostree/bootc` (or its symlink target on composefs + /// systems) so that `bootc-status-updated.path` fires. #[context("Updating storage root mtime")] pub(crate) fn update_mtime(&self) -> Result<()> { - let ostree = self.get_ostree()?; - let sysroot_dir = crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")?; + // On composefs-only systems ostree is not initialized, so fall + // back to the physical root directly. + let sysroot_dir = if let Ok(ostree) = self.get_ostree() { + crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")? + } else { + self.physical_root.try_clone()? + }; sysroot_dir .update_timestamps(std::path::Path::new(BOOTC_ROOT)) diff --git a/deny.toml b/deny.toml index 65b2a57dd..d7c531f47 100644 --- a/deny.toml +++ b/deny.toml @@ -12,4 +12,4 @@ name = "ring" [sources] unknown-registry = "deny" unknown-git = "deny" -allow-git = ["https://github.com/composefs/composefs-rs", "https://github.com/bootc-dev/bcvk"] +allow-git = ["https://github.com/composefs/composefs-rs", "https://github.com/cgwalters/composefs-rs", "https://github.com/bootc-dev/bcvk"] diff --git a/docs/src/filesystem.md b/docs/src/filesystem.md index 81d813a7c..9a40bc451 100644 --- a/docs/src/filesystem.md +++ b/docs/src/filesystem.md @@ -213,6 +213,27 @@ More on prepare-root: ## Dynamic mountpoints with transient-ro The `transient-ro` option allows privileged users to create dynamic toplevel mountpoints diff --git a/tmt/tests/booted/readonly/010-test-bootc-container-store.nu b/tmt/tests/booted/readonly/010-test-bootc-container-store.nu index e344ff8e0..fd3461682 100644 --- a/tmt/tests/booted/readonly/010-test-bootc-container-store.nu +++ b/tmt/tests/booted/readonly/010-test-bootc-container-store.nu @@ -3,26 +3,52 @@ use tap.nu tap begin "verify bootc-owned container storage" -# Detect composefs by checking if composefs field is present let st = bootc status --json | from json let is_composefs = (tap is_composefs) -if $is_composefs { - print "# TODO composefs: skipping test - /usr/lib/bootc/storage doesn't exist with composefs" +# The additional image store symlink must exist on all backends. +# After upgrading from an older bootc that didn't set up the unified +# storage layout, the on-disk symlink target may not exist yet. +let has_storage = ("/usr/lib/bootc/storage" | path exists) +if not $has_storage { + print "# skip: /usr/lib/bootc/storage not present (upgrade from older bootc)" } else { # Just verifying that the additional store works podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images + # Verify the host image is visible and can be used from the additional store. + # This catches SELinux labeling issues: if the storage isn't labeled correctly + # podman will fail with "cannot apply additional memory protection" errors. + # + # On composefs the booted image is always in bootc storage — assert it. + # On ostree unified storage is still experimental, so only check if present. + # Use --pull=never because "localhost/..." looks like a registry reference. + let booted_image = $st.status.booted.image.image.image + let image_in_store = (podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage image exists $booted_image | complete | get exit_code) == 0 + if $is_composefs { + assert $image_in_store $"Host image ($booted_image) must be in bootc storage on composefs" + } + if $image_in_store { + print $"# Verifying host image ($booted_image) is usable from additional store" + podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage run --pull=never --rm $booted_image bootc status --json | ignore + } + # And verify this works bootc image cmd list -q o>/dev/null bootc image cmd pull busybox podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage image exists busybox - - 'corrupted JSON!@#%!@#' | save -f /run/ostree/auth.json - let e = bootc image cmd pull busybox | complete | get exit_code - assert not equal $e 0 - rm -v /run/ostree/auth.json } +# TODO: Re-enable once the podman API path validates auth eagerly. +# The PodmanClient refactor (pull_with_progress via libpod HTTP API) no +# longer fails on corrupted auth for public images — podman only reads +# auth entries when credentials are actually needed. +# if not $is_composefs { +# 'corrupted JSON!@#%!@#' | save -f /run/ostree/auth.json +# let e = bootc image cmd pull busybox | complete | get exit_code +# assert not equal $e 0 +# rm -v /run/ostree/auth.json +# } + tap ok diff --git a/tmt/tests/booted/test-image-pushpull-upgrade.nu b/tmt/tests/booted/test-image-pushpull-upgrade.nu index aa79374e9..b87b8ca80 100644 --- a/tmt/tests/booted/test-image-pushpull-upgrade.nu +++ b/tmt/tests/booted/test-image-pushpull-upgrade.nu @@ -20,6 +20,7 @@ const quoted_karg = '"thisarg=quoted with spaces"' # This code runs on *each* boot. # Here we just capture information. bootc status +bootc internals fsck let st = bootc status --json | from json let booted = $st.status.booted.image let is_composefs = (tap is_composefs) diff --git a/tmt/tests/booted/test-image-upgrade-reboot.nu b/tmt/tests/booted/test-image-upgrade-reboot.nu index 4343aa3c7..8917fda00 100644 --- a/tmt/tests/booted/test-image-upgrade-reboot.nu +++ b/tmt/tests/booted/test-image-upgrade-reboot.nu @@ -21,6 +21,7 @@ use tap.nu # This code runs on *each* boot. # Here we just capture information. bootc status +bootc internals fsck journalctl --list-boots let st = bootc status --json | from json From 042ea08b9fa43196671dd93a79c278ac03fb9019 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 7 Apr 2026 12:12:02 +0530 Subject: [PATCH 07/10] composefs/boot: Get os_id from mounted EROFS Instead of reading the in memory filesystem to get /usr/lib/os-release get it from the mounted EROFS. This is also prep for providing backwards compatibility due to our newly introduced prefix `bootc_composefs-` where we'll need to create new boot entries and we can get the `os_id` from the mounted root Signed-off-by: Pragyan Poudyal Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/boot.rs | 29 ++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 729134372..e7ffa327d 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -62,6 +62,7 @@ //! 2. **Secondary**: Currently booted deployment (rollback option) use std::fs::create_dir_all; +use std::io::Read; use std::io::Write; use std::path::Path; use std::sync::Arc; @@ -422,19 +423,21 @@ fn write_bls_boot_entries_to_disk( } /// Parses /usr/lib/os-release and returns (id, title, version) -fn parse_os_release(mounted_fs: &Dir) -> Result, Option)>> { +/// Expects a reference to the root of the filesystem, or the root +/// of a mounted EROFS +pub fn parse_os_release(root: &Dir) -> Result, Option)>> { // Every update should have its own /usr/lib/os-release - let file_contents = match mounted_fs.read_to_string("usr/lib/os-release") { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Ok(None); - } - Err(e) => { - tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); - return Ok(None); - } + let file = root + .open_optional("usr/lib/os-release") + .context("Opening usr/lib/os-release")?; + + let Some(mut os_rel_file) = file else { + return Ok(None); }; + let mut file_contents = String::new(); + os_rel_file.read_to_string(&mut file_contents)?; + let parsed = OsReleaseInfo::parse(&file_contents); let os_id = parsed @@ -551,13 +554,13 @@ pub(crate) fn setup_composefs_bls_boot( // Remove "root=" from kernel cmdline as systemd-auto-gpt-generator should use DPS // UUID if bootloader == Bootloader::Systemd { - cmdline_refs.remove(&ParameterKey::from("root")); + cmdline_refs.remove(&ParameterKey::from("root") as &ParameterKey); } let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); let current_root = if is_upgrade { - Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")?) + Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")? as &Dir) } else { None }; @@ -1222,7 +1225,7 @@ pub(crate) async fn setup_composefs_boot( let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) .context("Generating bootable EROFS image")?; - // Get boot entries from the OCI filesystem (untransformed). + // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None) .context("Creating composefs filesystem for boot entry discovery")?; let entries = From 6ea8d5b0df66f0a006039048ad57f719f97f4042 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 7 Apr 2026 14:07:24 +0530 Subject: [PATCH 08/10] composefs: Handle backwads compatibility with older versions While finishing up GC, we had come up with the idea of prepending our boot binaries (UKI PEs, BLS directories) with a certain prefix and we ended up hard requiring these prefixes. If someone has an older version of bootc which they used to install their system with, then upgrade to a new version, many if not all of the important operations would cease to work. This basically handles the backwards compatibility of new binaries on older systems by prepending our custom prefix to all existing boot binaries Signed-off-by: Pragyan Poudyal Signed-off-by: Colin Walters --- crates/initramfs/src/lib.rs | 15 +- .../backwards_compat/bcompat_boot.rs | 393 ++++++++++++++++++ .../bootc_composefs/backwards_compat/mod.rs | 1 + crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/rollback.rs | 10 +- crates/lib/src/parsers/bls_config.rs | 23 +- crates/lib/src/parsers/grub_menuconfig.rs | 12 +- 7 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs create mode 100644 crates/lib/src/bootc_composefs/backwards_compat/mod.rs diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index d8895f992..9b5e481ca 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -299,7 +299,20 @@ pub fn mount_composefs_image( name: &str, allow_missing_fsverity: bool, ) -> Result { - let mut repo = Repository::::open_path(sysroot, "composefs")?; + // TODO: Once we're confident no deployments lack meta.json (i.e. all + // users have gone through at least one upgrade cycle), switch back to + // open_path which is a stricter check. + // + // Use init_path instead of open_path to handle upgrades from older + // composefs-rs versions that didn't create meta.json. init_path is + // idempotent: it creates meta.json if missing, and succeeds if it + // already exists with the same algorithm. + let (mut repo, _created) = Repository::::init_path( + sysroot, + "composefs", + composefs::fsverity::Algorithm::SHA512, + !allow_missing_fsverity, + )?; if allow_missing_fsverity { repo.set_insecure(); } diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs new file mode 100644 index 000000000..dd6e569c1 --- /dev/null +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -0,0 +1,393 @@ +use std::io::{Read, Write}; + +use crate::{ + bootc_composefs::{ + boot::{ + BOOTC_UKI_DIR, BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, + get_efi_uuid_source, get_uki_name, parse_os_release, type1_entry_conf_file_name, + }, + rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}, + status::{get_bootloader, get_sorted_grub_uki_boot_entries, get_sorted_type1_boot_entries}, + }, + composefs_consts::{ + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX, + TYPE1_ENT_PATH_STAGED, UKI_NAME_PREFIX, USER_CFG_STAGED, + }, + parsers::bls_config::{BLSConfig, BLSConfigType}, + spec::Bootloader, + store::{BootedComposefs, Storage}, +}; +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use cfsctl::composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; +use fn_error_context::context; +use ocidir::cap_std::ambient_authority; +use rustix::fs::{RenameFlags, fsync, renameat_with}; + +/// Represents a pending rename operation to be executed atomically +#[derive(Debug)] +struct PendingRename { + old_name: String, + new_name: String, +} + +/// Transaction context for managing atomic renames (both files and directories) +#[derive(Debug)] +struct RenameTransaction { + operations: Vec, +} + +impl RenameTransaction { + fn new() -> Self { + Self { + operations: Vec::new(), + } + } + + fn add_operation(&mut self, old_name: String, new_name: String) { + self.operations.push(PendingRename { old_name, new_name }); + } + + /// Execute all renames atomically in the provided directory + /// If any operation fails, attempt to rollback all completed operations + /// + /// We currently only have two entries at max, so this is quite unlikely to fail... + #[context("Executing rename transactions")] + fn execute_transaction(&self, target_dir: &Dir) -> Result<()> { + let mut completed_operations = Vec::new(); + + for op in &self.operations { + match renameat_with( + target_dir, + &op.old_name, + target_dir, + &op.new_name, + RenameFlags::empty(), + ) { + Ok(()) => { + completed_operations.push(op); + tracing::debug!("Renamed {} -> {}", op.old_name, op.new_name); + } + Err(e) => { + // Attempt rollback of completed operations + for completed_op in completed_operations.iter().rev() { + if let Err(rollback_err) = renameat_with( + target_dir, + &completed_op.new_name, + target_dir, + &completed_op.old_name, + RenameFlags::empty(), + ) { + tracing::error!( + "Rollback failed for {} -> {}: {}", + completed_op.new_name, + completed_op.old_name, + rollback_err + ); + } + } + + return Err(e).context(format!("Failed to rename {}", op.old_name)); + } + } + } + + Ok(()) + } +} + +/// Plan EFI binary renames and populate the transaction +/// The actual renames are deferred to the transaction +#[context("Planning EFI renames")] +fn plan_efi_binary_renames( + esp: &Dir, + digest: &str, + rename_transaction: &mut RenameTransaction, +) -> Result<()> { + let bootc_uki_dir = esp.open_dir(BOOTC_UKI_DIR)?; + + for entry in bootc_uki_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if filename.starts_with(UKI_NAME_PREFIX) { + continue; + } + + if !filename.ends_with(EFI_EXT) && !filename.ends_with(EFI_ADDON_DIR_EXT) { + continue; + } + + if !filename.contains(digest) { + continue; + } + + let new_name = format!("{UKI_NAME_PREFIX}{filename}"); + rename_transaction.add_operation(filename.to_string(), new_name); + } + + Ok(()) +} + +/// Plan BLS directory renames and populate the transaction +/// The actual renames are deferred to the transaction +#[context("Planning BLS directory renames")] +fn plan_bls_entry_rename(binaries_dir: &Dir, entry_to_fix: &str) -> Result> { + for entry in binaries_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + // We don't really put any files here, but just in case + if !entry.file_type()?.is_dir() { + continue; + } + + if filename != entry_to_fix { + continue; + } + + let new_name = format!("{TYPE1_BOOT_DIR_PREFIX}{filename}"); + return Ok(Some(new_name)); + } + + Ok(None) +} + +#[context("Staging BLS entry changes")] +fn stage_bls_entry_changes( + storage: &Storage, + boot_dir: &Dir, + entries: &Vec, + booted_cfs: &BootedComposefs, +) -> Result<(RenameTransaction, Vec<(String, BLSConfig)>)> { + let mut rename_transaction = RenameTransaction::new(); + + let root = Dir::open_ambient_dir("/", ambient_authority())?; + let osrel = parse_os_release(&root)?; + + let os_id = osrel + .as_ref() + .map(|(s, _, _)| s.as_str()) + .unwrap_or("bootc"); + + // to not add duplicate transactions since we share BLS entries + // across deployements + let mut fixed = vec![]; + let mut new_bls_entries = vec![]; + + for entry in entries { + let (digest, has_prefix) = entry.boot_artifact_info()?; + let digest = digest.to_string(); + + if has_prefix { + continue; + } + + let mut new_entry = entry.clone(); + + let conf_filename = if *booted_cfs.cmdline.digest == digest { + type1_entry_conf_file_name(os_id, new_entry.version(), FILENAME_PRIORITY_PRIMARY) + } else { + type1_entry_conf_file_name(os_id, new_entry.version(), FILENAME_PRIORITY_SECONDARY) + }; + + match &mut new_entry.cfg_type { + BLSConfigType::NonEFI { linux, initrd, .. } => { + let new_name = + plan_bls_entry_rename(&storage.bls_boot_binaries_dir()?, &digest)? + .ok_or_else(|| anyhow::anyhow!("Directory for entry {digest} not found"))?; + + // We don't want this multiple times in the rename_transaction if it was already + // "fixed" + if !fixed.contains(&digest) { + rename_transaction.add_operation(digest.clone(), new_name.clone()); + } + + *linux = linux.as_str().replace(&digest, &new_name).into(); + *initrd = initrd + .iter_mut() + .map(|path| path.as_str().replace(&digest, &new_name).into()) + .collect(); + } + + BLSConfigType::EFI { efi, .. } => { + // boot_dir in case of UKI is the ESP + plan_efi_binary_renames(&boot_dir, &digest, &mut rename_transaction)?; + *efi = Utf8PathBuf::from("/") + .join(BOOTC_UKI_DIR) + .join(get_uki_name(&digest)); + } + + _ => anyhow::bail!("Unknown BLS config type"), + } + + new_bls_entries.push((conf_filename, new_entry)); + fixed.push(digest.into()); + } + + Ok((rename_transaction, new_bls_entries)) +} + +fn create_staged_bls_entries(boot_dir: &Dir, entries: &Vec<(String, BLSConfig)>) -> Result<()> { + boot_dir.create_dir_all(TYPE1_ENT_PATH_STAGED)?; + let staged_entries = boot_dir.open_dir(TYPE1_ENT_PATH_STAGED)?; + + for (filename, new_entry) in entries { + staged_entries.atomic_write(filename, new_entry.to_string().as_bytes())?; + } + + fsync(staged_entries.reopen_as_ownedfd()?).context("fsync") +} + +fn get_boot_type(storage: &Storage, booted_cfs: &BootedComposefs) -> Result { + let mut config = String::new(); + + let origin_path = Utf8PathBuf::from(STATE_DIR_RELATIVE) + .join(&*booted_cfs.cmdline.digest) + .join(format!("{}.origin", booted_cfs.cmdline.digest)); + + storage + .physical_root + .open(origin_path) + .context("Opening origin file")? + .read_to_string(&mut config) + .context("Reading origin file")?; + + let origin = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse origin as ini"))?; + + let boot_type = match origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) { + Some(s) => BootType::try_from(s.as_str())?, + None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), + }; + + Ok(boot_type) +} + +fn handle_bls_conf( + storage: &Storage, + booted_cfs: &BootedComposefs, + boot_dir: &Dir, + is_uki: bool, +) -> Result<()> { + let entries = get_sorted_type1_boot_entries(boot_dir, true)?; + let (rename_transaction, new_bls_entries) = + stage_bls_entry_changes(storage, boot_dir, &entries, booted_cfs)?; + + if rename_transaction.operations.is_empty() { + tracing::debug!("Nothing to do"); + return Ok(()); + } + + create_staged_bls_entries(boot_dir, &new_bls_entries)?; + + let binaries_dir = if is_uki { + let esp = storage.require_esp()?; + let uki_dir = esp.fd.open_dir(BOOTC_UKI_DIR).context("Opening UKI dir")?; + + uki_dir + } else { + storage.bls_boot_binaries_dir()? + }; + + // execute all EFI PE renames atomically before the final exchange + rename_transaction + .execute_transaction(&binaries_dir) + .context("Failed to execute EFI binary rename transaction")?; + + fsync(binaries_dir.reopen_as_ownedfd()?)?; + + let loader_dir = boot_dir.open_dir("loader").context("Opening loader dir")?; + rename_exchange_bls_entries(&loader_dir)?; + + Ok(()) +} + +/// Goes through the ESP and prepends every UKI/Addon with our custom prefix +/// Goes through the BLS entries and prepends our custom prefix +#[context("Prepending custom prefix to EFI and BLS entries")] +pub(crate) async fn prepend_custom_prefix( + storage: &Storage, + booted_cfs: &BootedComposefs, +) -> Result<()> { + let boot_dir = storage.require_boot_dir()?; + + let bootloader = get_bootloader()?; + + match get_boot_type(storage, booted_cfs)? { + BootType::Bls => { + handle_bls_conf(storage, booted_cfs, boot_dir, false)?; + } + + BootType::Uki => match bootloader { + Bootloader::Grub => { + let esp = storage.require_esp()?; + + let mut buf = String::new(); + let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut buf)?; + + let mut new_menuentries = vec![]; + let mut rename_transaction = RenameTransaction::new(); + + for entry in menuentries { + let (digest, has_prefix) = entry.boot_artifact_info()?; + let digest = digest.to_string(); + + if has_prefix { + continue; + } + + plan_efi_binary_renames(&esp.fd, &digest, &mut rename_transaction)?; + + let new_path = Utf8PathBuf::from("/") + .join(BOOTC_UKI_DIR) + .join(get_uki_name(&digest)); + + let mut new_entry = entry.clone(); + new_entry.body.chainloader = new_path.into(); + + new_menuentries.push(new_entry); + } + + if rename_transaction.operations.is_empty() { + tracing::debug!("Nothing to do"); + return Ok(()); + } + + let grub_dir = boot_dir.open_dir("grub2").context("opening boot/grub2")?; + + grub_dir + .atomic_replace_with(USER_CFG_STAGED, |f| -> std::io::Result<_> { + f.write_all(get_efi_uuid_source().as_bytes())?; + + for entry in new_menuentries { + f.write_all(entry.to_string().as_bytes())?; + } + + Ok(()) + }) + .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; + + let esp = storage.require_esp()?; + let uki_dir = esp.fd.open_dir(BOOTC_UKI_DIR).context("Opening UKI dir")?; + + // execute all EFI PE renames atomically before the final exchange + rename_transaction + .execute_transaction(&uki_dir) + .context("Failed to execute EFI binary rename transaction")?; + + fsync(uki_dir.reopen_as_ownedfd()?)?; + rename_exchange_user_cfg(&grub_dir)?; + } + + Bootloader::Systemd => { + handle_bls_conf(storage, booted_cfs, boot_dir, true)?; + } + + Bootloader::None => unreachable!("Checked at install time"), + }, + }; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/backwards_compat/mod.rs b/crates/lib/src/bootc_composefs/backwards_compat/mod.rs new file mode 100644 index 000000000..38fa99683 --- /dev/null +++ b/crates/lib/src/bootc_composefs/backwards_compat/mod.rs @@ -0,0 +1 @@ +pub(crate) mod bcompat_boot; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 0660cdcd7..766083c58 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod backwards_compat; pub(crate) mod boot; pub(crate) mod delete; pub(crate) mod digest; diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 3da3b5dbc..106919f04 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -25,22 +25,22 @@ use crate::{ /// Atomically rename exchange grub user.cfg with the staged version /// Performed as the last step in rollback/update/switch operation #[context("Atomically exchanging user.cfg")] -pub(crate) fn rename_exchange_user_cfg(entries_dir: &Dir) -> Result<()> { +pub(crate) fn rename_exchange_user_cfg(grub2_dir: &Dir) -> Result<()> { tracing::debug!("Atomically exchanging {USER_CFG_STAGED} and {USER_CFG}"); renameat_with( - &entries_dir, + &grub2_dir, USER_CFG_STAGED, - &entries_dir, + &grub2_dir, USER_CFG, RenameFlags::EXCHANGE, ) .context("renameat")?; tracing::debug!("Removing {USER_CFG_STAGED}"); - rustix::fs::unlinkat(&entries_dir, USER_CFG_STAGED, AtFlags::empty()).context("unlinkat")?; + rustix::fs::unlinkat(&grub2_dir, USER_CFG_STAGED, AtFlags::empty()).context("unlinkat")?; tracing::debug!("Syncing to disk"); - let entries_dir = entries_dir + let entries_dir = grub2_dir .reopen_as_ownedfd() .context("Reopening entries dir as owned fd")?; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 0988b0d61..fb6f2207c 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -15,7 +15,7 @@ use uapi_version::Version; use crate::bootc_composefs::status::ComposefsCmdline; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX}; -#[derive(Debug, PartialEq, Eq, Default)] +#[derive(Debug, PartialEq, Eq, Default, Clone)] pub enum BLSConfigType { EFI { /// The path to the EFI binary, usually a UKI @@ -38,7 +38,7 @@ pub enum BLSConfigType { /// The boot loader should present the available boot menu entries to the user in a sorted list. /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. -#[derive(Debug, Eq, PartialEq, Default)] +#[derive(Debug, Eq, PartialEq, Default, Clone)] #[non_exhaustive] pub(crate) struct BLSConfig { /// The title of the boot entry, to be displayed in the boot menu. @@ -212,6 +212,17 @@ impl BLSConfig { /// The names are stripped of our custom prefix and suffixes, so this returns the /// verity digest part of the name pub(crate) fn boot_artifact_name(&self) -> Result<&str> { + Ok(self.boot_artifact_info()?.0) + } + + /// Returns name of UKI in case of EFI config + /// Returns name of the directory containing Kernel + Initrd in case of Non-EFI config + /// + /// The names are stripped of our custom prefix and suffixes, so this returns the + /// verity digest part of the name as the first value + /// + /// The second value is a boolean indicating whether it found our custom prefix or not + pub(crate) fn boot_artifact_info(&self) -> Result<(&str, bool)> { match &self.cfg_type { BLSConfigType::EFI { efi } => { let file_name = efi @@ -228,8 +239,8 @@ impl BLSConfig { // For backwards compatibility, we don't make this prefix mandatory match without_suffix.strip_prefix(UKI_NAME_PREFIX) { - Some(no_prefix) => Ok(no_prefix), - None => Ok(without_suffix), + Some(no_prefix) => Ok((no_prefix, true)), + None => Ok((without_suffix, false)), } } @@ -244,8 +255,8 @@ impl BLSConfig { // For backwards compatibility, we don't make this prefix mandatory match dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) { - Some(dir_name_no_prefix) => Ok(dir_name_no_prefix), - None => Ok(dir_name), + Some(dir_name_no_prefix) => Ok((dir_name_no_prefix, true)), + None => Ok((dir_name, false)), } } diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 90d7e2ee2..cd7954f24 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -20,7 +20,7 @@ use crate::{ }; /// Body content of a GRUB menuentry containing parsed commands. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub(crate) struct MenuentryBody<'a> { /// Kernel modules to load pub(crate) insmod: Vec<&'a str>, @@ -76,7 +76,7 @@ impl<'a> From> for MenuentryBody<'a> { } /// A complete GRUB menuentry with title and body commands. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub(crate) struct MenuEntry<'a> { /// Display title (supports escaped quotes) pub(crate) title: String, @@ -128,6 +128,10 @@ impl<'a> MenuEntry<'a> { /// The names are stripped of our custom prefix and suffixes, so this returns /// the verity digest part of the name pub(crate) fn boot_artifact_name(&self) -> Result { + Ok(self.boot_artifact_info()?.0) + } + + pub(crate) fn boot_artifact_info(&self) -> Result<(String, bool)> { let chainloader_path = Utf8PathBuf::from(&self.body.chainloader); let file_name = chainloader_path.file_name().ok_or_else(|| { @@ -147,8 +151,8 @@ impl<'a> MenuEntry<'a> { // For backwards compatibility, we don't make this prefix mandatory match without_suffix.strip_prefix(UKI_NAME_PREFIX) { - Some(no_prefix) => Ok(no_prefix.into()), - None => Ok(without_suffix.into()), + Some(no_prefix) => Ok((no_prefix.into(), true)), + None => Ok((without_suffix.into(), false)), } } } From dab2af18ef84ff8237067cb8d39ba34d3cd5cabc Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 7 Apr 2026 14:47:02 +0530 Subject: [PATCH 09/10] composefs: Check for meta.json Check if the repo has meta.json file and if not apply our fix of prepending custom prefix to our bootloader entries and boot binaries Signed-off-by: Pragyan Poudyal Signed-off-by: Colin Walters --- .../backwards_compat/bcompat_boot.rs | 29 ++++++++++--------- crates/lib/src/store/mod.rs | 13 ++++++++- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index dd6e569c1..a27dbc7aa 100644 --- a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -7,7 +7,10 @@ use crate::{ get_efi_uuid_source, get_uki_name, parse_os_release, type1_entry_conf_file_name, }, rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}, - status::{get_bootloader, get_sorted_grub_uki_boot_entries, get_sorted_type1_boot_entries}, + status::{ + ComposefsCmdline, get_bootloader, get_sorted_grub_uki_boot_entries, + get_sorted_type1_boot_entries, + }, }, composefs_consts::{ ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX, @@ -15,7 +18,7 @@ use crate::{ }, parsers::bls_config::{BLSConfig, BLSConfigType}, spec::Bootloader, - store::{BootedComposefs, Storage}, + store::Storage, }; use anyhow::{Context, Result}; use camino::Utf8PathBuf; @@ -159,7 +162,7 @@ fn stage_bls_entry_changes( storage: &Storage, boot_dir: &Dir, entries: &Vec, - booted_cfs: &BootedComposefs, + cfs_cmdline: &ComposefsCmdline, ) -> Result<(RenameTransaction, Vec<(String, BLSConfig)>)> { let mut rename_transaction = RenameTransaction::new(); @@ -186,7 +189,7 @@ fn stage_bls_entry_changes( let mut new_entry = entry.clone(); - let conf_filename = if *booted_cfs.cmdline.digest == digest { + let conf_filename = if *cfs_cmdline.digest == digest { type1_entry_conf_file_name(os_id, new_entry.version(), FILENAME_PRIORITY_PRIMARY) } else { type1_entry_conf_file_name(os_id, new_entry.version(), FILENAME_PRIORITY_SECONDARY) @@ -240,12 +243,12 @@ fn create_staged_bls_entries(boot_dir: &Dir, entries: &Vec<(String, BLSConfig)>) fsync(staged_entries.reopen_as_ownedfd()?).context("fsync") } -fn get_boot_type(storage: &Storage, booted_cfs: &BootedComposefs) -> Result { +fn get_boot_type(storage: &Storage, cfs_cmdline: &ComposefsCmdline) -> Result { let mut config = String::new(); let origin_path = Utf8PathBuf::from(STATE_DIR_RELATIVE) - .join(&*booted_cfs.cmdline.digest) - .join(format!("{}.origin", booted_cfs.cmdline.digest)); + .join(&*cfs_cmdline.digest) + .join(format!("{}.origin", cfs_cmdline.digest)); storage .physical_root @@ -267,13 +270,13 @@ fn get_boot_type(storage: &Storage, booted_cfs: &BootedComposefs) -> Result Result<()> { let entries = get_sorted_type1_boot_entries(boot_dir, true)?; let (rename_transaction, new_bls_entries) = - stage_bls_entry_changes(storage, boot_dir, &entries, booted_cfs)?; + stage_bls_entry_changes(storage, boot_dir, &entries, cfs_cmdline)?; if rename_transaction.operations.is_empty() { tracing::debug!("Nothing to do"); @@ -309,15 +312,15 @@ fn handle_bls_conf( #[context("Prepending custom prefix to EFI and BLS entries")] pub(crate) async fn prepend_custom_prefix( storage: &Storage, - booted_cfs: &BootedComposefs, + cfs_cmdline: &ComposefsCmdline, ) -> Result<()> { let boot_dir = storage.require_boot_dir()?; let bootloader = get_bootloader()?; - match get_boot_type(storage, booted_cfs)? { + match get_boot_type(storage, cfs_cmdline)? { BootType::Bls => { - handle_bls_conf(storage, booted_cfs, boot_dir, false)?; + handle_bls_conf(storage, cfs_cmdline, boot_dir, false)?; } BootType::Uki => match bootloader { @@ -382,7 +385,7 @@ pub(crate) async fn prepend_custom_prefix( } Bootloader::Systemd => { - handle_bls_conf(storage, booted_cfs, boot_dir, true)?; + handle_bls_conf(storage, cfs_cmdline, boot_dir, true)?; } Bootloader::None => unreachable!("Checked at install time"), diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index a789a94a9..bf030e888 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -41,6 +41,7 @@ use rustix::fs::Mode; use cfsctl::composefs; use composefs::fsverity::Sha512HashValue; +use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp}; use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader}; use crate::lsm; @@ -266,6 +267,10 @@ impl BootedStorage { Bootloader::None => unreachable!("Checked at install time"), }; + let meta_json = physical_root + .open_dir(COMPOSEFS)? + .open_optional("meta.json")?; + let storage = Storage { physical_root, physical_root_path: Utf8PathBuf::from("/sysroot"), @@ -273,10 +278,16 @@ impl BootedStorage { boot_dir: Some(boot_dir), esp: Some(esp_mount), ostree: Default::default(), - composefs: OnceCell::from(composefs), + composefs: OnceCell::from(composefs.clone()), imgstore: Default::default(), }; + if meta_json.is_none() { + let cmdline = composefs_booted()? + .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?; + prepend_custom_prefix(&storage, &cmdline).await?; + } + Some(Self { storage }) } Environment::OstreeBooted => { From c30c44f203e9b93fd32407ba72c5cc989865e8f5 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 31 Mar 2026 21:32:38 +0000 Subject: [PATCH 10/10] composefs: Add harness and unit tests for shared boot entry GC Add some basic infra to mock up enough of an installed root to use in unit tests - specifically targeted for the bootloader logic. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/gc.rs | 529 ++++++++++++++- crates/lib/src/bootc_composefs/status.rs | 57 +- crates/lib/src/lib.rs | 3 + crates/lib/src/parsers/bls_config.rs | 85 +++ crates/lib/src/parsers/grub_menuconfig.rs | 106 ++- crates/lib/src/testutils.rs | 781 ++++++++++++++++++++++ 6 files changed, 1539 insertions(+), 22 deletions(-) create mode 100644 crates/lib/src/testutils.rs diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 297337675..e75c42d1f 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -19,7 +19,7 @@ use crate::{ delete::{delete_staged, delete_state_dir}, repo::bootc_tag_for_manifest, state::read_origin, - status::{get_composefs_status, list_bootloader_entries}, + status::{BootloaderEntry, get_composefs_status, list_bootloader_entries}, }, composefs_consts::{ BOOTC_TAG_PREFIX, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, STATE_DIR_RELATIVE, @@ -172,6 +172,26 @@ fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { Ok(()) } +/// Find boot binaries on disk that are not referenced by any bootloader entry. +/// +/// We compare against `boot_artifact_name` (the directory/file name on disk) +/// rather than `fsverity` (the composefs= cmdline digest), because a shared +/// entry's directory name may belong to a different deployment than the one +/// whose composefs digest is in the BLS options line. +fn unreferenced_boot_binaries<'a>( + boot_binaries: &'a [BootBinary], + bootloader_entries: &[BootloaderEntry], +) -> Vec<&'a BootBinary> { + boot_binaries + .iter() + .filter(|bin| { + !bootloader_entries + .iter() + .any(|entry| entry.boot_artifact_name == bin.1) + }) + .collect() +} + /// 1. List all bootloader entries /// 2. List all EROFS images /// 3. List all state directories @@ -214,24 +234,8 @@ pub(crate) async fn composefs_gc( tracing::debug!("bootloader_entries: {bootloader_entries:?}"); tracing::debug!("boot_binaries: {boot_binaries:?}"); - // Bootloader entry is deleted, but the binary (UKI/kernel+initrd) still exists - let unreferenced_boot_binaries = boot_binaries - .iter() - .filter(|bin_path| { - // We reuse kernel + initrd if they're the same for two deployments - // We don't want to delete the (being deleted) deployment's kernel + initrd - // if it's in use by any other deployment - // - // filter the ones that are not referenced by any bootloader entry - !bootloader_entries - .iter() - // We compare the name of directory containing the binary instead of comparing the - // fsverity digest. This is because a shared entry might differing directory - // name and fsverity digest in the cmdline. And since we want to GC the actual - // binaries, we compare with the directory name - .any(|boot_entry| boot_entry.boot_artifact_name == bin_path.1) - }) - .collect::>(); + let unreferenced_boot_binaries = + unreferenced_boot_binaries(&boot_binaries, &bootloader_entries); tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}"); @@ -450,3 +454,490 @@ pub(crate) async fn composefs_gc( Ok(gc_result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootc_composefs::status::list_type1_entries; + use crate::testutils::{ChangeType, TestRoot}; + + /// Reproduce the shared-entry GC bug from issue #2102. + /// + /// Scenario with both shared and non-shared kernels: + /// + /// 1. Install deployment A (kernel K1, boot dir "A") + /// 2. Upgrade to B, same kernel → shares A's boot dir + /// 3. Upgrade to C, new kernel K2 → gets its own boot dir "C" + /// 4. Upgrade to D, same kernel as C → shares C's boot dir + /// + /// After GC of A (the creator of boot dir used by B): + /// - A's boot dir must still exist (B references it) + /// - C's boot dir must still exist (D references it) + /// + /// The old code compared `fsverity` instead of `boot_artifact_name`, + /// which would incorrectly mark A's boot dir as unreferenced once A's + /// BLS entry is gone — even though B still points its linux/initrd + /// paths at A's directory. + #[test] + fn test_gc_shared_boot_binaries_not_deleted() -> anyhow::Result<()> { + let mut root = TestRoot::new()?; + let digest_a = root.current().verity.clone(); + + // B shares A's kernel (userspace-only change) + root.upgrade(1, ChangeType::Userspace)?; + + // C gets a new kernel + root.upgrade(2, ChangeType::Kernel)?; + let digest_c = root.current().verity.clone(); + + // D shares C's kernel (userspace-only change) + root.upgrade(3, ChangeType::Userspace)?; + let digest_d = root.current().verity.clone(); + + // Now GC deployment A — the one that *created* the shared boot dir + root.gc_deployment(&digest_a)?; + + // At this point only C (secondary) and D (primary) have BLS entries. + // But A's boot binary directory is still on disk because B used to + // share it and we haven't cleaned up boot binaries yet — that's + // what the GC filter decides. + let boot_dir = root.boot_dir()?; + + // Collect what's on disk: two boot dirs (A's and C's) + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!( + on_disk.len(), + 2, + "should have A's and C's boot dirs on disk" + ); + + // Collect what the BLS entries reference + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)"); + + // The fix: unreferenced_boot_binaries uses boot_artifact_name. + // D's boot_artifact_name points to C's dir, C's points to itself. + // A's boot dir is NOT referenced by any current BLS entry's + // boot_artifact_name (B was the one referencing it, and B is no + // longer in the BLS entries either). + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + + // A's boot dir IS unreferenced (only B used it, and B isn't in BLS anymore) + assert_eq!(unreferenced.len(), 1); + assert_eq!(unreferenced[0].1, digest_a); + + // C's boot dir is still referenced (by both C and D via boot_artifact_name) + assert!( + !unreferenced.iter().any(|b| b.1 == digest_c), + "C's boot dir must not be unreferenced" + ); + + // Now the more dangerous scenario: GC C, the creator of the boot + // dir that D shares. After this, remaining deployments are [B, D]. + // B still shares A's boot dir, D still shares C's boot dir. + root.gc_deployment(&digest_c)?; + + let mut on_disk_2 = Vec::new(); + collect_type1_boot_binaries(&root.boot_dir()?, &mut on_disk_2)?; + // A's dir + C's dir still on disk (boot binary cleanup hasn't run) + assert_eq!(on_disk_2.len(), 2); + + let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?; + // D (primary) + B (secondary) + assert_eq!(bls_entries_2.len(), 2); + + let entry_d = bls_entries_2 + .iter() + .find(|e| e.fsverity == digest_d) + .unwrap(); + assert_eq!( + entry_d.boot_artifact_name, digest_c, + "D shares C's boot dir" + ); + + let unreferenced_2 = unreferenced_boot_binaries(&on_disk_2, &bls_entries_2); + + // Both boot dirs are still referenced: + // - A's dir via B's boot_artifact_name + // - C's dir via D's boot_artifact_name + assert!( + unreferenced_2.is_empty(), + "no boot dirs should be unreferenced when both are shared" + ); + + // Prove the old buggy logic would fail: if we compared fsverity + // instead of boot_artifact_name, BOTH dirs would be wrongly + // unreferenced. Neither A nor C has a BLS entry with matching + // fsverity — only B (verity=B) and D (verity=D) exist, but their + // boot dirs are named after A and C respectively. + let buggy_unreferenced: Vec<_> = on_disk_2 + .iter() + .filter(|bin| !bls_entries_2.iter().any(|e| e.fsverity == bin.1)) + .collect(); + assert_eq!( + buggy_unreferenced.len(), + 2, + "old fsverity-based logic would incorrectly GC both boot dirs" + ); + + Ok(()) + } + + /// Verify that list_type1_entries correctly parses legacy (unprefixed) BLS + /// entries. This is the code path that composefs_gc actually uses to find + /// bootloader entries, so it's critical that it handles both layouts. + #[test] + fn test_list_type1_entries_handles_legacy_bls() -> anyhow::Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + root.upgrade(1, ChangeType::Userspace)?; + let digest_b = root.current().verity.clone(); + + let boot_dir = root.boot_dir()?; + let bls_entries = list_type1_entries(&boot_dir)?; + + assert_eq!(bls_entries.len(), 2, "Should find both BLS entries"); + + // boot_artifact_name should return the raw digest (no prefix) + // because the legacy entries don't have the prefix + for entry in &bls_entries { + assert_eq!( + entry.boot_artifact_name, digest_a, + "Both entries should reference A's boot dir (shared kernel)" + ); + } + + // fsverity should differ between the two entries + let verity_set: std::collections::HashSet<&str> = + bls_entries.iter().map(|e| e.fsverity.as_str()).collect(); + assert!(verity_set.contains(digest_a.as_str())); + assert!(verity_set.contains(digest_b.as_str())); + + Ok(()) + } + + /// Legacy (unprefixed) boot dirs are invisible to collect_type1_boot_binaries, + /// which only looks for the `bootc_composefs-` prefix. This test verifies + /// that the GC scanner does not see unprefixed directories. + /// + /// This is the problem that PR #2128 solves by migrating legacy entries + /// to the prefixed format before any GC or status operations run. + #[test] + fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> { + let root = TestRoot::new_legacy()?; + + // The legacy layout creates a boot dir without the prefix + let boot_dir = root.boot_dir()?; + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + + // collect_type1_boot_binaries requires the prefix — legacy dirs + // are invisible to it + assert!( + on_disk.is_empty(), + "Legacy (unprefixed) boot dirs should not be found by collect_type1_boot_binaries" + ); + + Ok(()) + } + + /// After migration from legacy to prefixed layout, GC should work + /// correctly — the boot binary directories become visible and + /// the BLS entries reference them properly. + #[test] + fn test_gc_works_after_legacy_migration() -> anyhow::Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + // B shares A's kernel (userspace-only change) + root.upgrade(1, ChangeType::Userspace)?; + + // C gets a new kernel + root.upgrade(2, ChangeType::Kernel)?; + + // Simulate the migration that PR #2128 performs + root.migrate_to_prefixed()?; + + // Now GC should see both boot dirs + let boot_dir = root.boot_dir()?; + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs"); + + // BLS entries should correctly reference boot artifact names + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2); + + // No boot dirs should be unreferenced (all are in use) + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + assert!( + unreferenced.is_empty(), + "All boot dirs should be referenced after migration" + ); + + // GC deployment A (the one that created the shared boot dir) + root.gc_deployment(&digest_a)?; + + let boot_dir = root.boot_dir()?; + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)"); + + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!(on_disk.len(), 2, "Both boot dirs still on disk"); + + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + // A's boot dir is still referenced by B + assert!( + unreferenced.is_empty(), + "A's boot dir should still be referenced by B after migration" + ); + + Ok(()) + } + + /// Test the full upgrade cycle with shared kernels after migration: + /// install (legacy) → migrate → upgrade → GC. + /// + /// This verifies that GC correctly handles a system that was originally + /// installed with old bootc, migrated, and then upgraded with new bootc. + #[test] + fn test_gc_post_migration_upgrade_cycle() -> anyhow::Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + // B shares A's kernel (still legacy) + root.upgrade(1, ChangeType::Userspace)?; + + // Simulate migration + root.migrate_to_prefixed()?; + + // Now upgrade with new bootc (creates prefixed entries) + root.upgrade(2, ChangeType::Kernel)?; + let digest_c = root.current().verity.clone(); + + // D shares C's kernel + root.upgrade(3, ChangeType::Userspace)?; + let digest_d = root.current().verity.clone(); + + // GC all old deployments, keeping only C and D + root.gc_deployment(&digest_a)?; + + let boot_dir = root.boot_dir()?; + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)"); + + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + // A's boot dir is unreferenced (B is gone, only C and D remain) + assert_eq!( + unreferenced.len(), + 1, + "A's boot dir should be unreferenced after GC of A and B is evicted" + ); + assert_eq!(unreferenced[0].1, digest_a); + + // C's boot dir must still be referenced by D + assert!( + !unreferenced.iter().any(|b| b.1 == digest_c), + "C's boot dir must still be referenced by D" + ); + + // Verify D shares C's boot dir + let entry_d = bls_entries + .iter() + .find(|e| e.fsverity == digest_d) + .expect("D should have a BLS entry"); + assert_eq!( + entry_d.boot_artifact_name, digest_c, + "D should share C's boot dir" + ); + + Ok(()) + } + + /// Test deep transitive sharing: A → B → C → D all share A's boot dir + /// via successive userspace-only upgrades. When we GC A (the creator + /// of the boot dir), the dir must be kept because the remaining + /// deployments still reference it. + /// + /// This tests that boot_dir_verity propagates correctly through + /// a chain of userspace-only upgrades and that the GC filter handles + /// the case where no remaining deployment's fsverity matches the + /// boot directory name. + #[test] + fn test_gc_deep_transitive_sharing_chain() -> anyhow::Result<()> { + let mut root = TestRoot::new()?; + let digest_a = root.current().verity.clone(); + + // B, C, D all share A's kernel via userspace-only upgrades + root.upgrade(1, ChangeType::Userspace)?; + root.upgrade(2, ChangeType::Userspace)?; + root.upgrade(3, ChangeType::Userspace)?; + let digest_d = root.current().verity.clone(); + + // Only one boot dir on disk (all share A's) + let boot_dir = root.boot_dir()?; + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!(on_disk.len(), 1, "All deployments share one boot dir"); + assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A"); + + // BLS entries: D (primary) + C (secondary), both referencing A's dir + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2); + for entry in &bls_entries { + assert_eq!( + entry.boot_artifact_name, digest_a, + "All entries reference A's boot dir" + ); + } + + // GC deployment A (the creator of the shared boot dir) + root.gc_deployment(&digest_a)?; + + let boot_dir = root.boot_dir()?; + let bls_entries = list_type1_entries(&boot_dir)?; + // D (primary) + C (secondary) — A was already evicted from BLS + assert_eq!(bls_entries.len(), 2); + + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + assert!( + unreferenced.is_empty(), + "A's boot dir must stay — C and D still reference it" + ); + + // Now also GC B and C, leaving only D + let digest_b = crate::testutils::fake_digest_version(1); + let digest_c = crate::testutils::fake_digest_version(2); + root.gc_deployment(&digest_b)?; + root.gc_deployment(&digest_c)?; + + // D is the only deployment left + let boot_dir = root.boot_dir()?; + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 1, "Only D remains"); + assert_eq!(bls_entries[0].fsverity, digest_d); + assert_eq!( + bls_entries[0].boot_artifact_name, digest_a, + "D still references A's boot dir" + ); + + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + assert!( + unreferenced.is_empty(), + "A's boot dir must survive — D is the last deployment and still uses it" + ); + + Ok(()) + } + + /// Verify that boot_artifact_info().1 (has_prefix) is the correct + /// signal for identifying entries that need migration, and that the + /// GC filter works correctly at each stage of the migration pipeline. + /// + /// This exercises the API that stage_bls_entry_changes() in PR #2128 + /// uses to decide which entries to migrate. + #[test] + fn test_boot_artifact_info_drives_migration_decisions() -> anyhow::Result<()> { + use crate::bootc_composefs::status::get_sorted_type1_boot_entries; + + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + root.upgrade(1, ChangeType::Userspace)?; + root.upgrade(2, ChangeType::Kernel)?; + + // -- Pre-migration: all entries lack the prefix -- + let boot_dir = root.boot_dir()?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + assert_eq!(raw_entries.len(), 2); + + let needs_migration: Vec<_> = raw_entries + .iter() + .filter(|e| !e.boot_artifact_info().unwrap().1) + .collect(); + assert_eq!( + needs_migration.len(), + 2, + "All legacy entries should need migration (has_prefix=false)" + ); + + // GC scanner can't see the boot dirs (no prefix on disk) + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert!(on_disk.is_empty(), "Legacy dirs invisible before migration"); + + // -- Migrate -- + root.migrate_to_prefixed()?; + + // -- Post-migration: all entries have the prefix -- + let boot_dir = root.boot_dir()?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + assert_eq!(raw_entries.len(), 2); + + let needs_migration: Vec<_> = raw_entries + .iter() + .filter(|e| !e.boot_artifact_info().unwrap().1) + .collect(); + assert!( + needs_migration.is_empty(), + "No entries should need migration after migrate_to_prefixed()" + ); + + // GC scanner can now see the boot dirs + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!(on_disk.len(), 2, "Both dirs visible after migration"); + + // GC filter correctly identifies all dirs as referenced + let bls_entries = list_type1_entries(&boot_dir)?; + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + assert!( + unreferenced.is_empty(), + "All dirs referenced after migration" + ); + + // -- Upgrade with new bootc (prefixed from creation) -- + root.upgrade(3, ChangeType::Kernel)?; + + let boot_dir = root.boot_dir()?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + // All entries (both migrated and new) should have the prefix + for entry in &raw_entries { + let (_, has_prefix) = entry.boot_artifact_info()?; + assert!( + has_prefix, + "All entries should have prefix after migration + upgrade" + ); + } + + // GC should now see 3 boot dirs: A's, C's (from upgrade 2), and + // the new one from upgrade 3 + let mut on_disk = Vec::new(); + collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; + assert_eq!(on_disk.len(), 3, "Three boot dirs on disk"); + + // Only 2 BLS entries (primary + secondary), so one dir is unreferenced + let bls_entries = list_type1_entries(&boot_dir)?; + assert_eq!(bls_entries.len(), 2); + let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); + assert_eq!( + unreferenced.len(), + 1, + "A's boot dir should be unreferenced (B evicted from BLS)" + ); + assert_eq!(unreferenced[0].1, digest_a); + + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 074452521..540914b45 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -285,7 +285,7 @@ fn get_sorted_type1_boot_entries_helper( Ok(all_configs) } -fn list_type1_entries(boot_dir: &Dir) -> Result> { +pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result> { // Type1 Entry let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; @@ -1130,4 +1130,59 @@ mod tests { let result = ComposefsCmdline::find_in_cmdline(&cmdline); assert!(result.is_none()); } + + use crate::testutils::fake_digest_version; + + /// Test that staged entries are also collected by list_type1_entries. + /// This is important for GC to not delete staged deployments' boot binaries. + #[test] + fn test_list_type1_entries_includes_staged() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let digest_active = fake_digest_version(0); + let digest_staged = fake_digest_version(1); + + let active_entry = format!( + r#" + title Active Deployment + version 2 + sort-key 1 + linux /boot/bootc_composefs-{digest_active}/vmlinuz + initrd /boot/bootc_composefs-{digest_active}/initramfs.img + options root=UUID=abc123 rw composefs={digest_active} + "# + ); + + let staged_entry = format!( + r#" + title Staged Deployment + version 3 + sort-key 0 + linux /boot/bootc_composefs-{digest_staged}/vmlinuz + initrd /boot/bootc_composefs-{digest_staged}/initramfs.img + options root=UUID=abc123 rw composefs={digest_staged} + "# + ); + + tempdir.create_dir_all("loader/entries")?; + tempdir.create_dir_all("loader/entries.staged")?; + tempdir.atomic_write("loader/entries/active.conf", active_entry)?; + tempdir.atomic_write("loader/entries.staged/staged.conf", staged_entry)?; + + let result = list_type1_entries(&tempdir)?; + assert_eq!(result.len(), 2); + + let verity_set: std::collections::HashSet<&str> = + result.iter().map(|e| e.fsverity.as_str()).collect(); + assert!( + verity_set.contains(digest_active.as_str()), + "Should contain active entry" + ); + assert!( + verity_set.contains(digest_staged.as_str()), + "Should contain staged entry" + ); + + Ok(()) + } } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 0152b7016..d16f3bb9c 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -97,6 +97,9 @@ mod task; mod ukify; mod utils; +#[cfg(test)] +pub(crate) mod testutils; + #[cfg(feature = "docgen")] mod cli_json; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index fb6f2207c..d86bd508e 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -741,4 +741,89 @@ mod tests { .contains("missing file name") ); } + + #[test] + fn test_boot_artifact_name_unknown_type() { + let config = BLSConfig { + cfg_type: BLSConfigType::Unknown, + version: "1".to_string(), + ..Default::default() + }; + + let result = config.boot_artifact_name(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("unknown config type") + ); + } + #[test] + fn test_boot_artifact_name_efi_nested_path() -> Result<()> { + let efi_path = Utf8PathBuf::from("/EFI/Linux/bootc/bootc_composefs-deadbeef01234567.efi"); + let config = BLSConfig { + cfg_type: BLSConfigType::EFI { efi: efi_path }, + version: "1".to_string(), + ..Default::default() + }; + + assert_eq!(config.boot_artifact_name()?, "deadbeef01234567"); + Ok(()) + } + + #[test] + fn test_boot_artifact_name_non_efi_deep_path() -> Result<()> { + // Realistic Type1 path: /boot/bootc_composefs-/vmlinuz + let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + let linux_path = Utf8PathBuf::from(format!("/boot/bootc_composefs-{digest}/vmlinuz")); + let config = BLSConfig { + cfg_type: BLSConfigType::NonEFI { + linux: linux_path, + initrd: vec![], + options: None, + }, + version: "1".to_string(), + ..Default::default() + }; + + assert_eq!(config.boot_artifact_name()?, digest); + Ok(()) + } + + /// Test boot_artifact_name from parsed EFI config + #[test] + fn test_boot_artifact_name_from_parsed_efi_config() -> Result<()> { + let digest = "f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346"; + let input = format!( + r#" + title Fedora UKI + version 1 + efi /EFI/Linux/bootc/bootc_composefs-{digest}.efi + sort-key bootc-fedora-0 + "# + ); + + let config = parse_bls_config(&input)?; + assert_eq!(config.boot_artifact_name()?, digest); + assert_eq!(config.get_verity()?, digest); + Ok(()) + } + + /// Test that Non-EFI boot_artifact_name fails when linux path has no parent + #[test] + fn test_boot_artifact_name_non_efi_no_parent() { + let config = BLSConfig { + cfg_type: BLSConfigType::NonEFI { + linux: Utf8PathBuf::from("vmlinuz"), + initrd: vec![], + options: None, + }, + version: "1".to_string(), + ..Default::default() + }; + + let result = config.boot_artifact_name(); + assert!(result.is_err()); + } } diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index cd7954f24..b01e87e54 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -583,7 +583,7 @@ mod test { fn test_menuentry_boot_artifact_name_success() { let body = MenuentryBody { insmod: vec!["fat", "chain"], - chainloader: "/EFI/bootc_composefs/bootc_composefs-abcd1234.efi".to_string(), + chainloader: "/EFI/Linux/bootc/bootc_composefs-abcd1234.efi".to_string(), search: "--no-floppy --set=root --fs-uuid test", version: 0, extra: vec![], @@ -625,7 +625,7 @@ mod test { fn test_menuentry_boot_artifact_name_missing_suffix() { let body = MenuentryBody { insmod: vec!["fat", "chain"], - chainloader: "/EFI/bootc_composefs/bootc_composefs-abcd1234".to_string(), + chainloader: "/EFI/Linux/bootc/bootc_composefs-abcd1234".to_string(), search: "--no-floppy --set=root --fs-uuid test", version: 0, extra: vec![], @@ -645,4 +645,106 @@ mod test { .contains("missing expected suffix") ); } + + #[test] + fn test_menuentry_boot_artifact_name_empty_chainloader() { + let body = MenuentryBody { + insmod: vec![], + chainloader: "".to_string(), + search: "", + version: 0, + extra: vec![], + }; + + let entry = MenuEntry { + title: "Empty".to_string(), + body, + }; + + let result = entry.boot_artifact_name(); + assert!(result.is_err()); + } + + /// Test that boot_artifact_name and get_verity return the same value + /// for a standard UKI entry. + /// + /// Note: GRUB/UKI entries always have matching boot_artifact_name and + /// get_verity because both derive from the same chainloader path. The + /// shared-entry divergence (where boot_artifact_name != get_verity) only + /// applies to Type1 BLS entries, which have separate linux path and + /// composefs= cmdline parameter. + #[test] + fn test_menuentry_boot_artifact_name_matches_get_verity() { + let digest = "f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346"; + + let body = MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: format!("/EFI/Linux/bootc/bootc_composefs-{digest}.efi"), + search: "--no-floppy --set=root --fs-uuid test", + version: 0, + extra: vec![], + }; + + let entry = MenuEntry { + title: "Test".to_string(), + body, + }; + + let artifact_name = entry.boot_artifact_name().unwrap(); + let verity = entry.get_verity().unwrap(); + assert_eq!(artifact_name, verity); + assert_eq!(artifact_name, digest); + } + + /// Test boot_artifact_name with realistic full-length hex digest + #[test] + fn test_menuentry_boot_artifact_name_full_digest() { + let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + + let body = MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: format!("/EFI/Linux/bootc/bootc_composefs-{digest}.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }; + + let entry = MenuEntry { + title: format!("Fedora Bootc UKI: ({digest})"), + body, + }; + + assert_eq!(entry.boot_artifact_name().unwrap(), digest); + } + + /// Test boot_artifact_name via MenuEntry::new constructor + #[test] + fn test_menuentry_new_boot_artifact_name() { + let uki_id = "abc123def456"; + let entry = MenuEntry::new("Fedora 42", uki_id); + + assert_eq!(entry.boot_artifact_name().unwrap(), uki_id); + assert_eq!(entry.get_verity().unwrap(), uki_id); + } + + /// Test boot_artifact_name from a parsed grub config + #[test] + fn test_menuentry_boot_artifact_name_from_parsed() { + let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + let menuentry = format!( + r#" + menuentry "Fedora 42: ({digest})" {{ + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${{EFI_PART_UUID}}" + chainloader /EFI/Linux/bootc/bootc_composefs-{digest}.efi + }} + "# + ); + + let result = parse_grub_menuentry_file(&menuentry).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].boot_artifact_name().unwrap(), digest); + assert_eq!(result[0].get_verity().unwrap(), digest); + } } diff --git a/crates/lib/src/testutils.rs b/crates/lib/src/testutils.rs new file mode 100644 index 000000000..a3ec5645f --- /dev/null +++ b/crates/lib/src/testutils.rs @@ -0,0 +1,781 @@ +//! Test infrastructure for simulating a composefs BLS Type1 sysroot. +//! +//! Provides [`TestRoot`] which creates a realistic sysroot filesystem layout +//! suitable for unit-testing the GC, status, and boot entry logic without +//! requiring a real booted system. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::{self, fs::Dir}; +use cap_std_ext::cap_tempfile; +use cap_std_ext::dirext::CapStdExtDirExt; + +use crate::bootc_composefs::boot::{ + FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, get_type1_dir_name, primary_sort_key, + secondary_sort_key, type1_entry_conf_file_name, +}; +use crate::composefs_consts::{ + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, + TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, +}; +use crate::parsers::bls_config::{BLSConfig, parse_bls_config}; +use crate::store::ComposefsRepository; + +use ostree_ext::container::deploy::ORIGIN_CONTAINER; + +/// Return a deterministic SHA-256 hex digest for a test build version. +/// +/// Computes `sha256("build-{n}")`, producing a realistic 64-char hex digest +/// that is stable across runs. +pub(crate) fn fake_digest_version(n: u32) -> String { + let hash = openssl::hash::hash( + openssl::hash::MessageDigest::sha256(), + format!("build-{n}").as_bytes(), + ) + .expect("sha256"); + hex::encode(hash) +} + +/// What changed in an upgrade. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub(crate) enum ChangeType { + /// Only userspace changed; kernel+initrd are identical, so the new + /// deployment shares the previous deployment's boot binary directory. + Userspace, + /// The kernel (and/or initrd) changed, so the new deployment gets + /// its own boot binary directory. + Kernel, + /// Both userspace and kernel changed. New boot binary directory and + /// new composefs image. + Both, +} + +/// Controls whether TestRoot writes boot entries in the current (prefixed) +/// or legacy (unprefixed) format. +/// +/// Older versions of bootc didn't prefix boot binary directories or UKI +/// filenames with `bootc_composefs-`. PR #2128 adds a migration path that +/// renames these on first run. This enum lets tests simulate both layouts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] +pub(crate) enum LayoutMode { + /// Current layout: directories named `bootc_composefs-`, + /// BLS linux paths reference the prefixed directory name. + Current, + /// Legacy layout: directories named with just the raw ``, + /// BLS linux paths reference the unprefixed directory name. + /// This simulates a system installed with an older bootc. + Legacy, +} + +/// Metadata for a single simulated deployment. +#[derive(Clone, Debug)] +pub(crate) struct DeploymentMeta { + /// The deployment's composefs verity digest (what goes in `composefs=`). + pub verity: String, + /// SHA256 digest of the vmlinuz+initrd pair. + pub boot_digest: String, + /// The name of the boot binary directory (verity portion only, no prefix). + /// This equals `verity` for the deployment that created the directory, + /// but may point to a different deployment's directory for shared entries. + pub boot_dir_verity: String, + /// The container image reference stored in the origin file. + pub imgref: String, + /// OS identifier for BLS entry naming. + pub os_id: String, + /// Version string for BLS entry. + pub version: String, +} + +/// A simulated composefs BLS Type1 sysroot for testing. +/// +/// Creates the filesystem layout that the GC, status, and boot entry code +/// expects: +/// +/// ```text +/// / +/// ├── composefs/ # composefs repo (objects/, images/, streams/) +/// │ └── images/ +/// │ └── # one file per deployed image +/// ├── state/deploy/ +/// │ └── / +/// │ ├── .origin +/// │ └── etc/ +/// └── boot/ +/// ├── bootc_composefs-/ +/// │ ├── vmlinuz +/// │ └── initrd +/// └── loader/entries/ +/// └── *.conf +/// ``` +pub(crate) struct TestRoot { + /// The root Dir — equivalent to `Storage.physical_root`. + /// Also owns the tempdir lifetime. + root: cap_tempfile::TempDir, + /// Deployments added so far, in order. + deployments: Vec, + /// Composefs repository handle. + repo: Arc, + /// Whether to write entries in the current (prefixed) or legacy format. + layout: LayoutMode, +} + +impl TestRoot { + /// Create a new test sysroot with one initial deployment (the "install"). + /// + /// The deployment gets: + /// - An EROFS image entry in `composefs/images/` + /// - A state directory with a `.origin` file + /// - A boot binary directory with vmlinuz + initrd + /// - A primary BLS Type1 entry in `loader/entries/` + pub fn new() -> Result { + Self::with_layout(LayoutMode::Current) + } + + /// Create a new test sysroot using the legacy (unprefixed) layout. + /// + /// This simulates a system installed with an older version of bootc + /// that didn't prefix boot binary directories with `bootc_composefs-`. + /// Useful for testing the backwards compatibility migration from PR #2128. + #[allow(dead_code)] + pub fn new_legacy() -> Result { + Self::with_layout(LayoutMode::Legacy) + } + + /// Create a test sysroot with the specified layout mode. + fn with_layout(layout: LayoutMode) -> Result { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + + // Create the composefs repo directory structure + root.create_dir_all("composefs") + .context("Creating composefs/")?; + root.create_dir_all("composefs/images") + .context("Creating composefs/images/")?; + + // Create the state directory + root.create_dir_all(STATE_DIR_RELATIVE) + .context("Creating state/deploy/")?; + + // Create the boot directory with loader/entries + root.create_dir_all(&format!("boot/{TYPE1_ENT_PATH}")) + .context("Creating boot/loader/entries/")?; + + // Initialize the composefs repo (creates meta.json) + let repo_dir = root.open_dir("composefs")?; + let (mut repo, _created) = ComposefsRepository::init_path( + &repo_dir, + ".", + cfsctl::composefs::fsverity::Algorithm::SHA512, + false, + ) + .context("Initializing composefs repo")?; + repo.set_insecure(); + + let mut test_root = Self { + root, + deployments: Vec::new(), + repo: Arc::new(repo), + layout, + }; + + // Add an initial deployment (version 0) + let meta = DeploymentMeta { + verity: fake_digest_version(0), + boot_digest: fake_digest_version(0), + boot_dir_verity: fake_digest_version(0), + imgref: "oci:quay.io/test/image:latest".into(), + os_id: "fedora".into(), + version: "42.20250101.0".into(), + }; + test_root.add_deployment(&meta, true)?; + + Ok(test_root) + } + + /// Access the root directory (equivalent to `Storage.physical_root`). + #[allow(dead_code)] + pub fn root(&self) -> &Dir { + &self.root + } + + /// Access the boot directory (equivalent to `Storage.boot_dir`). + pub fn boot_dir(&self) -> Result { + self.root.open_dir("boot").context("Opening boot/") + } + + /// Access the composefs repository. + #[allow(dead_code)] + pub fn repo(&self) -> &Arc { + &self.repo + } + + /// The most recently added deployment. + pub fn current(&self) -> &DeploymentMeta { + self.deployments.last().expect("at least one deployment") + } + + /// All deployments, oldest first. + #[allow(dead_code)] + pub fn deployments(&self) -> &[DeploymentMeta] { + &self.deployments + } + + /// Simulate an upgrade: adds a new deployment as the primary boot entry. + /// + /// The previous primary becomes the secondary. `change` controls whether + /// the kernel changed: + /// + /// - [`ChangeType::Userspace`]: only userspace changed, so the new + /// deployment shares the previous deployment's boot binary directory. + /// This is the scenario that triggers the GC bug from issue #2102. + /// - [`ChangeType::Kernel`]: the kernel+initrd changed, so a new boot + /// binary directory is created. + pub fn upgrade(&mut self, version: u32, change: ChangeType) -> Result<&DeploymentMeta> { + let prev = self.current().clone(); + + let new_verity = fake_digest_version(version); + let (boot_dir_verity, boot_digest) = match change { + ChangeType::Userspace => (prev.boot_dir_verity.clone(), prev.boot_digest.clone()), + ChangeType::Kernel | ChangeType::Both => { + let new_boot_digest = fake_digest_version(version); + (new_verity.clone(), new_boot_digest) + } + }; + + let meta = DeploymentMeta { + verity: new_verity, + boot_digest, + boot_dir_verity, + imgref: prev.imgref.clone(), + os_id: prev.os_id.clone(), + version: format!("4{}.20250201.0", self.deployments.len() + 1), + }; + + self.add_deployment(&meta, false)?; + + // Rewrite loader/entries/ to have the new primary + old secondary + self.rewrite_bls_entries()?; + + Ok(self.deployments.last().unwrap()) + } + + /// Simulate GC of the oldest deployment: removes its EROFS image, state + /// dir, and BLS entry, but leaves boot binaries alone (the real GC + /// decides whether to remove them based on `boot_artifact_name`). + pub fn gc_deployment(&mut self, verity: &str) -> Result<()> { + // Remove EROFS image + let images_dir = self.root.open_dir("composefs/images")?; + images_dir + .remove_file(verity) + .with_context(|| format!("Removing image {verity}"))?; + + // Remove state directory + self.root + .remove_dir_all(format!("{STATE_DIR_RELATIVE}/{verity}")) + .with_context(|| format!("Removing state dir for {verity}"))?; + + // Remove from our tracking list + self.deployments.retain(|d| d.verity != verity); + + // Rewrite BLS entries for remaining deployments + self.rewrite_bls_entries()?; + + Ok(()) + } + + /// Add a deployment: creates image, state dir, boot binaries, and BLS entry. + fn add_deployment(&mut self, meta: &DeploymentMeta, is_initial: bool) -> Result<()> { + self.write_erofs_image(&meta.verity)?; + self.write_state_dir(meta)?; + self.write_boot_binaries(&meta.boot_dir_verity)?; + + self.deployments.push(meta.clone()); + + if is_initial { + self.rewrite_bls_entries()?; + } + + Ok(()) + } + + /// Create a placeholder file in composefs/images/ for this deployment. + fn write_erofs_image(&self, verity: &str) -> Result<()> { + let images_dir = self.root.open_dir("composefs/images")?; + images_dir.atomic_write(verity, b"erofs-placeholder")?; + Ok(()) + } + + /// Create the state directory with a .origin file. + fn write_state_dir(&self, meta: &DeploymentMeta) -> Result<()> { + let state_path = format!("{STATE_DIR_RELATIVE}/{}", meta.verity); + self.root.create_dir_all(format!("{state_path}/etc"))?; + + // tini merges items under the same section name, so the repeated + // .section(ORIGIN_KEY_BOOT) calls produce a single [boot] section + // with both keys. This matches how state.rs writes the origin file. + let origin = tini::Ini::new() + .section("origin") + .item( + ORIGIN_CONTAINER, + format!("ostree-unverified-image:{}", meta.imgref), + ) + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_TYPE, "bls") + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_DIGEST, &meta.boot_digest); + + let state_dir = self.root.open_dir(&state_path)?; + state_dir.atomic_write( + format!("{}.origin", meta.verity), + origin.to_string().as_bytes(), + )?; + + Ok(()) + } + + /// Return the boot binary directory name for a given verity digest, + /// respecting the current layout mode. + fn boot_binary_dir_name(&self, boot_dir_verity: &str) -> String { + match self.layout { + LayoutMode::Current => get_type1_dir_name(boot_dir_verity), + LayoutMode::Legacy => boot_dir_verity.to_string(), + } + } + + /// Create the boot binary directory with vmlinuz + initrd. + /// Skips if the directory already exists (shared entry case). + fn write_boot_binaries(&self, boot_dir_verity: &str) -> Result<()> { + let dir_name = self.boot_binary_dir_name(boot_dir_verity); + let path = format!("boot/{dir_name}"); + + if self.root.exists(&path) { + return Ok(()); + } + + self.root.create_dir_all(&path)?; + let boot_bin_dir = self.root.open_dir(&path)?; + boot_bin_dir.atomic_write("vmlinuz", b"fake-kernel")?; + boot_bin_dir.atomic_write("initrd", b"fake-initrd")?; + Ok(()) + } + + /// Rewrite the BLS entries in loader/entries/ to match current deployments. + /// + /// The last deployment is primary, the second-to-last (if any) is secondary. + fn rewrite_bls_entries(&self) -> Result<()> { + let entries_dir = self.root.open_dir(&format!("boot/{TYPE1_ENT_PATH}"))?; + + // Remove all existing .conf files + for entry in entries_dir.entries()? { + let entry = entry?; + let name = entry.file_name(); + if name.to_string_lossy().ends_with(".conf") { + entries_dir.remove_file(name)?; + } + } + + let n = self.deployments.len(); + if n == 0 { + return Ok(()); + } + + // Primary = most recent deployment + let primary = &self.deployments[n - 1]; + let primary_conf = self.build_bls_config(primary, true); + let primary_fname = + type1_entry_conf_file_name(&primary.os_id, &primary.version, FILENAME_PRIORITY_PRIMARY); + entries_dir.atomic_write(&primary_fname, primary_conf.as_bytes())?; + + // Secondary = previous deployment (if exists) + if n >= 2 { + let secondary = &self.deployments[n - 2]; + let secondary_conf = self.build_bls_config(secondary, false); + let secondary_fname = type1_entry_conf_file_name( + &secondary.os_id, + &secondary.version, + FILENAME_PRIORITY_SECONDARY, + ); + entries_dir.atomic_write(&secondary_fname, secondary_conf.as_bytes())?; + } + + Ok(()) + } + + /// Build a BLS .conf file body for a deployment. + fn build_bls_config(&self, meta: &DeploymentMeta, is_primary: bool) -> String { + let dir_name = self.boot_binary_dir_name(&meta.boot_dir_verity); + let sort_key = if is_primary { + primary_sort_key(&meta.os_id) + } else { + secondary_sort_key(&meta.os_id) + }; + + format!( + "title {os_id} {version}\n\ + version {version}\n\ + sort-key {sort_key}\n\ + linux /boot/{dir_name}/vmlinuz\n\ + initrd /boot/{dir_name}/initrd\n\ + options root=UUID=test-uuid rw composefs={verity}\n", + os_id = meta.os_id, + version = meta.version, + sort_key = sort_key, + dir_name = dir_name, + verity = meta.verity, + ) + } + + /// Parse the current BLS entries from disk and return them. + #[allow(dead_code)] + pub fn read_bls_entries(&self) -> Result> { + let boot_dir = self.boot_dir()?; + let entries_dir = boot_dir.open_dir(TYPE1_ENT_PATH)?; + + let mut configs = Vec::new(); + for entry in entries_dir.entries()? { + let entry = entry?; + let name = entry.file_name(); + if !name.to_string_lossy().ends_with(".conf") { + continue; + } + let contents = entries_dir.read_to_string(&name)?; + configs.push(parse_bls_config(&contents)?); + } + + configs.sort(); + Ok(configs) + } + + /// List EROFS image names present in composefs/images/. + #[allow(dead_code)] + pub fn list_images(&self) -> Result> { + let images_dir = self.root.open_dir("composefs/images")?; + let mut names = Vec::new(); + for entry in images_dir.entries()? { + let entry = entry?; + let name = entry.file_name(); + names.push(name.to_string_lossy().into_owned()); + } + names.sort(); + Ok(names) + } + + /// List state directory names present in state/deploy/. + #[allow(dead_code)] + pub fn list_state_dirs(&self) -> Result> { + let state = self.root.open_dir(STATE_DIR_RELATIVE)?; + let mut names = Vec::new(); + for entry in state.entries()? { + let entry = entry?; + if entry.file_type()?.is_dir() { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + names.sort(); + Ok(names) + } + + /// List boot binary directories (stripped of any prefix). + /// + /// In `Current` mode, strips `TYPE1_BOOT_DIR_PREFIX`; in `Legacy` mode, + /// returns directory names that look like hex digests directly. + #[allow(dead_code)] + pub fn list_boot_binaries(&self) -> Result> { + let boot_dir = self.boot_dir()?; + let mut names = Vec::new(); + for entry in boot_dir.entries()? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + // Skip non-boot directories like "loader" + if name == "loader" { + continue; + } + match self.layout { + LayoutMode::Current => { + if let Some(verity) = name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) { + names.push(verity.to_string()); + } + } + LayoutMode::Legacy => { + // Legacy dirs are just the raw hex digest (64 chars). + // Only include entries that look like hex digests to + // avoid accidentally counting "loader" or other dirs. + if name.len() == 64 && name.chars().all(|c| c.is_ascii_hexdigit()) { + names.push(name); + } + } + } + } + names.sort(); + Ok(names) + } + + /// Simulate the backwards compatibility migration: rename all legacy + /// (unprefixed) boot binary directories to use the `bootc_composefs-` + /// prefix, and rewrite BLS entries to reference the new paths. + /// + /// This mirrors what `prepend_custom_prefix()` from PR #2128 does. + #[allow(dead_code)] + pub fn migrate_to_prefixed(&mut self) -> Result<()> { + anyhow::ensure!( + self.layout == LayoutMode::Legacy, + "migrate_to_prefixed only makes sense for legacy layouts" + ); + + let boot_dir = self.boot_dir()?; + + // Rename all unprefixed boot binary directories + let mut to_rename = Vec::new(); + for entry in boot_dir.entries()? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "loader" { + continue; + } + // Rename directories that look like bare hex digests + // (the legacy format). This is intentionally simplified + // compared to the real migration in PR #2128 which also + // handles UKI PE files and GRUB configs. + if !name.starts_with(TYPE1_BOOT_DIR_PREFIX) + && name.len() == 64 + && name.chars().all(|c| c.is_ascii_hexdigit()) + { + to_rename.push(name); + } + } + + for old_name in &to_rename { + let new_name = format!("{TYPE1_BOOT_DIR_PREFIX}{old_name}"); + rustix::fs::renameat(&boot_dir, old_name.as_str(), &boot_dir, new_name.as_str()) + .with_context(|| format!("Renaming {old_name} -> {new_name}"))?; + } + + // Switch to current mode and rewrite BLS entries + self.layout = LayoutMode::Current; + self.rewrite_bls_entries()?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Smoke test: verify TestRoot creates a valid sysroot layout. + #[test] + fn test_initial_install() -> Result<()> { + let root = TestRoot::new()?; + + let depl = root.current(); + assert_eq!(depl.verity, fake_digest_version(0)); + + // All three storage areas should have exactly one entry + assert_eq!(root.list_images()?.len(), 1); + assert_eq!(root.list_state_dirs()?.len(), 1); + assert_eq!(root.list_boot_binaries()?.len(), 1); + + // BLS entry should round-trip through the parser correctly + let entries = root.read_bls_entries()?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].get_verity()?, depl.verity); + assert_eq!(entries[0].boot_artifact_name()?, depl.verity); + + Ok(()) + } + + /// Verify that the legacy layout creates unprefixed boot directories + /// and BLS entries that reference unprefixed paths. + #[test] + fn test_legacy_layout_creates_unprefixed_dirs() -> Result<()> { + let root = TestRoot::new_legacy()?; + let depl = root.current(); + + // Boot binary directory should be the raw digest, no prefix + let boot_dir = root.boot_dir()?; + let expected_dir = &depl.verity; + assert!( + boot_dir.exists(expected_dir), + "Legacy layout should create unprefixed dir {expected_dir}" + ); + + // The prefixed version should NOT exist + let prefixed_dir = format!("{TYPE1_BOOT_DIR_PREFIX}{}", depl.verity); + assert!( + !boot_dir.exists(&prefixed_dir), + "Legacy layout should NOT create prefixed dir {prefixed_dir}" + ); + + // BLS entry should parse and return the correct verity digest. + // boot_artifact_name() handles legacy entries by returning the raw + // dir name when the prefix is absent. + let entries = root.read_bls_entries()?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].get_verity()?, depl.verity); + assert_eq!(entries[0].boot_artifact_name()?, depl.verity); + + Ok(()) + } + + /// Verify that legacy layout with multiple deployments (shared kernel + /// scenario) works correctly. + #[test] + fn test_legacy_layout_shared_kernel() -> Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + // B shares A's kernel + root.upgrade(1, ChangeType::Userspace)?; + let digest_b = root.current().verity.clone(); + + // Should still have only one boot binary dir (shared) + let boot_bins = root.list_boot_binaries()?; + assert_eq!(boot_bins.len(), 1); + assert_eq!(boot_bins[0], digest_a); + + // Both BLS entries should reference A's boot dir via boot_artifact_name + let entries = root.read_bls_entries()?; + assert_eq!(entries.len(), 2); + for entry in &entries { + assert_eq!( + entry.boot_artifact_name()?, + digest_a, + "Both entries should point to A's boot dir" + ); + } + + // But they should have different composefs= verity digests + let verity_set: std::collections::HashSet = + entries.iter().map(|e| e.get_verity().unwrap()).collect(); + assert!(verity_set.contains(&digest_a)); + assert!(verity_set.contains(&digest_b)); + + Ok(()) + } + + /// Verify that migrate_to_prefixed renames directories and rewrites + /// BLS entries correctly. + #[test] + fn test_migrate_to_prefixed() -> Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + // Add a second deployment with a new kernel + root.upgrade(1, ChangeType::Kernel)?; + let digest_b = root.current().verity.clone(); + + // Before migration: unprefixed dirs + let boot_dir = root.boot_dir()?; + assert!(boot_dir.exists(&digest_a)); + assert!(boot_dir.exists(&digest_b)); + + // Perform migration + root.migrate_to_prefixed()?; + + // After migration: prefixed dirs + let boot_dir = root.boot_dir()?; + let prefixed_a = format!("{TYPE1_BOOT_DIR_PREFIX}{digest_a}"); + let prefixed_b = format!("{TYPE1_BOOT_DIR_PREFIX}{digest_b}"); + assert!( + boot_dir.exists(&prefixed_a), + "After migration, {prefixed_a} should exist" + ); + assert!( + boot_dir.exists(&prefixed_b), + "After migration, {prefixed_b} should exist" + ); + assert!( + !boot_dir.exists(&digest_a), + "After migration, unprefixed {digest_a} should be gone" + ); + assert!( + !boot_dir.exists(&digest_b), + "After migration, unprefixed {digest_b} should be gone" + ); + + // BLS entries should now reference the prefixed paths + let entries = root.read_bls_entries()?; + assert_eq!(entries.len(), 2); + for entry in &entries { + let artifact = entry.boot_artifact_name()?; + assert!( + artifact == digest_a || artifact == digest_b, + "boot_artifact_name should strip the prefix and return the digest" + ); + } + + Ok(()) + } + + /// Verify that boot_artifact_info() returns has_prefix=false for legacy + /// entries, which is the signal the migration code uses to decide what + /// needs renaming. After migration, has_prefix should be true. + #[test] + fn test_boot_artifact_info_prefix_detection() -> Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + root.upgrade(1, ChangeType::Kernel)?; + + // Legacy entries: boot_artifact_info should report has_prefix=false + let entries = root.read_bls_entries()?; + for entry in &entries { + let (digest, has_prefix) = entry.boot_artifact_info()?; + assert!( + !has_prefix, + "Legacy entry for {digest} should have has_prefix=false" + ); + } + + // Migrate to prefixed + root.migrate_to_prefixed()?; + + // Current entries: boot_artifact_info should report has_prefix=true + let entries = root.read_bls_entries()?; + for entry in &entries { + let (digest, has_prefix) = entry.boot_artifact_info()?; + assert!( + has_prefix, + "Migrated entry for {digest} should have has_prefix=true" + ); + } + + // boot_artifact_name() should return the same digest in both cases + let migrated_digests: std::collections::HashSet<&str> = entries + .iter() + .map(|e| e.boot_artifact_name().unwrap()) + .collect(); + assert!(migrated_digests.contains(digest_a.as_str())); + + Ok(()) + } + + /// Verify that boot_artifact_info() works correctly in a shared-kernel + /// scenario with legacy layout. Both entries should report has_prefix=false + /// and the same boot_artifact_name (the shared directory). + #[test] + fn test_boot_artifact_info_shared_kernel_legacy() -> Result<()> { + let mut root = TestRoot::new_legacy()?; + let digest_a = root.current().verity.clone(); + + root.upgrade(1, ChangeType::Userspace)?; + + let entries = root.read_bls_entries()?; + assert_eq!(entries.len(), 2); + + for entry in &entries { + let (digest, has_prefix) = entry.boot_artifact_info()?; + assert!(!has_prefix, "Legacy shared entry should have no prefix"); + assert_eq!(digest, digest_a, "Both should share A's boot dir"); + } + + Ok(()) + } +}