im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use std::fmt;
use std::path::{Path, PathBuf};

use serde_json::Value;

const DID_DOCUMENT_FILES: &[&str] = &["did.json", "did_document.json"];
const DEFAULT_SIGNING_PRIVATE_FILES: &[&str] = &["private.key", "key-1-private.pem"];
const E2EE_AGREEMENT_PRIVATE_FILES: &[&str] = &["e2ee-agreement-private.pem", "key-3-private.pem"];
const AUTH_STATE_FILE: &str = "auth.json";

pub(crate) struct FileBackedKeyMaterialProvider {
    identity_dir: PathBuf,
}

impl FileBackedKeyMaterialProvider {
    pub(crate) fn new(identity_dir: PathBuf) -> Self {
        Self { identity_dir }
    }

    pub(crate) fn did_document_path(&self) -> PathBuf {
        first_existing_path(&self.identity_dir, DID_DOCUMENT_FILES)
    }

    pub(crate) fn default_signing_private_key_path(&self) -> PathBuf {
        first_existing_path(&self.identity_dir, DEFAULT_SIGNING_PRIVATE_FILES)
    }

    pub(crate) fn e2ee_agreement_private_key_path(&self) -> PathBuf {
        first_existing_path(&self.identity_dir, E2EE_AGREEMENT_PRIVATE_FILES)
    }

    pub(crate) fn auth_state_path(&self) -> PathBuf {
        self.identity_dir.join(AUTH_STATE_FILE)
    }
}

impl fmt::Debug for FileBackedKeyMaterialProvider {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("FileBackedKeyMaterialProvider")
            .field("identity_dir", &self.identity_dir)
            .field("backend", &"file-backed")
            .finish_non_exhaustive()
    }
}

impl super::KeyMaterialProvider for FileBackedKeyMaterialProvider {
    fn did_document(&self) -> crate::ImResult<Value> {
        read_json_file(&self.did_document_path(), "did_document")
    }

    fn optional_did_document(&self) -> crate::ImResult<Option<Value>> {
        read_optional_json_file(&self.did_document_path(), "did_document")
    }

    fn default_signing_private_pem(&self) -> crate::ImResult<String> {
        read_non_empty_text_file(
            &self.default_signing_private_key_path(),
            "default_signing_private_key",
        )
    }

    fn e2ee_agreement_private_pem(&self) -> crate::ImResult<String> {
        read_non_empty_text_file(
            &self.e2ee_agreement_private_key_path(),
            "e2ee_agreement_private_key",
        )
    }

    fn auth_state(&self) -> crate::ImResult<crate::internal::auth::state::AuthStateSnapshot> {
        crate::internal::auth::state::read_auth_state(&self.auth_state_path())
    }

    fn valid_auth_token(&self) -> crate::ImResult<Option<String>> {
        crate::internal::auth::state::read_jwt_token(&self.auth_state_path())
    }

    fn persist_auth_token(&self, token: &str) -> crate::ImResult<()> {
        crate::internal::auth::state::persist_jwt_token(&self.auth_state_path(), token)
    }
}

fn first_existing_path(identity_dir: &Path, names: &[&str]) -> PathBuf {
    names
        .iter()
        .map(|name| identity_dir.join(name))
        .find(|path| path.exists())
        .unwrap_or_else(|| identity_dir.join(names[0]))
}

fn read_json_file(path: &Path, path_kind: &str) -> crate::ImResult<Value> {
    let raw = std::fs::read(path).map_err(|err| crate::ImError::CredentialFileUnreadable {
        path_kind: path_kind.to_string(),
        detail: err.to_string(),
    })?;
    serde_json::from_slice(&raw).map_err(|err| crate::ImError::Serialization {
        detail: err.to_string(),
    })
}

fn read_optional_json_file(path: &Path, path_kind: &str) -> crate::ImResult<Option<Value>> {
    let raw = match std::fs::read(path) {
        Ok(raw) => raw,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => {
            return Err(crate::ImError::CredentialFileUnreadable {
                path_kind: path_kind.to_string(),
                detail: err.to_string(),
            });
        }
    };
    serde_json::from_slice(&raw)
        .map(Some)
        .map_err(|err| crate::ImError::Serialization {
            detail: err.to_string(),
        })
}

