baseid_anoncreds/
schema.rs

1//! AnonCreds schema and credential definition types.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Errors returned by AnonCreds schema validation.
7#[derive(Debug, Error)]
8pub enum SchemaError {
9    /// Schema name must not be empty.
10    #[error("Schema name must not be empty / Le nom du schéma ne peut pas être vide")]
11    EmptyName,
12
13    /// Schema must have at least one attribute.
14    #[error("Schema must have at least one attribute / Le schéma doit avoir au moins un attribut")]
15    EmptyAttributes,
16
17    /// Schema version must follow semver-like `MAJOR.MINOR` format.
18    #[error(
19        "Invalid schema version '{0}' — expected MAJOR.MINOR / Version de schéma invalide '{0}'"
20    )]
21    InvalidVersion(String),
22}
23
24/// An AnonCreds schema defining the attribute names for a credential type.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Schema {
27    /// Schema identifier (e.g., `did:sov:NcYx...:2:degree:1.0`).
28    pub id: String,
29    /// Human-readable schema name.
30    pub name: String,
31    /// Schema version (`MAJOR.MINOR`).
32    pub version: String,
33    /// Attribute names defined by this schema.
34    pub attr_names: Vec<String>,
35}
36
37impl Schema {
38    /// Create a new schema with validation.
39    ///
40    /// # Errors
41    /// Returns `SchemaError` if the name is empty, attributes are empty,
42    /// or the version does not follow `MAJOR.MINOR` format.
43    pub fn new(
44        id: impl Into<String>,
45        name: impl Into<String>,
46        version: impl Into<String>,
47        attr_names: Vec<String>,
48    ) -> Result<Self, SchemaError> {
49        let name = name.into();
50        let version = version.into();
51
52        let schema = Self {
53            id: id.into(),
54            name,
55            version,
56            attr_names,
57        };
58        schema.validate()?;
59        Ok(schema)
60    }
61
62    /// Validate the schema fields.
63    pub fn validate(&self) -> Result<(), SchemaError> {
64        if self.name.trim().is_empty() {
65            return Err(SchemaError::EmptyName);
66        }
67        if self.attr_names.is_empty() {
68            return Err(SchemaError::EmptyAttributes);
69        }
70        if !is_valid_version(&self.version) {
71            return Err(SchemaError::InvalidVersion(self.version.clone()));
72        }
73        Ok(())
74    }
75
76    /// Check whether this schema defines a given attribute name.
77    pub fn contains_attribute(&self, attr: &str) -> bool {
78        self.attr_names.iter().any(|a| a == attr)
79    }
80}
81
82/// An AnonCreds credential definition referencing a schema.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CredentialDefinition {
85    /// Credential definition identifier.
86    pub id: String,
87    /// The schema this credential definition is based on.
88    pub schema_id: String,
89    /// Tag for distinguishing multiple cred-defs on the same schema.
90    pub tag: String,
91    /// Signature type — defaults to `"CL"` (Camenisch-Lysyanskaya).
92    #[serde(default = "default_signature_type")]
93    pub signature_type: String,
94}
95
96fn default_signature_type() -> String {
97    "CL".to_string()
98}
99
100impl CredentialDefinition {
101    /// Create a new credential definition with CL signature type.
102    pub fn new(
103        id: impl Into<String>,
104        schema_id: impl Into<String>,
105        tag: impl Into<String>,
106    ) -> Self {
107        Self {
108            id: id.into(),
109            schema_id: schema_id.into(),
110            tag: tag.into(),
111            signature_type: "CL".to_string(),
112        }
113    }
114
115    /// Check whether this credential definition references the given schema.
116    pub fn supports_schema(&self, schema_id: &str) -> bool {
117        self.schema_id == schema_id
118    }
119}
120
121/// Validate that a version string follows `MAJOR.MINOR` format (both numeric).
122fn is_valid_version(v: &str) -> bool {
123    let parts: Vec<&str> = v.split('.').collect();
124    if parts.len() != 2 {
125        return false;
126    }
127    parts[0].parse::<u32>().is_ok() && parts[1].parse::<u32>().is_ok()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn schema_create_valid() {
136        let schema = Schema::new(
137            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0",
138            "degree",
139            "1.0",
140            vec![
141                "name".to_string(),
142                "degree".to_string(),
143                "university".to_string(),
144            ],
145        )
146        .unwrap();
147        assert_eq!(schema.name, "degree");
148        assert_eq!(schema.attr_names.len(), 3);
149    }
150
151    #[test]
152    fn schema_validate_empty_name() {
153        let result = Schema::new("id", "", "1.0", vec!["a".to_string()]);
154        assert!(result.is_err());
155    }
156
157    #[test]
158    fn schema_validate_empty_attrs() {
159        let result = Schema::new("id", "degree", "1.0", vec![]);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn schema_validate_invalid_version() {
165        let result = Schema::new("id", "degree", "abc", vec!["a".to_string()]);
166        assert!(result.is_err());
167    }
168
169    #[test]
170    fn schema_contains_attribute() {
171        let schema = Schema::new(
172            "id",
173            "degree",
174            "1.0",
175            vec!["name".to_string(), "degree".to_string()],
176        )
177        .unwrap();
178        assert!(schema.contains_attribute("name"));
179        assert!(!schema.contains_attribute("age"));
180    }
181
182    #[test]
183    fn schema_serde_roundtrip() {
184        let schema = Schema::new(
185            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0",
186            "degree",
187            "1.0",
188            vec!["name".to_string(), "degree".to_string()],
189        )
190        .unwrap();
191        let json = serde_json::to_string(&schema).unwrap();
192        let deserialized: Schema = serde_json::from_str(&json).unwrap();
193        assert_eq!(schema, deserialized);
194    }
195
196    #[test]
197    fn cred_def_create_and_supports_schema() {
198        let cred_def = CredentialDefinition::new(
199            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:3:CL:12:default",
200            "did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0",
201            "default",
202        );
203        assert_eq!(cred_def.signature_type, "CL");
204        assert!(cred_def.supports_schema("did:sov:NcYxiDXkpYi6ov5FcYDi1e:2:degree:1.0"));
205        assert!(!cred_def.supports_schema("other:schema"));
206    }
207
208    #[test]
209    fn cred_def_serde_roundtrip() {
210        let cred_def = CredentialDefinition::new("id", "schema-id", "tag");
211        let json = serde_json::to_string(&cred_def).unwrap();
212        let deserialized: CredentialDefinition = serde_json::from_str(&json).unwrap();
213        assert_eq!(cred_def, deserialized);
214    }
215}