didwebvh-rs 0.4.1

Implementation of the did:webvh method in Rust
Documentation
/*!
*   Handles converting a WebVH DID to a Web DID Document
*/

use crate::{
    DIDWebVHError, DIDWebVHState, ensure_object_mut, log_entry_state::LogEntryState,
    resolve::implicit::update_implicit_services,
};
use serde_json::Value;

impl DIDWebVHState {
    /// Converts the last LogEntry to a DID Web Document
    /// Will change the DID ID's as required
    /// NOTE: You may still want to check the resulting DID Document for validity
    pub fn to_web_did(&self) -> Result<Value, DIDWebVHError> {
        if let Some(log_entry) = self.log_entries.last() {
            log_entry.to_web_did()
        } else {
            // There is no Log Entry
            Err(DIDWebVHError::NotFound(
                "No log entries available for did:web conversion".to_string(),
            ))
        }
    }

    /// Takes a did:webvh ID and converts it to a did:web ID
    pub fn convert_webvh_id_to_web_id(id: &str) -> String {
        // input: did:webvh:<SCID>:<path>
        let parts: Vec<&str> = id.split(':').collect();
        let mut new_did = String::new();
        new_did.push_str("did:web");
        for p in parts[3..].iter() {
            new_did.push(':');
            new_did.push_str(p);
        }
        new_did
    }

    /// Takes a did:webvh ID and converts it to a did:scid:vh ID
    pub fn convert_webvh_id_to_scid_id(id: &str) -> String {
        // input: did:webvh:<SCID>:<path>
        // Extract SCID and path after "did:webvh:"
        let mut new_did = String::from("did:scid:vh:1:");
        if let Some(rest) = id.strip_prefix("did:webvh:")
            && let Some((scid, path)) = rest.split_once(':')
        {
            new_did.push_str(scid);
            new_did.push_str("?src=");
            new_did.push_str(&path.replace(':', "/"));
        }
        new_did
    }
}

impl LogEntryState {
    /// Converts this LogEntry State to a DID Web Document
    /// Converts the DID references automatically
    /// NOTE: You may still want to check the resulting DID Document for validity
    pub fn to_web_did(&self) -> Result<Value, DIDWebVHError> {
        let state = self.get_state();
        if !state.is_object() {
            return Err(DIDWebVHError::DIDError(
                "State is not a valid JSON Object".to_string(),
            ));
        }

        to_web_did(state)
    }
}

/// Replaces all occurrences of `did:webvh:<SCID>` with `did:web` in a string.
/// The SCID is the segment between `did:webvh:` and the next `:` (or end of the
/// `did:webvh:...` token).
fn replace_webvh_prefix(input: &str) -> String {
    const PREFIX: &str = "did:webvh:";
    let mut result = String::with_capacity(input.len());
    let mut remaining = input;
    while let Some(start) = remaining.find(PREFIX) {
        result.push_str(&remaining[..start]);
        result.push_str("did:web");
        let after_prefix = &remaining[start + PREFIX.len()..];
        // Skip the SCID (everything up to the next ':' or non-DID character)
        let scid_end = after_prefix
            .find(|c: char| c == ':' || c == '"' || c.is_whitespace())
            .unwrap_or(after_prefix.len());
        remaining = &after_prefix[scid_end..];
    }
    result.push_str(remaining);
    result
}

fn to_web_did(old_state: &Value) -> Result<Value, DIDWebVHError> {
    // What is the new DID?
    let (webvh_did, web_did) = if let Some(id) = old_state.get("id")
        && let Some(id_str) = id.as_str()
    {
        (
            id_str.to_string(),
            DIDWebVHState::convert_webvh_id_to_web_id(id_str),
        )
    } else {
        return Err(DIDWebVHError::DIDError(
            "Couldn't find DID (id) attribute".to_string(),
        ));
    };

    let service = old_state.get("service");
    let mut old_state = old_state.clone();
    // Add implicit WebVH Services if not present
    update_implicit_services(service, &mut old_state, &webvh_did)?;

    let did_doc = serde_json::to_string(&old_state)
        .map_err(|e| DIDWebVHError::DIDError(format!("Couldn't serialize state: {}", e)))?;

    // Replace the existing did:webvh:<SCID> prefix with did:web throughout the document.
    // The SCID is always the segment between "did:webvh:" and the next ":"
    let new_did_doc = replace_webvh_prefix(&did_doc);

    let mut new_state: Value = serde_json::from_str(&new_did_doc)
        .map_err(|e| DIDWebVHError::DIDError(format!("Couldn't parse new state: {}", e)))?;

    // Set the DID id
    ensure_object_mut(&mut new_state)?.insert("id".to_string(), Value::String(web_did.clone()));

    // Reset the controller to be the webvh original ID
    ensure_object_mut(&mut new_state)?
        .insert("controller".to_string(), Value::String(webvh_did.clone()));

    // Update alsoKnownAs
    update_also_known_as(
        old_state.get("alsoKnownAs"),
        &mut new_state,
        &webvh_did,
        &web_did,
    )?;

    Ok(new_state)
}

