tandem-server 0.6.8

HTTP server for Tandem engine APIs
use std::path::Path;

use anyhow::Context;
use serde::de::DeserializeOwned;
use serde::Serialize;
use tandem_memory::MemoryCryptoProvider;
use tokio::fs;

pub(crate) const ENCRYPTED_PAYLOAD_PREFIX: &str = "tce1:";

fn crypto_provider() -> MemoryCryptoProvider {
    MemoryCryptoProvider::from_env()
}

pub(crate) fn is_encrypted_payload(stored: &str) -> bool {
    stored.trim_start().starts_with(ENCRYPTED_PAYLOAD_PREFIX)
}

pub(crate) fn encrypt_text(plaintext: &str) -> anyhow::Result<String> {
    crypto_provider()
        .encrypt_field(plaintext)
        .context("encrypt protected file-store payload")
}

pub(crate) fn decrypt_text(stored: &str) -> anyhow::Result<String> {
    let provider = crypto_provider();
    if provider.is_plaintext() && is_encrypted_payload(stored) {
        return Err(anyhow::Error::msg(
            "encrypted protected file-store payload requires a configured decrypt provider",
        ));
    }
    provider
        .decrypt_field(stored)
        .context("decrypt protected file-store payload")
}

pub(crate) fn encrypt_jsonl_line(plaintext: &str) -> anyhow::Result<String> {
    encrypt_text(plaintext)
}

pub(crate) fn decrypt_jsonl_line(stored: &str) -> anyhow::Result<Option<String>> {
    let trimmed = stored.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    decrypt_text(trimmed).map(Some)
}

pub(crate) async fn read_text_file(path: &Path) -> anyhow::Result<String> {
    let stored = fs::read_to_string(path).await?;
    decrypt_text(&stored)
}

pub(crate) async fn write_text_file(path: &Path, plaintext: &str) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).await?;
    }
    let stored = encrypt_text(plaintext)?;
    fs::write(path, stored).await?;
    Ok(())
}

pub(crate) async fn read_json_file<T>(path: &Path) -> anyhow::Result<T>
where
    T: DeserializeOwned,
{
    let plaintext = read_text_file(path).await?;
    serde_json::from_str(&plaintext).with_context(|| {
        format!(
            "parse protected file-store JSON payload from {}",
            path.display()
        )
    })
}

pub(crate) async fn write_json_file<T>(path: &Path, value: &T) -> anyhow::Result<()>
where
    T: Serialize,
{
    let plaintext = serde_json::to_string_pretty(value)?;
    write_text_file(path, &plaintext).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::collections::HashMap;

    struct EnvRestore {
        provider: Option<String>,
        key_file: Option<String>,
        required: Option<String>,
        principal: Option<String>,
    }

    impl EnvRestore {
        fn capture() -> Self {
            Self {
                provider: std::env::var("TANDEM_MEMORY_DECRYPT_PROVIDER").ok(),
                key_file: std::env::var("TANDEM_MEMORY_LOCAL_KEY_FILE").ok(),
                required: std::env::var("TANDEM_MEMORY_ENCRYPTION_REQUIRED").ok(),
                principal: std::env::var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID").ok(),
            }
        }
    }

    impl Drop for EnvRestore {
        fn drop(&mut self) {
            restore_var("TANDEM_MEMORY_DECRYPT_PROVIDER", self.provider.as_deref());
            restore_var("TANDEM_MEMORY_LOCAL_KEY_FILE", self.key_file.as_deref());
            restore_var(
                "TANDEM_MEMORY_ENCRYPTION_REQUIRED",
                self.required.as_deref(),
            );
            restore_var(
                "TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID",
                self.principal.as_deref(),
            );
        }
    }

    fn restore_var(key: &str, value: Option<&str>) {
        match value {
            Some(value) => std::env::set_var(key, value),
            None => std::env::remove_var(key),
        }
    }

    fn enable_local_encrypted(dir: &tempfile::TempDir) -> EnvRestore {
        let restore = EnvRestore::capture();
        std::env::set_var("TANDEM_MEMORY_DECRYPT_PROVIDER", "local-file");
        std::env::set_var(
            "TANDEM_MEMORY_LOCAL_KEY_FILE",
            dir.path().join("local_memory.key"),
        );
        std::env::remove_var("TANDEM_MEMORY_ENCRYPTION_REQUIRED");
        std::env::remove_var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID");
        restore
    }

    #[tokio::test]
    #[serial]
    async fn whole_file_json_round_trips_as_ciphertext() {
        let dir = tempfile::tempdir().expect("tempdir");
        let _restore = enable_local_encrypted(&dir);
        let path = dir.path().join("policy_decisions.json");
        let payload = HashMap::from([(
            "decision-1".to_string(),
            serde_json::json!({"tenant": "acme", "secret": "finance-decision"}),
        )]);

        write_json_file(&path, &payload)
            .await
            .expect("write encrypted");
        let raw = fs::read_to_string(&path).await.expect("read raw");
        assert!(is_encrypted_payload(&raw));
        assert!(!raw.contains("finance-decision"));

        let decoded: HashMap<String, serde_json::Value> =
            read_json_file(&path).await.expect("read encrypted");
        assert_eq!(decoded, payload);
    }

    #[tokio::test]
    #[serial]
    async fn encrypted_payload_without_decrypt_provider_fails_closed() {
        let dir = tempfile::tempdir().expect("tempdir");
        let encrypted = {
            let _restore = enable_local_encrypted(&dir);
            encrypt_text("protected-store-secret").expect("encrypt with local provider")
        };
        let _restore = EnvRestore::capture();
        std::env::remove_var("TANDEM_MEMORY_DECRYPT_PROVIDER");
        std::env::remove_var("TANDEM_MEMORY_LOCAL_KEY_FILE");
        std::env::remove_var("TANDEM_MEMORY_ENCRYPTION_REQUIRED");
        std::env::remove_var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID");

        let err = decrypt_text(&encrypted).expect_err("fail closed without provider");
        if !err
            .to_string()
            .contains("requires a configured decrypt provider")
        {
            std::panic::panic_any("unexpected decrypt provider error");
        }
    }

    #[tokio::test]
    #[serial]
    async fn hosted_required_refuses_plaintext_write() {
        let _restore = EnvRestore::capture();
        std::env::set_var("TANDEM_MEMORY_ENCRYPTION_REQUIRED", "true");
        std::env::set_var("TANDEM_MEMORY_DECRYPT_PROVIDER", "aws-kms");
        std::env::set_var("TANDEM_MEMORY_DECRYPT_PRINCIPAL_ID", "runtime-tandem");
        std::env::remove_var("TANDEM_MEMORY_LOCAL_KEY_FILE");

        let err = encrypt_text("must not land as plaintext").expect_err("fail closed");
        let rendered = format!("{err:?}");
        assert!(
            rendered.contains("refusing to store plaintext"),
            "unexpected error: {rendered}"
        );
    }
}