payload-loader 0.1.0

Production-ready Rust loader with cryptography, configuration, and security features
Documentation
//! Configuration file support for loader.
//!
//! Allows loading settings from TOML or JSON config files with profile support.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;

/// Configuration for a single profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
    /// Authenticated Additional Data (AAD) for AEAD
    pub aad: Option<String>,

    /// Base64-encoded 32-byte key
    pub key_b64: Option<String>,

    /// Direct URL
    pub url: Option<String>,

    /// Reversed URL (runtime reconstructed)
    pub url_rev: Option<String>,

    /// Base64-encoded URL
    pub url_b64: Option<String>,

    /// Optional output path for plaintext
    pub output: Option<String>,
}

impl Default for Profile {
    fn default() -> Self {
        Self {
            aad: None,
            key_b64: None,
            url: None,
            url_rev: None,
            url_b64: None,
            output: None,
        }
    }
}

/// Root configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Default profile
    pub default: Option<Profile>,

    /// Named profiles for different environments
    #[serde(default)]
    pub profile: std::collections::HashMap<String, Profile>,
}

impl Config {
    /// Load configuration from a TOML file
    pub async fn from_toml_file<P: AsRef<Path>>(path: P) -> Result<Self> {
        let content = fs::read_to_string(path.as_ref())
            .await
            .context("failed to read config file")?;
        toml::from_str(&content).context("failed to parse TOML config")
    }

    /// Load configuration from a JSON file
    pub async fn from_json_file<P: AsRef<Path>>(path: P) -> Result<Self> {
        let content = fs::read_to_string(path.as_ref())
            .await
            .context("failed to read config file")?;
        serde_json::from_str(&content).context("failed to parse JSON config")
    }

    /// Get a profile by name, falling back to default
    pub fn get_profile(&self, name: Option<&str>) -> Option<Profile> {
        match name {
            Some(profile_name) => self.profile.get(profile_name).cloned(),
            None => self.default.clone(),
        }
    }

    /// Merge CLI args into a profile (CLI args take precedence)
    pub fn merge_with_cli(
        mut profile: Profile,
        key_b64: Option<String>,
        url: Option<String>,
        url_rev: Option<String>,
        url_b64: Option<String>,
        aad: Option<String>,
        output: Option<String>,
    ) -> Profile {
        if let Some(k) = key_b64 {
            profile.key_b64 = Some(k);
        }
        if let Some(u) = url {
            profile.url = Some(u);
        }
        if let Some(ur) = url_rev {
            profile.url_rev = Some(ur);
        }
        if let Some(ub) = url_b64 {
            profile.url_b64 = Some(ub);
        }
        if let Some(a) = aad {
            profile.aad = Some(a);
        }
        if let Some(o) = output {
            profile.output = Some(o);
        }
        profile
    }
}

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

    #[test]
    fn test_profile_merge() {
        let profile = Profile {
            aad: Some("aad-1".to_string()),
            key_b64: Some("key1".to_string()),
            url: Some("https://example.com".to_string()),
            url_rev: None,
            url_b64: None,
            output: None,
        };

        let merged = Config::merge_with_cli(
            profile,
            None,
            Some("https://override.com".to_string()),
            None,
            None,
            None,
            None,
        );

        assert_eq!(merged.url.unwrap(), "https://override.com");
        assert_eq!(merged.aad.unwrap(), "aad-1");
    }
}