1use haz_domain::path::CanonicalPath;
21use serde::{Deserialize, Serialize};
22use snafu::{ResultExt, Snafu};
23
24use crate::key::CacheKey;
25use crate::key::prefix::CHAPTER_REVISION;
26
27#[derive(Debug, Snafu)]
29pub enum ManifestParseError {
30 #[snafu(display("manifest is not valid JSON or does not match the schema: {source}"))]
35 InvalidJson {
36 source: serde_json::Error,
38 },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct OutputBlob {
45 #[serde(with = "canonical_path_serde")]
53 pub workspace_absolute_path: CanonicalPath,
54
55 #[serde(with = "hex_digest")]
58 pub content_hash: [u8; 32],
59
60 pub size: u64,
62
63 pub mode: u32,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum HashFunctionLabel {
75 Blake3,
77 Sha256,
79}
80
81impl From<haz_domain::settings::cache::HashAlgo> for HashFunctionLabel {
82 fn from(algo: haz_domain::settings::cache::HashAlgo) -> Self {
83 match algo {
84 haz_domain::settings::cache::HashAlgo::Blake3 => Self::Blake3,
85 haz_domain::settings::cache::HashAlgo::Sha256 => Self::Sha256,
86 }
87 }
88}
89
90impl From<HashFunctionLabel> for haz_domain::settings::cache::HashAlgo {
91 fn from(label: HashFunctionLabel) -> Self {
92 match label {
93 HashFunctionLabel::Blake3 => Self::Blake3,
94 HashFunctionLabel::Sha256 => Self::Sha256,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct Manifest {
109 pub chapter_revision: u8,
113
114 pub hash_function: HashFunctionLabel,
118
119 #[serde(with = "hex_key")]
122 pub key: CacheKey,
123
124 pub outputs: Vec<OutputBlob>,
128
129 pub stdout_len: u64,
131
132 pub stderr_len: u64,
134
135 #[serde(with = "hex_digest")]
140 pub stdout_hash: [u8; 32],
141
142 #[serde(with = "hex_digest")]
144 pub stderr_hash: [u8; 32],
145
146 pub exit_status: i32,
150
151 pub created_at_unix: u64,
154}
155
156impl Manifest {
157 #[must_use]
161 pub fn current_chapter_revision_matches(&self) -> bool {
162 self.chapter_revision == CHAPTER_REVISION
163 }
164
165 #[must_use]
176 pub fn to_json_bytes(&self) -> Vec<u8> {
177 let mut bytes =
178 serde_json::to_vec_pretty(self).expect("Manifest serialises to JSON unconditionally");
179 bytes.push(b'\n');
180 bytes
181 }
182
183 pub fn from_json(bytes: &[u8]) -> Result<Self, ManifestParseError> {
192 serde_json::from_slice(bytes).context(InvalidJsonSnafu)
193 }
194}
195
196mod hex_digest {
197 use serde::de::Error as _;
198 use serde::{Deserializer, Serializer};
199
200 use crate::hex;
201
202 pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
203 s.serialize_str(&hex::encode_32(bytes))
204 }
205
206 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
207 use serde::Deserialize as _;
208 let s = String::deserialize(d)?;
209 hex::decode_32(&s).map_err(D::Error::custom)
210 }
211}
212
213mod hex_key {
214 use serde::de::Error as _;
215 use serde::{Deserializer, Serializer};
216
217 use crate::key::CacheKey;
218
219 pub fn serialize<S: Serializer>(key: &CacheKey, s: S) -> Result<S::Ok, S::Error> {
220 s.serialize_str(&key.to_hex())
221 }
222
223 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CacheKey, D::Error> {
224 use serde::Deserialize as _;
225 let s = String::deserialize(d)?;
226 CacheKey::from_hex(&s).map_err(D::Error::custom)
227 }
228}
229
230mod canonical_path_serde {
236 use haz_domain::path::CanonicalPath;
237 use serde::de::Error as _;
238 use serde::{Deserializer, Serializer};
239
240 pub fn serialize<S: Serializer>(p: &CanonicalPath, s: S) -> Result<S::Ok, S::Error> {
241 s.collect_str(p)
242 }
243
244 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CanonicalPath, D::Error> {
245 use serde::Deserialize as _;
246 let s = String::deserialize(d)?;
247 CanonicalPath::parse_workspace_absolute(&s).map_err(D::Error::custom)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use haz_domain::path::CanonicalPath;
254
255 use crate::CacheKey;
256 use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
257
258 fn sample_key() -> CacheKey {
259 let mut bytes = [0u8; 32];
260 bytes[0] = 0xAB;
261 bytes[1] = 0xCD;
262 CacheKey::from_bytes(bytes)
263 }
264
265 fn cp(s: &str) -> CanonicalPath {
266 CanonicalPath::parse_workspace_absolute(s)
267 .expect("test helper expects a valid workspace-absolute path")
268 }
269
270 fn sample_manifest() -> Manifest {
271 Manifest {
272 chapter_revision: 0,
273 hash_function: HashFunctionLabel::Blake3,
274 key: sample_key(),
275 outputs: vec![OutputBlob {
276 workspace_absolute_path: cp("/lib_core/target/debug/lib_core"),
277 content_hash: [0x11; 32],
278 size: 1024,
279 mode: 0o755,
280 }],
281 stdout_len: 42,
282 stderr_len: 0,
283 stdout_hash: [0x22; 32],
284 stderr_hash: [0x33; 32],
285 exit_status: 0,
286 created_at_unix: 1_715_718_000,
287 }
288 }
289
290 #[test]
293 fn cache_011_round_trip_preserves_every_field() {
294 let original = sample_manifest();
295 let bytes = original.to_json_bytes();
296 let parsed = Manifest::from_json(&bytes).unwrap();
297 assert_eq!(parsed, original);
298 }
299
300 #[test]
301 fn cache_011_round_trip_with_empty_outputs() {
302 let mut m = sample_manifest();
303 m.outputs.clear();
304 let bytes = m.to_json_bytes();
305 let parsed = Manifest::from_json(&bytes).unwrap();
306 assert_eq!(parsed.outputs.len(), 0);
307 assert_eq!(parsed, m);
308 }
309
310 #[test]
311 fn cache_011_round_trip_with_multiple_outputs() {
312 let mut m = sample_manifest();
313 m.outputs.push(OutputBlob {
314 workspace_absolute_path: cp("/lib_core/target/debug/lib_core.d"),
315 content_hash: [0x44; 32],
316 size: 7,
317 mode: 0o644,
318 });
319 m.outputs.push(OutputBlob {
320 workspace_absolute_path: cp("/lib_core/another"),
321 content_hash: [0x55; 32],
322 size: 0,
323 mode: 0o600,
324 });
325 let bytes = m.to_json_bytes();
326 let parsed = Manifest::from_json(&bytes).unwrap();
327 assert_eq!(parsed, m);
328 assert_eq!(parsed.outputs.len(), 3);
329 }
330
331 #[test]
332 fn cache_011_to_json_bytes_ends_with_newline() {
333 let m = sample_manifest();
334 let bytes = m.to_json_bytes();
335 assert_eq!(*bytes.last().unwrap(), b'\n');
336 }
337
338 #[test]
341 fn cache_011_hash_function_serialises_as_lowercase_string() {
342 let m = sample_manifest();
343 let json = String::from_utf8(m.to_json_bytes()).unwrap();
344 assert!(json.contains("\"hash_function\": \"blake3\""));
345 }
346
347 #[test]
348 fn cache_011_hash_function_sha256_serialises_correctly() {
349 let mut m = sample_manifest();
350 m.hash_function = HashFunctionLabel::Sha256;
351 let json = String::from_utf8(m.to_json_bytes()).unwrap();
352 assert!(json.contains("\"hash_function\": \"sha256\""));
353 }
354
355 #[test]
356 fn cache_011_key_serialises_as_hex_string() {
357 let m = sample_manifest();
358 let json = String::from_utf8(m.to_json_bytes()).unwrap();
359 assert!(json.contains("\"key\": \"abcd00"));
361 }
362
363 #[test]
364 fn cache_011_content_hash_serialises_as_hex_string() {
365 let m = sample_manifest();
366 let json = String::from_utf8(m.to_json_bytes()).unwrap();
367 assert!(json.contains(&"11".repeat(32)));
369 }
370
371 #[test]
374 fn cache_011_rejects_unknown_top_level_field() {
375 let m = sample_manifest();
376 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
377 value
378 .as_object_mut()
379 .unwrap()
380 .insert("future_field".into(), serde_json::json!("surprise"));
381 let bytes = serde_json::to_vec(&value).unwrap();
382 let err = Manifest::from_json(&bytes).unwrap_err();
383 let msg = format!("{err}");
384 assert!(
385 msg.contains("future_field") || msg.contains("unknown"),
386 "expected unknown-field error, got: {msg}"
387 );
388 }
389
390 #[test]
391 fn cache_011_rejects_unknown_field_in_output_blob() {
392 let m = sample_manifest();
393 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
394 value["outputs"][0]
395 .as_object_mut()
396 .unwrap()
397 .insert("future_field".into(), serde_json::json!(0));
398 let bytes = serde_json::to_vec(&value).unwrap();
399 let err = Manifest::from_json(&bytes).unwrap_err();
400 let msg = format!("{err}");
401 assert!(
402 msg.contains("future_field") || msg.contains("unknown"),
403 "expected unknown-field error, got: {msg}"
404 );
405 }
406
407 #[test]
408 fn cache_011_rejects_missing_required_field() {
409 let m = sample_manifest();
410 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
411 value.as_object_mut().unwrap().remove("hash_function");
412 let bytes = serde_json::to_vec(&value).unwrap();
413 let err = Manifest::from_json(&bytes).unwrap_err();
414 let msg = format!("{err}");
415 assert!(
416 msg.contains("hash_function") || msg.contains("missing"),
417 "expected missing-field error, got: {msg}"
418 );
419 }
420
421 #[test]
424 fn rejects_short_hex_in_key() {
425 let m = sample_manifest();
426 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
427 value["key"] = serde_json::json!("ab");
428 let bytes = serde_json::to_vec(&value).unwrap();
429 let err = Manifest::from_json(&bytes).unwrap_err();
430 let _ = format!("{err}");
431 }
432
433 #[test]
434 fn rejects_non_hex_character_in_content_hash() {
435 let m = sample_manifest();
436 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
437 let mut bad = "1".repeat(64);
438 bad.replace_range(30..31, "z");
439 value["outputs"][0]["content_hash"] = serde_json::json!(bad);
440 let bytes = serde_json::to_vec(&value).unwrap();
441 let err = Manifest::from_json(&bytes).unwrap_err();
442 let _ = format!("{err}");
443 }
444
445 #[test]
446 fn rejects_unknown_hash_function_label() {
447 let m = sample_manifest();
448 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
449 value["hash_function"] = serde_json::json!("blake2b");
450 let bytes = serde_json::to_vec(&value).unwrap();
451 let err = Manifest::from_json(&bytes).unwrap_err();
452 let _ = format!("{err}");
453 }
454
455 #[test]
458 fn rejects_workspace_absolute_path_with_parent_dir_segment() {
459 let m = sample_manifest();
460 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
461 value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/../etc/passwd");
462 let bytes = serde_json::to_vec(&value).unwrap();
463 let err = Manifest::from_json(&bytes).unwrap_err();
464 let msg = format!("{err}");
465 assert!(
466 msg.contains("..") || msg.contains("invalid"),
467 "expected traversal rejection, got: {msg}"
468 );
469 }
470
471 #[test]
472 fn rejects_workspace_absolute_path_with_dot_segment() {
473 let m = sample_manifest();
474 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
475 value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/./bar");
476 let bytes = serde_json::to_vec(&value).unwrap();
477 Manifest::from_json(&bytes).unwrap_err();
478 }
479
480 #[test]
481 fn rejects_workspace_absolute_path_that_is_project_relative() {
482 let m = sample_manifest();
483 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
484 value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("foo/bar");
485 let bytes = serde_json::to_vec(&value).unwrap();
486 Manifest::from_json(&bytes).unwrap_err();
487 }
488
489 #[test]
490 fn rejects_workspace_absolute_path_bare_root() {
491 let m = sample_manifest();
492 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
493 value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/");
494 let bytes = serde_json::to_vec(&value).unwrap();
495 Manifest::from_json(&bytes).unwrap_err();
496 }
497
498 #[test]
499 fn rejects_workspace_absolute_path_with_bidi_control_codepoint() {
500 let m = sample_manifest();
504 let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
505 value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/bar\u{202E}baz");
506 let bytes = serde_json::to_vec(&value).unwrap();
507 Manifest::from_json(&bytes).unwrap_err();
508 }
509
510 #[test]
511 fn workspace_absolute_path_serialises_as_plain_string() {
512 let m = sample_manifest();
513 let json = String::from_utf8(m.to_json_bytes()).unwrap();
514 assert!(
515 json.contains("\"workspace_absolute_path\": \"/lib_core/target/debug/lib_core\""),
516 "expected JSON to carry the rendered path string, got: {json}"
517 );
518 }
519
520 #[test]
523 fn hash_function_label_round_trips_through_domain_algo() {
524 use haz_domain::settings::cache::HashAlgo;
525 for algo in [HashAlgo::Blake3, HashAlgo::Sha256] {
526 let label: HashFunctionLabel = algo.into();
527 let back: HashAlgo = label.into();
528 assert_eq!(algo, back);
529 }
530 }
531
532 #[test]
535 fn cache_003_current_chapter_revision_matches_initial_value() {
536 let m = sample_manifest();
537 assert!(m.current_chapter_revision_matches());
538 }
539
540 #[test]
541 fn cache_003_current_chapter_revision_does_not_match_future_value() {
542 let mut m = sample_manifest();
543 m.chapter_revision = m.chapter_revision.saturating_add(1);
544 assert!(!m.current_chapter_revision_matches());
545 }
546}