kpx 0.0.1

Pure Rust KeePassXC browser integration client with a simple synchronous API
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};

/// Supported KeePassXC browser actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(clippy::upper_case_acronyms)]
pub enum Action {
    Associate,
    ChangePublicKeys,
    CreateNewGroup,
    DatabaseLocked,
    DatabaseUnlocked,
    GeneratePassword,
    GetDatabaseGroups,
    GetDatabaseHash,
    GetLogins,
    GetLoginsCount,
    GetTotp,
    LockDatabase,
    PasskeysGet,
    PasskeysRegister,
    RequestAutotype,
    SetLogin,
    TestAssociate,
}

impl Action {
    pub const fn as_str(self) -> &'static str {
        match self {
            Action::Associate => "associate",
            Action::ChangePublicKeys => "change-public-keys",
            Action::CreateNewGroup => "create-new-group",
            Action::DatabaseLocked => "database-locked",
            Action::DatabaseUnlocked => "database-unlocked",
            Action::GeneratePassword => "generate-password",
            Action::GetDatabaseGroups => "get-database-groups",
            Action::GetDatabaseHash => "get-databasehash",
            Action::GetLogins => "get-logins",
            Action::GetLoginsCount => "get-logins-count",
            Action::GetTotp => "get-totp",
            Action::LockDatabase => "lock-database",
            Action::PasskeysGet => "passkeys-get",
            Action::PasskeysRegister => "passkeys-register",
            Action::RequestAutotype => "request-autotype",
            Action::SetLogin => "set-login",
            Action::TestAssociate => "test-associate",
        }
    }
}

impl fmt::Display for Action {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl TryFrom<&str> for Action {
    type Error = ();

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Ok(match value {
            "associate" => Action::Associate,
            "change-public-keys" => Action::ChangePublicKeys,
            "create-new-group" => Action::CreateNewGroup,
            "database-locked" => Action::DatabaseLocked,
            "database-unlocked" => Action::DatabaseUnlocked,
            "generate-password" => Action::GeneratePassword,
            "get-database-groups" => Action::GetDatabaseGroups,
            "get-databasehash" => Action::GetDatabaseHash,
            "get-logins" => Action::GetLogins,
            "get-logins-count" => Action::GetLoginsCount,
            "get-totp" => Action::GetTotp,
            "lock-database" => Action::LockDatabase,
            "passkeys-get" => Action::PasskeysGet,
            "passkeys-register" => Action::PasskeysRegister,
            "request-autotype" => Action::RequestAutotype,
            "set-login" => Action::SetLogin,
            "test-associate" => Action::TestAssociate,
            _ => return Err(()),
        })
    }
}

/// Envelope returned by KeePassXC for every request.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Envelope {
    pub action: String,
    #[serde(default)]
    pub message: Option<String>,
    #[serde(default)]
    pub nonce: Option<String>,
    #[serde(default)]
    pub client_id: Option<String>,
    #[serde(default)]
    pub version: Option<String>,
    #[serde(default)]
    pub public_key: Option<String>,
    #[serde(default)]
    pub success: Option<SuccessValue>,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub error_code: Option<ErrorCode>,
}

/// Helper enum to decode the `"success"` field that can either be a string or a boolean.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum SuccessValue {
    Bool(bool),
    String(String),
}

impl SuccessValue {
    pub fn is_true(&self) -> bool {
        match self {
            SuccessValue::Bool(value) => *value,
            SuccessValue::String(value) => value.eq_ignore_ascii_case("true"),
        }
    }
}

/// Helper enum to decode the `"errorCode"` field that can be either a string or an integer.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ErrorCode {
    Int(i32),
    String(String),
}

impl ErrorCode {
    pub fn as_i32(&self) -> Option<i32> {
        match self {
            ErrorCode::Int(value) => Some(*value),
            ErrorCode::String(value) => value.parse().ok(),
        }
    }
}

