From eba176ccbbd510dbcd2d8b798892cb7da6c6728a Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 10 Oct 2026 16:49:16 +0000 Subject: [PATCH 1/3] tailor: fix image build arguments and signing Fix several bugs in tailor discovered while validating signed and convert-based image builds: - stop emitting --image-cache-dir for Image Customizer convert and inject-files operations - restore fragment-level signing through render and build selection for signed VM image cells - fall back to pesign when sbsign is unavailable on Azure Linux hosts - bound leaf certificate common names to the X.509 64-character limit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/tailor/Cargo.lock | 1 + .../crates/tailor-config/src/fragment.rs | 5 +- .../tailor/crates/tailor-config/src/render.rs | 20 +- tools/tailor/crates/tailor-core/src/domain.rs | 4 +- .../crates/tailor-core/src/orchestrator.rs | 1 + tools/tailor/crates/tailor-core/src/ports.rs | 4 +- .../tailor/crates/tailor-core/src/selector.rs | 1 + .../tailor/crates/tailor-core/src/signing.rs | 4 +- .../crates/tailor-exec/src/arg_builder.rs | 22 +- .../tailor/crates/tailor-exec/src/executor.rs | 1 + tools/tailor/crates/tailor-sign/Cargo.toml | 1 + tools/tailor/crates/tailor-sign/src/lib.rs | 217 ++++++++++++++++-- tools/tailor/crates/tailor/src/run.rs | 83 ++++--- .../docs/explanation/crate-architecture.md | 2 +- 14 files changed, 304 insertions(+), 62 deletions(-) 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..18029fc74 100644 --- a/tools/tailor/crates/tailor-config/src/render.rs +++ b/tools/tailor/crates/tailor-config/src/render.rs @@ -18,7 +18,7 @@ use crate::{ include, interpolate, matrix, matrix::AxisTuple, merge, - schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec}, + schema::{BaseSource, ExtraParam, ImageDefinition, OutputSpec, SigningRef}, types::{OutputFormat, ParamValue}, }; @@ -65,6 +65,8 @@ pub struct RenderedCell { 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`). @@ -203,6 +205,7 @@ fn render_cell( .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 @@ -223,6 +226,7 @@ fn render_cell( outputs, rpm_sources, extra_params, + signing, skip, skip_pins, }) @@ -323,6 +327,20 @@ fn resolve_outputs( } } +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. 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/orchestrator.rs b/tools/tailor/crates/tailor-core/src/orchestrator.rs index d43617f13..fcc1a31cb 100644 --- a/tools/tailor/crates/tailor-core/src/orchestrator.rs +++ b/tools/tailor/crates/tailor-core/src/orchestrator.rs @@ -556,6 +556,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, 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..48296e3d1 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 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..7855a9ef0 100644 --- a/tools/tailor/crates/tailor-exec/src/executor.rs +++ b/tools/tailor/crates/tailor-exec/src/executor.rs @@ -802,6 +802,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..987046668 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,31 @@ 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 + format!("{CN_PREFIX}{}-{hash}", &leaf_id[..keep]) +} + /// 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 +402,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 +431,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 +462,63 @@ 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 +548,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 +683,35 @@ 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 artifact_type_dispatch_covers_every_kind() { let pe = |t: ArtifactType| InjectArtifact { @@ -590,7 +770,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 +795,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..e0df36856 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)?; @@ -1677,14 +1701,11 @@ 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, @@ -1978,25 +1999,27 @@ 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 +2076,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. From 746cee7b2b9c7dfc4b0d113243a01d96743463d3 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 10 Oct 2026 18:34:22 +0000 Subject: [PATCH 2/3] tailor-config: resolve $include under base: before merge config: already resolved $include before merging each fragment's delta (render_cell calls include::resolve_includes on fragment.doc.config), but resolve_base merged each fragment's raw base: value directly, so a base: { $include: ... } fragment hit merge.rs's generic "directive $include ... must be resolved before merge" error - that generic message exists precisely to catch an $include that should have already been resolved by an earlier pass, and base: simply never had one. Thread image_dir into resolve_base and call include::resolve_includes on each fragment's base value before merging, mirroring config:'s handling exactly. This lets a by-/.yaml fragment's base: override point at a shared snippet instead of repeating the same $set inline in every family that needs the same base override - useful when several otherwise-unrelated images share an identical by-arch (or similar) fragment. Added a regression test exercising two by-arch fragments that each $include a different shared base snippet, confirming both resolve to the expected path base per cell. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tailor/crates/tailor-config/src/render.rs | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tools/tailor/crates/tailor-config/src/render.rs b/tools/tailor/crates/tailor-config/src/render.rs index 18029fc74..b01e08336 100644 --- a/tools/tailor/crates/tailor-config/src/render.rs +++ b/tools/tailor/crates/tailor-config/src/render.rs @@ -192,7 +192,7 @@ fn render_cell( let mut ic_config = Value::Mapping(config); interpolate::interpolate_tree(&mut ic_config, &context)?; - let base = resolve_base(image, &tuple, &matched, &context)?; + let base = resolve_base(image, image_dir, &tuple, &matched, &context)?; let outputs = resolve_outputs(image, &tuple, &matched)?; for output in &outputs { validate_output_compression(image, &tuple, output)?; @@ -256,13 +256,17 @@ fn merge_params(matched: &[&LoadedFragment]) -> Result, 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(value) = fragment.doc.base.clone() { + 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, @@ -713,6 +717,62 @@ mod tests { 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(); From b6e70bac9f22b0c1265dd858c7e1d4f78d764f4b Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 10 Oct 2026 23:10:37 +0000 Subject: [PATCH 3/3] tailor: support outputs[].name overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tailor/crates/tailor-config/src/render.rs | 36 ++++++++++++++- .../tailor/crates/tailor-config/src/schema.rs | 4 ++ .../crates/tailor-core/src/fingerprint.rs | 19 ++++++++ .../tailor/crates/tailor-core/src/imagedep.rs | 1 + tools/tailor/crates/tailor-core/src/lib.rs | 2 +- .../crates/tailor-core/src/orchestrator.rs | 44 +++++++++++++++++-- .../tailor/crates/tailor-core/src/selector.rs | 1 + .../crates/tailor-exec/src/arg_builder.rs | 10 +++-- .../tailor/crates/tailor-exec/src/executor.rs | 15 ++++--- tools/tailor/crates/tailor/src/run.rs | 2 + 10 files changed, 119 insertions(+), 15 deletions(-) diff --git a/tools/tailor/crates/tailor-config/src/render.rs b/tools/tailor/crates/tailor-config/src/render.rs index b01e08336..3e74e3356 100644 --- a/tools/tailor/crates/tailor-config/src/render.rs +++ b/tools/tailor/crates/tailor-config/src/render.rs @@ -193,7 +193,7 @@ fn render_cell( interpolate::interpolate_tree(&mut ic_config, &context)?; let base = resolve_base(image, image_dir, &tuple, &matched, &context)?; - let outputs = resolve_outputs(image, &tuple, &matched)?; + let outputs = resolve_outputs(image, &tuple, &matched, &context)?; for output in &outputs { validate_output_compression(image, &tuple, output)?; } @@ -313,6 +313,7 @@ fn resolve_outputs( image: &ImageDefinition, tuple: &AxisTuple, matched: &[&LoadedFragment], + context: &interpolate::Context, ) -> Result, ConfigError> { let mut outputs: Option = None; for fragment in matched { @@ -326,7 +327,10 @@ fn resolve_outputs( } } match outputs { - Some(value) => deserialize_field(value, image, tuple, OUTPUTS_FIELD), + Some(mut value) => { + interpolate::interpolate_tree(&mut value, context)?; + deserialize_field(value, image, tuple, OUTPUTS_FIELD) + } None => Ok(Vec::new()), } } @@ -504,6 +508,34 @@ mod tests { .collect() } + #[test] + fn output_name_parses_static_and_interpolated_values() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "image.yaml", + indoc! {" + name: named + matrix: + arch: [amd64] + outputs: + - format: cosi + name: legacy-static + - format: raw + name: '${arch}-artifact' + base: + path: ./b.img + config: + os: { hostname: named } + "}, + ); + let image = load_image(tmp.path().join("image.yaml")).unwrap(); + let cells = render_image(&image, tmp.path()).unwrap(); + assert_eq!(cells[0].outputs.len(), 2); + assert_eq!(cells[0].outputs[0].name.as_deref(), Some("legacy-static")); + assert_eq!(cells[0].outputs[1].name.as_deref(), Some("amd64-artifact")); + } + #[test] fn compression_parses_and_is_validated_against_the_format() { // A raw disk-image format accepts compression; the parsed OutputSpec carries it. diff --git a/tools/tailor/crates/tailor-config/src/schema.rs b/tools/tailor/crates/tailor-config/src/schema.rs index 003e57934..592c5e462 100644 --- a/tools/tailor/crates/tailor-config/src/schema.rs +++ b/tools/tailor/crates/tailor-config/src/schema.rs @@ -804,6 +804,10 @@ pub struct AzureLinuxBase { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OutputSpec { pub format: OutputFormat, + /// Optional published artifact basename override. When omitted, tailor publishes `.`; + /// when set, it publishes `.` (and still suffixes clones with `_cloneN`). + #[serde(default)] + pub name: Option, #[serde(default)] pub cosi_compression_level: Option, /// Post-build compression tailor applies to the artifact (e.g. `zstd` ⇒ `img.vhd.zst`). Invalid diff --git a/tools/tailor/crates/tailor-core/src/fingerprint.rs b/tools/tailor/crates/tailor-core/src/fingerprint.rs index d77e6aac2..296cef677 100644 --- a/tools/tailor/crates/tailor-core/src/fingerprint.rs +++ b/tools/tailor/crates/tailor-core/src/fingerprint.rs @@ -39,6 +39,8 @@ pub struct FingerprintInputs<'a> { pub compression: Option, /// COSI compression level (`--cosi-compression-level`); a change rebuilds the COSI. pub cosi_compression_level: Option, + /// Published artifact basename override from `outputs[].name`; a change rebuilds the cell. + pub output_name: Option<&'a str>, } /// Compute the canonical fingerprint. Each field is domain-separated and length-prefixed so distinct @@ -100,6 +102,9 @@ 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(name) = inputs.output_name { + field(&mut hasher, b"output.name", name.as_bytes()); + } Fingerprint(hasher.finalize().into()) } @@ -154,6 +159,7 @@ mod tests { extra_params: &[], compression: None, cosi_compression_level: None, + output_name: None, } } @@ -236,6 +242,19 @@ mod tests { assert_ne!(fingerprint(&level_one), fingerprint(&level_two)); } + #[test] + fn output_name_changes_fingerprint() { + let base = base(); + let cfg: Value = serde_yaml_ng::from_str("os: + hostname: a +").unwrap(); + let mut a = inputs("cell", &cfg, &base); + a.output_name = Some("legacy-name"); + let mut b = inputs("cell", &cfg, &base); + b.output_name = Some("other-name"); + assert_ne!(fingerprint(&a), fingerprint(&b)); + } + #[test] fn input_dep_hashes_change_the_fingerprint() { let base = base(); diff --git a/tools/tailor/crates/tailor-core/src/imagedep.rs b/tools/tailor/crates/tailor-core/src/imagedep.rs index a7ba40ebe..f77406cec 100644 --- a/tools/tailor/crates/tailor-core/src/imagedep.rs +++ b/tools/tailor/crates/tailor-core/src/imagedep.rs @@ -604,6 +604,7 @@ mod tests { dir, default_outputs: vec![OutputSpec { format: OutputFormat::Cosi, + name: None, cosi_compression_level: None, compression: None, }], diff --git a/tools/tailor/crates/tailor-core/src/lib.rs b/tools/tailor/crates/tailor-core/src/lib.rs index f25562621..4d55464ce 100644 --- a/tools/tailor/crates/tailor-core/src/lib.rs +++ b/tools/tailor/crates/tailor-core/src/lib.rs @@ -30,7 +30,7 @@ pub use hashcache::{FileHash, hash_file_cached}; pub use lockfile::{LockedBase, LockedContainer, LockedRuntime, Lockfile}; pub use orchestrator::{ BuildOptions, BuildProgress, BuildSelection, Orchestrator, ResolvedToolchain, - ResolvedToolsDirSource, artifact_name, cells, cells_selected, output_slug, + ResolvedToolsDirSource, artifact_name, cells, cells_selected, output_name, output_slug, published_artifact_name, runtime_config, select_node_cells, toolchain_for, toolchain_key, tools_dir_key, }; diff --git a/tools/tailor/crates/tailor-core/src/orchestrator.rs b/tools/tailor/crates/tailor-core/src/orchestrator.rs index fcc1a31cb..040eff61f 100644 --- a/tools/tailor/crates/tailor-core/src/orchestrator.rs +++ b/tools/tailor/crates/tailor-core/src/orchestrator.rs @@ -181,9 +181,14 @@ impl Orchestrator { extra_params: &cell.extra_params, compression: cell.output.compression, cosi_compression_level: cell.output.cosi_compression_level, + output_name: cell.output.name.as_deref(), }); let artifact = output_dir.join(published_artifact_name( - cell.slug.as_ref(), + &output_name( + cell.slug.as_ref(), + cell.output.name.as_deref(), + None, + ), cell.output.format, cell.output.compression, )); @@ -901,9 +906,20 @@ 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 published artifact basename for a build run: either the explicit `outputs[].name` override +/// or the cell slug, with the clone suffix applied when building `--clones`. +#[must_use] +pub fn output_name(slug: &str, override_name: Option<&str>, clone_index: Option) -> String { + let base = override_name.unwrap_or(slug); + match clone_index { + Some(clone) => format!("{base}_clone{clone}"), + None => base.to_owned(), + } +} + +/// 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", @@ -968,6 +984,24 @@ mod tests { ); } + #[test] + fn output_name_prefers_explicit_names_and_suffixes_clones() { + assert_eq!(output_name("img_amd64_cosi", None, None), "img_amd64_cosi"); + assert_eq!(output_name("img_amd64_cosi", Some("legacy-name"), None), "legacy-name"); + assert_eq!( + output_name("img_amd64_cosi", Some("legacy-name"), Some(1)), + "legacy-name_clone1" + ); + assert_eq!( + published_artifact_name( + &output_name("img_amd64_cosi", Some("legacy-name"), Some(1)), + OutputFormat::Cosi, + None, + ), + "legacy-name_clone1.cosi" + ); + } + #[test] fn published_artifact_name_appends_compression_suffix() { // Uncompressed: the plain format extension. @@ -1348,6 +1382,7 @@ mod tests { dir: tmp.path().to_path_buf(), default_outputs: vec![OutputSpec { format: OutputFormat::Cosi, + name: None, cosi_compression_level: None, compression: None, }], @@ -1490,6 +1525,7 @@ mod tests { dir: tmp.path().to_path_buf(), default_outputs: vec![OutputSpec { format: OutputFormat::Cosi, + name: None, cosi_compression_level: None, compression: None, }], diff --git a/tools/tailor/crates/tailor-core/src/selector.rs b/tools/tailor/crates/tailor-core/src/selector.rs index 469a6c123..6a9ad40b3 100644 --- a/tools/tailor/crates/tailor-core/src/selector.rs +++ b/tools/tailor/crates/tailor-core/src/selector.rs @@ -158,6 +158,7 @@ mod tests { arch: Arch::Amd64, output: OutputSpec { format: OutputFormat::Cosi, + name: None, cosi_compression_level: None, compression: None, }, diff --git a/tools/tailor/crates/tailor-exec/src/arg_builder.rs b/tools/tailor/crates/tailor-exec/src/arg_builder.rs index 48296e3d1..91460a924 100644 --- a/tools/tailor/crates/tailor-exec/src/arg_builder.rs +++ b/tools/tailor/crates/tailor-exec/src/arg_builder.rs @@ -4,7 +4,7 @@ use std::{ }; use tailor_config::{Access, BaseSource, ExtraParam, Operation}; -use tailor_core::{Cell, ExecError, ExecutionContext, RuntimeConfig, artifact_name, output_slug}; +use tailor_core::{Cell, ExecError, ExecutionContext, RuntimeConfig, artifact_name, output_name, output_slug}; use crate::{guard, path_translate, working_copy}; @@ -267,10 +267,14 @@ pub(crate) fn intermediate_path(cell: &Cell, context: &ExecutionContext) -> 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_name( + cell.slug.as_ref(), + cell.output.name.as_deref(), + 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` diff --git a/tools/tailor/crates/tailor-exec/src/executor.rs b/tools/tailor/crates/tailor-exec/src/executor.rs index 7855a9ef0..befbe1ab3 100644 --- a/tools/tailor/crates/tailor-exec/src/executor.rs +++ b/tools/tailor/crates/tailor-exec/src/executor.rs @@ -13,7 +13,7 @@ use tailor_config::{Compression, OutputArtifactsPolicy, OutputFormat}; use tailor_core::{ Cell, ContainerConfig, ContainerResult, ContainerRuntime, ExecError, ExecutionContext, ExecutionResult, Executor, RuntimeConfig, Signer, SigningPlan, ToolsDirPlan, artifact_name, - atomic, output_slug, published_artifact_name, + atomic, output_name, output_slug, published_artifact_name, }; use crate::{arg_builder, guard, janitor, output_artifacts, rpm_farm, working_copy}; @@ -48,13 +48,18 @@ 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 even when `outputs[].name` overrides the basename. + let out_name = output_name( + cell.slug.as_ref(), + cell.output.name.as_deref(), + 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, )); diff --git a/tools/tailor/crates/tailor/src/run.rs b/tools/tailor/crates/tailor/src/run.rs index e0df36856..0244b465d 100644 --- a/tools/tailor/crates/tailor/src/run.rs +++ b/tools/tailor/crates/tailor/src/run.rs @@ -1429,6 +1429,7 @@ fn convert_cell(input: &Path, dir: &Path, arch: Arch, format: OutputFormat, slug }; let output = OutputSpec { format, + name: None, cosi_compression_level: None, compression: None, }; @@ -2225,6 +2226,7 @@ pub(crate) fn init_timestamps(mode: TimestampMode) { fn default_cosi() -> Vec { vec![OutputSpec { format: OutputFormat::Cosi, + name: None, cosi_compression_level: None, compression: None, }]