use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Required,
Optional,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum TaskContextBinding {
SameExchange,
ForeignExchange { thread_id: String },
Absent,
Unthreaded,
}
impl TaskContextBinding {
pub fn is_same_exchange(&self) -> bool {
matches!(self, Self::SameExchange)
}
}
pub fn resolve(
requirement: Requirement,
asserted: Option<&str>,
exchange_thread: Option<&str>,
) -> Result<TaskContextBinding, AppError> {
let Some(asserted) = asserted else {
return match requirement {
Requirement::Required => Err(missing()),
Requirement::Optional => Ok(TaskContextBinding::Absent),
};
};
Ok(match exchange_thread {
Some(thread) if thread == asserted => TaskContextBinding::SameExchange,
Some(_) => TaskContextBinding::ForeignExchange {
thread_id: asserted.to_string(),
},
None => TaskContextBinding::Unthreaded,
})
}
pub fn requirement_for_predicate(predicate: Option<&str>) -> Requirement {
match predicate.and_then(dtg_credentials::PredicateProfile::core) {
Some(profile) if profile.task_context_required => Requirement::Required,
_ => Requirement::Optional,
}
}
pub fn missing() -> AppError {
AppError::Validation(
"statement is missing the required `taskContext`; its predicate profile \
(DTG VSC registry) marks it REQUIRED and a verifier cannot bind the \
credential to the exchange it was issued in without it"
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
const THREAD: &str = "urn:uuid:0b3f2a8e-1f8c-4a3d-9c1a-6d2f5e7b8c90";
#[test]
fn the_predicate_decides_the_requirement() {
for required in [
dtg_credentials::WITNESSED_V1,
dtg_credentials::VETTED_V1,
dtg_credentials::PRESENTED_V1,
] {
assert_eq!(
requirement_for_predicate(Some(required)),
Requirement::Required
);
}
assert_eq!(
requirement_for_predicate(Some(dtg_credentials::ENDORSES_V1)),
Requirement::Optional
);
assert_eq!(requirement_for_predicate(None), Requirement::Optional);
}
#[test]
fn a_credential_naming_this_exchange_is_bound_to_it() {
assert_eq!(
resolve(Requirement::Optional, Some(THREAD), Some(THREAD)).unwrap(),
TaskContextBinding::SameExchange
);
assert!(
resolve(Requirement::Required, Some(THREAD), Some(THREAD))
.unwrap()
.is_same_exchange()
);
}
#[test]
fn a_credential_from_another_exchange_is_foreign_not_bound() {
let binding = resolve(Requirement::Optional, Some("urn:uuid:other"), Some(THREAD)).unwrap();
assert_eq!(
binding,
TaskContextBinding::ForeignExchange {
thread_id: "urn:uuid:other".into()
}
);
assert!(!binding.is_same_exchange());
}
#[test]
fn a_witness_without_a_task_context_is_refused_not_defaulted() {
let err = resolve(Requirement::Required, None, Some(THREAD)).unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("taskContext")),
"{err:?}"
);
}
#[test]
fn a_witness_without_a_task_context_is_refused_even_unthreaded() {
assert!(resolve(Requirement::Required, None, None).is_err());
}
#[test]
fn absence_is_fine_where_the_specification_makes_it_optional() {
assert_eq!(
resolve(Requirement::Optional, None, Some(THREAD)).unwrap(),
TaskContextBinding::Absent
);
}
#[test]
fn an_unthreaded_ceremony_binds_nothing() {
assert_eq!(
resolve(Requirement::Optional, Some(THREAD), None).unwrap(),
TaskContextBinding::Unthreaded
);
}
#[test]
fn thread_ids_are_compared_verbatim() {
let bare = "0b3f2a8e-1f8c-4a3d-9c1a-6d2f5e7b8c90";
assert!(
!resolve(Requirement::Required, Some(bare), Some(THREAD))
.unwrap()
.is_same_exchange()
);
}
#[test]
fn the_wire_shape_matches_the_facts_contract() {
assert_eq!(
serde_json::to_value(TaskContextBinding::SameExchange).unwrap(),
serde_json::json!({ "state": "sameExchange" })
);
assert_eq!(
serde_json::to_value(TaskContextBinding::ForeignExchange {
thread_id: "urn:uuid:other".into()
})
.unwrap(),
serde_json::json!({ "state": "foreignExchange", "thread_id": "urn:uuid:other" })
);
let parsed: TaskContextBinding =
serde_json::from_value(serde_json::json!({ "state": "absent" })).unwrap();
assert_eq!(parsed, TaskContextBinding::Absent);
}
}