agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretRef {
    pub name: String,
    pub logical_location: String,
    pub required: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub struct RedactedTree {
    pub value: Value,
    pub references: Vec<SecretRef>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretPolicy {
    patterns: BTreeSet<String>,
}

impl Default for SecretPolicy {
    fn default() -> Self {
        Self {
            patterns: [
                "api_key",
                "authorization",
                "cookie",
                "credential",
                "password",
                "secret",
                "token",
            ]
            .into_iter()
            .map(str::to_owned)
            .collect(),
        }
    }
}

impl SecretPolicy {
    #[must_use]
    pub fn is_sensitive(&self, key: &str) -> bool {
        let normalized = key.to_ascii_lowercase();
        let compact = normalized
            .chars()
            .filter(char::is_ascii_alphanumeric)
            .collect::<String>();
        self.patterns.iter().any(|pattern| {
            normalized.contains(pattern) || compact.contains(&pattern.replace(['_', '-'], ""))
        })
    }
}

/// Redacts a JSON boundary value and records logical secret references.
///
/// # Errors
///
/// Returns an error when a secret key cannot form a stable reference name.
pub fn redact_json(input: &Value, policy: &SecretPolicy) -> Result<RedactedTree, SecretError> {
    let mut redactor = Redactor::new(policy);
    let value = redactor.redact_value(input, "$")?;
    Ok(RedactedTree {
        value,
        references: redactor.references,
    })
}

#[derive(Debug)]
struct Redactor<'a> {
    policy: &'a SecretPolicy,
    references: Vec<SecretRef>,
}

impl<'a> Redactor<'a> {
    fn new(policy: &'a SecretPolicy) -> Self {
        Self {
            policy,
            references: Vec::new(),
        }
    }

    fn redact_value(&mut self, input: &Value, location: &str) -> Result<Value, SecretError> {
        match input {
            Value::Object(object) => self.redact_object(object, location),
            Value::Array(values) => values
                .iter()
                .enumerate()
                .map(|(index, value)| self.redact_value(value, &format!("{location}[{index}]")))
                .collect::<Result<Vec<_>, _>>()
                .map(Value::Array),
            scalar => Ok(scalar.clone()),
        }
    }

    fn redact_object(
        &mut self,
        input: &Map<String, Value>,
        location: &str,
    ) -> Result<Value, SecretError> {
        let mut output = Map::new();
        for (key, value) in input {
            let child_location = format!("{location}.{key}");
            if self.policy.is_sensitive(key) {
                let name = reference_name(key)?;
                self.references.push(SecretRef {
                    name: name.clone(),
                    logical_location: child_location,
                    required: !value.is_null(),
                });
                output.insert(
                    key.clone(),
                    Value::String(format!("${{SECRET_REF:{name}}}")),
                );
            } else {
                output.insert(key.clone(), self.redact_value(value, &child_location)?);
            }
        }
        Ok(Value::Object(output))
    }
}

fn reference_name(key: &str) -> Result<String, SecretError> {
    let name = key
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_uppercase()
            } else {
                '_'
            }
        })
        .collect::<String>();
    if name.is_empty() {
        return Err(SecretError::EmptyReferenceName);
    }
    Ok(name)
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum SecretError {
    #[error("secret reference name cannot be empty")]
    EmptyReferenceName,
}