oth_rvault 0.2.0

Partial Ansible Vault encoder and decoder
Documentation
use serde_json::Value;

use crate::types::{new_key_path, ProcessHandling, TypedValue, ValueType};

fn typed_value_from_json(val: &Value) -> Option<(TypedValue, ValueType)> {
    match val {
        Value::Bool(b) => Some((TypedValue::Bool(*b), ValueType::Bool)),
        Value::String(s) => Some((TypedValue::String(s.clone()), ValueType::String)),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                // serde_json Number is i64 if it was parsed from an integer literal
                if n.is_i64() {
                    return Some((TypedValue::Integer(i), ValueType::Integer));
                }
            }
            n.as_f64()
                .map(|f| (TypedValue::Number(f), ValueType::Number))
        }
        Value::Null => Some((TypedValue::Null, ValueType::Null)),
        _ => None, // Object/Array – handled by recursion
    }
}

fn typed_value_to_json(typed: TypedValue) -> Value {
    match typed {
        TypedValue::Bool(b) => Value::Bool(b),
        TypedValue::Integer(i) => Value::Number(i.into()),
        TypedValue::Number(f) => Value::Number(
            serde_json::Number::from_f64(f).unwrap_or_else(|| 0i64.into()),
        ),
        TypedValue::String(s) => Value::String(s),
        TypedValue::Null => Value::Null,
    }
}

type Processor<'a> = &'a mut dyn FnMut(
    TypedValue,
    ValueType,
    &str,
) -> Result<(TypedValue, ValueType, ProcessHandling), Box<dyn std::error::Error>>;

fn traverse(
    val: &mut Value,
    key_path: &str,
    processor: Processor<'_>,
    filter_keys: &[String],
) -> Result<bool, Box<dyn std::error::Error>> {
    match val {
        Value::Object(map) => {
            // Collect keys first so we can iterate with mutable access to values.
            let keys: Vec<String> = map.keys().cloned().collect();
            for key in &keys {
                let new_path = new_key_path(key_path, key);
                let child = map.get_mut(key).unwrap();
                if !traverse(child, &new_path, processor, filter_keys)? {
                    return Ok(false);
                }
            }
        }
        Value::Array(arr) => {
            for item in arr.iter_mut() {
                if !traverse(item, key_path, processor, filter_keys)? {
                    return Ok(false);
                }
            }
        }
        scalar => {
            if !filter_keys.is_empty()
                && !filter_keys.iter().any(|k| k.as_str() == key_path)
            {
                return Ok(true);
            }
            if let Some((typed, vt)) = typed_value_from_json(scalar) {
                match processor(typed, vt, key_path) {
                    Ok((new_typed, _, ProcessHandling::Process)) => {
                        *scalar = typed_value_to_json(new_typed);
                    }
                    Ok((_, _, ProcessHandling::Cancel)) => return Ok(false),
                    Ok((_, _, ProcessHandling::Skip)) => {}
                    Err(e) => {
                        eprintln!("error processing key '{}': {}", key_path, e);
                        return Ok(false);
                    }
                }
            }
        }
    }
    Ok(true)
}

/// Read a JSON file, apply `processor` to every matching scalar value, and write the result.
/// `filter_keys` is a list of dot-notation key paths to restrict processing to; empty means all values.
/// Key insertion order is preserved via serde_json's `preserve_order` feature.
pub fn process_file(
    input: &str,
    output: &str,
    filter_keys: &[String],
    processor: Processor<'_>,
) -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string(input)
        .map_err(|e| format!("error reading '{}': {}", input, e))?;
    let mut root: Value = serde_json::from_str(&content)
        .map_err(|e| format!("error parsing JSON from '{}': {}", input, e))?;

    if !traverse(&mut root, "", processor, filter_keys)? {
        return Err("processing canceled".into());
    }

    let output_str = serde_json::to_string_pretty(&root)?;
    if output == "stdout" {
        println!("{}", output_str);
    } else {
        std::fs::write(output, &output_str)
            .map_err(|e| format!("error writing '{}': {}", output, e))?;
    }
    Ok(())
}

/// Return the dot-notation paths of all vault-encrypted values in a JSON file.
pub fn get_encrypted_keys(input: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string(input)?;
    let root: Value = serde_json::from_str(&content)?;
    let mut encrypted = Vec::new();
    collect_encrypted(&root, "", &mut encrypted);
    Ok(encrypted)
}

fn collect_encrypted(val: &Value, key_path: &str, out: &mut Vec<String>) {
    match val {
        Value::Object(map) => {
            for (key, child) in map.iter() {
                collect_encrypted(child, &new_key_path(key_path, key), out);
            }
        }
        Value::Array(arr) => {
            for item in arr.iter() {
                collect_encrypted(item, key_path, out);
            }
        }
        Value::String(s) if s.contains("$ANSIBLE_VAULT;") => {
            out.push(key_path.to_string());
        }
        _ => {}
    }
}