diff --git a/tools/tailor/Cargo.lock b/tools/tailor/Cargo.lock index 54f782a55..19f71bea5 100644 --- a/tools/tailor/Cargo.lock +++ b/tools/tailor/Cargo.lock @@ -1992,6 +1992,7 @@ dependencies = [ "tailor-core", "tempfile", "tracing", + "xxhash-rust", ] [[package]] diff --git a/tools/tailor/crates/tailor-config/src/fragment.rs b/tools/tailor/crates/tailor-config/src/fragment.rs index 1fdc993f5..e899e9125 100644 --- a/tools/tailor/crates/tailor-config/src/fragment.rs +++ b/tools/tailor/crates/tailor-config/src/fragment.rs @@ -18,7 +18,7 @@ use serde_yaml_ng::Value; use crate::{ error::ConfigError, - schema::{AxisValues, ExtraParam}, + schema::{AxisValues, ExtraParam, SigningRef}, types::ParamValue, }; @@ -45,6 +45,9 @@ pub(crate) struct Fragment { /// (base → most-specific), mirroring `rpmSources`. #[serde(default)] pub(crate) extra_params: Vec, + /// Opt a matched cell into a workspace signing profile. + #[serde(default)] + pub(crate) signing: Option, /// Drop cells this fragment applies to from bulk selection unless the run pins the fragment's /// value or names the cell (`meta/docs/2026-07-22-fragment-skip.md`). Mergeable last-wins; a /// more-specific fragment may set `false` to un-skip. diff --git a/tools/tailor/crates/tailor-config/src/render.rs b/tools/tailor/crates/tailor-config/src/render.rs index 2295fb477..53b40a533 100644 --- a/tools/tailor/crates/tailor-config/src/render.rs +++ b/tools/tailor/crates/tailor-config/src/render.rs @@ -1,882 +1,1013 @@ -//! The per-cell render pipeline: gather matched fragments, resolve `$include`, merge the config and -//! tailor fields, interpolate `${…}`, and emit one runnable cell (`meta/docs/2026-06-22-image-definitions.md` §7, -//! §9.3). Pure and deterministic, so the emitted config is a stable golden snapshot. - -use std::{ - collections::BTreeMap, - fs, - path::{Path, PathBuf}, -}; - -use indexmap::IndexMap; -use serde::de::DeserializeOwned; -use serde_yaml_ng::{Mapping, Value}; - -use crate::{ - error::ConfigError, - fragment::{self, LoadedFragment}, - include, interpolate, matrix, - matrix::AxisTuple, - merge, - schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec}, - types::{OutputFormat, ParamValue}, -}; - -const BASE_FIELD: &str = "base"; -const OUTPUTS_FIELD: &str = "outputs"; -const RENDERED_DIR: &str = ".rendered"; - -/// Write a cell's normalized golden snapshot to `/.rendered/.yaml` (for review and -/// CI blast-radius diffing, `meta/docs/2026-06-22-image-definitions.md` §9.3). Returns the written path. -pub fn write_golden( - image_dir: &Path, - slug: &str, - ic_config: &Value, -) -> Result { - let dir = image_dir.join(RENDERED_DIR); - fs::create_dir_all(&dir).map_err(|source| ConfigError::Write { - path: dir.clone(), - source, - })?; - let path = dir.join(format!("{slug}.yaml")); - let text = serde_yaml_ng::to_string(ic_config).map_err(|source| ConfigError::Parse { - path: path.clone(), - source, - })?; - fs::write(&path, text).map_err(|source| ConfigError::Write { - path: path.clone(), - source, - })?; - Ok(path) -} - -/// One fully rendered matrix cell, ready for execution or golden snapshotting. -#[derive(Debug, Clone)] -pub struct RenderedCell { - /// The cell's axis coordinate (matrix-declared order). - pub tuple: AxisTuple, - /// The merged, interpolated Image Customizer config (the `config:` tree). - pub ic_config: Value, - /// The single resolved base image source. - pub base: BaseSource, - /// The output formats to build for this cell. - pub outputs: Vec, - /// Local RPM sources (directories or `.repo` files) passed to IC as `--rpm-source`. - pub rpm_sources: Vec, - /// Extra IC command-line flags appended verbatim, concatenated across matched fragments. - pub extra_params: Vec, - /// Resolved `skip` for this cell (merged from fragment `skip:` fields, last-wins). When `true`, - /// the cell is dropped from bulk selection unless specifically requested - /// (`meta/docs/2026-07-22-fragment-skip.md`). - pub skip: bool, - /// The `(axis, value)` coordinates that a `-s` selector must pin to keep this cell despite `skip` - /// (the predicate of the fragment that set `skip: true`); empty when not skipped. - pub skip_pins: Vec<(String, String)>, -} - -/// Render every cell of an image. `$include` paths resolve relative to `image_dir`. -pub fn render_image( - image: &ImageDefinition, - image_dir: impl AsRef, -) -> Result, ConfigError> { - let image_dir = image_dir.as_ref(); - let fragments = fragment::discover(image_dir, image.matrix.as_ref(), &image.features)?; - let cells = match &image.matrix { - Some(axes) => matrix::expand(axes, image.selectors.as_ref())?, - None if image.selectors.is_some() => { - return Err(ConfigError::SelectorsWithoutMatrix { - slug: image.name.clone(), - }); - } - None => vec![AxisTuple { values: Vec::new() }], - }; - cells - .into_iter() - .map(|tuple| render_cell(image, image_dir, &fragments, tuple)) - .collect() -} - -/// One step of a cell's merge plan: the fragment file, why it applies, and any `$include`d libraries. -#[derive(Debug, Clone)] -pub struct MergeStep { - /// The fragment's source label, e.g. `by-boot+verity/uki+root.yaml` (or `image.yaml` for the base). - pub label: String, - /// Why it applies: `base`, `boot=uki ∧ verity=root`, `mode ∈ {dev,test}`, or `feature `. - pub reason: String, - /// Repo-relative `$include` targets this fragment splices in (in document order). - pub includes: Vec, -} - -/// The ordered list of fragment files that merge into one cell — the literal `merge_into` sequence -/// (base first, later steps win), for `tailor explain`. Reuses the same discovery and precedence sort -/// as [`render_image`], so the order cannot drift from a real render. -pub fn merge_plan( - image: &ImageDefinition, - image_dir: impl AsRef, - axes: &BTreeMap, -) -> Result, ConfigError> { - let fragments = fragment::discover(image_dir.as_ref(), image.matrix.as_ref(), &image.features)?; - Ok(fragments - .iter() - .filter(|f| f.applies(axes, &image.features)) - .map(|f| MergeStep { - label: f.label.clone(), - reason: f.reason(), - includes: collect_includes(f.doc.config.as_ref()), - }) - .collect()) -} - -/// Collect the `$include` targets referenced anywhere in a fragment's `config:` tree, in order. -fn collect_includes(config: Option<&Value>) -> Vec { - let mut out = Vec::new(); - if let Some(value) = config { - walk_includes(value, &mut out); - } - out -} - -fn walk_includes(value: &Value, out: &mut Vec) { - match value { - Value::Mapping(map) => { - for (key, child) in map { - if key.as_str() == Some("$include") { - if let Some(target) = child.as_str() { - out.push(target.to_owned()); - } - } else { - walk_includes(child, out); - } - } - } - Value::Sequence(items) => items.iter().for_each(|item| walk_includes(item, out)), - _ => {} - } -} - -fn render_cell( - image: &ImageDefinition, - image_dir: &Path, - fragments: &[LoadedFragment], - tuple: AxisTuple, -) -> Result { - let axes: BTreeMap = tuple.values.iter().cloned().collect(); - let matched: Vec<&LoadedFragment> = fragments - .iter() - .filter(|f| f.applies(&axes, &image.features)) - .collect(); - - let params = merge_params(&matched)?; - let context = interpolate::build_context(&axes, ¶ms)?; - - let mut config = Mapping::new(); - for fragment in &matched { - let Some(delta) = fragment.doc.config.clone() else { - continue; - }; - let mut delta = delta; - include::resolve_includes(&mut delta, image_dir)?; - let Value::Mapping(delta) = delta else { - return Err(ConfigError::InvalidField { - slug: slug(image, &tuple), - field: "config", - detail: - "expected a mapping (inline IC config); path-string config is not yet supported" - .to_owned(), - }); - }; - merge::merge_into(&mut config, delta, &fragment.label)?; - } - let mut ic_config = Value::Mapping(config); - interpolate::interpolate_tree(&mut ic_config, &context)?; - - let base = resolve_base(image, &tuple, &matched, &context)?; - let outputs = resolve_outputs(image, &tuple, &matched)?; - for output in &outputs { - validate_output_compression(image, &tuple, output)?; - } - let rpm_sources = matched - .iter() - .flat_map(|f| f.doc.rpm_sources.clone()) - .collect(); - let extra_params = matched - .iter() - .flat_map(|f| f.doc.extra_params.clone()) - .collect(); - - // Resolve `skip` last-wins over the matched fragments (base → most-specific). When the winning - // value is `true`, remember that fragment's predicate coordinates as the pins that can override - // the skip via `-s` (`meta/docs/2026-07-22-fragment-skip.md`). - let mut skip = false; - let mut skip_pins: Vec<(String, String)> = Vec::new(); - for fragment in &matched { - if let Some(flag) = fragment.doc.skip { - skip = flag; - skip_pins = if flag { fragment.pins() } else { Vec::new() }; - } - } - - Ok(RenderedCell { - tuple, - ic_config, - base, - outputs, - rpm_sources, - extra_params, - skip, - skip_pins, - }) -} - -fn merge_params(matched: &[&LoadedFragment]) -> Result, ConfigError> { - let mut params: IndexMap = IndexMap::new(); - for fragment in matched { - for (name, value) in &fragment.doc.params { - let value = param_string(value); - match params.get(name) { - Some(existing) if existing != &value => { - return Err(ConfigError::ParamConflict { - name: name.clone(), - existing: existing.clone(), - incoming: value, - }); - } - _ => { - params.insert(name.clone(), value); - } - } - } - } - Ok(params) -} - -fn resolve_base( - image: &ImageDefinition, - tuple: &AxisTuple, - matched: &[&LoadedFragment], - context: &interpolate::Context, -) -> Result { - let mut base: Option = None; - for fragment in matched { - if let Some(value) = fragment.doc.base.clone() { - base = Some(merge::merge_field( - base, - value, - BASE_FIELD, - &fragment.label, - )?); - } - } - let Some(mut base) = base else { - return Err(ConfigError::MissingBase { - slug: slug(image, tuple), - }); - }; - interpolate::interpolate_tree(&mut base, context)?; - ensure_single_base_kind(&base, image, tuple)?; - deserialize_field(base, image, tuple, BASE_FIELD) -} - -/// A base is a `oneOf` (`path` | `oci` | `azureLinux` | `image`). Two fragments merging incompatible -/// base *kinds* without `$set` would otherwise silently keep the first; reject the ambiguity loudly. -fn ensure_single_base_kind( - base: &Value, - image: &ImageDefinition, - tuple: &AxisTuple, -) -> Result<(), ConfigError> { - const KINDS: [&str; 5] = ["path", "oci", "azureLinux", "ref", "image"]; - let present: Vec<&str> = KINDS - .into_iter() - .filter(|kind| base.get(kind).is_some()) - .collect(); - match present.len() { - 1 => Ok(()), - 0 => Err(ConfigError::MissingBase { - slug: slug(image, tuple), - }), - _ => Err(ConfigError::AmbiguousBase { - slug: slug(image, tuple), - kinds: present.join(", "), - }), - } -} - -fn resolve_outputs( - image: &ImageDefinition, - tuple: &AxisTuple, - matched: &[&LoadedFragment], -) -> Result, ConfigError> { - let mut outputs: Option = None; - for fragment in matched { - if let Some(value) = fragment.doc.outputs.clone() { - outputs = Some(merge::merge_field( - outputs, - value, - OUTPUTS_FIELD, - &fragment.label, - )?); - } - } - match outputs { - Some(value) => deserialize_field(value, image, tuple, OUTPUTS_FIELD), - None => Ok(Vec::new()), - } -} - -/// Reject `compression:` on formats where it makes no sense: `cosi` (IC already compresses it), -/// `iso` (compressing the image breaks bootability), and the `pxe-*` outputs (a directory / an -/// already-gzipped tar). Compression is a tailor post-step over a single raw disk image. -fn validate_output_compression( - image: &ImageDefinition, - tuple: &AxisTuple, - output: &OutputSpec, -) -> Result<(), ConfigError> { - let Some(compression) = output.compression else { - return Ok(()); - }; - if matches!( - output.format, - OutputFormat::Cosi | OutputFormat::Iso | OutputFormat::PxeDir | OutputFormat::PxeTar - ) { - return Err(ConfigError::InvalidField { - slug: slug(image, tuple), - field: OUTPUTS_FIELD, - detail: format!( - "compression `{compression}` is not supported for format `{}` (only raw disk-image \ - formats: vhd, vhd-fixed, vhdx, qcow2, raw, baremetal-image)", - output.format - ), - }); - } - Ok(()) -} - -fn deserialize_field( - value: Value, - image: &ImageDefinition, - tuple: &AxisTuple, - field: &'static str, -) -> Result { - serde_yaml_ng::from_value(value).map_err(|source| ConfigError::InvalidField { - slug: slug(image, tuple), - field, - detail: source.to_string(), - }) -} - -fn param_string(value: &ParamValue) -> String { - match value { - ParamValue::Bool(b) => b.to_string(), - ParamValue::Int(i) => i.to_string(), - ParamValue::Float(f) => f.to_string(), - ParamValue::Str(s) => s.clone(), - } -} - -fn slug(image: &ImageDefinition, tuple: &AxisTuple) -> String { - let coordinate = tuple.coordinate(); - if coordinate.is_empty() { - image.name.clone() - } else { - format!("{}_{coordinate}", image.name) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use indoc::indoc; - use tempfile::TempDir; - - use crate::{loader::load_image, types::OutputFormat}; - - /// Write `body` to `/`, creating parent directories as needed. - fn write(root: &Path, rel: &str, body: &str) { - let path = root.join(rel); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, body).unwrap(); - } - - /// A small matrix image exercising every render operation: list append, `$remove`, `$replace`, - /// `$set`, `$include`, and (nested) parameter interpolation across by-arch/by-edition fragments. - fn mini_image(root: &Path) -> ImageDefinition { - write( - root, - "image.yaml", - indoc! {" - name: mini - matrix: - arch: [amd64, arm64] - edition: [lite, pro] - outputs: - - format: cosi - config: - os: - packages: - install: - - core - - \"${bootPkg}\" - "}, - ); - write( - root, - "by-arch/amd64.yaml", - "base:\n path: ./amd64.img\nparams:\n efiArch: x64\n", - ); - write( - root, - "by-arch/arm64.yaml", - "base:\n path: ./arm64.img\nparams:\n efiArch: aa64\n", - ); - write( - root, - "by-edition/lite.yaml", - indoc! {" - params: - bootPkg: \"boot-${efiArch}\" - config: - storage: - $include: layouts/disk.yaml - "}, - ); - write( - root, - "by-edition/pro.yaml", - indoc! {" - outputs: - $replace: - - format: raw - params: - bootPkg: boot-pro - base: - $set: - oci: - uri: registry.example/mini:pro - platform: \"linux/${arch}\" - config: - os: - packages: - install: - $remove: - - core - "}, - ); - write(root, "layouts/disk.yaml", "bootType: efi\n"); - load_image(root.join("image.yaml")).unwrap() - } - - fn cell<'a>(cells: &'a [RenderedCell], edition: &str, arch: &str) -> &'a RenderedCell { - cells - .iter() - .find(|c| c.tuple.get("edition") == Some(edition) && c.tuple.get("arch") == Some(arch)) - .unwrap_or_else(|| panic!("no {edition}/{arch} cell")) - } - - fn install(cell: &RenderedCell) -> Vec<&str> { - cell.ic_config["os"]["packages"]["install"] - .as_sequence() - .unwrap() - .iter() - .filter_map(Value::as_str) - .collect() - } - - #[test] - fn compression_parses_and_is_validated_against_the_format() { - // A raw disk-image format accepts compression; the parsed OutputSpec carries it. - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - indoc! {" - name: comp - base: - path: ./b.img - outputs: - - format: raw - compression: zstd - config: - os: { hostname: comp } - "}, - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let cells = render_image(&image, tmp.path()).unwrap(); - assert_eq!( - cells[0].outputs[0].compression, - Some(crate::types::Compression::Zstd) - ); - - // COSI is already compressed by IC — `compression:` on it is rejected. - let bad = TempDir::new().unwrap(); - write( - bad.path(), - "image.yaml", - indoc! {" - name: comp - base: - path: ./b.img - outputs: - - format: cosi - compression: zstd - config: - os: { hostname: comp } - "}, - ); - let image = load_image(bad.path().join("image.yaml")).unwrap(); - let err = render_image(&image, bad.path()).unwrap_err(); - assert!( - matches!( - err, - ConfigError::InvalidField { - field: "outputs", - .. - } - ), - "got {err:?}" - ); - } - - #[test] - fn renders_one_cell_per_matrix_point() { - let tmp = TempDir::new().unwrap(); - let image = mini_image(tmp.path()); - let cells = render_image(&image, tmp.path()).unwrap(); - assert_eq!(cells.len(), 4); // edition[2] × arch[2] - } - - #[test] - fn selectors_filter_the_matrix_through_render() { - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - indoc! {" - name: sel - matrix: - arch: [amd64, arm64] - edition: [lite, pro] - selectors: - include: - - { arch: amd64 } # both editions on amd64 - - { arch: arm64, edition: [lite] } # only lite on arm64 (list value) - outputs: - - format: cosi - base: - path: ./b.img - config: - os: { hostname: sel } - "}, - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let cells = render_image(&image, tmp.path()).unwrap(); - let coords: Vec = cells.iter().map(|c| c.tuple.coordinate()).collect(); - // amd64_lite, amd64_pro, arm64_lite — the arm64_pro cell is filtered out. - assert_eq!(coords, ["amd64_lite", "amd64_pro", "arm64_lite"]); - } - - #[test] - fn selectors_without_a_matrix_is_an_error() { - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - indoc! {" - name: bad - selectors: - include: - - { arch: amd64 } - base: - path: ./b.img - config: - os: { hostname: bad } - "}, - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let err = render_image(&image, tmp.path()).unwrap_err(); - assert!( - matches!(err, ConfigError::SelectorsWithoutMatrix { .. }), - "got {err:?}" - ); - } - - #[test] - fn extra_params_concatenate_base_then_fragment() { - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - indoc! {" - name: xp - matrix: - arch: [amd64, arm64] - extraParams: - - param: --base-flag - value: on - base: - path: ./b.img - config: - os: { hostname: xp } - "}, - ); - write( - tmp.path(), - "by-arch/arm64.yaml", - indoc! {" - extraParams: - - param: --arm64-only - "}, - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let cells = render_image(&image, tmp.path()).unwrap(); - - let amd64 = cells - .iter() - .find(|c| c.tuple.get("arch") == Some("amd64")) - .unwrap(); - let arm64 = cells - .iter() - .find(|c| c.tuple.get("arch") == Some("arm64")) - .unwrap(); - - let flags = |c: &RenderedCell| -> Vec<(String, Option)> { - c.extra_params - .iter() - .map(|p| (p.param.clone(), p.value.clone())) - .collect() - }; - assert_eq!( - flags(amd64), - [("--base-flag".to_owned(), Some("on".to_owned()))] - ); - // base → most-specific: the fragment's flag is appended after the base document's. - assert_eq!( - flags(arm64), - [ - ("--base-flag".to_owned(), Some("on".to_owned())), - ("--arm64-only".to_owned(), None), - ] - ); - } - - #[test] - fn lite_cell_keeps_path_base_appends_packages_and_resolves_include_and_nested_params() { - let tmp = TempDir::new().unwrap(); - let image = mini_image(tmp.path()); - let cells = render_image(&image, tmp.path()).unwrap(); - let lite = cell(&cells, "lite", "amd64"); - - // by-arch supplies a local path base; the inherited cosi output is unchanged. - assert!(matches!(&lite.base, BaseSource::Path { .. })); - assert_eq!(lite.outputs.len(), 1); - assert_eq!(lite.outputs[0].format, OutputFormat::Cosi); - // List append + nested interpolation: bootPkg = "boot-${efiArch}", efiArch = x64. - assert_eq!(install(lite), ["core", "boot-x64"]); - // $include splices the storage layout as the value of `storage`. - assert_eq!(lite.ic_config["storage"]["bootType"].as_str(), Some("efi")); - } - - #[test] - fn pro_cell_applies_set_base_replace_outputs_and_remove() { - let tmp = TempDir::new().unwrap(); - let image = mini_image(tmp.path()); - let cells = render_image(&image, tmp.path()).unwrap(); - let pro = cell(&cells, "pro", "arm64"); - - // $set overrides the by-arch path base wholesale; ${arch} interpolates into the platform. - match &pro.base { - BaseSource::Oci { oci } => assert_eq!(oci.platform.as_deref(), Some("linux/arm64")), - other => panic!("expected an OCI base, got {other:?}"), - } - // $replace swaps the whole output list. - assert_eq!(pro.outputs.len(), 1); - assert_eq!(pro.outputs[0].format, OutputFormat::Raw); - // $remove drops `core`, leaving only the interpolated boot package. - assert_eq!(install(pro), ["boot-pro"]); - } - - #[test] - fn an_image_with_no_base_is_an_error() { - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - "name: nobase\nconfig:\n os:\n hostname: x\n", - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let err = render_image(&image, tmp.path()).unwrap_err(); - assert!( - matches!(err, ConfigError::MissingBase { .. }), - "got {err:?}" - ); - } - - #[test] - fn merge_precedence_follows_axis_declaration_order_not_directory_names() { - // Two axes whose fragments both `$set` the same scalar. The axis declared LAST wins, - // regardless of how the `by-*` directories sort on disk (by-aa < by-zz alphabetically). - fn rendered_hostname(first_axis: &str, second_axis: &str) -> String { - let tmp = TempDir::new().unwrap(); - write( - tmp.path(), - "image.yaml", - &format!( - "name: ord\nmatrix:\n {first_axis}: [x]\n {second_axis}: [x]\n\ - outputs:\n - format: cosi\nbase:\n path: ./b.img\n\ - config:\n os:\n hostname: from-base\n" - ), - ); - write( - tmp.path(), - "by-aa/x.yaml", - "config:\n os:\n hostname:\n $set: from-aa\n", - ); - write( - tmp.path(), - "by-zz/x.yaml", - "config:\n os:\n hostname:\n $set: from-zz\n", - ); - let image = load_image(tmp.path().join("image.yaml")).unwrap(); - let cells = render_image(&image, tmp.path()).unwrap(); - cells[0].ic_config["os"]["hostname"] - .as_str() - .unwrap() - .to_owned() - } - // Declaring `aa` before `zz` makes `zz` win; reversing the declaration flips the winner — - // even though the on-disk directory names (by-aa, by-zz) are identical in both cases. - assert_eq!(rendered_hostname("aa", "zz"), "from-zz"); - assert_eq!(rendered_hostname("zz", "aa"), "from-aa"); - } - - /// Image exercising composite paths, single-axis disjunction, `$unset`, and `$prepend` together - /// through the full render+merge pipeline. - fn combo_image(root: &Path) -> ImageDefinition { - write( - root, - "image.yaml", - indoc! {" - name: combo - matrix: - boot: [grub, uki] - verity: [none, root, usr] - mode: [dev, test, prod] - outputs: - - format: cosi - base: - path: ./b.img - config: - os: - hostname: base-host - selinux: - mode: disabled - scripts: - post: - - path: mid.sh - "}, - ); - // single-axis disjunction (broader) then the singular override (narrower → wins for `dev`). - write( - root, - "by-mode/dev+test.yaml", - "config:\n os:\n hostname:\n $set: shared\n", - ); - write( - root, - "by-mode/dev.yaml", - "config:\n os:\n hostname:\n $set: dev\n", - ); - // $unset removes an inherited key for one axis value. - write( - root, - "by-verity/none.yaml", - "config:\n os:\n selinux: $unset\n", - ); - // multi-axis composite applies only to the (uki, root) pair. - write( - root, - "by-boot+verity/uki+root.yaml", - "config:\n os:\n label: uki-root\n", - ); - // $prepend puts a script before the inherited one for uki boots. - write( - root, - "by-boot/uki.yaml", - "config:\n scripts:\n post:\n $prepend:\n - path: first.sh\n", - ); - load_image(root.join("image.yaml")).unwrap() - } - - fn combo_cell<'a>( - cells: &'a [RenderedCell], - boot: &str, - verity: &str, - mode: &str, - ) -> &'a RenderedCell { - cells - .iter() - .find(|c| { - c.tuple.get("boot") == Some(boot) - && c.tuple.get("verity") == Some(verity) - && c.tuple.get("mode") == Some(mode) - }) - .unwrap_or_else(|| panic!("no {boot}/{verity}/{mode} cell")) - } - - fn scripts(cell: &RenderedCell) -> Vec<&str> { - cell.ic_config["scripts"]["post"] - .as_sequence() - .unwrap() - .iter() - .map(|s| s["path"].as_str().unwrap()) - .collect() - } - - #[test] - fn composite_disjunction_unset_and_prepend_render_together() { - let tmp = TempDir::new().unwrap(); - let image = combo_image(tmp.path()); - let cells = render_image(&image, tmp.path()).unwrap(); - assert_eq!(cells.len(), 2 * 3 * 3); // boot × verity × mode - - // (uki, root, dev): singular `dev` beats the `dev+test` disjunction; composite label applies; - // selinux survives (verity != none); $prepend puts first.sh ahead of the inherited mid.sh. - let a = combo_cell(&cells, "uki", "root", "dev"); - assert_eq!(a.ic_config["os"]["hostname"].as_str(), Some("dev")); - assert_eq!(a.ic_config["os"]["label"].as_str(), Some("uki-root")); - assert_eq!( - a.ic_config["os"]["selinux"]["mode"].as_str(), - Some("disabled") - ); - assert_eq!(scripts(a), ["first.sh", "mid.sh"]); - - // (uki, none, test): disjunction wins (no singular for `test`); selinux key removed by $unset; - // composite does NOT apply (verity != root); $prepend still applies (boot == uki). - let b = combo_cell(&cells, "uki", "none", "test"); - assert_eq!(b.ic_config["os"]["hostname"].as_str(), Some("shared")); - assert!( - !b.ic_config["os"] - .as_mapping() - .unwrap() - .contains_key("selinux") - ); - assert!( - !b.ic_config["os"] - .as_mapping() - .unwrap() - .contains_key("label") - ); - assert_eq!(scripts(b), ["first.sh", "mid.sh"]); - - // (grub, root, prod): no axis override → base hostname; selinux kept; no composite; no prepend. - let c = combo_cell(&cells, "grub", "root", "prod"); - assert_eq!(c.ic_config["os"]["hostname"].as_str(), Some("base-host")); - assert!( - !c.ic_config["os"] - .as_mapping() - .unwrap() - .contains_key("label") - ); - assert_eq!(scripts(c), ["mid.sh"]); - } -} +//! The per-cell render pipeline: gather matched fragments, resolve `$include`, merge the config and +//! tailor fields, interpolate `${…}`, and emit one runnable cell (`meta/docs/2026-06-22-image-definitions.md` §7, +//! §9.3). Pure and deterministic, so the emitted config is a stable golden snapshot. + +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +use indexmap::IndexMap; +use serde::de::DeserializeOwned; +use serde_yaml_ng::{Mapping, Value}; + +use crate::{ + error::ConfigError, + fragment::{self, LoadedFragment}, + include, interpolate, matrix, + matrix::AxisTuple, + merge, + schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec, SigningRef}, + types::{OutputFormat, ParamValue}, +}; + +const BASE_FIELD: &str = "base"; +const OUTPUTS_FIELD: &str = "outputs"; +const RENDERED_DIR: &str = ".rendered"; + +/// Write a cell's normalized golden snapshot to `/.rendered/.yaml` (for review and +/// CI blast-radius diffing, `meta/docs/2026-06-22-image-definitions.md` §9.3). Returns the written path. +pub fn write_golden( + image_dir: &Path, + slug: &str, + ic_config: &Value, +) -> Result { + let dir = image_dir.join(RENDERED_DIR); + fs::create_dir_all(&dir).map_err(|source| ConfigError::Write { + path: dir.clone(), + source, + })?; + let path = dir.join(format!("{slug}.yaml")); + let text = serde_yaml_ng::to_string(ic_config).map_err(|source| ConfigError::Parse { + path: path.clone(), + source, + })?; + fs::write(&path, text).map_err(|source| ConfigError::Write { + path: path.clone(), + source, + })?; + Ok(path) +} + +/// One fully rendered matrix cell, ready for execution or golden snapshotting. +#[derive(Debug, Clone)] +pub struct RenderedCell { + /// The cell's axis coordinate (matrix-declared order). + pub tuple: AxisTuple, + /// The merged, interpolated Image Customizer config (the `config:` tree). + pub ic_config: Value, + /// The single resolved base image source. + pub base: BaseSource, + /// The output formats to build for this cell. + pub outputs: Vec, + /// Local RPM sources (directories or `.repo` files) passed to IC as `--rpm-source`. + pub rpm_sources: Vec, + /// Extra IC command-line flags appended verbatim, concatenated across matched fragments. + pub extra_params: Vec, + /// The resolved per-cell `signing:` opt-in after fragment merging. + pub signing: Option, + /// Resolved `skip` for this cell (merged from fragment `skip:` fields, last-wins). When `true`, + /// the cell is dropped from bulk selection unless specifically requested + /// (`meta/docs/2026-07-22-fragment-skip.md`). + pub skip: bool, + /// The `(axis, value)` coordinates that a `-s` selector must pin to keep this cell despite `skip` + /// (the predicate of the fragment that set `skip: true`); empty when not skipped. + pub skip_pins: Vec<(String, String)>, +} + +/// Render every cell of an image. `$include` paths resolve relative to `image_dir`. +pub fn render_image( + image: &ImageDefinition, + image_dir: impl AsRef, +) -> Result, ConfigError> { + let image_dir = image_dir.as_ref(); + let fragments = fragment::discover(image_dir, image.matrix.as_ref(), &image.features)?; + let cells = match &image.matrix { + Some(axes) => matrix::expand(axes, image.selectors.as_ref())?, + None if image.selectors.is_some() => { + return Err(ConfigError::SelectorsWithoutMatrix { + slug: image.name.clone(), + }); + } + None => vec![AxisTuple { values: Vec::new() }], + }; + cells + .into_iter() + .map(|tuple| render_cell(image, image_dir, &fragments, tuple)) + .collect() +} + +/// One step of a cell's merge plan: the fragment file, why it applies, and any `$include`d libraries. +#[derive(Debug, Clone)] +pub struct MergeStep { + /// The fragment's source label, e.g. `by-boot+verity/uki+root.yaml` (or `image.yaml` for the base). + pub label: String, + /// Why it applies: `base`, `boot=uki ∧ verity=root`, `mode ∈ {dev,test}`, or `feature `. + pub reason: String, + /// Repo-relative `$include` targets this fragment splices in (in document order). + pub includes: Vec, +} + +/// The ordered list of fragment files that merge into one cell — the literal `merge_into` sequence +/// (base first, later steps win), for `tailor explain`. Reuses the same discovery and precedence sort +/// as [`render_image`], so the order cannot drift from a real render. +pub fn merge_plan( + image: &ImageDefinition, + image_dir: impl AsRef, + axes: &BTreeMap, +) -> Result, ConfigError> { + let fragments = fragment::discover(image_dir.as_ref(), image.matrix.as_ref(), &image.features)?; + Ok(fragments + .iter() + .filter(|f| f.applies(axes, &image.features)) + .map(|f| MergeStep { + label: f.label.clone(), + reason: f.reason(), + // `base:` fragments can `$include` a shared snippet too (`resolve_base` below), so the + // explain plan must report those targets alongside `config:`'s, or it silently omits + // whichever base the fragment actually spliced in. + includes: { + let mut includes = collect_includes(f.doc.config.as_ref()); + includes.extend(collect_includes(f.doc.base.as_ref())); + includes + }, + }) + .collect()) +} + +/// Collect the `$include` targets referenced anywhere in a fragment's `config:` tree, in order. +fn collect_includes(config: Option<&Value>) -> Vec { + let mut out = Vec::new(); + if let Some(value) = config { + walk_includes(value, &mut out); + } + out +} + +fn walk_includes(value: &Value, out: &mut Vec) { + match value { + Value::Mapping(map) => { + for (key, child) in map { + if key.as_str() == Some("$include") { + if let Some(target) = child.as_str() { + out.push(target.to_owned()); + } + } else { + walk_includes(child, out); + } + } + } + Value::Sequence(items) => items.iter().for_each(|item| walk_includes(item, out)), + _ => {} + } +} + +fn render_cell( + image: &ImageDefinition, + image_dir: &Path, + fragments: &[LoadedFragment], + tuple: AxisTuple, +) -> Result { + let axes: BTreeMap = tuple.values.iter().cloned().collect(); + let matched: Vec<&LoadedFragment> = fragments + .iter() + .filter(|f| f.applies(&axes, &image.features)) + .collect(); + + let params = merge_params(&matched)?; + let context = interpolate::build_context(&axes, ¶ms)?; + + let mut config = Mapping::new(); + for fragment in &matched { + let Some(delta) = fragment.doc.config.clone() else { + continue; + }; + let mut delta = delta; + include::resolve_includes(&mut delta, image_dir)?; + let Value::Mapping(delta) = delta else { + return Err(ConfigError::InvalidField { + slug: slug(image, &tuple), + field: "config", + detail: + "expected a mapping (inline IC config); path-string config is not yet supported" + .to_owned(), + }); + }; + merge::merge_into(&mut config, delta, &fragment.label)?; + } + let mut ic_config = Value::Mapping(config); + interpolate::interpolate_tree(&mut ic_config, &context)?; + + let base = resolve_base(image, image_dir, &tuple, &matched, &context)?; + let outputs = resolve_outputs(image, &tuple, &matched, &context)?; + for output in &outputs { + validate_output_compression(image, &tuple, output)?; + } + let rpm_sources = matched + .iter() + .flat_map(|f| f.doc.rpm_sources.clone()) + .collect(); + let extra_params = matched + .iter() + .flat_map(|f| f.doc.extra_params.clone()) + .collect(); + let signing = resolve_signing_ref(image, &tuple, &matched)?; + + // Resolve `skip` last-wins over the matched fragments (base → most-specific). When the winning + // value is `true`, remember that fragment's predicate coordinates as the pins that can override + // the skip via `-s` (`meta/docs/2026-07-22-fragment-skip.md`). + let mut skip = false; + let mut skip_pins: Vec<(String, String)> = Vec::new(); + for fragment in &matched { + if let Some(flag) = fragment.doc.skip { + skip = flag; + skip_pins = if flag { fragment.pins() } else { Vec::new() }; + } + } + + Ok(RenderedCell { + tuple, + ic_config, + base, + outputs, + rpm_sources, + extra_params, + signing, + skip, + skip_pins, + }) +} + +fn merge_params(matched: &[&LoadedFragment]) -> Result, ConfigError> { + let mut params: IndexMap = IndexMap::new(); + for fragment in matched { + for (name, value) in &fragment.doc.params { + let value = param_string(value); + match params.get(name) { + Some(existing) if existing != &value => { + return Err(ConfigError::ParamConflict { + name: name.clone(), + existing: existing.clone(), + incoming: value, + }); + } + _ => { + params.insert(name.clone(), value); + } + } + } + } + Ok(params) +} + +fn resolve_base( + image: &ImageDefinition, + image_dir: &Path, + tuple: &AxisTuple, + matched: &[&LoadedFragment], + context: &interpolate::Context, +) -> Result { + let mut base: Option = None; + for fragment in matched { + if let Some(mut value) = fragment.doc.base.clone() { + // Mirror config:'s $include handling (resolved before merge, see render_cell above) so a + // base: fragment can splice a shared snippet too, instead of only config: being able to. + include::resolve_includes(&mut value, image_dir)?; + base = Some(merge::merge_field( + base, + value, + BASE_FIELD, + &fragment.label, + )?); + } + } + let Some(mut base) = base else { + return Err(ConfigError::MissingBase { + slug: slug(image, tuple), + }); + }; + interpolate::interpolate_tree(&mut base, context)?; + ensure_single_base_kind(&base, image, tuple)?; + deserialize_field(base, image, tuple, BASE_FIELD) +} + +/// A base is a `oneOf` (`path` | `oci` | `azureLinux` | `image`). Two fragments merging incompatible +/// base *kinds* without `$set` would otherwise silently keep the first; reject the ambiguity loudly. +fn ensure_single_base_kind( + base: &Value, + image: &ImageDefinition, + tuple: &AxisTuple, +) -> Result<(), ConfigError> { + const KINDS: [&str; 5] = ["path", "oci", "azureLinux", "ref", "image"]; + let present: Vec<&str> = KINDS + .into_iter() + .filter(|kind| base.get(kind).is_some()) + .collect(); + match present.len() { + 1 => Ok(()), + 0 => Err(ConfigError::MissingBase { + slug: slug(image, tuple), + }), + _ => Err(ConfigError::AmbiguousBase { + slug: slug(image, tuple), + kinds: present.join(", "), + }), + } +} + +fn resolve_outputs( + image: &ImageDefinition, + tuple: &AxisTuple, + matched: &[&LoadedFragment], + context: &interpolate::Context, +) -> Result, ConfigError> { + let mut outputs: Option = None; + for fragment in matched { + if let Some(value) = fragment.doc.outputs.clone() { + outputs = Some(merge::merge_field( + outputs, + value, + OUTPUTS_FIELD, + &fragment.label, + )?); + } + } + match outputs { + Some(mut value) => { + interpolate::interpolate_tree(&mut value, context)?; + deserialize_field(value, image, tuple, OUTPUTS_FIELD) + } + None => Ok(Vec::new()), + } +} + +fn resolve_signing_ref( + _image: &ImageDefinition, + _tuple: &AxisTuple, + matched: &[&LoadedFragment], +) -> Result, ConfigError> { + let mut signing: Option = None; + for fragment in matched { + if let Some(value) = fragment.doc.signing.clone() { + signing = Some(value); + } + } + Ok(signing) +} + +/// Reject `compression:` on formats where it makes no sense: `cosi` (IC already compresses it), +/// `iso` (compressing the image breaks bootability), and the `pxe-*` outputs (a directory / an +/// already-gzipped tar). Compression is a tailor post-step over a single raw disk image. +fn validate_output_compression( + image: &ImageDefinition, + tuple: &AxisTuple, + output: &OutputSpec, +) -> Result<(), ConfigError> { + let Some(compression) = output.compression else { + return Ok(()); + }; + if matches!( + output.format, + OutputFormat::Cosi | OutputFormat::Iso | OutputFormat::PxeDir | OutputFormat::PxeTar + ) { + return Err(ConfigError::InvalidField { + slug: slug(image, tuple), + field: OUTPUTS_FIELD, + detail: format!( + "compression `{compression}` is not supported for format `{}` (only raw disk-image \ + formats: vhd, vhd-fixed, vhdx, qcow2, raw, baremetal-image)", + output.format + ), + }); + } + Ok(()) +} +fn deserialize_field( + value: Value, + image: &ImageDefinition, + tuple: &AxisTuple, + field: &'static str, +) -> Result { + serde_yaml_ng::from_value(value).map_err(|source| ConfigError::InvalidField { + slug: slug(image, tuple), + field, + detail: source.to_string(), + }) +} + +fn param_string(value: &ParamValue) -> String { + match value { + ParamValue::Bool(b) => b.to_string(), + ParamValue::Int(i) => i.to_string(), + ParamValue::Float(f) => f.to_string(), + ParamValue::Str(s) => s.clone(), + } +} + +fn slug(image: &ImageDefinition, tuple: &AxisTuple) -> String { + let coordinate = tuple.coordinate(); + if coordinate.is_empty() { + image.name.clone() + } else { + format!("{}_{coordinate}", image.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use indoc::indoc; + use tempfile::TempDir; + + use crate::{loader::load_image, types::OutputFormat}; + + /// Write `body` to `/`, creating parent directories as needed. + fn write(root: &Path, rel: &str, body: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); + } + + /// A small matrix image exercising every render operation: list append, `$remove`, `$replace`, + /// `$set`, `$include`, and (nested) parameter interpolation across by-arch/by-edition fragments. + fn mini_image(root: &Path) -> ImageDefinition { + write( + root, + "image.yaml", + indoc! {" + name: mini + matrix: + arch: [amd64, arm64] + edition: [lite, pro] + outputs: + - format: cosi + config: + os: + packages: + install: + - core + - \"${bootPkg}\" + "}, + ); + write( + root, + "by-arch/amd64.yaml", + "base:\n path: ./amd64.img\nparams:\n efiArch: x64\n", + ); + write( + root, + "by-arch/arm64.yaml", + "base:\n path: ./arm64.img\nparams:\n efiArch: aa64\n", + ); + write( + root, + "by-edition/lite.yaml", + indoc! {" + params: + bootPkg: \"boot-${efiArch}\" + config: + storage: + $include: layouts/disk.yaml + "}, + ); + write( + root, + "by-edition/pro.yaml", + indoc! {" + outputs: + $replace: + - format: raw + params: + bootPkg: boot-pro + base: + $set: + oci: + uri: registry.example/mini:pro + platform: \"linux/${arch}\" + config: + os: + packages: + install: + $remove: + - core + "}, + ); + write(root, "layouts/disk.yaml", "bootType: efi\n"); + load_image(root.join("image.yaml")).unwrap() + } + + fn cell<'a>(cells: &'a [RenderedCell], edition: &str, arch: &str) -> &'a RenderedCell { + cells + .iter() + .find(|c| c.tuple.get("edition") == Some(edition) && c.tuple.get("arch") == Some(arch)) + .unwrap_or_else(|| panic!("no {edition}/{arch} cell")) + } + + fn install(cell: &RenderedCell) -> Vec<&str> { + cell.ic_config["os"]["packages"]["install"] + .as_sequence() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect() + } + + #[test] + fn merge_plan_reports_includes_from_base_as_well_as_config() { + // `resolve_base` splices `$include` under `base:` just like `config:` does, so the explain + // plan must surface it too -- otherwise `tailor explain` silently omits a library an image + // actually depends on. + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: based + matrix: + arch: [amd64] + outputs: + - format: cosi + config: {} + "}, + ); + write( + tmp.path(), + "by-arch/amd64.yaml", + "base:\n $include: common/base-amd64.yaml\n", + ); + write(tmp.path(), "common/base-amd64.yaml", "path: ./amd64.img\n"); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let mut axes = BTreeMap::new(); + axes.insert("arch".to_owned(), "amd64".to_owned()); + let plan = merge_plan(&image, tmp.path(), &axes).unwrap(); + let step = plan + .iter() + .find(|step| step.label == "by-arch/amd64.yaml") + .expect("by-arch step"); + assert_eq!(step.includes, vec!["common/base-amd64.yaml".to_owned()]); + } + + #[test] + fn compression_parses_and_is_validated_against_the_format() { + // A raw disk-image format accepts compression; the parsed OutputSpec carries it. + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: comp + base: + path: ./b.img + outputs: + - format: raw + compression: zstd + config: + os: { hostname: comp } + "}, + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + assert_eq!( + cells[0].outputs[0].compression, + Some(crate::types::Compression::Zstd) + ); + + // COSI is already compressed by IC — `compression:` on it is rejected. + let bad = TempDir::new().unwrap(); + write( + bad.path(), + "image.yaml", + indoc! {" + name: comp + base: + path: ./b.img + outputs: + - format: cosi + compression: zstd + config: + os: { hostname: comp } + "}, + ); + let image = load_image(bad.path().join("image.yaml")).unwrap(); + let err = render_image(&image, bad.path()).unwrap_err(); + assert!( + matches!( + err, + ConfigError::InvalidField { + field: "outputs", + .. + } + ), + "got {err:?}" + ); + } + + #[test] + fn renders_one_cell_per_matrix_point() { + let tmp = TempDir::new().unwrap(); + let image = mini_image(tmp.path()); + let cells = render_image(&image, tmp.path()).unwrap(); + assert_eq!(cells.len(), 4); // edition[2] × arch[2] + } + + #[test] + fn selectors_filter_the_matrix_through_render() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: sel + matrix: + arch: [amd64, arm64] + edition: [lite, pro] + selectors: + include: + - { arch: amd64 } # both editions on amd64 + - { arch: arm64, edition: [lite] } # only lite on arm64 (list value) + outputs: + - format: cosi + base: + path: ./b.img + config: + os: { hostname: sel } + "}, + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + let coords: Vec = cells.iter().map(|c| c.tuple.coordinate()).collect(); + // amd64_lite, amd64_pro, arm64_lite — the arm64_pro cell is filtered out. + assert_eq!(coords, ["amd64_lite", "amd64_pro", "arm64_lite"]); + } + + #[test] + fn selectors_without_a_matrix_is_an_error() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: bad + selectors: + include: + - { arch: amd64 } + base: + path: ./b.img + config: + os: { hostname: bad } + "}, + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let err = render_image(&image, tmp.path()).unwrap_err(); + assert!( + matches!(err, ConfigError::SelectorsWithoutMatrix { .. }), + "got {err:?}" + ); + } + + #[test] + fn extra_params_concatenate_base_then_fragment() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: xp + matrix: + arch: [amd64, arm64] + extraParams: + - param: --base-flag + value: on + base: + path: ./b.img + config: + os: { hostname: xp } + "}, + ); + write( + tmp.path(), + "by-arch/arm64.yaml", + indoc! {" + extraParams: + - param: --arm64-only + "}, + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + + let amd64 = cells + .iter() + .find(|c| c.tuple.get("arch") == Some("amd64")) + .unwrap(); + let arm64 = cells + .iter() + .find(|c| c.tuple.get("arch") == Some("arm64")) + .unwrap(); + + let flags = |c: &RenderedCell| -> Vec<(String, Option)> { + c.extra_params + .iter() + .map(|p| (p.param.clone(), p.value.clone())) + .collect() + }; + assert_eq!( + flags(amd64), + [("--base-flag".to_owned(), Some("on".to_owned()))] + ); + // base → most-specific: the fragment's flag is appended after the base document's. + assert_eq!( + flags(arm64), + [ + ("--base-flag".to_owned(), Some("on".to_owned())), + ("--arm64-only".to_owned(), None), + ] + ); + } + + #[test] + fn lite_cell_keeps_path_base_appends_packages_and_resolves_include_and_nested_params() { + let tmp = TempDir::new().unwrap(); + let image = mini_image(tmp.path()); + let cells = render_image(&image, tmp.path()).unwrap(); + let lite = cell(&cells, "lite", "amd64"); + + // by-arch supplies a local path base; the inherited cosi output is unchanged. + assert!(matches!(&lite.base, BaseSource::Path { .. })); + assert_eq!(lite.outputs.len(), 1); + assert_eq!(lite.outputs[0].format, OutputFormat::Cosi); + // List append + nested interpolation: bootPkg = "boot-${efiArch}", efiArch = x64. + assert_eq!(install(lite), ["core", "boot-x64"]); + // $include splices the storage layout as the value of `storage`. + assert_eq!(lite.ic_config["storage"]["bootType"].as_str(), Some("efi")); + } + + #[test] + fn pro_cell_applies_set_base_replace_outputs_and_remove() { + let tmp = TempDir::new().unwrap(); + let image = mini_image(tmp.path()); + let cells = render_image(&image, tmp.path()).unwrap(); + let pro = cell(&cells, "pro", "arm64"); + + // $set overrides the by-arch path base wholesale; ${arch} interpolates into the platform. + match &pro.base { + BaseSource::Oci { oci } => assert_eq!(oci.platform.as_deref(), Some("linux/arm64")), + other => panic!("expected an OCI base, got {other:?}"), + } + // $replace swaps the whole output list. + assert_eq!(pro.outputs.len(), 1); + assert_eq!(pro.outputs[0].format, OutputFormat::Raw); + // $remove drops `core`, leaving only the interpolated boot package. + assert_eq!(install(pro), ["boot-pro"]); + } + + #[test] + fn base_include_splices_a_shared_snippet_before_merge() { + // A by-arch fragment can $include a shared base snippet instead of inlining it, exactly + // like config: already could - regression test for the "must be resolved before merge" + // bug where base: never ran $include resolution. + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: shared-base + matrix: + arch: [amd64, arm64] + outputs: + - format: cosi + config: {} + "}, + ); + write( + tmp.path(), + "by-arch/amd64.yaml", + "base: + $include: common/base-amd64.yaml +", + ); + write( + tmp.path(), + "by-arch/arm64.yaml", + "base: + $include: common/base-arm64.yaml +", + ); + write( + tmp.path(), + "common/base-amd64.yaml", + "path: ./amd64.img +", + ); + write( + tmp.path(), + "common/base-arm64.yaml", + "path: ./arm64.img +", + ); + + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + let by_arch = |arch: &str| -> &RenderedCell { + cells + .iter() + .find(|c| c.tuple.get("arch") == Some(arch)) + .unwrap_or_else(|| panic!("no {arch} cell")) + }; + + match &by_arch("amd64").base { + BaseSource::Path { path, .. } => assert_eq!(path, Path::new("./amd64.img")), + other => panic!("expected a path base, got {other:?}"), + } + match &by_arch("arm64").base { + BaseSource::Path { path, .. } => assert_eq!(path, Path::new("./arm64.img")), + other => panic!("expected a path base, got {other:?}"), + } + } + + #[test] + fn an_image_with_no_base_is_an_error() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + "name: nobase\nconfig:\n os:\n hostname: x\n", + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let err = render_image(&image, tmp.path()).unwrap_err(); + assert!( + matches!(err, ConfigError::MissingBase { .. }), + "got {err:?}" + ); + } + + #[test] + fn merge_precedence_follows_axis_declaration_order_not_directory_names() { + // Two axes whose fragments both `$set` the same scalar. The axis declared LAST wins, + // regardless of how the `by-*` directories sort on disk (by-aa < by-zz alphabetically). + fn rendered_hostname(first_axis: &str, second_axis: &str) -> String { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + &format!( + "name: ord\nmatrix:\n {first_axis}: [x]\n {second_axis}: [x]\n\ + outputs:\n - format: cosi\nbase:\n path: ./b.img\n\ + config:\n os:\n hostname: from-base\n" + ), + ); + write( + tmp.path(), + "by-aa/x.yaml", + "config:\n os:\n hostname:\n $set: from-aa\n", + ); + write( + tmp.path(), + "by-zz/x.yaml", + "config:\n os:\n hostname:\n $set: from-zz\n", + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + cells[0].ic_config["os"]["hostname"] + .as_str() + .unwrap() + .to_owned() + } + // Declaring `aa` before `zz` makes `zz` win; reversing the declaration flips the winner — + // even though the on-disk directory names (by-aa, by-zz) are identical in both cases. + assert_eq!(rendered_hostname("aa", "zz"), "from-zz"); + assert_eq!(rendered_hostname("zz", "aa"), "from-aa"); + } + + /// Image exercising composite paths, single-axis disjunction, `$unset`, and `$prepend` together + /// through the full render+merge pipeline. + fn combo_image(root: &Path) -> ImageDefinition { + write( + root, + "image.yaml", + indoc! {" + name: combo + matrix: + boot: [grub, uki] + verity: [none, root, usr] + mode: [dev, test, prod] + outputs: + - format: cosi + base: + path: ./b.img + config: + os: + hostname: base-host + selinux: + mode: disabled + scripts: + post: + - path: mid.sh + "}, + ); + // single-axis disjunction (broader) then the singular override (narrower → wins for `dev`). + write( + root, + "by-mode/dev+test.yaml", + "config:\n os:\n hostname:\n $set: shared\n", + ); + write( + root, + "by-mode/dev.yaml", + "config:\n os:\n hostname:\n $set: dev\n", + ); + // $unset removes an inherited key for one axis value. + write( + root, + "by-verity/none.yaml", + "config:\n os:\n selinux: $unset\n", + ); + // multi-axis composite applies only to the (uki, root) pair. + write( + root, + "by-boot+verity/uki+root.yaml", + "config:\n os:\n label: uki-root\n", + ); + // $prepend puts a script before the inherited one for uki boots. + write( + root, + "by-boot/uki.yaml", + "config:\n scripts:\n post:\n $prepend:\n - path: first.sh\n", + ); + load_image(root.join("image.yaml")).unwrap() + } + + fn combo_cell<'a>( + cells: &'a [RenderedCell], + boot: &str, + verity: &str, + mode: &str, + ) -> &'a RenderedCell { + cells + .iter() + .find(|c| { + c.tuple.get("boot") == Some(boot) + && c.tuple.get("verity") == Some(verity) + && c.tuple.get("mode") == Some(mode) + }) + .unwrap_or_else(|| panic!("no {boot}/{verity}/{mode} cell")) + } + + fn scripts(cell: &RenderedCell) -> Vec<&str> { + cell.ic_config["scripts"]["post"] + .as_sequence() + .unwrap() + .iter() + .map(|s| s["path"].as_str().unwrap()) + .collect() + } + + #[test] + fn composite_disjunction_unset_and_prepend_render_together() { + let tmp = TempDir::new().unwrap(); + let image = combo_image(tmp.path()); + let cells = render_image(&image, tmp.path()).unwrap(); + assert_eq!(cells.len(), 2 * 3 * 3); // boot × verity × mode + + // (uki, root, dev): singular `dev` beats the `dev+test` disjunction; composite label applies; + // selinux survives (verity != none); $prepend puts first.sh ahead of the inherited mid.sh. + let a = combo_cell(&cells, "uki", "root", "dev"); + assert_eq!(a.ic_config["os"]["hostname"].as_str(), Some("dev")); + assert_eq!(a.ic_config["os"]["label"].as_str(), Some("uki-root")); + assert_eq!( + a.ic_config["os"]["selinux"]["mode"].as_str(), + Some("disabled") + ); + assert_eq!(scripts(a), ["first.sh", "mid.sh"]); + + // (uki, none, test): disjunction wins (no singular for `test`); selinux key removed by $unset; + // composite does NOT apply (verity != root); $prepend still applies (boot == uki). + let b = combo_cell(&cells, "uki", "none", "test"); + assert_eq!(b.ic_config["os"]["hostname"].as_str(), Some("shared")); + assert!( + !b.ic_config["os"] + .as_mapping() + .unwrap() + .contains_key("selinux") + ); + assert!( + !b.ic_config["os"] + .as_mapping() + .unwrap() + .contains_key("label") + ); + assert_eq!(scripts(b), ["first.sh", "mid.sh"]); + + // (grub, root, prod): no axis override → base hostname; selinux kept; no composite; no prepend. + let c = combo_cell(&cells, "grub", "root", "prod"); + assert_eq!(c.ic_config["os"]["hostname"].as_str(), Some("base-host")); + assert!( + !c.ic_config["os"] + .as_mapping() + .unwrap() + .contains_key("label") + ); + assert_eq!(scripts(c), ["mid.sh"]); + } +} diff --git a/tools/tailor/crates/tailor-config/src/schema.rs b/tools/tailor/crates/tailor-config/src/schema.rs index 003e57934..5aa788a68 100644 --- a/tools/tailor/crates/tailor-config/src/schema.rs +++ b/tools/tailor/crates/tailor-config/src/schema.rs @@ -799,7 +799,10 @@ pub struct AzureLinuxBase { pub variant: String, } -/// One output: a format plus optional knobs. +/// One output: a format plus optional knobs. The published artifact basename is always the cell's +/// slug (`.`, clones suffixed `_cloneN`) -- there is no per-output name override +/// (`meta/docs/2026-06-22-image-definitions.md` §10 settled on slug-only publishing to avoid a +/// second, effectively per-cell naming scheme that complicates dependencies, caching, and clean). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OutputSpec { diff --git a/tools/tailor/crates/tailor-core/src/domain.rs b/tools/tailor/crates/tailor-core/src/domain.rs index 56835740b..f80a01543 100644 --- a/tools/tailor/crates/tailor-core/src/domain.rs +++ b/tools/tailor/crates/tailor-core/src/domain.rs @@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc}; use serde_yaml_ng::Value; use tailor_config::{ Arch, BaseImageCatalogue, BaseSource, ExtraParam, ImageDefinition, OutputArtifactsPolicy, - OutputSpec, ToolsDirSource, ToolsDirSourceInline, + OutputSpec, SigningRef, ToolsDirSource, ToolsDirSourceInline, }; /// A resolved image — the catalogue/authoring unit, after config load and defaults are applied. @@ -62,6 +62,8 @@ pub struct Cell { pub rpm_sources: Vec, /// Extra IC command-line flags appended verbatim after every tailor-managed flag. pub extra_params: Vec, + /// The resolved per-cell `signing:` opt-in after fragment merging. + pub signing: Option, /// Resolved `${inputs.}` producer artifact paths substituted into `ic_config`, in declared /// order. Content-hashed into the fingerprint (like `extraDependencies`) so a producer rebuild /// invalidates this cell (`meta/docs/2026-09-09-inter-image-dependencies.md`). Live in the (already diff --git a/tools/tailor/crates/tailor-core/src/error.rs b/tools/tailor/crates/tailor-core/src/error.rs index fbbf3f4f5..212ed47b0 100644 --- a/tools/tailor/crates/tailor-core/src/error.rs +++ b/tools/tailor/crates/tailor-core/src/error.rs @@ -184,6 +184,17 @@ pub enum CoreError { )] CompressedImageBase { image: String, dependency: String }, + #[error( + "cells `{first}` and `{second}` both publish to `{}` -- give the cells distinct matrix \ + axis values (or format/compression) so their slugs no longer collide", + .path.display() + )] + DuplicateOutputPath { + path: PathBuf, + first: String, + second: String, + }, + #[error( "image `{image}` cell `{slug}` depends on `{producer}`, but `{producer}` has axis `{axis}` \ that `{image}` does not — the producer cell is ambiguous ({axis} ∈ {{{values}}}); pin it \ diff --git a/tools/tailor/crates/tailor-core/src/fingerprint.rs b/tools/tailor/crates/tailor-core/src/fingerprint.rs index d77e6aac2..030bb5aca 100644 --- a/tools/tailor/crates/tailor-core/src/fingerprint.rs +++ b/tools/tailor/crates/tailor-core/src/fingerprint.rs @@ -39,6 +39,15 @@ pub struct FingerprintInputs<'a> { pub compression: Option, /// COSI compression level (`--cosi-compression-level`); a change rebuilds the COSI. pub cosi_compression_level: Option, + /// Effective signing profile descriptor -- `:::` + /// (`meta/docs/2026-06-29-signing.md` §4); a change -- including unsigned-to-signed, a profile + /// swap, or editing the backend/vault/certificate fields under the same id -- rebuilds the cell + /// so an unsigned (or differently-keyed) artifact isn't silently reused. + pub signing_profile: Option<&'a str>, + /// Sorted content hashes of the profile's local key/cert files (XXH3-128, `deps.rs`); a change + /// to the file *contents* alone (same declared path) still busts the fingerprint, unlike the id + /// or path strings above which only change when the config text itself changes. + pub signing_key_hashes: &'a [[u8; 16]], } /// Compute the canonical fingerprint. Each field is domain-separated and length-prefixed so distinct @@ -100,6 +109,12 @@ pub fn fingerprint(inputs: &FingerprintInputs<'_>) -> Fingerprint { if let Some(level) = inputs.cosi_compression_level { field(&mut hasher, b"cosi-compression-level", &[level]); } + if let Some(profile) = inputs.signing_profile { + field(&mut hasher, b"signing.profile", profile.as_bytes()); + } + for hash in inputs.signing_key_hashes { + field(&mut hasher, b"signing.key", hash); + } Fingerprint(hasher.finalize().into()) } @@ -154,6 +169,8 @@ mod tests { extra_params: &[], compression: None, cosi_compression_level: None, + signing_profile: None, + signing_key_hashes: &[], } } @@ -166,6 +183,36 @@ mod tests { assert_eq!(a, b); } + #[test] + fn fingerprint_changes_when_signing_profile_changes() { + let base = base(); + let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap(); + let mut unsigned = inputs("cell", &cfg, &base); + let mut signed = inputs("cell", &cfg, &base); + signed.signing_profile = Some("release"); + assert_ne!(fingerprint(&unsigned), fingerprint(&signed)); + + unsigned.signing_profile = Some("release"); + let mut other_profile = inputs("cell", &cfg, &base); + other_profile.signing_profile = Some("dev"); + assert_ne!(fingerprint(&unsigned), fingerprint(&other_profile)); + } + + #[test] + fn fingerprint_changes_when_signing_key_hash_changes() { + // A credential edit under the *same* profile id (e.g. rotating the keypair's PEM files in + // place) must still bust the fingerprint -- the id string alone doesn't change. + let base = base(); + let cfg: Value = serde_yaml_ng::from_str("os:\n hostname: a\n").unwrap(); + let mut a = inputs("cell", &cfg, &base); + a.signing_profile = Some("release:keypair::"); + a.signing_key_hashes = &[[1; 16], [2; 16]]; + let mut b = inputs("cell", &cfg, &base); + b.signing_profile = Some("release:keypair::"); + b.signing_key_hashes = &[[1; 16], [9; 16]]; + assert_ne!(fingerprint(&a), fingerprint(&b)); + } + #[test] fn config_change_changes_fingerprint() { let base = base(); diff --git a/tools/tailor/crates/tailor-core/src/orchestrator.rs b/tools/tailor/crates/tailor-core/src/orchestrator.rs index d43617f13..9e324b65e 100644 --- a/tools/tailor/crates/tailor-core/src/orchestrator.rs +++ b/tools/tailor/crates/tailor-core/src/orchestrator.rs @@ -4,7 +4,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, - path::Path, + path::{Path, PathBuf}, sync::Arc, }; @@ -89,6 +89,11 @@ impl Orchestrator { /// Plan a build: render and resolve every selected cell of every target, fingerprint it, and /// decide whether it is up to date against its build stamp. + /// + /// `seen_artifacts` accumulates claimed output paths across *every* call for a build run (the + /// caller plans one node at a time, `run.rs`'s per-node scheduling loop) -- a map scoped to a + /// single call only catches collisions within one target, not across the whole dependency + /// closure, letting two different images silently publish to the same path. #[allow( clippy::too_many_arguments, reason = "planning needs config, lock, pre-resolved images, selection, and output root" @@ -104,6 +109,7 @@ impl Orchestrator { selection: &BuildSelection<'_>, output_dir: &Path, hash_cache_dir: Option<&Path>, + seen_artifacts: &mut BTreeMap, ) -> Result { let mut planned = Vec::new(); for target in targets { @@ -168,6 +174,19 @@ impl Orchestrator { hash_cache_dir, target.name(), )?; + let resolved_signing_profile = + tailor_config::resolve_signing(cell.signing.as_ref(), tool.signing.as_ref()) + .ok() + .flatten(); + let signing_profile_descriptor_value = resolved_signing_profile + .as_ref() + .map(|(id, profile)| signing_profile_descriptor(id, profile)); + let signing_key_hashes_list = match &resolved_signing_profile { + Some((_, profile)) => { + signing_key_hashes(profile, &target.root, hash_cache_dir, target.name())? + } + None => Vec::new(), + }; let print = fingerprint(&FingerprintInputs { slug: cell.slug.as_ref(), toolchain_digest: &resolved_toolchain.ic_image_ref, @@ -181,12 +200,34 @@ impl Orchestrator { extra_params: &cell.extra_params, compression: cell.output.compression, cosi_compression_level: cell.output.cosi_compression_level, + signing_profile: signing_profile_descriptor_value.as_deref(), + signing_key_hashes: &signing_key_hashes_list, }); let artifact = output_dir.join(published_artifact_name( cell.slug.as_ref(), cell.output.format, cell.output.compression, )); + // Claim both the final published path and the uncompressed path Image Customizer + // actually writes: with the same slug/format, a compressed and an uncompressed + // output both execute through the same IC-written `artifact_name`, so checking only + // the (possibly different) published path misses that collision. + let written = + output_dir.join(artifact_name(cell.slug.as_ref(), cell.output.format)); + for claim in [artifact.clone(), written] + .into_iter() + .collect::>() + { + if let Some(first_slug) = + seen_artifacts.insert(claim.clone(), cell.slug.to_string()) + { + return Err(CoreError::DuplicateOutputPath { + path: claim, + first: first_slug, + second: cell.slug.to_string(), + }); + } + } let up_to_date = stamp::is_up_to_date(output_dir, cell.slug.as_ref(), print, &artifact); let runtime = runtime_config(tool, lock, &target.root); @@ -386,6 +427,41 @@ fn resolved_tools_dir<'a>( .ok_or(CoreError::MissingResolvedToolsDir { reference: key }) } +/// A deterministic descriptor of a signing profile's build-affecting *reference* settings -- +/// everything except local key/cert file contents, which are content-hashed separately +/// (`signing_key_hashes`) since a path alone doesn't change when the file it names is edited in +/// place. +fn signing_profile_descriptor(id: &str, profile: &tailor_config::SigningProfile) -> String { + format!( + "{id}:{}:{}:{}:{}", + profile.backend.as_str(), + profile.vault.as_deref().unwrap_or_default(), + profile.certificate.as_deref().unwrap_or_default(), + profile + .publish_ca_cert + .as_deref() + .map(Path::to_string_lossy) + .unwrap_or_default(), + ) +} + +/// Content-hash a signing profile's local key/cert files (`keypair` backend only -- other backends +/// declare no local file, so this is empty for them), resolved against the workspace root the same +/// way `tailor_sign::build_signer` resolves them. +fn signing_key_hashes( + profile: &tailor_config::SigningProfile, + workspace_root: &Path, + cache_dir: Option<&Path>, + image: &str, +) -> Result, CoreError> { + let paths: Vec = [profile.key.as_deref(), profile.cert.as_deref()] + .into_iter() + .flatten() + .map(PathBuf::from) + .collect(); + deps::hash_dependencies(&paths, workspace_root, None, cache_dir, image) +} + fn resolve_tools_dir(target: &Target) -> Result, CoreError> { let Some(tools_dir) = &target.definition.tools_dir else { return Ok(None); @@ -556,6 +632,7 @@ pub fn cells(target: &Arc) -> Result, CoreError> { base_image: base_image.clone(), rpm_sources: rc.rpm_sources.clone(), extra_params: rc.extra_params.clone(), + signing: rc.signing.clone(), input_deps: Vec::new(), tools_dir, skip: rc.skip, @@ -900,9 +977,9 @@ pub fn output_slug(slug: &str, clone_index: Option) -> String { } } -/// The artifact filename for a cell slug + format (a directory for `pxe-dir`). This is what Image -/// Customizer writes — the **uncompressed** name; see [`published_artifact_name`] for the final -/// artifact tailor publishes when `compression:` is set. +/// The artifact filename for a published basename + format (a directory for `pxe-dir`). This is +/// what Image Customizer writes — the **uncompressed** name; see [`published_artifact_name`] for the +/// final artifact tailor publishes when `compression:` is set. pub fn artifact_name(slug: &str, format: OutputFormat) -> String { let extension = match format { OutputFormat::Cosi => "cosi", @@ -1086,6 +1163,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap(); @@ -1150,6 +1228,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap() @@ -1208,6 +1287,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), TempDir::new().unwrap().path(), None, + &mut BTreeMap::new(), ) .await .unwrap_err(); @@ -1236,6 +1316,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap(); @@ -1275,6 +1356,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap(); @@ -1646,6 +1728,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap_err(); @@ -1739,6 +1822,7 @@ mod tests { &BuildSelection::from_selector(&Selector::default()), out.path(), None, + &mut BTreeMap::new(), ) .await .unwrap(); diff --git a/tools/tailor/crates/tailor-core/src/ports.rs b/tools/tailor/crates/tailor-core/src/ports.rs index fa7b5ab0f..478205383 100644 --- a/tools/tailor/crates/tailor-core/src/ports.rs +++ b/tools/tailor/crates/tailor-core/src/ports.rs @@ -85,10 +85,10 @@ pub struct ToolsDirPlan { /// The host-side signing port: sign the boot artifacts IC emits from a `customize` pass, in place, /// so IC's `inject-files` pass can re-inject them (`meta/docs/2026-06-29-signing.md` §6). Object-safe and /// **synchronous** — held as `dyn Signer` in [`ExecutionContext`]; the executor invokes it on a -/// blocking thread so the async runtime is never blocked on `openssl`/`sbsign`. +/// blocking thread so the async runtime is never blocked on `openssl`/`sbsign`/`pesign`. pub trait Signer: Send + Sync + fmt::Debug { /// Cheap, side-effect-free check that this backend can sign: required host binaries present - /// (`openssl`, and `sbsign` when PE artifacts are expected) and key material resolvable. Called + /// (`openssl`, plus either `sbsign` or the `pesign` toolchain when PE artifacts are expected) and key material resolvable. Called /// once per build, before any IC run (`meta/docs/2026-06-29-signing.md` §5.1). fn preflight(&self) -> Result<(), SignError>; diff --git a/tools/tailor/crates/tailor-core/src/selector.rs b/tools/tailor/crates/tailor-core/src/selector.rs index 0f2bffeac..469a6c123 100644 --- a/tools/tailor/crates/tailor-core/src/selector.rs +++ b/tools/tailor/crates/tailor-core/src/selector.rs @@ -172,6 +172,7 @@ mod tests { extra_params: vec![], input_deps: vec![], tools_dir: None, + signing: None, skip: false, skip_pins: Vec::new(), } diff --git a/tools/tailor/crates/tailor-core/src/signing.rs b/tools/tailor/crates/tailor-core/src/signing.rs index d86fa2428..8fd0f93a3 100644 --- a/tools/tailor/crates/tailor-core/src/signing.rs +++ b/tools/tailor/crates/tailor-core/src/signing.rs @@ -6,7 +6,7 @@ //! is cheap and side-effect-free. //! //! This module is the **foundation** (config + preflight). The signing *execution* — cert minting via -//! `rcgen`, PE signing via `sbsign`, and the `inject-files` IC pass — is a later milestone +//! `rcgen`, PE signing via `sbsign`/`pesign`, and the `inject-files` IC pass — is a later milestone //! (`meta/docs/2026-06-29-signing.md` §11, S1-remainder). Until it lands, `tailor` refuses a signed build rather //! than silently emit an unsigned image. @@ -42,7 +42,7 @@ pub enum SignError { /// Preflight found unmet prerequisites — a missing tool binary, an unreadable key, etc. Reported /// in aggregate, before any (slow, privileged) IC run. Preflight { missing: Vec }, - /// A signing step failed during execution (openssl/sbsign, `inject-files.yaml` handling, or IO). + /// A signing step failed during execution (openssl/sbsign/pesign, `inject-files.yaml` handling, or IO). Execution { detail: String }, } diff --git a/tools/tailor/crates/tailor-exec/src/arg_builder.rs b/tools/tailor/crates/tailor-exec/src/arg_builder.rs index 2c093df1e..6bb408e3d 100644 --- a/tools/tailor/crates/tailor-exec/src/arg_builder.rs +++ b/tools/tailor/crates/tailor-exec/src/arg_builder.rs @@ -124,11 +124,16 @@ pub fn build_ic_args(cell: &Cell, context: &ExecutionContext) -> Result Path context.output_dir.join(format!("{slug}.intermediate.raw")) } pub(crate) fn artifact_path(cell: &Cell, context: &ExecutionContext) -> PathBuf { - let slug = output_slug(cell.slug.as_ref(), context.clone_index); + let name = output_slug(cell.slug.as_ref(), context.clone_index); context .output_dir - .join(artifact_name(&slug, cell.output.format)) + .join(artifact_name(&name, cell.output.format)) } /// The host path of the per-cell IC log when on-disk persistence is enabled, else `None` @@ -656,7 +661,7 @@ pub(crate) fn render_signed_dry_run( Ok(format!( "# {slug} — signed 3-pass (meta/docs/2026-06-29-signing.md §5)\n\ # pass 1/3: customize -> raw intermediate ({intermediate})\n{customize}\n\n\ - # pass 2/3: host-side sign the staged boot artifacts (openssl + sbsign); publish CA -> {ca}\n\ + # pass 2/3: host-side sign the staged boot artifacts (openssl + sbsign/pesign); publish CA -> {ca}\n\ # pass 3/3: inject-files -> final {fmt} ({final})", slug = cell.slug.as_ref(), intermediate = intermediate.display(), @@ -827,6 +832,10 @@ mod tests { assert!(!args.iter().any(|arg| arg == FLAG_CONFIG_FILE)); assert!(!args.iter().any(|arg| arg == FLAG_RPM_SOURCE)); + // IC's `convert` subcommand has no --image-cache-dir flag (only `customize` downloads/caches + // OCI/azureLinux bases) and rejects unknown flags outright, so tailor must never emit it here + // even though sample_context() configures a cache dir (as a real workspace would). + assert!(!args.iter().any(|arg| arg == FLAG_IMAGE_CACHE_DIR)); assert_eq!(args[0], SUBCOMMAND_CONVERT); assert!( args.windows(2) @@ -1285,6 +1294,7 @@ mod tests { extra_params: Vec::new(), input_deps: Vec::new(), tools_dir: None, + signing: None, skip: false, skip_pins: Vec::new(), } diff --git a/tools/tailor/crates/tailor-exec/src/executor.rs b/tools/tailor/crates/tailor-exec/src/executor.rs index 6875d0c76..50d2d2f49 100644 --- a/tools/tailor/crates/tailor-exec/src/executor.rs +++ b/tools/tailor/crates/tailor-exec/src/executor.rs @@ -48,13 +48,14 @@ impl Executor for IcExecutor { ) -> Result { // Image Customizer writes the uncompressed artifact; when `compression:` is set, tailor // compresses it into `published_path` after the build (`ic_output_path` is then removed). - // The output slug carries the clone index, so each `--clones` run publishes a distinct file. - let out_slug = output_slug(cell.slug.as_ref(), context.clone_index); + // The published artifact name carries the clone index, so each `--clones` run still yields + // a distinct file. + let out_name = output_slug(cell.slug.as_ref(), context.clone_index); let ic_output_path = context .output_dir - .join(artifact_name(&out_slug, cell.output.format)); + .join(artifact_name(&out_name, cell.output.format)); let published_path = context.output_dir.join(published_artifact_name( - &out_slug, + &out_name, cell.output.format, cell.output.compression, )); @@ -802,6 +803,7 @@ mod tests { extra_params: Vec::new(), input_deps: Vec::new(), tools_dir: None, + signing: None, skip: false, skip_pins: Vec::new(), } diff --git a/tools/tailor/crates/tailor-sign/Cargo.toml b/tools/tailor/crates/tailor-sign/Cargo.toml index b07e96cfd..938937b50 100644 --- a/tools/tailor/crates/tailor-sign/Cargo.toml +++ b/tools/tailor/crates/tailor-sign/Cargo.toml @@ -15,6 +15,7 @@ serde = { workspace = true } serde_yaml_ng = { workspace = true } tracing = { workspace = true } tempfile = { workspace = true } +xxhash-rust = { workspace = true } [dev-dependencies] diff --git a/tools/tailor/crates/tailor-sign/src/lib.rs b/tools/tailor/crates/tailor-sign/src/lib.rs index 63f2e8c04..3407ec80e 100644 --- a/tools/tailor/crates/tailor-sign/src/lib.rs +++ b/tools/tailor/crates/tailor-sign/src/lib.rs @@ -2,8 +2,9 @@ //! (`meta/docs/2026-06-29-signing.md` §6). //! //! External **host** tools only, no bundled crypto: `openssl` mints the per-cell CA + code-signing -//! leaf and signs verity-hash artifacts (detached CMS/DER), and `sbsign` signs PE/Authenticode boot -//! artifacts (UKIs, shim, systemd-boot). The signer reads the `inject-files.yaml` IC emits from a +//! leaf and signs verity-hash artifacts (detached CMS/DER), while PE/Authenticode boot artifacts +//! (UKIs, shim, systemd-boot) are signed with either `sbsign` or `pesign` depending on which host +//! toolchain is available. The signer reads the `inject-files.yaml` IC emits from a //! `customize` pass and signs each listed artifact **in place**, so IC's `inject-files` pass can //! re-inject the now-signed files. @@ -18,6 +19,7 @@ use std::{ use serde::Deserialize; use tempfile::TempDir; use tracing::debug; +use xxhash_rust::xxh3::xxh3_64; use tailor_config::{SigningBackend, SigningProfile}; use tailor_core::{MissingPrerequisite, SignError, Signer, SigningPlan, SigningResult}; @@ -26,6 +28,13 @@ use tailor_core::{MissingPrerequisite, SignError, Signer, SigningPlan, SigningRe const OPENSSL: &str = "openssl"; /// The `sbsign` binary — PE/Authenticode signing. const SBSIGN: &str = "sbsign"; +/// The `pesign` binary — alternate PE/Authenticode signing backend used on AZL/Mariner hosts. +const PESIGN: &str = "pesign"; +/// NSS DB init/import tools used by the `pesign` fallback. +const CERTUTIL: &str = "certutil"; +const PK12UTIL: &str = "pk12util"; +/// Friendly name used when importing a generated PEM keypair into a temporary NSS DB for `pesign`. +const PESIGN_NICKNAME: &str = "tailor-signing-leaf"; /// Validity of the minted local test CA and leaf. Long enough that CI never trips over expiry; this /// is explicitly **not** a production trust root (`meta/docs/2026-06-29-signing.md` §6). const CERT_VALIDITY_DAYS: &str = "3650"; @@ -88,7 +97,7 @@ struct Ca { cert: PathBuf, } -/// A host-tool signer (`openssl` + `sbsign`) parameterized by its [`KeySource`]. +/// A host-tool signer (`openssl` + `sbsign`/`pesign`) parameterized by its [`KeySource`]. #[derive(Debug)] struct HostSigner { profile_id: String, @@ -108,19 +117,16 @@ impl Signer for HostSigner { }); }; - // Both tools are checked up front (fail-fast, `meta/docs/2026-06-29-signing.md` §5.1): `openssl` mints and - // signs verity-hash, `sbsign` signs PE artifacts. We cannot yet know which artifact types IC - // will emit, so require both whenever a profile signs — a missing tool fails at build start - // rather than minutes into a customize. + // `openssl` is always required (CA/leaf minting + verity CMS). PE artifacts are signed with + // either `sbsign` or the AZL/Mariner-native `pesign` toolchain, so fail fast only when + // neither path is available. if !tool_on_path(OPENSSL) { note(format!( "`{OPENSSL}` not found on PATH (required to mint/sign)" )); } - if !tool_on_path(SBSIGN) { - note(format!( - "`{SBSIGN}` not found on PATH (required to sign PE/UKI boot artifacts)" - )); + if let Err(detail) = check_pe_signer_tools() { + note(detail); } match &self.key_source { KeySource::LocalTestCa { .. } => {} @@ -297,7 +303,7 @@ enum ArtifactType { /// How an artifact is signed. #[derive(Debug, PartialEq, Eq)] enum ArtifactKind { - /// PE/Authenticode via `sbsign` (UKIs, shim, systemd-boot `.efi`). + /// PE/Authenticode via `sbsign` or `pesign` (UKIs, shim, systemd-boot `.efi`). Pe, /// Detached CMS/DER via `openssl smime` (dm-verity root hash). Verity, @@ -326,7 +332,7 @@ fn infer_kind(source: &Path) -> ArtifactKind { } } -// ───────────────────────────── openssl / sbsign orchestration ───────────────────────────── +// ───────────────────────────── openssl / sbsign / pesign orchestration ───────────────────────────── /// Mint a self-signed CA (RSA-2048) with `openssl req -x509`. fn mint_ca(ca_key: &Path, ca_cert: &Path) -> Result<(), SignError> { @@ -350,8 +356,40 @@ fn mint_ca(ca_key: &Path, ca_cert: &Path) -> Result<(), SignError> { ) } +/// X.509 limits a `CommonName` attribute to 64 characters (RFC 5280 `ub-common-name`); OpenSSL's +/// `ASN1_mbstring_ncopy` enforces this and aborts the whole `req` with "string too long" if we hand +/// it anything longer. Cell slugs (image + every matrix axis + format, optionally `_cloneN`) easily +/// exceed that once `leaf_id` is embedded verbatim in `/CN=tailor leaf {leaf_id}` (12 literal chars +/// leave only 52 for `leaf_id`), so bound the CN to a safe length and disambiguate any truncation +/// with a short content hash - the full `leaf_id` is still the one used for on-disk file naming +/// elsewhere, this only affects the certificate's CN attribute. +const CN_MAX_LEN: usize = 64; +const CN_PREFIX: &str = "tailor leaf "; + +fn leaf_common_name(leaf_id: &str) -> String { + let budget = CN_MAX_LEN - CN_PREFIX.len(); + if leaf_id.len() <= budget { + return format!("{CN_PREFIX}{leaf_id}"); + } + // Keep a readable prefix and append a short hash of the *full* leaf_id so two different + // leaf_ids that share a truncated prefix never collide on CN alone. + let hash = format!("{:016x}", xxh3_64(leaf_id.as_bytes())); + let keep = budget.saturating_sub(hash.len() + 1); // +1 for the separator + // Truncate at the last UTF-8 char boundary at or before `keep` bytes, not a raw byte index -- + // leaf_id (image/matrix axis strings) is not restricted to ASCII, and slicing mid-character + // panics. This keeps the original byte-length CN budget while never splitting a character. + let safe_len = leaf_id + .char_indices() + .map(|(idx, _)| idx) + .take_while(|&idx| idx <= keep) + .last() + .unwrap_or(0); + format!("{CN_PREFIX}{}-{hash}", &leaf_id[..safe_len]) +} + /// Mint a code-signing leaf (`extendedKeyUsage=codeSigning`) signed by the CA. The leaf CN embeds the -/// per-cell `leaf_id` so parallel cells never share a leaf. +/// per-cell `leaf_id` (bounded to fit X.509's 64-character CN limit, see [`leaf_common_name`]) so +/// parallel cells never share a leaf. fn mint_leaf( ca_key: &Path, ca_cert: &Path, @@ -373,7 +411,7 @@ fn mint_leaf( "-out", &lossy(&csr), "-subj", - &format!("/CN=tailor leaf {leaf_id}"), + &format!("/CN={}", leaf_common_name(leaf_id)), ], )?; // codeSigning EKU via an ext file (process substitution isn't available through `Command`). @@ -402,8 +440,21 @@ fn mint_leaf( ) } -/// PE/Authenticode-sign `file` in place with `sbsign` (writes to a temp output, then renames over). +/// PE/Authenticode-sign `file` in place, preferring `sbsign` and falling back to `pesign` when the +/// host only has the AZL/Mariner-native toolchain. fn sign_pe(file: &Path, key: &Path, cert: &Path) -> Result<(), SignError> { + match pe_signing_tool() { + Some(PeSigningTool::Sbsign) => sign_pe_with_sbsign(file, key, cert), + Some(PeSigningTool::Pesign) => sign_pe_with_pesign(file, key, cert), + None => Err(SignError::Execution { + detail: format!( + "neither `{SBSIGN}` nor the `pesign` toolchain (`{PESIGN}`, `{CERTUTIL}`, `{PK12UTIL}`) is available on PATH" + ), + }), + } +} + +fn sign_pe_with_sbsign(file: &Path, key: &Path, cert: &Path) -> Result<(), SignError> { let signed = with_suffix(file, ".signed"); run( SBSIGN, @@ -420,6 +471,56 @@ fn sign_pe(file: &Path, key: &Path, cert: &Path) -> Result<(), SignError> { fs::rename(&signed, file).map_err(|e| io_err("replace with signed PE", file, &e)) } +fn sign_pe_with_pesign(file: &Path, key: &Path, cert: &Path) -> Result<(), SignError> { + let temp = TempDir::new().map_err(|e| SignError::Execution { + detail: format!("create pesign temp dir: {e}"), + })?; + let nssdb = temp.path().join("nssdb"); + fs::create_dir_all(&nssdb).map_err(|e| io_err("create pesign NSS DB dir", &nssdb, &e))?; + run(CERTUTIL, &["-N", "-d", &lossy(&nssdb), "--empty-password"])?; + + let pkcs12 = temp.path().join("leaf.p12"); + run( + OPENSSL, + &[ + "pkcs12", + "-export", + "-out", + &lossy(&pkcs12), + "-inkey", + &lossy(key), + "-in", + &lossy(cert), + "-passout", + "pass:", + "-name", + PESIGN_NICKNAME, + ], + )?; + run( + PK12UTIL, + &["-i", &lossy(&pkcs12), "-d", &lossy(&nssdb), "-W", ""], + )?; + + let cert_arg = pesign_certificate_arg()?; + let signed = with_suffix(file, ".signed"); + let args = vec![ + "--certdir".to_owned(), + lossy(&nssdb), + cert_arg, + PESIGN_NICKNAME.to_owned(), + "--sign".to_owned(), + "--in".to_owned(), + lossy(file), + "--out".to_owned(), + lossy(&signed), + "--force".to_owned(), + ]; + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run(PESIGN, &arg_refs)?; + fs::rename(&signed, file).map_err(|e| io_err("replace with signed PE", file, &e)) +} + /// Sign a dm-verity root hash with a detached CMS/DER signature (`openssl smime`), replacing the /// artifact in place — mirroring Trident's `sign.py`. /// @@ -449,6 +550,58 @@ fn sign_verity(file: &Path, key: &Path, cert: &Path) -> Result<(), SignError> { fs::rename(&sig, file).map_err(|e| io_err("replace with verity signature", file, &e)) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PeSigningTool { + Sbsign, + Pesign, +} + +fn pe_signing_tool() -> Option { + if tool_on_path(SBSIGN) { + Some(PeSigningTool::Sbsign) + } else if tool_on_path(PESIGN) && tool_on_path(CERTUTIL) && tool_on_path(PK12UTIL) { + Some(PeSigningTool::Pesign) + } else { + None + } +} + +fn check_pe_signer_tools() -> Result<(), String> { + if pe_signing_tool().is_some() { + return Ok(()); + } + let mut missing = Vec::new(); + for tool in [PESIGN, CERTUTIL, PK12UTIL] { + if !tool_on_path(tool) { + missing.push(format!("`{tool}`")); + } + } + Err(format!( + "neither `{SBSIGN}` nor the `pesign` toolchain is available on PATH (need `{SBSIGN}` or `{PESIGN}` + `{CERTUTIL}` + `{PK12UTIL}` to sign PE/UKI boot artifacts; missing: {})", + missing.join(", ") + )) +} + +fn pesign_certificate_arg() -> Result { + let output = Command::new(PESIGN) + .arg("--help") + .output() + .map_err(|e| SignError::Execution { + detail: format!("failed to run `{PESIGN} --help`: {e}"), + })?; + let help = format!( + "{} +{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(if help.contains("--certficate") { + "--certficate".to_owned() + } else { + "--certificate".to_owned() + }) +} + // ───────────────────────────── small helpers ───────────────────────────── /// Is `name` an executable file on `PATH`? A cheap, side-effect-free presence probe for preflight. @@ -532,6 +685,60 @@ mod tests { tool_on_path(OPENSSL) } + #[test] + fn leaf_common_name_fits_within_64_chars_for_short_ids() { + let cn = leaf_common_name("solo_amd64_cosi"); + assert_eq!(cn, "tailor leaf solo_amd64_cosi"); + assert!(cn.len() <= 64); + } + + #[test] + fn leaf_common_name_truncates_and_hashes_long_ids() { + // Reproduces the exact real-world overflow: a `trident-verity-testimage` container/usr + // clone0 slug is 69 chars once prefixed with "tailor leaf ", which OpenSSL's + // ASN1_mbstring_ncopy (ub-common-name = 64) rejects outright. + let long_id = "trident-verity-testimage_container_usr_amd64_cosi_clone0"; + assert!(format!("tailor leaf {long_id}").len() > 64); + + let cn = leaf_common_name(long_id); + assert!( + cn.len() <= 64, + "CN still too long: {cn:?} ({} chars)", + cn.len() + ); + assert!(cn.starts_with("tailor leaf ")); + + // Two different long ids sharing the same truncated prefix must not collide. + let other_id = "trident-verity-testimage_container_usr_amd64_cosi_clone1"; + let other_cn = leaf_common_name(other_id); + assert_ne!(cn, other_cn); + assert!(other_cn.len() <= 64); + + // Deterministic: same input always yields the same CN. + assert_eq!(cn, leaf_common_name(long_id)); + } + + #[test] + fn leaf_common_name_does_not_panic_on_multibyte_truncation_boundary() { + // A long, non-ASCII leaf_id whose naive byte-index truncation point would fall inside a + // multi-byte UTF-8 character must not panic, and the CN must still respect the original + // byte-length budget. + let long_id = "\u{1F600}".repeat(40); // 4-byte emoji, well past the CN budget + let cn = leaf_common_name(&long_id); + assert!( + cn.len() <= 64, + "CN still too long: {cn:?} ({} bytes)", + cn.len() + ); + assert!(cn.starts_with("tailor leaf ")); + + // Deterministic and collision-resistant, same as the ASCII long-id case. + let other_id = "\u{1F601}".repeat(40); + let other_cn = leaf_common_name(&other_id); + assert_ne!(cn, other_cn); + assert_eq!(cn, leaf_common_name(&long_id)); + } + #[test] fn artifact_type_dispatch_covers_every_kind() { let pe = |t: ArtifactType| InjectArtifact { @@ -590,7 +797,8 @@ mod tests { let SignError::Preflight { missing } = signer.preflight().unwrap_err() else { panic!("expected a preflight error"); }; - // openssl/sbsign presence varies by host, so assert on the key/cert prerequisites we control. + // PE signer availability varies by host (`sbsign` vs `pesign`), so assert only on the + // key/cert prerequisites we control here. assert!(missing.iter().any(|m| m.detail.contains("`key`"))); assert!(missing.iter().any(|m| m.detail.contains("`cert`"))); } @@ -614,7 +822,7 @@ mod tests { let dir = tempdir().unwrap(); let artifacts = dir.path().join("artifacts"); fs::create_dir_all(&artifacts).unwrap(); - // A verity-hash artifact (openssl can sign arbitrary bytes; sbsign needs a real PE, so this + // A verity-hash artifact (openssl can sign arbitrary bytes; PE signing needs a real EFI // test exercises the openssl path end to end without a PE fixture). fs::write(artifacts.join("root.hash"), b"deadbeef root hash bytes").unwrap(); fs::write( diff --git a/tools/tailor/crates/tailor/src/run.rs b/tools/tailor/crates/tailor/src/run.rs index fff51e955..61f229b29 100644 --- a/tools/tailor/crates/tailor/src/run.rs +++ b/tools/tailor/crates/tailor/src/run.rs @@ -338,7 +338,29 @@ fn validate(workspace: &Workspace, names: &[String], selector: &Selector) -> Res } // Surface signing prerequisites non-fatally (meta/docs/2026-06-29-signing.md §5.1) so they are discoverable // without starting a real build. - let signing = signing_requirements(&targets, tool.signing.as_ref())?; + let mut signing: Vec> = Vec::new(); + for target in &targets { + for cell in cells_selected(target, selector)? { + let Some((profile_id, profile)) = + tailor_config::resolve_signing(cell.signing.as_ref(), tool.signing.as_ref())? + else { + continue; + }; + let slug = cell.slug.as_ref().to_owned(); + if let Some(existing) = signing + .iter_mut() + .find(|requirement| requirement.profile_id == profile_id) + { + existing.images.push(slug); + } else { + signing.push(SigningRequirement { + profile_id, + profile, + images: vec![slug], + }); + } + } + } ensure_signing_preview(&tool, &signing)?; report_signing(&build_signers(&signing, &workspace.root)); Ok(()) @@ -1424,6 +1446,7 @@ fn convert_cell(input: &Path, dir: &Path, arch: Arch, format: OutputFormat, slug base_image: None, rpm_sources: Vec::new(), extra_params: Vec::new(), + signing: None, input_deps: Vec::new(), tools_dir: None, skip: false, @@ -1501,21 +1524,22 @@ async fn build( .get_or_insert_with(Runtime::default) .build_dir_base = Some(output_dir.join(TAILOR_STATE_DIR).join(BUILD_DIR)); } - let signing = signing_requirements(&targets, tool.signing.as_ref())?; + let signing = signing_requirements(&ordered, &build_selection, tool.signing.as_ref())?; ensure_signing_preview(&tool, &signing)?; // Build one signer per required profile (a shared CA per build); `signer_for` resolves a cell to - // its signer via the image name (meta/docs/2026-06-29-signing.md §6). Empty when nothing signs. + // its signer via the rendered cell slug, so only the cells whose merged fragments opted into + // `signing:` take the signed three-pass path. let signers = build_signers(&signing, &workspace.root); - let image_signer: HashMap<&str, Arc> = signers + let slug_signer: HashMap<&str, Arc> = signers .iter() .flat_map(|(requirement, signer)| { requirement .images .iter() - .map(move |image| (image.as_str(), Arc::clone(signer))) + .map(move |slug| (slug.as_str(), Arc::clone(signer))) }) .collect(); - let signer_for = |cell: &Cell| image_signer.get(cell.target.name()).cloned(); + let signer_for = |cell: &Cell| slug_signer.get(cell.slug.as_ref()).cloned(); // Dry-run prints each selected cell's container invocation (the signed 3-pass for signed cells) // without resolving digests, running, or contacting any engine — daemon-free via a no-op runtime. @@ -1542,7 +1566,7 @@ async fn build( return Ok(()); } - // Fail fast on every signing prerequisite — including the `openssl`/`sbsign` binaries — before any + // Fail fast on every signing prerequisite — including the `openssl`/PE-signing binaries — before any // (slow, privileged) IC run (meta/docs/2026-06-29-signing.md §5.1). preflight_signers(&signers)?; @@ -1593,6 +1617,10 @@ async fn build( let clones = args.clones.max(1); let mut built = 0usize; let mut selected_cells = 0usize; + // Threaded across every per-node `plan()` call below (not reset each iteration) so a collision + // between two different targets' output paths is still caught -- a map scoped to one call only + // sees that target's own cells (`orchestrator.rs::plan`). + let mut seen_artifacts = BTreeMap::new(); for node in &ordered { let plan = orchestrator .plan( @@ -1605,6 +1633,7 @@ async fn build( &build_selection, &output_dir, Some(&base_hash_cache_dir), + &mut seen_artifacts, ) .await?; selected_cells += plan.cells.len(); @@ -1677,18 +1706,25 @@ async fn clean( for target in &targets { // A signed image also drops an enrollable CA cert beside each cell's image (§6); remove it // too. Lenient on config: `validate` surfaces signing errors; cleanup should not fail on them. - let signed = tailor_config::resolve_signing( - target.definition.signing.as_ref(), - tool.signing.as_ref(), - ) - .ok() - .flatten() - .is_some(); for cell in cells_selected(target, selector)? { + let signed = + tailor_config::resolve_signing(cell.signing.as_ref(), tool.signing.as_ref()) + .ok() + .flatten() + .is_some(); paths.push(output_dir.join(tailor_core::artifact_name( cell.slug.as_ref(), cell.output.format, ))); + // A compressed artifact publishes under its own post-compression filename; make sure + // `clean` also knows about the compressed form. + if cell.output.compression.is_some() { + paths.push(output_dir.join(tailor_core::published_artifact_name( + cell.slug.as_ref(), + cell.output.format, + cell.output.compression, + ))); + } if signed { paths.push(output_dir.join(ca_cert_name(cell.slug.as_ref()))); } @@ -1978,25 +2014,33 @@ fn ensure_signing_preview( /// each (`meta/docs/2026-06-29-signing.md` §5.1). Resolution also validates each referenced profile. fn signing_requirements<'a>( targets: &[Arc], + selector: &BuildSelection<'_>, workspace_signing: Option<&'a tailor_config::SigningConfig>, ) -> Result>, AppError> { let mut requirements: Vec> = Vec::new(); for target in targets { - let resolved = - tailor_config::resolve_signing(target.definition.signing.as_ref(), workspace_signing)?; - if let Some((profile_id, profile)) = resolved { - let image = target.definition.name.clone(); - if let Some(existing) = requirements - .iter_mut() - .find(|requirement| requirement.profile_id == profile_id) - { - existing.images.push(image); - } else { - requirements.push(SigningRequirement { - profile_id, - profile, - images: vec![image], - }); + for cell in select_node_cells( + target, + selector.selector, + selector.requested.contains(target.name()), + selector.required.get(target.name()), + )? { + let resolved = + tailor_config::resolve_signing(cell.signing.as_ref(), workspace_signing)?; + if let Some((profile_id, profile)) = resolved { + let slug = cell.slug.as_ref().to_owned(); + if let Some(existing) = requirements + .iter_mut() + .find(|requirement| requirement.profile_id == profile_id) + { + existing.images.push(slug); + } else { + requirements.push(SigningRequirement { + profile_id, + profile, + images: vec![slug], + }); + } } } } @@ -2053,7 +2097,7 @@ fn preflight_signers(signers: &[ProfileSigner<'_>]) -> Result<(), AppError> { } /// Report signing-preflight status **without failing** — for `validate` and `--dry-run` -/// (`meta/docs/2026-06-29-signing.md` §5.1). Runs each signer's preflight so missing `openssl`/`sbsign` binaries +/// (`meta/docs/2026-06-29-signing.md` §5.1). Runs each signer's preflight so missing `openssl`/PE-signing binaries /// surface too. fn report_signing(signers: &[ProfileSigner<'_>]) { for (requirement, signer) in signers { diff --git a/tools/tailor/docs/explanation/crate-architecture.md b/tools/tailor/docs/explanation/crate-architecture.md index 578c8c38e..c63b96532 100644 --- a/tools/tailor/docs/explanation/crate-architecture.md +++ b/tools/tailor/docs/explanation/crate-architecture.md @@ -21,7 +21,7 @@ graph TD | `tailor-core` | Domain model, build plans, lockfile/stamp logic, orchestration, and port traits. | | `tailor-resolve` | Resolve toolchain and base image digests/hashes. | | `tailor-exec` | Docker/Bollard execution adapter, IC arg construction, path translation, cleanup. | -| `tailor-sign` | Host-side signing backends for the `Signer` port (openssl/`sbsign`); preview-gated. | +| `tailor-sign` | Host-side signing backends for the `Signer` port (openssl/`sbsign`/`pesign`); preview-gated. | | `tailor` | CLI parsing, command dispatch, output formatting, and composition root. | The config-to-render path is deterministic and synchronous. Container execution is the async boundary because Docker/Bollard and log streaming are async. diff --git a/tools/tailor/docs/reference/image-yaml.md b/tools/tailor/docs/reference/image-yaml.md index f15b7c95a..3fd57815a 100644 --- a/tools/tailor/docs/reference/image-yaml.md +++ b/tools/tailor/docs/reference/image-yaml.md @@ -264,12 +264,13 @@ entries whose kind is `image` add a build-order edge; each input `name` is uniqu outputs: - format: cosi cosiCompressionLevel: 6 - name: "${name}-${arch}" - format: vhd-fixed compression: zstd # tailor compresses the artifact → .vhd.zst ``` -`format` is required. `cosiCompressionLevel`, `compression`, and `name` are optional. +`format` is required. `cosiCompressionLevel` and `compression` are optional. The published +basename is always the cell's slug (`.`, clone-suffixed `_cloneN` under `--clones`) -- +there is no per-output name override. ### `compression`