fn read_non_empty_text_file(path: &Path, path_kind: &str) -> crate::ImResult<String> {
    let value =
        std::fs::read_to_string(path).map_err(|err| crate::ImError::CredentialFileUnreadable {
            path_kind: path_kind.to_string(),
            detail: err.to_string(),
        })?;
    if value.trim().is_empty() {
        return Err(crate::ImError::CredentialFileUnreadable {
            path_kind: path_kind.to_string(),
            detail: "file is empty".to_string(),
        });
    }
    Ok(value)
}

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

    const TEST_SIGNING_PEM: &str =
        "-----BEGIN PRIVATE KEY-----\nprovider-signing-secret\n-----END PRIVATE KEY-----\n";
    const TEST_AGREEMENT_PEM: &str =
        "-----BEGIN PRIVATE KEY-----\nprovider-agreement-secret\n-----END PRIVATE KEY-----\n";

    #[test]
    fn identity_key_provider_reads_current_file_backed_material() {
        let root = tempfile::tempdir().unwrap();
        let identity_dir = root.path().join("identity");
        std::fs::create_dir_all(&identity_dir).unwrap();
        std::fs::write(
            identity_dir.join("did_document.json"),
            serde_json::to_vec(&json!({"id": "did:wba:alice.example"})).unwrap(),
        )
        .unwrap();
        std::fs::write(identity_dir.join("key-1-private.pem"), TEST_SIGNING_PEM).unwrap();
        std::fs::write(
            identity_dir.join("e2ee-agreement-private.pem"),
            TEST_AGREEMENT_PEM,
        )
        .unwrap();
        std::fs::write(
            identity_dir.join("auth.json"),
            br#"{"jwt_token":"token-current"}"#,
        )
        .unwrap();

        let provider = FileBackedKeyMaterialProvider::new(identity_dir);

        assert_eq!(
            provider
                .did_document()
                .unwrap()
                .get("id")
                .and_then(Value::as_str),
            Some("did:wba:alice.example")
        );
        assert_eq!(
            provider.default_signing_private_pem().unwrap(),
            TEST_SIGNING_PEM
        );
        assert_eq!(
            provider.e2ee_agreement_private_pem().unwrap(),
            TEST_AGREEMENT_PEM
        );
        assert_eq!(
            provider.valid_auth_token().unwrap().as_deref(),
            Some("token-current")
        );
    }

    #[test]
    fn identity_key_provider_prefers_legacy_fallback_files_when_present() {
        let root = tempfile::tempdir().unwrap();
        let identity_dir = root.path().join("identity");
        std::fs::create_dir_all(&identity_dir).unwrap();
        std::fs::write(
            identity_dir.join("did.json"),
            serde_json::to_vec(&json!({"id": "did:wba:legacy.example"})).unwrap(),
        )
        .unwrap();
        std::fs::write(
            identity_dir.join("did_document.json"),
            serde_json::to_vec(&json!({"id": "did:wba:current.example"})).unwrap(),
        )
        .unwrap();
        std::fs::write(identity_dir.join("private.key"), "legacy-signing").unwrap();
        std::fs::write(identity_dir.join("key-1-private.pem"), "current-signing").unwrap();
        std::fs::write(identity_dir.join("key-3-private.pem"), "legacy-agreement").unwrap();

        let provider = FileBackedKeyMaterialProvider::new(identity_dir);

        assert_eq!(
            provider
                .did_document()
                .unwrap()
                .get("id")
                .and_then(Value::as_str),
            Some("did:wba:legacy.example")
        );
        assert_eq!(
            provider.default_signing_private_pem().unwrap(),
            "legacy-signing"
        );
        assert_eq!(
            provider.e2ee_agreement_private_pem().unwrap(),
            "legacy-agreement"
        );
    }

    #[test]
    fn identity_key_provider_optional_did_document_preserves_missing_file_compatibility() {
        let root = tempfile::tempdir().unwrap();
        let identity_dir = root.path().join("identity");
        std::fs::create_dir_all(&identity_dir).unwrap();

        let provider = FileBackedKeyMaterialProvider::new(identity_dir);

        assert_eq!(provider.optional_did_document().unwrap(), None);
    }

    #[test]
    fn identity_key_provider_persists_auth_token_without_debug_leak() {
        let root = tempfile::tempdir().unwrap();
        let identity_dir = root.path().join("identity");
        std::fs::create_dir_all(&identity_dir).unwrap();

        let provider = FileBackedKeyMaterialProvider::new(identity_dir);
        provider.persist_auth_token("token-secret-value").unwrap();

        assert_eq!(
            provider.valid_auth_token().unwrap().as_deref(),
            Some("token-secret-value")
        );
        let debug = format!("{provider:?}");
        assert!(!debug.contains("token-secret-value"));
        assert!(!debug.contains("BEGIN PRIVATE KEY"));
    }

    #[test]
    fn identity_key_provider_reports_empty_private_key_without_secret_material() {
        let root = tempfile::tempdir().unwrap();
        let identity_dir = root.path().join("identity");
        std::fs::create_dir_all(&identity_dir).unwrap();
        std::fs::write(identity_dir.join("private.key"), "   \n").unwrap();

        let err = FileBackedKeyMaterialProvider::new(identity_dir)
            .default_signing_private_pem()
            .unwrap_err()
            .to_string();

        assert!(err.contains("default_signing_private_key"));
        assert!(err.contains("file is empty"));
        assert!(!err.contains("BEGIN PRIVATE KEY"));
    }
}