From bf00184a09418750caeb488d8d71f9dc7afd7aff Mon Sep 17 00:00:00 2001
From: cel 🌸 <cel@bunny.garden>
Date: Thu, 3 Apr 2025 11:06:00 +0100
Subject: feat(filamento): caps 1.0 helper functions & tests

---
 filamento/Cargo.toml   |   4 +-
 filamento/src/caps.rs  | 222 ++++++++++++++++++++++++++++++++++++++++++++++++-
 filamento/src/error.rs |   6 ++
 3 files changed, 227 insertions(+), 5 deletions(-)

diff --git a/filamento/Cargo.toml b/filamento/Cargo.toml
index 3530ab9..1c28c39 100644
--- a/filamento/Cargo.toml
+++ b/filamento/Cargo.toml
@@ -8,7 +8,7 @@ futures = "0.3.31"
 lampada = { version = "0.1.0", path = "../lampada" }
 tokio = "1.42.0"
 thiserror = "2.0.11"
-stanza = { version = "0.1.0", path = "../stanza", features = ["rfc_6121", "xep_0203", "xep_0030", "xep_0060", "xep_0172", "xep_0390", "xep_0128"] }
+stanza = { version = "0.1.0", path = "../stanza", features = ["rfc_6121", "xep_0203", "xep_0030", "xep_0060", "xep_0172", "xep_0390", "xep_0128", "xep_0115"] }
 sqlx = { version = "0.8.3", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
 # TODO: re-export jid?
 jid = { version = "0.1.0", path = "../jid", features = ["sqlx"] }
@@ -18,9 +18,11 @@ chrono = "0.4.40"
 sha2 = "0.10.8"
 sha3 = "0.10.8"
 base64 = "0.22.1"
+sha1 = "0.10.6"
 
 [dev-dependencies]
 tracing-subscriber = "0.3.19"
+peanuts = { version = "0.1.0", path = "../../peanuts" }
 
 [[example]]
 name = "example"
diff --git a/filamento/src/caps.rs b/filamento/src/caps.rs
index c87e48a..a0709eb 100644
--- a/filamento/src/caps.rs
+++ b/filamento/src/caps.rs
@@ -1,20 +1,119 @@
 use std::str::FromStr;
 
 use base64::{Engine, prelude::BASE64_STANDARD};
+use sha1::Sha1;
 use sha2::{Digest, Sha256};
 use sha3::Sha3_256;
 use stanza::{
     xep_0030::info,
+    xep_0115::{self, C},
     xep_0300::{self, Algo, Hash},
-    xep_0390::C,
+    xep_0390,
 };
+use tracing::trace;
 
 use crate::{
     disco::{Identity, Info, identity::Category},
-    error::{CapsDecodeError, HashNodeConversionError},
+    error::{CapsDecodeError, CapsEncodeError, HashNodeConversionError},
 };
 
-pub fn caps(query: info::Query) -> C {
+pub fn caps(node: String, query: info::Query) -> Result<xep_0115::C, CapsEncodeError> {
+    let mut string = String::new();
+
+    // identities string
+    let mut identities = Vec::new();
+    for identity in query.identities {
+        let mut string = String::new();
+        string.push_str(&identity.category);
+        string.push('/');
+        string.push_str(&identity.r#type);
+        string.push('/');
+        string.push_str(&identity.lang.unwrap_or_default());
+        string.push('/');
+        string.push_str(&identity.name.unwrap_or_default());
+        string.push('<');
+        identities.push(string);
+    }
+    identities.sort();
+    let identities_string = identities.concat();
+    string.push_str(&identities_string);
+
+    // features string
+    let mut features = Vec::new();
+    for feature in query.features {
+        let mut string = String::new();
+        string.push_str(&feature.var);
+        string.push('<');
+        features.push(string);
+    }
+    features.sort();
+    let features_string = features.concat();
+    string.push_str(&features_string);
+
+    // extensions string
+    let mut extensions = Vec::new();
+    for extension in query.extensions {
+        let mut string = String::new();
+        let form_type = extension
+            .fields
+            .iter()
+            .find(|field| field.var.as_deref() == Some("FORM_TYPE"))
+            .ok_or(CapsEncodeError::InvalidDataForm)?
+            .values
+            .clone()
+            .into_iter()
+            .map(|value| value.0)
+            .collect::<Vec<String>>()
+            .concat();
+        string.push_str(&form_type);
+        string.push('<');
+        let mut fields = Vec::new();
+        for field in extension.fields {
+            if field.var.as_deref() == Some("FORM_TYPE") {
+                continue;
+            }
+            let mut string = String::new();
+            string.push_str(&field.var.unwrap_or_default());
+            string.push('<');
+            let mut values = Vec::new();
+            for value in field.values {
+                let mut string = String::new();
+                string.push_str(&value.0);
+                string.push('<');
+                values.push(string);
+            }
+            values.sort();
+            let values_string = values.concat();
+            string.push_str(&values_string);
+            fields.push(string);
+        }
+        fields.sort();
+        let fields_string = fields.concat();
+        string.push_str(&fields_string);
+        extensions.push(string);
+    }
+    extensions.sort();
+    let extensions_string = extensions.concat();
+    string.push_str(&extensions_string);
+
+    trace!("generated caps string: `{}`", string);
+
+    let mut sha1 = Sha1::new();
+
+    sha1.update(&string);
+
+    let result = sha1.finalize();
+    let sha1_result = BASE64_STANDARD.encode(result);
+
+    Ok(C {
+        ext: None,
+        hash: "sha-1".to_string(),
+        node,
+        ver: sha1_result,
+    })
+}
+
+pub fn caps2(query: info::Query) -> xep_0390::C {
     let mut string = String::new();
 
     // features string
@@ -97,7 +196,7 @@ pub fn caps(query: info::Query) -> C {
     let result = sha3_256.finalize();
     let sha3_256_result = BASE64_STANDARD.encode(result);
 
-    C(vec![
+    xep_0390::C(vec![
         Hash {
             algo: Algo::SHA256,
             hash: sha256_result,
@@ -182,3 +281,118 @@ static CLIENT_INFO: Info = Info {
     features: vec![],
     identities: vec![],
 };
+
+#[cfg(test)]
+mod tests {
+    use peanuts::{Writer, element::IntoElement};
+    use stanza::{
+        xep_0004::{Field, FieldType, Value, X, XType},
+        xep_0030::info::{Feature, Identity},
+    };
+
+    use super::*;
+
+    #[tokio::test]
+    async fn test_caps() {
+        tracing_subscriber::fmt().init();
+
+        let info = info::Query {
+            node: Some("http://psi-im.org".to_string()),
+            features: vec![
+                Feature {
+                    var: "http://jabber.org/protocol/caps".to_string(),
+                },
+                Feature {
+                    var: "http://jabber.org/protocol/disco#items".to_string(),
+                },
+                Feature {
+                    var: "http://jabber.org/protocol/disco#info".to_string(),
+                },
+                Feature {
+                    var: "http://jabber.org/protocol/muc".to_string(),
+                },
+            ],
+            identities: vec![
+                Identity {
+                    category: "client".to_string(),
+                    name: Some("Ψ 0.11".to_string()),
+                    r#type: "pc".to_string(),
+                    lang: Some("el".to_string()),
+                },
+                Identity {
+                    category: "client".to_string(),
+                    name: Some("Psi 0.11".to_string()),
+                    r#type: "pc".to_string(),
+                    lang: Some("en".to_string()),
+                },
+            ],
+            extensions: vec![X {
+                r#type: XType::Result,
+                instructions: Vec::new(),
+                title: None,
+                fields: vec![
+                    Field {
+                        label: None,
+                        r#type: None,
+                        var: Some("ip_version".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("ipv4".to_string()), Value("ipv6".to_string())],
+                        options: Vec::new(),
+                    },
+                    Field {
+                        label: None,
+                        r#type: None,
+                        var: Some("software".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("Psi".to_string())],
+                        options: Vec::new(),
+                    },
+                    Field {
+                        label: None,
+                        r#type: Some(FieldType::Hidden),
+                        var: Some("FORM_TYPE".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("urn:xmpp:dataforms:softwareinfo".to_string())],
+                        options: Vec::new(),
+                    },
+                    Field {
+                        label: None,
+                        r#type: None,
+                        var: Some("os".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("Mac".to_string())],
+                        options: Vec::new(),
+                    },
+                    Field {
+                        label: None,
+                        r#type: None,
+                        var: Some("os_version".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("10.5.1".to_string())],
+                        options: Vec::new(),
+                    },
+                    Field {
+                        label: None,
+                        r#type: None,
+                        var: Some("software_version".to_string()),
+                        desc: None,
+                        required: false,
+                        values: vec![Value("0.11".to_string())],
+                        options: Vec::new(),
+                    },
+                ],
+                reported: None,
+                items: Vec::new(),
+            }],
+        };
+        let caps = caps("https://macaw.chat".to_string(), info).unwrap();
+        let stdout = tokio::io::stdout();
+        let mut writer = Writer::new(stdout);
+        writer.write(&caps).await;
+    }
+}
diff --git a/filamento/src/error.rs b/filamento/src/error.rs
index 1dd4f47..b785f26 100644
--- a/filamento/src/error.rs
+++ b/filamento/src/error.rs
@@ -248,6 +248,12 @@ pub enum CapsDecodeError {
     MissingIdentityName,
 }
 
+#[derive(Debug, Error, Clone)]
+pub enum CapsEncodeError {
+    #[error("invalid data form in disco extensions")]
+    InvalidDataForm,
+}
+
 #[derive(Debug, Error, Clone)]
 pub enum HashNodeConversionError {
     #[error("no prefix")]
-- 
cgit