use exo_core::Did;
use serde::{Deserialize, Deserializer, Serialize, de};
use crate::error::{ExoError, ExoResult};
#[derive(Debug, Clone)]
pub struct BailmentBuilder {
bailor: Did,
bailee: Did,
scope: Option<String>,
duration_hours: Option<u64>,
}
impl BailmentBuilder {
#[must_use]
pub fn new(bailor: Did, bailee: Did) -> Self {
Self {
bailor,
bailee,
scope: None,
duration_hours: None,
}
}
#[must_use]
pub fn scope(mut self, scope: &str) -> Self {
self.scope = Some(scope.to_owned());
self
}
#[must_use]
pub fn duration_hours(mut self, hours: u64) -> Self {
self.duration_hours = Some(hours);
self
}
pub fn build(self) -> ExoResult<BailmentProposal> {
let scope = self
.scope
.ok_or_else(|| ExoError::Consent("scope is required".into()))?;
let duration_hours = self
.duration_hours
.ok_or_else(|| ExoError::Consent("duration_hours is required".into()))?;
validate_bailment_fields(&self.bailor, &self.bailee, &scope, duration_hours)
.map_err(ExoError::Consent)?;
let proposal_id = proposal_id_for(&self.bailor, &self.bailee, &scope, duration_hours);
Ok(BailmentProposal {
proposal_id,
bailor: self.bailor,
bailee: self.bailee,
scope,
duration_hours,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BailmentProposal {
pub proposal_id: String,
pub bailor: Did,
pub bailee: Did,
pub scope: String,
pub duration_hours: u64,
}
impl<'de> Deserialize<'de> for BailmentProposal {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct WireBailmentProposal {
proposal_id: String,
bailor: Did,
bailee: Did,
scope: String,
duration_hours: u64,
}
let wire = WireBailmentProposal::deserialize(deserializer)?;
validate_bailment_proposal(
&wire.proposal_id,
&wire.bailor,
&wire.bailee,
&wire.scope,
wire.duration_hours,
)
.map_err(de::Error::custom)?;
Ok(Self {
proposal_id: wire.proposal_id,
bailor: wire.bailor,
bailee: wire.bailee,
scope: wire.scope,
duration_hours: wire.duration_hours,
})
}
}
fn validate_bailment_fields(
bailor: &Did,
bailee: &Did,
scope: &str,
duration_hours: u64,
) -> Result<(), String> {
if scope.is_empty() {
return Err("scope must be non-empty".into());
}
if scope.contains('\0') {
return Err("scope must not contain NUL bytes".into());
}
if bailor.as_str().contains('\0') || bailee.as_str().contains('\0') {
return Err("DID fields must not contain NUL bytes".into());
}
if duration_hours == 0 {
return Err("duration_hours must be > 0".into());
}
Ok(())
}
fn validate_bailment_proposal(
proposal_id: &str,
bailor: &Did,
bailee: &Did,
scope: &str,
duration_hours: u64,
) -> Result<(), String> {
validate_bailment_fields(bailor, bailee, scope, duration_hours)?;
let expected = proposal_id_for(bailor, bailee, scope, duration_hours);
if proposal_id != expected {
return Err(format!(
"proposal_id does not match canonical content hash: expected {expected}, got {proposal_id}"
));
}
Ok(())
}
fn proposal_id_for(bailor: &Did, bailee: &Did, scope: &str, duration_hours: u64) -> String {
let mut payload = Vec::new();
payload.extend_from_slice(bailor.as_str().as_bytes());
payload.push(0);
payload.extend_from_slice(bailee.as_str().as_bytes());
payload.push(0);
payload.extend_from_slice(scope.as_bytes());
payload.push(0);
payload.extend_from_slice(&duration_hours.to_le_bytes());
let digest = blake3::hash(&payload);
let bytes = digest.as_bytes();
let mut hex = String::with_capacity(16);
for byte in bytes.iter().take(8) {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn did(s: &str) -> Did {
Did::new(s).expect("valid DID")
}
#[test]
fn builder_pattern_works() {
let bailor = did("did:exo:alice");
let bailee = did("did:exo:bob");
let proposal = BailmentBuilder::new(bailor.clone(), bailee.clone())
.scope("data:medical")
.duration_hours(24)
.build()
.expect("valid proposal");
assert_eq!(proposal.bailor, bailor);
assert_eq!(proposal.bailee, bailee);
assert_eq!(proposal.scope, "data:medical");
assert_eq!(proposal.duration_hours, 24);
assert_eq!(proposal.proposal_id.len(), 16);
assert!(proposal.proposal_id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn missing_scope_fails() {
let err = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.duration_hours(1)
.build()
.unwrap_err();
assert!(matches!(err, ExoError::Consent(_)));
}
#[test]
fn empty_scope_fails() {
let err = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("")
.duration_hours(1)
.build()
.unwrap_err();
assert!(matches!(err, ExoError::Consent(_)));
}
#[test]
fn null_byte_scope_fails() {
let err = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("data\0medical")
.duration_hours(1)
.build()
.unwrap_err();
assert!(matches!(err, ExoError::Consent(_)));
}
#[test]
fn missing_duration_fails() {
let err = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("data")
.build()
.unwrap_err();
assert!(matches!(err, ExoError::Consent(_)));
}
#[test]
fn zero_duration_fails() {
let err = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("data")
.duration_hours(0)
.build()
.unwrap_err();
assert!(matches!(err, ExoError::Consent(_)));
}
#[test]
fn proposal_id_is_deterministic() {
let bailor = did("did:exo:a");
let bailee = did("did:exo:b");
let p1 = BailmentBuilder::new(bailor.clone(), bailee.clone())
.scope("s")
.duration_hours(1)
.build()
.expect("ok");
let p2 = BailmentBuilder::new(bailor, bailee)
.scope("s")
.duration_hours(1)
.build()
.expect("ok");
assert_eq!(p1.proposal_id, p2.proposal_id);
}
#[test]
fn proposal_id_differs_for_different_inputs() {
let bailor = did("did:exo:a");
let bailee = did("did:exo:b");
let p1 = BailmentBuilder::new(bailor.clone(), bailee.clone())
.scope("s1")
.duration_hours(1)
.build()
.expect("ok");
let p2 = BailmentBuilder::new(bailor, bailee)
.scope("s2")
.duration_hours(1)
.build()
.expect("ok");
assert_ne!(p1.proposal_id, p2.proposal_id);
}
#[test]
fn proposal_serde_roundtrip() {
let proposal = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("data:medical")
.duration_hours(48)
.build()
.expect("ok");
let json = serde_json::to_string(&proposal).expect("serialize");
let decoded: BailmentProposal = serde_json::from_str(&json).expect("deserialize");
assert_eq!(proposal, decoded);
}
#[test]
fn proposal_deserialization_rejects_zero_duration() {
let json = serde_json::json!({
"proposal_id": "0000000000000000",
"bailor": "did:exo:a",
"bailee": "did:exo:b",
"scope": "data",
"duration_hours": 0
});
let result = serde_json::from_value::<BailmentProposal>(json);
assert!(
result.is_err(),
"deserialization must enforce non-zero duration"
);
}
#[test]
fn proposal_deserialization_rejects_forged_proposal_id() {
let proposal = BailmentBuilder::new(did("did:exo:a"), did("did:exo:b"))
.scope("data")
.duration_hours(1)
.build()
.expect("valid");
let mut json = serde_json::to_value(&proposal).expect("serialize");
json["proposal_id"] = serde_json::json!("ffffffffffffffff");
let result = serde_json::from_value::<BailmentProposal>(json);
assert!(
result.is_err(),
"deserialization must recompute and verify the content-addressed ID"
);
}
#[test]
fn proposal_deserialization_rejects_null_byte_scope() {
let json = serde_json::json!({
"proposal_id": "0000000000000000",
"bailor": "did:exo:a",
"bailee": "did:exo:b",
"scope": "data\0medical",
"duration_hours": 1
});
let result = serde_json::from_value::<BailmentProposal>(json);
assert!(
result.is_err(),
"deserialization must reject NUL-delimited scope collisions"
);
}
}