openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OpenlatchMarker {
    pub v: u8,
    pub id: String,
    pub installed_at: DateTime<Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hmac: Option<String>,
}

impl OpenlatchMarker {
    pub fn new(id: String) -> Self {
        Self {
            v: 1,
            id,
            installed_at: Utc::now(),
            hmac: None,
        }
    }

    pub fn with_hmac(mut self, hmac: String) -> Self {
        self.hmac = Some(hmac);
        self
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum MarkerShape {
    Missing,
    Legacy,
    Current(OpenlatchMarker),
}

pub fn classify_marker(value: &serde_json::Value) -> MarkerShape {
    let obj = match value.as_object() {
        Some(o) => o,
        None => return MarkerShape::Missing,
    };

    match obj.get("_openlatch") {
        Some(serde_json::Value::Bool(true)) => MarkerShape::Legacy,
        Some(serde_json::Value::Object(_)) => {
            match serde_json::from_value::<OpenlatchMarker>(obj["_openlatch"].clone()) {
                Ok(marker) => MarkerShape::Current(marker),
                Err(_) => MarkerShape::Legacy,
            }
        }
        _ => MarkerShape::Missing,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn classify_missing_when_no_marker() {
        let v = json!({"hooks": []});
        assert_eq!(classify_marker(&v), MarkerShape::Missing);
    }

    #[test]
    fn classify_legacy_bool_marker() {
        let v = json!({"_openlatch": true, "hooks": []});
        assert_eq!(classify_marker(&v), MarkerShape::Legacy);
    }

    #[test]
    fn classify_current_object_marker() {
        let v = json!({
            "_openlatch": {
                "v": 1,
                "id": "test-id",
                "installed_at": "2026-04-16T12:00:00Z",
                "hmac": "dGVzdA"
            },
            "hooks": []
        });
        assert!(matches!(classify_marker(&v), MarkerShape::Current(_)));
    }

    #[test]
    fn classify_missing_on_non_object() {
        let v = json!("string");
        assert_eq!(classify_marker(&v), MarkerShape::Missing);
    }

    #[test]
    fn classify_missing_when_openlatch_false() {
        let v = json!({"_openlatch": false});
        assert_eq!(classify_marker(&v), MarkerShape::Missing);
    }

    #[test]
    fn marker_new_sets_v1() {
        let m = OpenlatchMarker::new("test-id".into());
        assert_eq!(m.v, 1);
        assert!(m.hmac.is_none());
    }

    #[test]
    fn marker_with_hmac() {
        let m = OpenlatchMarker::new("test-id".into()).with_hmac("abc".into());
        assert_eq!(m.hmac.as_deref(), Some("abc"));
    }

    #[test]
    fn marker_roundtrip_serde() {
        let m = OpenlatchMarker::new("test-id".into()).with_hmac("abc".into());
        let json = serde_json::to_value(&m).unwrap();
        let m2: OpenlatchMarker = serde_json::from_value(json).unwrap();
        assert_eq!(m.v, m2.v);
        assert_eq!(m.id, m2.id);
        assert_eq!(m.hmac, m2.hmac);
    }
}