use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum PresentationError {
MissingCredentials,
InvalidStructure(String),
JsonParseError(String),
}
impl fmt::Display for PresentationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PresentationError::MissingCredentials => {
write!(f, "Presentation must include at least one credential")
}
PresentationError::InvalidStructure(msg) => {
write!(f, "Invalid presentation structure: {msg}")
}
PresentationError::JsonParseError(msg) => {
write!(f, "JSON parse error: {msg}")
}
}
}
}
impl std::error::Error for PresentationError {}
#[derive(Debug, Clone)]
pub struct CredentialSubject {
pub id: Option<String>,
pub claims: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ProofBlock {
pub proof_type: String,
pub created: String,
pub verification_method: String,
pub proof_purpose: String,
pub proof_value: String,
}
#[derive(Debug, Clone)]
pub struct VerifiableCredential {
pub context: Vec<String>,
pub id: Option<String>,
pub types: Vec<String>,
pub issuer: String,
pub issuance_date: String,
pub expiration_date: Option<String>,
pub subject: CredentialSubject,
pub proof: Option<ProofBlock>,
}
#[derive(Debug, Clone)]
pub struct VerifiablePresentation {
pub context: Vec<String>,
pub id: Option<String>,
pub types: Vec<String>,
pub holder: Option<String>,
pub credentials: Vec<VerifiableCredential>,
pub challenge: Option<String>,
pub domain: Option<String>,
proof: Option<ProofBlock>,
}
impl VerifiablePresentation {
pub fn add_proof(&mut self, proof: ProofBlock) {
self.proof = Some(proof);
}
pub fn credential_count(&self) -> usize {
self.credentials.len()
}
pub fn is_valid_structure(&self) -> bool {
let has_vp_type = self.types.iter().any(|t| t == "VerifiablePresentation");
has_vp_type && !self.credentials.is_empty()
}
pub fn credential_subjects(&self) -> Vec<&CredentialSubject> {
self.credentials.iter().map(|vc| &vc.subject).collect()
}
pub fn to_json(&self) -> String {
let mut out = String::from("{\n");
out.push_str(" \"@context\": [");
let ctx_items: Vec<String> = self
.context
.iter()
.map(|s| format!("\"{}\"", escape_json(s)))
.collect();
out.push_str(&ctx_items.join(", "));
out.push_str("],\n");
if let Some(ref id) = self.id {
out.push_str(&format!(" \"id\": \"{}\",\n", escape_json(id)));
}
out.push_str(" \"type\": [");
let type_items: Vec<String> = self
.types
.iter()
.map(|t| format!("\"{}\"", escape_json(t)))
.collect();
out.push_str(&type_items.join(", "));
out.push_str("],\n");
if let Some(ref h) = self.holder {
out.push_str(&format!(" \"holder\": \"{}\",\n", escape_json(h)));
}
if let Some(ref c) = self.challenge {
out.push_str(&format!(" \"challenge\": \"{}\",\n", escape_json(c)));
}
if let Some(ref d) = self.domain {
out.push_str(&format!(" \"domain\": \"{}\",\n", escape_json(d)));
}
out.push_str(" \"verifiableCredential\": [\n");
let vc_strs: Vec<String> = self.credentials.iter().map(vc_to_json).collect();
out.push_str(&vc_strs.join(",\n"));
out.push_str("\n ]");
if let Some(ref p) = self.proof {
out.push_str(",\n \"proof\": ");
out.push_str(&proof_to_json(p));
}
out.push_str("\n}");
out
}
pub fn from_json(s: &str) -> Result<Self, PresentationError> {
let types = extract_string_array(s, "\"type\"")
.or_else(|| extract_string_array(s, "\"@type\""))
.unwrap_or_default();
if types.is_empty() {
return Err(PresentationError::JsonParseError(
"Missing 'type' field".to_string(),
));
}
let context = extract_string_array(s, "\"@context\"")
.unwrap_or_else(|| vec!["https://www.w3.org/2018/credentials/v1".to_string()]);
let id = extract_string_value(s, "\"id\"");
let holder = extract_string_value(s, "\"holder\"");
let challenge = extract_string_value(s, "\"challenge\"");
let domain = extract_string_value(s, "\"domain\"");
let credentials = parse_credentials_from_json(s);
Ok(VerifiablePresentation {
context,
id,
types,
holder,
credentials,
challenge,
domain,
proof: None,
})
}
}
pub struct PresentationBuilder {
id: Option<String>,
holder: Option<String>,
credentials: Vec<VerifiableCredential>,
context: Vec<String>,
types: Vec<String>,
challenge: Option<String>,
domain: Option<String>,
}
impl PresentationBuilder {
pub fn new() -> Self {
Self {
id: None,
holder: None,
credentials: Vec::new(),
context: vec!["https://www.w3.org/2018/credentials/v1".to_string()],
types: vec!["VerifiablePresentation".to_string()],
challenge: None,
domain: None,
}
}
pub fn id(mut self, id: &str) -> Self {
self.id = Some(id.to_string());
self
}
pub fn holder(mut self, did: &str) -> Self {
self.holder = Some(did.to_string());
self
}
pub fn add_credential(mut self, vc: VerifiableCredential) -> Self {
self.credentials.push(vc);
self
}
pub fn add_context(mut self, ctx: &str) -> Self {
let ctx_str = ctx.to_string();
if !self.context.contains(&ctx_str) {
self.context.push(ctx_str);
}
self
}
pub fn add_type(mut self, t: &str) -> Self {
let t_str = t.to_string();
if !self.types.contains(&t_str) {
self.types.push(t_str);
}
self
}
pub fn challenge(mut self, c: &str) -> Self {
self.challenge = Some(c.to_string());
self
}
pub fn domain(mut self, d: &str) -> Self {
self.domain = Some(d.to_string());
self
}
pub fn build(self) -> Result<VerifiablePresentation, PresentationError> {
if self.credentials.is_empty() {
return Err(PresentationError::MissingCredentials);
}
Ok(VerifiablePresentation {
context: self.context,
id: self.id,
types: self.types,
holder: self.holder,
credentials: self.credentials,
challenge: self.challenge,
domain: self.domain,
proof: None,
})
}
}
impl Default for PresentationBuilder {
fn default() -> Self {
Self::new()
}
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn proof_to_json(p: &ProofBlock) -> String {
format!(
"{{\n \"type\": \"{}\",\n \"created\": \"{}\",\n \"verificationMethod\": \"{}\",\n \"proofPurpose\": \"{}\",\n \"proofValue\": \"{}\"\n }}",
escape_json(&p.proof_type),
escape_json(&p.created),
escape_json(&p.verification_method),
escape_json(&p.proof_purpose),
escape_json(&p.proof_value),
)
}
fn vc_to_json(vc: &VerifiableCredential) -> String {
let mut out = String::from(" {\n");
out.push_str(" \"@context\": [");
let ctx_items: Vec<String> = vc
.context
.iter()
.map(|s| format!("\"{}\"", escape_json(s)))
.collect();
out.push_str(&ctx_items.join(", "));
out.push_str("],\n");
if let Some(ref id) = vc.id {
out.push_str(&format!(" \"id\": \"{}\",\n", escape_json(id)));
}
out.push_str(" \"type\": [");
let type_items: Vec<String> = vc
.types
.iter()
.map(|t| format!("\"{}\"", escape_json(t)))
.collect();
out.push_str(&type_items.join(", "));
out.push_str("],\n");
out.push_str(&format!(
" \"issuer\": \"{}\",\n",
escape_json(&vc.issuer)
));
out.push_str(&format!(
" \"issuanceDate\": \"{}\",\n",
escape_json(&vc.issuance_date)
));
if let Some(ref exp) = vc.expiration_date {
out.push_str(&format!(
" \"expirationDate\": \"{}\",\n",
escape_json(exp)
));
}
out.push_str(" \"credentialSubject\": {\n");
if let Some(ref id) = vc.subject.id {
out.push_str(&format!(" \"id\": \"{}\"", escape_json(id)));
if !vc.subject.claims.is_empty() {
out.push(',');
}
out.push('\n');
}
let claim_items: Vec<String> = vc
.subject
.claims
.iter()
.map(|(k, v)| format!(" \"{}\": \"{}\"", escape_json(k), escape_json(v)))
.collect();
out.push_str(&claim_items.join(",\n"));
if !claim_items.is_empty() {
out.push('\n');
}
out.push_str(" }");
if let Some(ref p) = vc.proof {
out.push_str(",\n \"proof\": ");
out.push_str(
&proof_to_json(p)
.replace(" {", " {")
.replace(" }", " }"),
);
}
out.push_str("\n }");
out
}
fn extract_string_value(json: &str, key: &str) -> Option<String> {
let key_pos = json.find(key)?;
let after_key = &json[key_pos + key.len()..];
let colon_pos = after_key.find(':')?;
let after_colon = &after_key[colon_pos + 1..];
let open_quote = after_colon.find('"')?;
let rest = &after_colon[open_quote + 1..];
let close_quote = rest.find('"')?;
Some(unescape_json(&rest[..close_quote]))
}
fn extract_string_array(json: &str, key: &str) -> Option<Vec<String>> {
let key_pos = json.find(key)?;
let after_key = &json[key_pos + key.len()..];
let colon_pos = after_key.find(':')?;
let after_colon = &after_key[colon_pos + 1..];
let open_bracket = after_colon.find('[')?;
let after_open = &after_colon[open_bracket + 1..];
let close_bracket = after_open.find(']')?;
let array_content = &after_open[..close_bracket];
let items: Vec<String> = array_content
.split(',')
.filter_map(|item| {
let trimmed = item.trim();
if trimmed.starts_with('"') && trimmed.ends_with('"') {
Some(unescape_json(&trimmed[1..trimmed.len() - 1]))
} else {
None
}
})
.collect();
if items.is_empty() {
None
} else {
Some(items)
}
}
fn unescape_json(s: &str) -> String {
s.replace("\\\"", "\"")
.replace("\\\\", "\\")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
}
fn parse_credentials_from_json(json: &str) -> Vec<VerifiableCredential> {
let Some(vc_pos) = json.find("\"verifiableCredential\"") else {
return Vec::new();
};
let after = &json[vc_pos..];
let Some(open_bracket) = after.find('[') else {
return Vec::new();
};
let array_start = vc_pos + open_bracket + 1;
let mut depth = 1i32;
let mut pos = array_start;
let bytes = json.as_bytes();
let mut blocks: Vec<&str> = Vec::new();
let mut block_start: Option<usize> = None;
while pos < bytes.len() && depth > 0 {
match bytes[pos] {
b'[' if block_start.is_none() => depth += 1,
b']' if block_start.is_none() => {
depth -= 1;
}
b'{' => {
if depth == 1 {
block_start = Some(pos);
}
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 1 {
if let Some(start) = block_start.take() {
blocks.push(&json[start..=pos]);
}
}
}
_ => {}
}
pos += 1;
}
blocks
.into_iter()
.map(|block| {
let issuer =
extract_string_value(block, "\"issuer\"").unwrap_or_else(|| "unknown".to_string());
let issuance_date = extract_string_value(block, "\"issuanceDate\"")
.unwrap_or_else(|| "unknown".to_string());
let id = extract_string_value(block, "\"id\"");
let types = extract_string_array(block, "\"type\"")
.unwrap_or_else(|| vec!["VerifiableCredential".to_string()]);
let context = extract_string_array(block, "\"@context\"")
.unwrap_or_else(|| vec!["https://www.w3.org/2018/credentials/v1".to_string()]);
let subject_id = extract_string_value(block, "\"id\"");
VerifiableCredential {
context,
id,
types,
issuer,
issuance_date,
expiration_date: extract_string_value(block, "\"expirationDate\""),
subject: CredentialSubject {
id: subject_id,
claims: HashMap::new(),
},
proof: None,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_vc() -> VerifiableCredential {
VerifiableCredential {
context: vec!["https://www.w3.org/2018/credentials/v1".to_string()],
id: Some("urn:uuid:vc-1".to_string()),
types: vec![
"VerifiableCredential".to_string(),
"UniversityDegree".to_string(),
],
issuer: "did:example:issuer".to_string(),
issuance_date: "2024-01-15T10:00:00Z".to_string(),
expiration_date: Some("2025-01-15T10:00:00Z".to_string()),
subject: CredentialSubject {
id: Some("did:example:holder".to_string()),
claims: HashMap::from([
("degree".to_string(), "Bachelor of Science".to_string()),
("gpa".to_string(), "3.8".to_string()),
]),
},
proof: None,
}
}
fn sample_proof() -> ProofBlock {
ProofBlock {
proof_type: "Ed25519Signature2020".to_string(),
created: "2024-01-15T10:01:00Z".to_string(),
verification_method: "did:example:issuer#key-1".to_string(),
proof_purpose: "assertionMethod".to_string(),
proof_value: "z3FXVHm5X5wZYZ".to_string(),
}
}
#[test]
fn test_builder_basic() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.expect("should build");
assert!(vp.is_valid_structure());
}
#[test]
fn test_builder_missing_credentials() {
let err = PresentationBuilder::new().build().unwrap_err();
assert_eq!(err, PresentationError::MissingCredentials);
}
#[test]
fn test_builder_with_id() {
let vp = PresentationBuilder::new()
.id("urn:uuid:pres-123")
.add_credential(sample_vc())
.build()
.unwrap();
assert_eq!(vp.id.as_deref(), Some("urn:uuid:pres-123"));
}
#[test]
fn test_builder_with_holder() {
let vp = PresentationBuilder::new()
.holder("did:example:holder")
.add_credential(sample_vc())
.build()
.unwrap();
assert_eq!(vp.holder.as_deref(), Some("did:example:holder"));
}
#[test]
fn test_builder_with_challenge() {
let vp = PresentationBuilder::new()
.challenge("random-nonce-42")
.add_credential(sample_vc())
.build()
.unwrap();
assert_eq!(vp.challenge.as_deref(), Some("random-nonce-42"));
}
#[test]
fn test_builder_with_domain() {
let vp = PresentationBuilder::new()
.domain("https://verifier.example")
.add_credential(sample_vc())
.build()
.unwrap();
assert_eq!(vp.domain.as_deref(), Some("https://verifier.example"));
}
#[test]
fn test_builder_add_context_dedup() {
let vp = PresentationBuilder::new()
.add_context("https://www.w3.org/2018/credentials/v1") .add_context("https://schema.org")
.add_credential(sample_vc())
.build()
.unwrap();
let count = vp
.context
.iter()
.filter(|c| c.as_str() == "https://www.w3.org/2018/credentials/v1")
.count();
assert_eq!(count, 1);
assert!(vp.context.contains(&"https://schema.org".to_string()));
}
#[test]
fn test_builder_add_type() {
let vp = PresentationBuilder::new()
.add_type("CredentialManagerPresentation")
.add_credential(sample_vc())
.build()
.unwrap();
assert!(vp
.types
.contains(&"CredentialManagerPresentation".to_string()));
assert!(vp.types.contains(&"VerifiablePresentation".to_string()));
}
#[test]
fn test_builder_multiple_credentials() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.add_credential(sample_vc())
.build()
.unwrap();
assert_eq!(vp.credential_count(), 2);
}
#[test]
fn test_is_valid_structure_true() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
assert!(vp.is_valid_structure());
}
#[test]
fn test_is_valid_structure_no_vp_type() {
let mut vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
vp.types = vec!["SomethingElse".to_string()];
assert!(!vp.is_valid_structure());
}
#[test]
fn test_add_proof() {
let mut vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
assert!(vp.proof.is_none());
vp.add_proof(sample_proof());
assert!(vp.proof.is_some());
}
#[test]
fn test_proof_clone() {
let p = sample_proof();
let p2 = p.clone();
assert_eq!(p.proof_type, p2.proof_type);
}
#[test]
fn test_credential_subjects() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let subjects = vp.credential_subjects();
assert_eq!(subjects.len(), 1);
assert_eq!(subjects[0].id.as_deref(), Some("did:example:holder"));
}
#[test]
fn test_to_json_contains_type() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("VerifiablePresentation"));
}
#[test]
fn test_to_json_contains_context() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("https://www.w3.org/2018/credentials/v1"));
}
#[test]
fn test_to_json_contains_holder() {
let vp = PresentationBuilder::new()
.holder("did:example:alice")
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("did:example:alice"));
}
#[test]
fn test_to_json_contains_issuer() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("did:example:issuer"));
}
#[test]
fn test_to_json_contains_challenge() {
let vp = PresentationBuilder::new()
.challenge("abc-123")
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("abc-123"));
}
#[test]
fn test_to_json_contains_domain() {
let vp = PresentationBuilder::new()
.domain("https://verifier.example")
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
assert!(json.contains("https://verifier.example"));
}
#[test]
fn test_to_json_with_proof() {
let mut vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
vp.add_proof(sample_proof());
let json = vp.to_json();
assert!(json.contains("Ed25519Signature2020"));
assert!(json.contains("assertionMethod"));
}
#[test]
fn test_from_json_round_trip_type() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
let parsed = VerifiablePresentation::from_json(&json).unwrap();
assert!(parsed.types.contains(&"VerifiablePresentation".to_string()));
}
#[test]
fn test_from_json_round_trip_holder() {
let vp = PresentationBuilder::new()
.holder("did:example:holder")
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
let parsed = VerifiablePresentation::from_json(&json).unwrap();
assert_eq!(parsed.holder.as_deref(), Some("did:example:holder"));
}
#[test]
fn test_from_json_missing_type() {
let err = VerifiablePresentation::from_json("{}").unwrap_err();
matches!(err, PresentationError::JsonParseError(_));
}
#[test]
fn test_from_json_credential_count() {
let vp = PresentationBuilder::new()
.add_credential(sample_vc())
.build()
.unwrap();
let json = vp.to_json();
let parsed = VerifiablePresentation::from_json(&json).unwrap();
assert_eq!(parsed.credential_count(), 1);
}
#[test]
fn test_error_display_missing_credentials() {
let e = PresentationError::MissingCredentials;
assert!(e.to_string().contains("credential"));
}
#[test]
fn test_error_display_invalid_structure() {
let e = PresentationError::InvalidStructure("oops".to_string());
assert!(e.to_string().contains("oops"));
}
#[test]
fn test_error_display_json_parse() {
let e = PresentationError::JsonParseError("bad json".to_string());
assert!(e.to_string().contains("bad json"));
}
#[test]
fn test_escape_json_quotes() {
let escaped = escape_json("say \"hello\"");
assert_eq!(escaped, r#"say \"hello\""#);
}
#[test]
fn test_escape_json_backslash() {
let escaped = escape_json(r"c:\path");
assert_eq!(escaped, r"c:\\path");
}
}