Skip to main content

airbender_core/
manifest.rs

1//! Manifest schema shared between build and host tooling.
2
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7pub const MANIFEST_VERSION_V1: &str = "v1";
8pub const CODEC_VERSION_V0: &str = "v0";
9
10/// Build profile recorded in the manifest for reproducibility.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Profile {
14    Debug,
15    Release,
16}
17
18impl Profile {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Profile::Debug => "debug",
22            Profile::Release => "release",
23        }
24    }
25}
26
27/// Serialized manifest describing the build artifacts for a guest program.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Manifest {
30    /// Cargo package identity this dist bundle comes from.
31    pub package: String,
32    /// Optional binary target name when it differs from `package`.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub bin_name: Option<String>,
35    /// Manifest schema version for compatibility checks.
36    pub manifest: String,
37    /// Host/guest codec version used to encode runtime payloads.
38    pub codec: String,
39    /// Target triple used for the build.
40    pub target: String,
41    /// Binary image consumed by runtime and proving flows.
42    pub bin: ArtifactEntry,
43    /// ELF image used for symbol/debug workflows.
44    pub elf: ArtifactEntry,
45    /// Text-section image used by transpiler execution.
46    pub text: ArtifactEntry,
47    /// Build provenance metadata captured at packaging time.
48    pub build: BuildMetadata,
49}
50
51/// One artifact entry recorded in the manifest.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ArtifactEntry {
54    /// Artifact path relative to the dist directory.
55    pub path: String,
56    /// SHA-256 digest for artifact integrity verification.
57    pub sha256: String,
58}
59
60/// Build metadata captured while creating dist artifacts.
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct BuildMetadata {
63    /// Cargo build profile used to produce artifacts.
64    pub profile: Profile,
65    /// Git branch name at build time, or `N/A` if unavailable.
66    pub git_branch: String,
67    /// Git commit hash at build time, or `N/A` if unavailable.
68    pub git_commit: String,
69    /// Indicates unstaged changes at build time.
70    #[serde(default, skip_serializing_if = "is_false")]
71    pub is_dirty: bool,
72    /// Indicates this build was produced inside a pinned Docker container for reproducibility.
73    #[serde(default, skip_serializing_if = "is_false")]
74    pub reproducible: bool,
75}
76
77/// Errors returned by manifest read, write, and parse operations.
78#[derive(Debug, thiserror::Error)]
79pub enum ManifestError {
80    #[error("io error: {0}")]
81    Io(#[from] std::io::Error),
82    #[error("failed to parse manifest: {0}")]
83    Parse(#[from] toml::de::Error),
84    #[error("failed to serialize manifest: {0}")]
85    Serialize(#[from] toml::ser::Error),
86    #[error("unsupported manifest version `{0}`")]
87    UnsupportedManifestVersion(String),
88}
89
90impl Manifest {
91    /// Read a manifest from a TOML file.
92    pub fn read_from_file(path: &Path) -> Result<Self, ManifestError> {
93        let content = fs::read_to_string(path)?;
94        Self::parse(&content)
95    }
96
97    /// Write this manifest to a TOML file.
98    pub fn write_to_file(&self, path: &Path) -> Result<(), ManifestError> {
99        let payload = self.to_toml()?;
100        fs::write(path, payload)?;
101        Ok(())
102    }
103
104    /// Parse and validate a manifest from TOML text.
105    pub fn parse(content: &str) -> Result<Self, ManifestError> {
106        let manifest: Self = toml::from_str(content)?;
107        if manifest.manifest != MANIFEST_VERSION_V1 {
108            return Err(ManifestError::UnsupportedManifestVersion(manifest.manifest));
109        }
110        Ok(manifest)
111    }
112
113    /// Serialize this manifest to TOML text.
114    pub fn to_toml(&self) -> Result<String, ManifestError> {
115        Ok(toml::to_string(self)?)
116    }
117}
118
119fn is_false(value: &bool) -> bool {
120    !*value
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn manifest_roundtrip() {
129        let manifest = Manifest {
130            package: "demo".to_string(),
131            bin_name: None,
132            manifest: MANIFEST_VERSION_V1.to_string(),
133            codec: CODEC_VERSION_V0.to_string(),
134            target: "riscv32im-risc0-zkvm-elf".to_string(),
135            bin: ArtifactEntry {
136                path: "app.bin".to_string(),
137                sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
138                    .to_string(),
139            },
140            elf: ArtifactEntry {
141                path: "app.elf".to_string(),
142                sha256: "b8f1d1d6b064577aa66013024e69c0dcde721573ae58da439b84e1c862437288"
143                    .to_string(),
144            },
145            text: ArtifactEntry {
146                path: "app.text".to_string(),
147                sha256: "0476bd0bb997b387b72f721b3f0f38f112e43e32f151e1d1f2ec20bc7c5ad5a6"
148                    .to_string(),
149            },
150            build: BuildMetadata {
151                profile: Profile::Release,
152                git_branch: "main".to_string(),
153                git_commit: "abc123".to_string(),
154                is_dirty: false,
155                reproducible: false,
156            },
157        };
158        let toml = manifest.to_toml().expect("serialize");
159        let first_line = toml
160            .lines()
161            .find(|line| !line.trim().is_empty())
162            .expect("manifest must have at least one line");
163        assert_eq!(first_line, "package = \"demo\"");
164        assert!(!toml.contains("bin_name"));
165        assert!(toml.contains("[bin]"));
166        assert!(toml.contains("[elf]"));
167        assert!(toml.contains("[text]"));
168        assert!(toml.contains("[build]"));
169        assert!(!toml.contains("is_dirty"));
170        let parsed = Manifest::parse(&toml).expect("parse");
171        assert_eq!(parsed, manifest);
172    }
173
174    #[test]
175    fn reproducible_field_omitted_when_false() {
176        let manifest = Manifest {
177            package: "demo".to_string(),
178            bin_name: None,
179            manifest: MANIFEST_VERSION_V1.to_string(),
180            codec: CODEC_VERSION_V0.to_string(),
181            target: "riscv32im-risc0-zkvm-elf".to_string(),
182            bin: ArtifactEntry {
183                path: "app.bin".to_string(),
184                sha256: "abc".to_string(),
185            },
186            elf: ArtifactEntry {
187                path: "app.elf".to_string(),
188                sha256: "def".to_string(),
189            },
190            text: ArtifactEntry {
191                path: "app.text".to_string(),
192                sha256: "ghi".to_string(),
193            },
194            build: BuildMetadata {
195                profile: Profile::Release,
196                git_branch: "main".to_string(),
197                git_commit: "abc123".to_string(),
198                is_dirty: false,
199                reproducible: false,
200            },
201        };
202        let toml = manifest.to_toml().expect("serialize");
203        assert!(!toml.contains("reproducible"));
204    }
205
206    #[test]
207    fn reproducible_field_present_when_true() {
208        let manifest = Manifest {
209            package: "demo".to_string(),
210            bin_name: None,
211            manifest: MANIFEST_VERSION_V1.to_string(),
212            codec: CODEC_VERSION_V0.to_string(),
213            target: "riscv32im-risc0-zkvm-elf".to_string(),
214            bin: ArtifactEntry {
215                path: "app.bin".to_string(),
216                sha256: "abc".to_string(),
217            },
218            elf: ArtifactEntry {
219                path: "app.elf".to_string(),
220                sha256: "def".to_string(),
221            },
222            text: ArtifactEntry {
223                path: "app.text".to_string(),
224                sha256: "ghi".to_string(),
225            },
226            build: BuildMetadata {
227                profile: Profile::Release,
228                git_branch: "main".to_string(),
229                git_commit: "abc123".to_string(),
230                is_dirty: false,
231                reproducible: true,
232            },
233        };
234        let toml = manifest.to_toml().expect("serialize");
235        assert!(toml.contains("reproducible = true"));
236        let parsed = Manifest::parse(&toml).expect("parse");
237        assert!(parsed.build.reproducible);
238    }
239
240    #[test]
241    fn includes_dirty_flag_when_true() {
242        let manifest = Manifest {
243            package: "demo".to_string(),
244            bin_name: None,
245            manifest: MANIFEST_VERSION_V1.to_string(),
246            codec: CODEC_VERSION_V0.to_string(),
247            target: "riscv32im-risc0-zkvm-elf".to_string(),
248            bin: ArtifactEntry {
249                path: "app.bin".to_string(),
250                sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
251                    .to_string(),
252            },
253            elf: ArtifactEntry {
254                path: "app.elf".to_string(),
255                sha256: "b8f1d1d6b064577aa66013024e69c0dcde721573ae58da439b84e1c862437288"
256                    .to_string(),
257            },
258            text: ArtifactEntry {
259                path: "app.text".to_string(),
260                sha256: "0476bd0bb997b387b72f721b3f0f38f112e43e32f151e1d1f2ec20bc7c5ad5a6"
261                    .to_string(),
262            },
263            build: BuildMetadata {
264                profile: Profile::Release,
265                git_branch: "main".to_string(),
266                git_commit: "abc123".to_string(),
267                is_dirty: true,
268                reproducible: false,
269            },
270        };
271
272        let toml = manifest.to_toml().expect("serialize");
273        assert!(toml.contains("is_dirty = true"));
274    }
275
276    #[test]
277    fn includes_bin_name_when_present() {
278        let manifest = Manifest {
279            package: "demo".to_string(),
280            bin_name: Some("worker".to_string()),
281            manifest: MANIFEST_VERSION_V1.to_string(),
282            codec: CODEC_VERSION_V0.to_string(),
283            target: "riscv32im-risc0-zkvm-elf".to_string(),
284            bin: ArtifactEntry {
285                path: "app.bin".to_string(),
286                sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
287                    .to_string(),
288            },
289            elf: ArtifactEntry {
290                path: "app.elf".to_string(),
291                sha256: "b8f1d1d6b064577aa66013024e69c0dcde721573ae58da439b84e1c862437288"
292                    .to_string(),
293            },
294            text: ArtifactEntry {
295                path: "app.text".to_string(),
296                sha256: "0476bd0bb997b387b72f721b3f0f38f112e43e32f151e1d1f2ec20bc7c5ad5a6"
297                    .to_string(),
298            },
299            build: BuildMetadata {
300                profile: Profile::Release,
301                git_branch: "main".to_string(),
302                git_commit: "abc123".to_string(),
303                is_dirty: false,
304                reproducible: false,
305            },
306        };
307
308        let toml = manifest.to_toml().expect("serialize");
309        assert!(toml.contains("bin_name = \"worker\""));
310        let parsed = Manifest::parse(&toml).expect("parse");
311        assert_eq!(parsed.bin_name.as_deref(), Some("worker"));
312    }
313
314    #[test]
315    fn rejects_unknown_manifest_version() {
316        let mut manifest = Manifest {
317            package: "demo".to_string(),
318            bin_name: None,
319            manifest: MANIFEST_VERSION_V1.to_string(),
320            codec: CODEC_VERSION_V0.to_string(),
321            target: "riscv32im-risc0-zkvm-elf".to_string(),
322            bin: ArtifactEntry {
323                path: "app.bin".to_string(),
324                sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
325                    .to_string(),
326            },
327            elf: ArtifactEntry {
328                path: "app.elf".to_string(),
329                sha256: "b8f1d1d6b064577aa66013024e69c0dcde721573ae58da439b84e1c862437288"
330                    .to_string(),
331            },
332            text: ArtifactEntry {
333                path: "app.text".to_string(),
334                sha256: "0476bd0bb997b387b72f721b3f0f38f112e43e32f151e1d1f2ec20bc7c5ad5a6"
335                    .to_string(),
336            },
337            build: BuildMetadata {
338                profile: Profile::Release,
339                git_branch: "main".to_string(),
340                git_commit: "abc123".to_string(),
341                is_dirty: false,
342                reproducible: false,
343            },
344        };
345        manifest.manifest = "v2".to_string();
346        let toml = manifest.to_toml().expect("serialize");
347        let err = Manifest::parse(&toml).expect_err("error");
348        assert!(matches!(err, ManifestError::UnsupportedManifestVersion(_)));
349    }
350}