use crate::aee_seal::{is_sha256_hex, ObservationEnvironment};
pub const RUN_CONTEXT_SCHEMA: &str = "assay.aee_run_context.v0";
const REQUIRED_MEMBERS: [&str; 6] = [
"catchPolicy",
"corpus",
"observationVocabulary",
"runEntropy",
"subject",
"substrate",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunContextError {
NotStrictJson(String),
NotAnObject,
UnknownSchema { found: String },
MissingMember { member: &'static str },
MalformedMember { member: &'static str },
MissingSha256 { member: &'static str },
NotLowercaseSha256Hex { member: &'static str },
UnknownMember { member: String },
}
impl std::fmt::Display for RunContextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotStrictJson(detail) => {
write!(f, "run context is not strict JSON: {detail}")
}
Self::NotAnObject => write!(f, "run context is not a JSON object"),
Self::UnknownSchema { found } => write!(
f,
"run context declares schema {found:?}, this build implements {RUN_CONTEXT_SCHEMA:?}"
),
Self::MissingMember { member } => write!(f, "run context has no {member:?}"),
Self::MalformedMember { member } => {
write!(f, "run context {member:?} is not an object carrying a digest")
}
Self::MissingSha256 { member } => {
write!(f, "run context {member:?} has no sha256 digest")
}
Self::NotLowercaseSha256Hex { member } => write!(
f,
"run context {member:?} sha256 is not 64 lowercase hex characters"
),
Self::UnknownMember { member } => {
write!(f, "run context carries an unknown member {member:?}")
}
}
}
}
impl std::error::Error for RunContextError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AeeRunContext {
catch_policy: String,
corpus: String,
observation_vocabulary: String,
run_entropy: String,
subject: String,
substrate: String,
}
impl AeeRunContext {
pub fn parse(raw: &str) -> Result<Self, RunContextError> {
let value = assay_canonical::parse_strict(raw)
.map_err(|e| RunContextError::NotStrictJson(e.to_string()))?;
let object = value.as_object().ok_or(RunContextError::NotAnObject)?;
match object.get("schema").and_then(|s| s.as_str()) {
Some(RUN_CONTEXT_SCHEMA) => {}
other => {
return Err(RunContextError::UnknownSchema {
found: other.unwrap_or("<absent>").to_string(),
})
}
}
for key in object.keys() {
if key != "schema" && !REQUIRED_MEMBERS.contains(&key.as_str()) {
return Err(RunContextError::UnknownMember {
member: key.clone(),
});
}
}
let mut digests = Vec::with_capacity(REQUIRED_MEMBERS.len());
for member in REQUIRED_MEMBERS {
digests.push(read_sha256(object, member)?);
}
debug_assert_eq!(REQUIRED_MEMBERS.len(), digests.len());
Ok(Self {
catch_policy: digests[0].clone(),
corpus: digests[1].clone(),
observation_vocabulary: digests[2].clone(),
run_entropy: digests[3].clone(),
subject: digests[4].clone(),
substrate: digests[5].clone(),
})
}
pub fn into_environment(self, network_posture: serde_json::Value) -> ObservationEnvironment {
ObservationEnvironment {
subject_digest: self.subject,
substrate_digest: self.substrate,
corpus_digest: self.corpus,
catch_policy_digest: self.catch_policy,
observation_vocabulary_digest: self.observation_vocabulary,
run_entropy_digest: self.run_entropy,
network_posture,
}
}
}
fn read_sha256(
object: &serde_json::Map<String, serde_json::Value>,
member: &'static str,
) -> Result<String, RunContextError> {
let entry = object
.get(member)
.ok_or(RunContextError::MissingMember { member })?;
let digest = entry
.get("digest")
.and_then(|d| d.as_object())
.ok_or(RunContextError::MalformedMember { member })?;
let sha256 = digest
.get("sha256")
.and_then(|v| v.as_str())
.ok_or(RunContextError::MissingSha256 { member })?;
if !is_sha256_hex(sha256) {
return Err(RunContextError::NotLowercaseSha256Hex { member });
}
Ok(sha256.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn digest(byte: u8) -> String {
format!("{byte:02x}").repeat(32)
}
fn context_json(overrides: &[(&str, serde_json::Value)]) -> String {
let mut doc = serde_json::json!({ "schema": RUN_CONTEXT_SCHEMA });
for (index, member) in REQUIRED_MEMBERS.iter().enumerate() {
doc[*member] = serde_json::json!({
"digest": { "sha256": digest(index as u8 + 1) }
});
}
for (key, value) in overrides {
if value.is_null() {
doc.as_object_mut().expect("object").remove(*key);
} else {
doc[*key] = value.clone();
}
}
serde_json::to_string(&doc).expect("serialize")
}
#[test]
fn a_well_formed_context_carries_each_digest_to_its_own_field() {
let ctx = AeeRunContext::parse(&context_json(&[])).expect("parse");
let env = ctx.into_environment(serde_json::json!({}));
assert_eq!(env.catch_policy_digest, digest(1));
assert_eq!(env.corpus_digest, digest(2));
assert_eq!(env.observation_vocabulary_digest, digest(3));
assert_eq!(env.run_entropy_digest, digest(4));
assert_eq!(env.subject_digest, digest(5));
assert_eq!(env.substrate_digest, digest(6));
}
#[test]
fn a_duplicate_key_is_refused_rather_than_resolved() {
let raw = context_json(&[]);
let doubled = raw.replacen(
"\"corpus\":",
"\"corpus\":{\"digest\":{\"sha256\":\"aa\"}},\"corpus\":",
1,
);
assert!(serde_json::from_str::<serde_json::Value>(&doubled).is_ok());
assert!(matches!(
AeeRunContext::parse(&doubled),
Err(RunContextError::NotStrictJson(_))
));
}
#[test]
fn an_extra_algorithm_beside_sha256_is_ignored_not_refused() {
let raw = context_json(&[(
"corpus",
serde_json::json!({
"digest": { "sha256": digest(2), "sha512": "f".repeat(128) }
}),
)]);
let env = AeeRunContext::parse(&raw)
.expect("parse")
.into_environment(serde_json::json!({}));
assert_eq!(env.corpus_digest, digest(2));
}
#[test]
fn a_digest_map_without_sha256_is_refused() {
let raw = context_json(&[(
"corpus",
serde_json::json!({ "digest": { "sha512": "f".repeat(128) } }),
)]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::MissingSha256 { member: "corpus" })
);
}
#[test]
fn every_required_member_is_required_by_name() {
for member in REQUIRED_MEMBERS {
let raw = context_json(&[(member, serde_json::Value::Null)]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::MissingMember { member }),
"{member} was not required"
);
}
}
#[test]
fn an_unknown_member_is_refused() {
let raw = context_json(&[(
"corpusDigest",
serde_json::json!({"digest":{"sha256":digest(9)}}),
)]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::UnknownMember {
member: "corpusDigest".to_string()
})
);
}
#[test]
fn an_uppercase_or_short_digest_is_refused() {
for bad in [
"AA".repeat(32),
"ab".repeat(31),
String::new(),
"g".repeat(64),
] {
let raw = context_json(&[("corpus", serde_json::json!({"digest":{"sha256":bad}}))]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::NotLowercaseSha256Hex { member: "corpus" }),
"accepted {bad:?}"
);
}
}
#[test]
fn a_missing_or_wrong_schema_is_refused() {
let absent = context_json(&[("schema", serde_json::Value::Null)]);
assert_eq!(
AeeRunContext::parse(&absent),
Err(RunContextError::UnknownSchema {
found: "<absent>".to_string()
})
);
let wrong = context_json(&[("schema", serde_json::json!("assay.aee_run_context.v1"))]);
assert!(matches!(
AeeRunContext::parse(&wrong),
Err(RunContextError::UnknownSchema { .. })
));
}
#[test]
fn a_member_that_is_not_an_object_is_refused() {
let raw = context_json(&[("corpus", serde_json::json!(digest(2)))]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::MalformedMember { member: "corpus" })
);
}
#[test]
fn a_non_object_document_is_refused() {
assert_eq!(
AeeRunContext::parse("[]"),
Err(RunContextError::NotAnObject)
);
assert!(matches!(
AeeRunContext::parse("not json"),
Err(RunContextError::NotStrictJson(_))
));
}
#[test]
fn the_posture_comes_from_the_caller_of_into_environment_not_the_file() {
let raw = context_json(&[(
"networkPosture",
serde_json::json!({"digest": {"sha256": digest(7)}}),
)]);
assert_eq!(
AeeRunContext::parse(&raw),
Err(RunContextError::UnknownMember {
member: "networkPosture".to_string()
})
);
let env = AeeRunContext::parse(&context_json(&[]))
.expect("parse")
.into_environment(serde_json::json!({"observed": true}));
assert_eq!(env.network_posture, serde_json::json!({"observed": true}));
}
#[test]
fn every_error_naming_a_member_prints_it() {
let cases = [
RunContextError::MissingMember { member: "corpus" },
RunContextError::MalformedMember { member: "corpus" },
RunContextError::MissingSha256 { member: "corpus" },
RunContextError::NotLowercaseSha256Hex { member: "corpus" },
RunContextError::UnknownMember {
member: "corpus".into(),
},
];
for case in cases {
assert!(case.to_string().contains("corpus"), "{case:?}");
}
}
}