Skip to main content

stow_types/
bundle_schema.rs

1//! Schema/identity validation for assembled artifact bundles.
2//!
3//! The trusted publish stage assembles the bundle tar the edge later streams
4//! byte-for-byte; before the tar is pushed it must prove to be a well-formed
5//! stow bundle whose embedded config matches the stable public-cache identity,
6//! because nothing between GHCR and the CLI inspects it again.
7
8use std::io::Cursor;
9
10use crate::bundle::{
11    ArtifactBlobConfig, ArtifactBundleManifest, STOW_BUNDLE_MANIFEST_PATH, STOW_DYLIB_MEDIA_TYPE,
12    STOW_PROC_MACRO_MEDIA_TYPE, STOW_RLIB_MEDIA_TYPE, STOW_RMETA_MEDIA_TYPE,
13};
14use crate::public_cache::stable_c_metadata_for_compile_key;
15
16/// An assembled bundle failed schema or identity validation.
17#[derive(Debug, thiserror::Error)]
18#[error("invalid bundle: {0}")]
19pub struct BundleSchemaError(pub String);
20
21/// Validate an assembled bundle tar: it must carry a `manifest.json` whose
22/// config names a stable public-cache identity and canonical output files.
23///
24/// # Errors
25/// Returns [`BundleSchemaError`] when the tar cannot be read, the manifest is
26/// missing or malformed, or the config's identity is inconsistent.
27pub fn validate_bundle_schema(bytes: &[u8]) -> Result<(), BundleSchemaError> {
28    let mut archive = tar::Archive::new(Cursor::new(bytes));
29    let mut manifest_bytes = None::<Vec<u8>>;
30    for entry in archive
31        .entries()
32        .map_err(|error| BundleSchemaError(format!("read bundle entries: {error}")))?
33    {
34        let mut entry =
35            entry.map_err(|error| BundleSchemaError(format!("read bundle entry: {error}")))?;
36        let path = entry
37            .path()
38            .map_err(|error| BundleSchemaError(format!("read bundle path: {error}")))?
39            .to_string_lossy()
40            .to_string();
41        if path != STOW_BUNDLE_MANIFEST_PATH {
42            continue;
43        }
44        let mut bytes = Vec::new();
45        std::io::Read::read_to_end(&mut entry, &mut bytes)
46            .map_err(|error| BundleSchemaError(format!("read bundle manifest payload: {error}")))?;
47        manifest_bytes = Some(bytes);
48        break;
49    }
50    let manifest_bytes = manifest_bytes
51        .ok_or_else(|| BundleSchemaError("bundle is missing manifest.json".to_owned()))?;
52    let manifest = serde_json::from_slice::<ArtifactBundleManifest>(&manifest_bytes)
53        .map_err(|error| BundleSchemaError(format!("parse bundle manifest json: {error}")))?;
54    validate_bundle_config_identity(&manifest.config)?;
55    Ok(())
56}
57
58fn validate_bundle_config_identity(config: &ArtifactBlobConfig) -> Result<(), BundleSchemaError> {
59    let stable_c_metadata =
60        stable_c_metadata_for_compile_key(&config.compile_key).map_err(|error| {
61            BundleSchemaError(format!(
62                "bundle compile_key {} is not a valid stable public-cache identity: {error}",
63                config.compile_key
64            ))
65        })?;
66    if stable_c_metadata != config.c_metadata.as_str() {
67        return Err(BundleSchemaError(format!(
68            "bundle c_metadata {} does not match stable compile_key prefix {}",
69            config.c_metadata, stable_c_metadata
70        )));
71    }
72
73    let canonical_crate_name = config.crate_name.as_str().replace('-', "_");
74    let canonical_stem = format!(
75        "lib{}{extra}",
76        canonical_crate_name,
77        extra = config.extra_filename
78    );
79    let mut saw_canonical_rlib = false;
80    let mut saw_canonical_rmeta = false;
81    let mut saw_canonical_dynamic = false;
82
83    for output in &config.outputs {
84        let file_name = std::path::Path::new(&output.file_name);
85        if file_name.components().count() != 1 {
86            return Err(BundleSchemaError(format!(
87                "bundle output {} is not a single path component",
88                output.file_name
89            )));
90        }
91        match output.media_type.as_str() {
92            STOW_RLIB_MEDIA_TYPE => {
93                saw_canonical_rlib |= output.file_name == format!("{canonical_stem}.rlib");
94            }
95            STOW_RMETA_MEDIA_TYPE => {
96                saw_canonical_rmeta |= output.file_name == format!("{canonical_stem}.rmeta");
97            }
98            STOW_DYLIB_MEDIA_TYPE | STOW_PROC_MACRO_MEDIA_TYPE => {
99                saw_canonical_dynamic |=
100                    output.file_name.starts_with(&format!("{canonical_stem}."));
101            }
102            other => {
103                return Err(BundleSchemaError(format!(
104                    "bundle output {} has unsupported media type {}",
105                    output.file_name, other
106                )));
107            }
108        }
109    }
110
111    if config
112        .outputs
113        .iter()
114        .any(|output| output.media_type == STOW_RLIB_MEDIA_TYPE)
115        && !saw_canonical_rlib
116    {
117        return Err(BundleSchemaError(format!(
118            "bundle is missing canonical rlib output for stable metadata {}",
119            config.c_metadata
120        )));
121    }
122    if config
123        .outputs
124        .iter()
125        .any(|output| output.media_type == STOW_RMETA_MEDIA_TYPE)
126        && !saw_canonical_rmeta
127    {
128        return Err(BundleSchemaError(format!(
129            "bundle is missing canonical rmeta output for stable metadata {}",
130            config.c_metadata
131        )));
132    }
133    if config.outputs.iter().any(|output| {
134        output.media_type == STOW_DYLIB_MEDIA_TYPE
135            || output.media_type == STOW_PROC_MACRO_MEDIA_TYPE
136    }) && !saw_canonical_dynamic
137    {
138        return Err(BundleSchemaError(format!(
139            "bundle is missing canonical dynamic output for stable metadata {}",
140            config.c_metadata
141        )));
142    }
143
144    Ok(())
145}
146
147#[cfg(test)]
148mod tests {
149    use std::io::Cursor;
150
151    use super::validate_bundle_schema;
152    use crate::artifact::{ArtifactKind, RustCrateType};
153    use crate::bundle::{
154        ArtifactBlobConfig, ArtifactBundleFile, ArtifactBundleManifest, STOW_BUNDLE_MANIFEST_PATH,
155        STOW_RLIB_MEDIA_TYPE, STOW_RMETA_MEDIA_TYPE,
156    };
157    use crate::identity::{
158        CMetadata, CrateName, DependencyCMetadataIdentity, DependencyCMetadataJson, FeaturesJson,
159        TargetTriple, WireRustcVersion,
160    };
161    use crate::platform::{PanicStrategy, Profile, StripLevel};
162    use tar::{Builder, Header};
163
164    fn profile() -> Profile {
165        Profile {
166            opt_level: "0".to_owned(),
167            debuginfo: 1,
168            debug_assertions: true,
169            overflow_checks: true,
170            panic: PanicStrategy::Unwind,
171            strip: StripLevel::None,
172        }
173    }
174
175    fn bundle_file(file_name: &str, media_type: &str) -> ArtifactBundleFile {
176        ArtifactBundleFile {
177            file_name: file_name.to_owned(),
178            media_type: media_type.to_owned(),
179            sha256: "deadbeef".to_owned(),
180        }
181    }
182
183    fn stable_config(outputs: Vec<ArtifactBundleFile>) -> ArtifactBlobConfig {
184        let dependency_identity = DependencyCMetadataIdentity {
185            crate_name: CrateName::parse("unicode_ident").unwrap(),
186            c_metadata: CMetadata::parse(
187                "0e63365407e7f07c2be3d7da23fc1e46fdf371b2b1e7030e54325461657e757f",
188            )
189            .unwrap(),
190        };
191        ArtifactBlobConfig {
192            compile_key: "df1c5df8d44a9ede068e852b56a99270d4d6b905ee849e7f4861e2c13699f43e"
193                .to_owned(),
194            crate_name: CrateName::parse("proc-macro2").unwrap(),
195            crate_version: "1.0.106".parse().unwrap(),
196            c_metadata: CMetadata::parse("df1c5df8d44a9ede").unwrap(),
197            extra_filename: "-df1c5df8d44a9ede".to_owned(),
198            target: TargetTriple::parse("aarch64-apple-darwin").unwrap(),
199            rustc_version: WireRustcVersion::parse("1.91.1").unwrap(),
200            features_json: FeaturesJson::canonicalize(vec![
201                "default".to_owned(),
202                "proc-macro".to_owned(),
203            ])
204            .unwrap(),
205            dependency_compile_keys_json: DependencyCMetadataJson::from_sorted(vec![
206                dependency_identity.clone(),
207            ])
208            .unwrap()
209            .raw(),
210            dependency_c_metadata_json: DependencyCMetadataJson::from_sorted(vec![
211                dependency_identity,
212            ])
213            .unwrap(),
214            profile: profile(),
215            emit: vec![
216                "dep-info".to_owned(),
217                "link".to_owned(),
218                "metadata".to_owned(),
219            ],
220            artifact_size: 1,
221            compile_millis: 0,
222            kind: ArtifactKind::Rlib,
223            crate_types: vec![RustCrateType::Lib],
224            outputs,
225            native: None,
226            native_archive: None,
227        }
228    }
229
230    fn bundle_bytes(config: ArtifactBlobConfig) -> Vec<u8> {
231        let manifest = ArtifactBundleManifest {
232            oci_reference: "ghcr.io/water-rs/stow-cache:proc-macro2.test".to_owned(),
233            oci_digest: "sha256:test".to_owned(),
234            config,
235            sigstore_signatures: Vec::new(),
236        };
237        let manifest_json = serde_json::to_vec(&manifest).unwrap();
238        let mut tar = Builder::new(Vec::new());
239        let mut header = Header::new_gnu();
240        header.set_size(manifest_json.len() as u64);
241        header.set_mode(0o644);
242        header.set_cksum();
243        tar.append_data(
244            &mut header,
245            STOW_BUNDLE_MANIFEST_PATH,
246            Cursor::new(manifest_json),
247        )
248        .unwrap();
249        tar.into_inner().unwrap()
250    }
251
252    #[test]
253    fn validate_bundle_schema_rejects_stable_bundle_without_canonical_output_names() {
254        let bytes = bundle_bytes(stable_config(vec![
255            bundle_file("libproc_macro2-68afcc2f66100859.rlib", STOW_RLIB_MEDIA_TYPE),
256            bundle_file(
257                "libproc_macro2-68afcc2f66100859.rmeta",
258                STOW_RMETA_MEDIA_TYPE,
259            ),
260        ]));
261
262        assert!(validate_bundle_schema(&bytes).is_err());
263    }
264
265    #[test]
266    fn validate_bundle_schema_accepts_stable_bundle_with_canonical_output_names() {
267        let bytes = bundle_bytes(stable_config(vec![
268            bundle_file("libproc_macro2-57f123ce754eb51b.rlib", STOW_RLIB_MEDIA_TYPE),
269            bundle_file("libproc_macro2-df1c5df8d44a9ede.rlib", STOW_RLIB_MEDIA_TYPE),
270            bundle_file(
271                "libproc_macro2-57f123ce754eb51b.rmeta",
272                STOW_RMETA_MEDIA_TYPE,
273            ),
274            bundle_file(
275                "libproc_macro2-df1c5df8d44a9ede.rmeta",
276                STOW_RMETA_MEDIA_TYPE,
277            ),
278        ]));
279
280        validate_bundle_schema(&bytes).unwrap();
281    }
282}