use haz_domain::path::CanonicalPath;
use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu};
use crate::key::CacheKey;
use crate::key::prefix::CHAPTER_REVISION;
#[derive(Debug, Snafu)]
pub enum ManifestParseError {
#[snafu(display("manifest is not valid JSON or does not match the schema: {source}"))]
InvalidJson {
source: serde_json::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutputBlob {
#[serde(with = "canonical_path_serde")]
pub workspace_absolute_path: CanonicalPath,
#[serde(with = "hex_digest")]
pub content_hash: [u8; 32],
pub size: u64,
pub mode: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HashFunctionLabel {
Blake3,
Sha256,
}
impl From<haz_domain::settings::cache::HashAlgo> for HashFunctionLabel {
fn from(algo: haz_domain::settings::cache::HashAlgo) -> Self {
match algo {
haz_domain::settings::cache::HashAlgo::Blake3 => Self::Blake3,
haz_domain::settings::cache::HashAlgo::Sha256 => Self::Sha256,
}
}
}
impl From<HashFunctionLabel> for haz_domain::settings::cache::HashAlgo {
fn from(label: HashFunctionLabel) -> Self {
match label {
HashFunctionLabel::Blake3 => Self::Blake3,
HashFunctionLabel::Sha256 => Self::Sha256,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub chapter_revision: u8,
pub hash_function: HashFunctionLabel,
#[serde(with = "hex_key")]
pub key: CacheKey,
pub outputs: Vec<OutputBlob>,
pub stdout_len: u64,
pub stderr_len: u64,
#[serde(with = "hex_digest")]
pub stdout_hash: [u8; 32],
#[serde(with = "hex_digest")]
pub stderr_hash: [u8; 32],
pub exit_status: i32,
pub created_at_unix: u64,
}
impl Manifest {
#[must_use]
pub fn current_chapter_revision_matches(&self) -> bool {
self.chapter_revision == CHAPTER_REVISION
}
#[must_use]
pub fn to_json_bytes(&self) -> Vec<u8> {
let mut bytes =
serde_json::to_vec_pretty(self).expect("Manifest serialises to JSON unconditionally");
bytes.push(b'\n');
bytes
}
pub fn from_json(bytes: &[u8]) -> Result<Self, ManifestParseError> {
serde_json::from_slice(bytes).context(InvalidJsonSnafu)
}
}
mod hex_digest {
use serde::de::Error as _;
use serde::{Deserializer, Serializer};
use crate::hex;
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode_32(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
use serde::Deserialize as _;
let s = String::deserialize(d)?;
hex::decode_32(&s).map_err(D::Error::custom)
}
}
mod hex_key {
use serde::de::Error as _;
use serde::{Deserializer, Serializer};
use crate::key::CacheKey;
pub fn serialize<S: Serializer>(key: &CacheKey, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&key.to_hex())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CacheKey, D::Error> {
use serde::Deserialize as _;
let s = String::deserialize(d)?;
CacheKey::from_hex(&s).map_err(D::Error::custom)
}
}
mod canonical_path_serde {
use haz_domain::path::CanonicalPath;
use serde::de::Error as _;
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(p: &CanonicalPath, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(p)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CanonicalPath, D::Error> {
use serde::Deserialize as _;
let s = String::deserialize(d)?;
CanonicalPath::parse_workspace_absolute(&s).map_err(D::Error::custom)
}
}
#[cfg(test)]
mod tests {
use haz_domain::path::CanonicalPath;
use crate::CacheKey;
use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
fn sample_key() -> CacheKey {
let mut bytes = [0u8; 32];
bytes[0] = 0xAB;
bytes[1] = 0xCD;
CacheKey::from_bytes(bytes)
}
fn cp(s: &str) -> CanonicalPath {
CanonicalPath::parse_workspace_absolute(s)
.expect("test helper expects a valid workspace-absolute path")
}
fn sample_manifest() -> Manifest {
Manifest {
chapter_revision: 0,
hash_function: HashFunctionLabel::Blake3,
key: sample_key(),
outputs: vec![OutputBlob {
workspace_absolute_path: cp("/lib_core/target/debug/lib_core"),
content_hash: [0x11; 32],
size: 1024,
mode: 0o755,
}],
stdout_len: 42,
stderr_len: 0,
stdout_hash: [0x22; 32],
stderr_hash: [0x33; 32],
exit_status: 0,
created_at_unix: 1_715_718_000,
}
}
#[test]
fn cache_011_round_trip_preserves_every_field() {
let original = sample_manifest();
let bytes = original.to_json_bytes();
let parsed = Manifest::from_json(&bytes).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn cache_011_round_trip_with_empty_outputs() {
let mut m = sample_manifest();
m.outputs.clear();
let bytes = m.to_json_bytes();
let parsed = Manifest::from_json(&bytes).unwrap();
assert_eq!(parsed.outputs.len(), 0);
assert_eq!(parsed, m);
}
#[test]
fn cache_011_round_trip_with_multiple_outputs() {
let mut m = sample_manifest();
m.outputs.push(OutputBlob {
workspace_absolute_path: cp("/lib_core/target/debug/lib_core.d"),
content_hash: [0x44; 32],
size: 7,
mode: 0o644,
});
m.outputs.push(OutputBlob {
workspace_absolute_path: cp("/lib_core/another"),
content_hash: [0x55; 32],
size: 0,
mode: 0o600,
});
let bytes = m.to_json_bytes();
let parsed = Manifest::from_json(&bytes).unwrap();
assert_eq!(parsed, m);
assert_eq!(parsed.outputs.len(), 3);
}
#[test]
fn cache_011_to_json_bytes_ends_with_newline() {
let m = sample_manifest();
let bytes = m.to_json_bytes();
assert_eq!(*bytes.last().unwrap(), b'\n');
}
#[test]
fn cache_011_hash_function_serialises_as_lowercase_string() {
let m = sample_manifest();
let json = String::from_utf8(m.to_json_bytes()).unwrap();
assert!(json.contains("\"hash_function\": \"blake3\""));
}
#[test]
fn cache_011_hash_function_sha256_serialises_correctly() {
let mut m = sample_manifest();
m.hash_function = HashFunctionLabel::Sha256;
let json = String::from_utf8(m.to_json_bytes()).unwrap();
assert!(json.contains("\"hash_function\": \"sha256\""));
}
#[test]
fn cache_011_key_serialises_as_hex_string() {
let m = sample_manifest();
let json = String::from_utf8(m.to_json_bytes()).unwrap();
assert!(json.contains("\"key\": \"abcd00"));
}
#[test]
fn cache_011_content_hash_serialises_as_hex_string() {
let m = sample_manifest();
let json = String::from_utf8(m.to_json_bytes()).unwrap();
assert!(json.contains(&"11".repeat(32)));
}
#[test]
fn cache_011_rejects_unknown_top_level_field() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value
.as_object_mut()
.unwrap()
.insert("future_field".into(), serde_json::json!("surprise"));
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("future_field") || msg.contains("unknown"),
"expected unknown-field error, got: {msg}"
);
}
#[test]
fn cache_011_rejects_unknown_field_in_output_blob() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]
.as_object_mut()
.unwrap()
.insert("future_field".into(), serde_json::json!(0));
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("future_field") || msg.contains("unknown"),
"expected unknown-field error, got: {msg}"
);
}
#[test]
fn cache_011_rejects_missing_required_field() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value.as_object_mut().unwrap().remove("hash_function");
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("hash_function") || msg.contains("missing"),
"expected missing-field error, got: {msg}"
);
}
#[test]
fn rejects_short_hex_in_key() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["key"] = serde_json::json!("ab");
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let _ = format!("{err}");
}
#[test]
fn rejects_non_hex_character_in_content_hash() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
let mut bad = "1".repeat(64);
bad.replace_range(30..31, "z");
value["outputs"][0]["content_hash"] = serde_json::json!(bad);
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let _ = format!("{err}");
}
#[test]
fn rejects_unknown_hash_function_label() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["hash_function"] = serde_json::json!("blake2b");
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let _ = format!("{err}");
}
#[test]
fn rejects_workspace_absolute_path_with_parent_dir_segment() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/../etc/passwd");
let bytes = serde_json::to_vec(&value).unwrap();
let err = Manifest::from_json(&bytes).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("..") || msg.contains("invalid"),
"expected traversal rejection, got: {msg}"
);
}
#[test]
fn rejects_workspace_absolute_path_with_dot_segment() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/./bar");
let bytes = serde_json::to_vec(&value).unwrap();
Manifest::from_json(&bytes).unwrap_err();
}
#[test]
fn rejects_workspace_absolute_path_that_is_project_relative() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("foo/bar");
let bytes = serde_json::to_vec(&value).unwrap();
Manifest::from_json(&bytes).unwrap_err();
}
#[test]
fn rejects_workspace_absolute_path_bare_root() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/");
let bytes = serde_json::to_vec(&value).unwrap();
Manifest::from_json(&bytes).unwrap_err();
}
#[test]
fn rejects_workspace_absolute_path_with_bidi_control_codepoint() {
let m = sample_manifest();
let mut value: serde_json::Value = serde_json::from_slice(&m.to_json_bytes()).unwrap();
value["outputs"][0]["workspace_absolute_path"] = serde_json::json!("/foo/bar\u{202E}baz");
let bytes = serde_json::to_vec(&value).unwrap();
Manifest::from_json(&bytes).unwrap_err();
}
#[test]
fn workspace_absolute_path_serialises_as_plain_string() {
let m = sample_manifest();
let json = String::from_utf8(m.to_json_bytes()).unwrap();
assert!(
json.contains("\"workspace_absolute_path\": \"/lib_core/target/debug/lib_core\""),
"expected JSON to carry the rendered path string, got: {json}"
);
}
#[test]
fn hash_function_label_round_trips_through_domain_algo() {
use haz_domain::settings::cache::HashAlgo;
for algo in [HashAlgo::Blake3, HashAlgo::Sha256] {
let label: HashFunctionLabel = algo.into();
let back: HashAlgo = label.into();
assert_eq!(algo, back);
}
}
#[test]
fn cache_003_current_chapter_revision_matches_initial_value() {
let m = sample_manifest();
assert!(m.current_chapter_revision_matches());
}
#[test]
fn cache_003_current_chapter_revision_does_not_match_future_value() {
let mut m = sample_manifest();
m.chapter_revision = m.chapter_revision.saturating_add(1);
assert!(!m.current_chapter_revision_matches());
}
}