// Manages the updates to the alsoKnownAs DID attribute
// Checks each entry, removes itself if it exists already
// Adds the WebVH entry if it doesn't exist
fn update_also_known_as(
    also_known_as: Option<&Value>,
    new_state: &mut Value,
    old_did: &str,
    new_did: &str,
) -> Result<(), DIDWebVHError> {
    let Some(also_known_as) = also_known_as else {
        // There is no alsoKnownAs, add the old_did
        ensure_object_mut(new_state)?.insert(
            "alsoKnownAs".to_string(),
            Value::Array(vec![Value::String(old_did.to_string())]),
        );
        return Ok(());
    };

    let mut did_webvh_exists = false;
    let mut new_aliases = vec![];

    if let Some(aliases) = also_known_as.as_array() {
        for alias in aliases {
            if let Some(alias_str) = alias.as_str() {
                if alias_str == new_did {
                    // did:web already exists, skip it
                } else if alias_str == old_did {
                    // did:webvh already exists, add it
                    did_webvh_exists = true;
                    new_aliases.push(alias.clone());
                } else {
                    new_aliases.push(alias.clone());
                }
            }
        }
    } else {
        return Err(DIDWebVHError::DIDError(
            "alsoKnownAs is not an array".to_string(),
        ));
    }

    if !did_webvh_exists {
        // webvh DID isn't an alias, add it
        new_aliases.push(Value::String(old_did.to_string()));
    }

    ensure_object_mut(new_state)?.insert("alsoKnownAs".to_string(), Value::Array(new_aliases));

    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::{DIDWebVHState, did_web::to_web_did};
    use serde::Deserialize;
    use serde_json::{Value, json};

    // Dummy struct to help with testing DID Services
    #[derive(Deserialize, PartialEq)]
    struct Service {
        pub id: String,
        #[serde(rename = "serviceEndpoint")]
        pub service_endpoint: String,
    }

    #[test]
    fn test_no_log_entry() {
        let state = DIDWebVHState::default();

        assert!(state.to_web_did().is_err());
    }

    #[test]
    fn test_id_conversion() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com:path"});

        let new_state = to_web_did(&old_state).expect("Couldn't convert to did:web");
        assert_eq!(
            new_state
                .get("id")
                .expect("Couldn't find (id)")
                .as_str()
                .expect("Expected a string for (id)"),
            "did:web:affinidi.com:path"
        );

        assert_eq!(
            new_state
                .get("controller")
                .expect("Couldn't find (controller)")
                .as_str()
                .expect("Expected a string for (controller)"),
            "did:webvh:acme1234:affinidi.com:path"
        );
    }

    #[test]
    fn test_missing_id() {
        let old_state = json!({"not_id": "did:webvh:acme1234:affinidi.com:path"});

        assert!(to_web_did(&old_state).is_err());
    }

    #[test]
    fn test_not_object() {
        let old_state = Value::String("Not an object".to_string());

        assert!(to_web_did(&old_state).is_err());
    }

    #[test]
    fn test_also_known_as_empty() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com"});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let also_known_as: Vec<String> = serde_json::from_value(
            did_web
                .get("alsoKnownAs")
                .expect("alsoKnownAs in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process alsoKnownAs attribute");

        assert_eq!(also_known_as.len(), 1);
        assert!(also_known_as.contains(&"did:webvh:acme1234:affinidi.com".to_string()));
    }

    #[test]
    fn test_also_known_as_existing_webvh() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com", "alsoKnownAs": ["did:webvh:acme1234:affinidi.com"]});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let also_known_as: Vec<String> = serde_json::from_value(
            did_web
                .get("alsoKnownAs")
                .expect("alsoKnownAs in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process alsoKnownAs attribute");

        assert_eq!(also_known_as.len(), 1);
        assert!(also_known_as.contains(&"did:webvh:acme1234:affinidi.com".to_string()));
    }

    #[test]
    fn test_also_known_as_existing_web() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com", "alsoKnownAs": ["did:web:affinidi.com"]});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let also_known_as: Vec<String> = serde_json::from_value(
            did_web
                .get("alsoKnownAs")
                .expect("alsoKnownAs in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process alsoKnownAs attribute");

        assert_eq!(also_known_as.len(), 1);
        assert!(!also_known_as.contains(&"did:web:affinidi.com".to_string()));
        assert!(also_known_as.contains(&"did:webvh:acme1234:affinidi.com".to_string()));
    }

    #[test]
    fn test_also_known_as_existing_many() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com", "alsoKnownAs": ["did:web:affinidi.com", "did:webvh:acme1234:affinidi.com", "did:web:unknown.com", "did:web:another.alias"]});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let also_known_as: Vec<String> = serde_json::from_value(
            did_web
                .get("alsoKnownAs")
                .expect("alsoKnownAs in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process alsoKnownAs attribute");

        assert_eq!(also_known_as.len(), 3);
        assert!(!also_known_as.contains(&"did:web:affinidi.com".to_string()));
        assert!(also_known_as.contains(&"did:web:unknown.com".to_string()));
        assert!(also_known_as.contains(&"did:webvh:acme1234:affinidi.com".to_string()));
    }

    #[test]
    fn test_services_none() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com"});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let services: Vec<Service> = serde_json::from_value(
            did_web
                .get("service")
                .expect("service in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process service attribute");

        assert_eq!(services.len(), 2);
        assert!(services.contains(&Service {
            id: "did:web:affinidi.com#files".to_string(),
            service_endpoint: "https://affinidi.com/".to_string()
        }));
        assert!(services.contains(&Service {
            id: "did:web:affinidi.com#whois".to_string(),
            service_endpoint: "https://affinidi.com/whois.vp".to_string()
        }));
    }

    #[test]
    fn test_convert_webvh_to_scid() {
        let scid =
            DIDWebVHState::convert_webvh_id_to_scid_id("did:webvh:acme1234:affinidi.com:path");
        assert_eq!(scid, "did:scid:vh:1:acme1234?src=affinidi.com/path");
    }

    #[test]
    fn test_convert_webvh_to_scid_no_path() {
        let scid = DIDWebVHState::convert_webvh_id_to_scid_id("did:webvh:acme1234:affinidi.com");
        assert_eq!(scid, "did:scid:vh:1:acme1234?src=affinidi.com");
    }

    #[test]
    fn test_replace_webvh_prefix_multiple() {
        use super::replace_webvh_prefix;
        let input = r#""did:webvh:scid1:a.com" and "did:webvh:scid2:b.com""#;
        let output = replace_webvh_prefix(input);
        assert_eq!(output, r#""did:web:a.com" and "did:web:b.com""#);
    }

    #[test]
    fn test_replace_webvh_prefix_no_match() {
        use super::replace_webvh_prefix;
        let input = "did:web:example.com";
        assert_eq!(replace_webvh_prefix(input), input);
    }

    #[test]
    fn test_services_none_custom_path() {
        let old_state = json!({"id": "did:webvh:acme1234:affinidi.com:custom:path"});

        let did_web = to_web_did(&old_state).expect("Couldn't convert to did:web");

        let services: Vec<Service> = serde_json::from_value(
            did_web
                .get("service")
                .expect("service in did:web doesn't exist")
                .to_owned(),
        )
        .expect("Couldn't process service attribute");

        assert_eq!(services.len(), 2);
        assert!(services.contains(&Service {
            id: "did:web:affinidi.com:custom:path#files".to_string(),
            service_endpoint: "https://affinidi.com/custom/path/".to_string()
        }));
        assert!(services.contains(&Service {
            id: "did:web:affinidi.com:custom:path#whois".to_string(),
            service_endpoint: "https://affinidi.com/custom/path/whois.vp".to_string()
        }));
    }
}