1use 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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Manifest {
30 pub package: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub bin_name: Option<String>,
35 pub manifest: String,
37 pub codec: String,
39 pub target: String,
41 pub bin: ArtifactEntry,
43 pub elf: ArtifactEntry,
45 pub text: ArtifactEntry,
47 pub build: BuildMetadata,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ArtifactEntry {
54 pub path: String,
56 pub sha256: String,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct BuildMetadata {
63 pub profile: Profile,
65 pub git_branch: String,
67 pub git_commit: String,
69 #[serde(default, skip_serializing_if = "is_false")]
71 pub is_dirty: bool,
72 #[serde(default, skip_serializing_if = "is_false")]
74 pub reproducible: bool,
75}
76
77#[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 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 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 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 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}