Skip to main content

stow_types/
bundle.rs

1//! OCI bundle layout and assembly.
2//!
3//! The media types and in-tar paths of a stow artifact bundle, the manifest
4//! wire types the CLI reads after signature verification, and the assembler
5//! that builds the bundle tar the trusted publish stage pushes and the edge
6//! streams byte-for-byte.
7
8use std::io::Cursor;
9
10use serde::{Deserialize, Serialize};
11use tar::{Builder, Header};
12
13use crate::artifact::{ArtifactKind, NativeArtifacts, RustCrateType};
14use crate::identity::{
15    CMetadata, CrateName, CrateVersion, DependencyCMetadataJson, FeaturesJson, TargetTriple,
16    WireRustcVersion,
17};
18use crate::platform::Profile;
19
20/// Media type of the single-artifact bundle tar layer.
21pub const STOW_BUNDLE_MEDIA_TYPE: &str = "application/vnd.stow.bundle.v1+tar";
22/// Media type of an `.rlib` file inside a bundle.
23pub const STOW_RLIB_MEDIA_TYPE: &str = "application/vnd.stow.rlib.v1";
24/// Media type of an `.rmeta` file inside a bundle.
25pub const STOW_RMETA_MEDIA_TYPE: &str = "application/vnd.stow.rmeta.v1";
26/// Media type of a dylib file inside a bundle.
27pub const STOW_DYLIB_MEDIA_TYPE: &str = "application/vnd.stow.dylib.v1";
28/// Media type of a proc-macro dynamic library inside a bundle.
29pub const STOW_PROC_MACRO_MEDIA_TYPE: &str = "application/vnd.stow.proc-macro.v1";
30/// A tar of the build script's `OUT_DIR` tree, carried as its own layer.
31pub const STOW_NATIVE_ARCHIVE_MEDIA_TYPE: &str = "application/vnd.stow.native-out-dir.v1+tar";
32/// Media type suffix marking a zstd-compressed blob.
33pub const STOW_ZSTD_MEDIA_TYPE_SUFFIX: &str = "+zstd";
34/// Path of the per-artifact manifest inside a bundle tar.
35pub const STOW_BUNDLE_MANIFEST_PATH: &str = "manifest.json";
36/// Path of the OCI manifest JSON inside a bundle tar.
37pub const STOW_OCI_MANIFEST_PATH: &str = "oci/manifest.json";
38/// Path of the OCI config JSON inside a bundle tar — the document the cosign
39/// signature covers.
40pub const STOW_OCI_CONFIG_PATH: &str = "oci/config.json";
41/// Directory inside a bundle tar holding Sigstore signature material.
42pub const STOW_SIGSTORE_PAYLOAD_DIR: &str = "sigstore";
43/// Directory inside a bundle tar holding the artifact's layer payloads.
44pub const STOW_BUNDLE_FILES_DIR: &str = "files";
45/// Media type of the signed artifact's OCI config blob.
46pub const STOW_ARTIFACT_CONFIG_MEDIA_TYPE: &str = "application/vnd.stow.artifact.config.v1+json";
47/// Media type of the `<tag>.bundle` artifact's OCI config blob.
48pub const STOW_BUNDLE_CONFIG_MEDIA_TYPE: &str = "application/vnd.stow.bundle.config.v1+json";
49/// Media type of an OCI image manifest, which every stow artifact is pushed as.
50pub const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
51/// Media type cosign gives the simple-signing payload layer of a signature image.
52pub const SIGSTORE_OCI_MEDIA_TYPE: &str = "application/vnd.dev.cosign.simplesigning.v1+json";
53/// Layer annotation carrying the base64 signature over the payload.
54pub const SIGSTORE_SIGNATURE_ANNOTATION: &str = "dev.cosignproject.cosign/signature";
55/// Layer annotation carrying the Rekor bundle JSON, when uploaded.
56pub const SIGSTORE_BUNDLE_ANNOTATION: &str = "dev.sigstore.cosign/bundle";
57/// Layer annotation carrying the PEM Fulcio certificate of the signer.
58pub const SIGSTORE_CERT_ANNOTATION: &str = "dev.sigstore.cosign/certificate";
59
60/// The tag cosign stores an artifact's signature image under: the manifest
61/// digest with `:` replaced by `-`, plus `.sig`.
62#[must_use]
63pub fn sigstore_signature_tag(oci_digest: &str) -> String {
64    format!("{}.sig", oci_digest.replace(':', "-"))
65}
66
67/// In-tar path of a layer payload.
68#[must_use]
69pub fn bundle_file_path(file_name: &str) -> String {
70    format!("{STOW_BUNDLE_FILES_DIR}/{file_name}")
71}
72
73/// In-tar path of the `index`-th sigstore payload.
74#[must_use]
75pub fn sigstore_payload_path(index: usize) -> String {
76    format!("{STOW_SIGSTORE_PAYLOAD_DIR}/payload-{index}.json")
77}
78
79/// Config blob of the `<tag>.bundle` artifact: which signed artifact the
80/// bundle was assembled from.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct BundleArtifactConfig {
83    /// Canonical stow reference of the signed artifact.
84    pub oci_reference: String,
85    /// Manifest digest of the signed artifact.
86    pub oci_digest: String,
87}
88
89/// One cosign signature as read off the signature image: the payload blob
90/// plus the annotations carried on its layer.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct BundleSignatureMaterial {
93    /// In-tar path of the payload, `sigstore/payload-N.json`.
94    pub payload_path: String,
95    /// The simple-signing payload bytes exactly as the layer stores them.
96    pub payload_bytes: Vec<u8>,
97    /// Base64 signature over the payload.
98    pub signature: String,
99    /// PEM Fulcio certificate of the signer.
100    pub certificate_pem: String,
101    /// Rekor bundle JSON, when the signer uploaded one.
102    pub rekor_bundle_json: Option<String>,
103}
104
105/// Everything a bundle tar is assembled from.
106///
107/// The signed artifact's raw manifest and config bytes (stored verbatim so
108/// the CLI can re-hash them against the cosign payload), the signature
109/// materials, and every layer's stored bytes in manifest order.
110#[derive(Debug, Clone)]
111pub struct BundleParts<'a> {
112    /// Canonical stow reference of the signed artifact.
113    pub oci_reference: &'a str,
114    /// Manifest digest of the signed artifact.
115    pub oci_digest: &'a str,
116    /// The OCI manifest bytes exactly as the registry stores them.
117    pub manifest_bytes: &'a [u8],
118    /// The OCI config bytes exactly as the registry stores them.
119    pub config_bytes: &'a [u8],
120    /// The parsed config — `config_bytes` decoded.
121    pub config: &'a ArtifactBlobConfig,
122    /// Signature materials in signature-image layer order.
123    pub signatures: &'a [BundleSignatureMaterial],
124    /// Layer payloads in manifest order: the config's `outputs`, then the
125    /// native archive when the config declares one. Each entry is the layer's
126    /// stored (zstd) bytes; the media type is the file's storage media type.
127    pub layers: &'a [BundleLayer<'a>],
128}
129
130/// One layer payload handed to [`assemble_bundle`].
131#[derive(Debug, Clone)]
132pub struct BundleLayer<'a> {
133    /// Media type of the layer as the registry stores it.
134    pub media_type: &'a str,
135    /// Stored bytes of the layer.
136    pub bytes: &'a [u8],
137}
138
139/// Assembly failed: the parts do not describe one consistent artifact.
140#[derive(Debug, thiserror::Error)]
141pub enum BundleAssemblyError {
142    /// The bundle manifest could not be serialized.
143    #[error("serialize bundle manifest: {0}")]
144    SerializeManifest(serde_json::Error),
145    /// A tar entry could not be written.
146    #[error("build bundle tar: {0}")]
147    BuildTar(std::io::Error),
148    /// The layer list does not match the config's declared files.
149    #[error("bundle layers do not match config outputs: {0}")]
150    LayerMismatch(String),
151}
152
153/// Assemble the bundle tar for one signed artifact.
154///
155/// Entry order is part of the format: `manifest.json`, `oci/manifest.json`,
156/// `oci/config.json`, each `sigstore/payload-N.json`, then `files/<name>`
157/// for every layer in manifest order. Every entry is a regular file with
158/// mode `0644`, so the same parts always produce the same bytes.
159///
160/// # Errors
161/// Returns [`BundleAssemblyError`] when the layer count or a layer media
162/// type disagrees with the config's declared files, or a tar write fails.
163pub fn assemble_bundle(parts: &BundleParts<'_>) -> Result<Vec<u8>, BundleAssemblyError> {
164    let expected = parts
165        .config
166        .outputs
167        .iter()
168        .chain(parts.config.native_archive.as_ref())
169        .collect::<Vec<_>>();
170    if expected.len() != parts.layers.len() {
171        return Err(BundleAssemblyError::LayerMismatch(format!(
172            "{} layers for {} declared files",
173            parts.layers.len(),
174            expected.len()
175        )));
176    }
177    for (file, layer) in expected.iter().zip(parts.layers) {
178        let storage_media_type = file.storage_media_type();
179        if layer.media_type != storage_media_type {
180            return Err(BundleAssemblyError::LayerMismatch(format!(
181                "{} is stored as {} but the layer is {}",
182                file.file_name, storage_media_type, layer.media_type
183            )));
184        }
185    }
186
187    let mut tar = Builder::new(Vec::new());
188    let manifest = ArtifactBundleManifest {
189        oci_reference: parts.oci_reference.to_owned(),
190        oci_digest: parts.oci_digest.to_owned(),
191        config: parts.config.clone(),
192        sigstore_signatures: parts
193            .signatures
194            .iter()
195            .map(|material| SigstoreSignature {
196                payload_path: material.payload_path.clone(),
197                signature: material.signature.clone(),
198                certificate_pem: material.certificate_pem.clone(),
199                rekor_bundle_json: material.rekor_bundle_json.clone(),
200            })
201            .collect(),
202    };
203    let manifest_json =
204        serde_json::to_vec(&manifest).map_err(BundleAssemblyError::SerializeManifest)?;
205    append_entry(&mut tar, STOW_BUNDLE_MANIFEST_PATH, &manifest_json)?;
206    append_entry(&mut tar, STOW_OCI_MANIFEST_PATH, parts.manifest_bytes)?;
207    append_entry(&mut tar, STOW_OCI_CONFIG_PATH, parts.config_bytes)?;
208    for material in parts.signatures {
209        append_entry(&mut tar, &material.payload_path, &material.payload_bytes)?;
210    }
211    for (file, layer) in expected.iter().zip(parts.layers) {
212        append_entry(&mut tar, &bundle_file_path(&file.file_name), layer.bytes)?;
213    }
214    tar.into_inner().map_err(BundleAssemblyError::BuildTar)
215}
216
217fn append_entry(
218    tar: &mut Builder<Vec<u8>>,
219    path: &str,
220    bytes: &[u8],
221) -> Result<(), BundleAssemblyError> {
222    let mut header = Header::new_gnu();
223    header.set_size(bytes.len() as u64);
224    header.set_mode(0o644);
225    header.set_cksum();
226    tar.append_data(&mut header, path, Cursor::new(bytes))
227        .map_err(BundleAssemblyError::BuildTar)
228}
229
230/// Manifest at `manifest.json` inside a bundle tar, describing the artifact
231/// the bundle carries and the signatures covering it.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct ArtifactBundleManifest {
234    /// OCI reference the bundle was fetched from.
235    pub oci_reference: String,
236    /// OCI manifest digest the bundle was fetched as.
237    pub oci_digest: String,
238    /// Identity and file listing of the artifact; must equal the
239    /// signature-covered `oci/config.json`.
240    pub config: ArtifactBlobConfig,
241    /// Sigstore signatures over the bundle's OCI config.
242    pub sigstore_signatures: Vec<SigstoreSignature>,
243}
244
245/// Embedded JSON config describing one artifact bundle's identity.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ArtifactBlobConfig {
248    /// Stable hash of the trusted build's exact rustc invocation identity.
249    pub compile_key: String,
250    /// Crate name.
251    pub crate_name: CrateName,
252    /// Crate version.
253    pub crate_version: CrateVersion,
254    /// Cargo `-C metadata` value.
255    pub c_metadata: CMetadata,
256    /// Cargo `-C extra-filename` suffix.
257    pub extra_filename: String,
258    /// Compilation target triple.
259    pub target: TargetTriple,
260    /// Stable rustc version.
261    pub rustc_version: WireRustcVersion,
262    /// Canonical features list.
263    pub features_json: FeaturesJson,
264    /// Sorted dependency identities driving the cache key.
265    pub dependency_c_metadata_json: DependencyCMetadataJson,
266    /// JSON-encoded compile keys of dependencies.
267    pub dependency_compile_keys_json: String,
268    /// Cargo profile.
269    pub profile: Profile,
270    /// Sorted, deduplicated emit modes.
271    pub emit: Vec<String>,
272    /// Bundle size in bytes.
273    pub artifact_size: u64,
274    /// Wall-clock milliseconds the captured rustc invocation took. Bundles
275    /// published before the field existed carry no timing and count as zero
276    /// CPU time saved.
277    #[serde(default)]
278    pub compile_millis: u64,
279    /// Artifact kind.
280    pub kind: ArtifactKind,
281    /// Declared crate types.
282    pub crate_types: Vec<RustCrateType>,
283    /// Files in this bundle.
284    pub outputs: Vec<ArtifactBundleFile>,
285    /// Optional native artifacts.
286    pub native: Option<NativeArtifacts>,
287    /// The bundle file carrying `native`'s `OUT_DIR` tree, when there is one.
288    ///
289    /// Kept out of `outputs` because those are rustc products with a
290    /// materialization path in the target directory; this is replay input for
291    /// a build script. It rides as a normal zstd-compressed OCI layer, so the
292    /// cosign signature covers it exactly as it covers every other layer.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub native_archive: Option<ArtifactBundleFile>,
295}
296
297/// One file inside an artifact bundle tar.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct ArtifactBundleFile {
300    /// File name as it appears inside the bundle.
301    pub file_name: String,
302    /// Media type of the uncompressed file.
303    pub media_type: String,
304    /// SHA-256 of the file's bytes, hex-encoded.
305    pub sha256: String,
306}
307
308impl ArtifactBundleFile {
309    /// Media type this file is stored under in the OCI layer — the plain
310    /// media type plus the `+zstd` compression suffix.
311    #[must_use]
312    pub fn storage_media_type(&self) -> String {
313        format!("{}{}", self.media_type, STOW_ZSTD_MEDIA_TYPE_SUFFIX)
314    }
315}
316
317/// One Sigstore (cosign) signature over a bundle's OCI config.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct SigstoreSignature {
320    /// Path of the signed payload inside the bundle's `sigstore/` directory.
321    pub payload_path: String,
322    /// Signature over the payload.
323    pub signature: String,
324    /// PEM-encoded Fulcio certificate of the signing identity.
325    pub certificate_pem: String,
326    /// Rekor transparency-log bundle JSON, when the signer uploaded one.
327    pub rekor_bundle_json: Option<String>,
328}