/// Association credentials persisted by the client for future sessions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Association {
    pub id: String,
    pub key: String,
}

/// Association metadata returned by KeePassXC.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssociationRecord {
    pub association: Association,
    pub database_hash: String,
    pub version: String,
}

/// Request payload for retrieving logins.
#[derive(Debug, Clone)]
pub struct LoginQuery {
    pub url: String,
    pub submit_url: Option<String>,
    pub http_auth: Option<String>,
    pub keys: Vec<Association>,
    pub primary_id: Option<String>,
}

impl LoginQuery {
    pub fn new(url: impl Into<String>, associations: Vec<Association>) -> Self {
        Self {
            url: url.into(),
            submit_url: None,
            http_auth: None,
            keys: associations,
            primary_id: None,
        }
    }

    pub fn with_submit_url(mut self, submit_url: impl Into<String>) -> Self {
        self.submit_url = Some(submit_url.into());
        self
    }

    pub fn with_http_auth(mut self, http_auth: impl Into<String>) -> Self {
        self.http_auth = Some(http_auth.into());
        self
    }

    pub fn with_primary_id(mut self, primary_id: impl Into<String>) -> Self {
        self.primary_id = Some(primary_id.into());
        self
    }
}

/// Login entry returned by KeePassXC.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LoginEntry {
    pub login: String,
    pub name: String,
    pub password: String,
    #[serde(default)]
    pub uuid: Option<String>,
    #[serde(default)]
    pub totp: Option<String>,
    #[serde(default)]
    pub expired: Option<String>,
    #[serde(default)]
    pub group: Option<String>,
    #[serde(default)]
    pub string_fields: Option<Vec<serde_json::Value>>,
}

/// Response for `get-logins`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GetLoginsResponse {
    #[serde(deserialize_with = "deserialize_number_as_string")]
    pub count: Option<String>,
    pub entries: Option<Vec<LoginEntry>>,
    pub nonce: Option<String>,
    pub success: Option<SuccessValue>,
    pub hash: Option<String>,
    pub version: Option<String>,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub id: Option<String>,
}

fn deserialize_number_as_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::{self, Visitor};
    use std::fmt;

    struct NumberOrString;

    impl<'de> Visitor<'de> for NumberOrString {
        type Value = Option<String>;

        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
            formatter.write_str("a number or string")
        }

        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(value.to_string()))
        }

        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(value.to_string()))
        }

        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(value.to_string()))
        }

        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(Some(value))
        }

        fn visit_none<E>(self) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(None)
        }

        fn visit_unit<E>(self) -> Result<Self::Value, E>
        where
            E: de::Error,
        {
            Ok(None)
        }
    }

    deserializer.deserialize_any(NumberOrString)
}

/// Response for `get-databasehash`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseHash {
    pub action: Option<String>,
    pub hash: Option<String>,
    pub version: Option<String>,
}

/// Node of the database groups tree.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GroupNode {
    pub name: String,
    pub uuid: String,
    pub children: Vec<GroupNode>,
}

/// Response for `get-database-groups`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseGroups {
    pub default_group: Option<String>,
    pub default_group_always_allow: Option<bool>,
    pub groups: Vec<GroupNode>,
}

/// Response payload for `generate-password`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordResponse {
    pub version: Option<String>,
    pub password: Option<String>,
    pub success: Option<SuccessValue>,
    pub nonce: Option<String>,
}

/// Response payload for `get-totp`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TotpResponse {
    pub version: Option<String>,
    pub totp: Option<String>,
    pub success: Option<SuccessValue>,
    pub nonce: Option<String>,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub error_code: Option<ErrorCode>,
}

/// Response payload for `test-associate`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TestAssociationResult {
    pub version: Option<String>,
    pub nonce: Option<String>,
    pub hash: Option<String>,
    pub id: Option<String>,
    pub success: Option<SuccessValue>,
}