oth_rvault 0.3.0

Partial Ansible Vault encoder and decoder
Documentation
use inquire::InquireError;

use crate::{
    keys::{check_key_conflicts, is_encrypted},
    typedetect::{FileFormat, detect_format},
    types::{ProcessHandling, TypedValue, ValueType},
    vaultfunc,
};

fn get_at_path_json<'a>(
    val: &'a serde_json::Value,
    path: &str,
) -> Option<&'a serde_json::Value> {
    let mut current = val;
    for part in path.split('.') {
        match current {
            serde_json::Value::Object(map) => current = map.get(part)?,
            _ => return None,
        }
    }
    Some(current)
}

fn set_at_path_json(target: &mut serde_json::Value, path: &str, value: serde_json::Value) {
    let parts: Vec<&str> = path.split('.').collect();
    let mut current = target;
    for (i, &part) in parts.iter().enumerate() {
        if i == parts.len() - 1 {
            if let serde_json::Value::Object(map) = current {
                map.insert(part.to_string(), value);
            }
            return;
        }
        if let serde_json::Value::Object(map) = current {
            map.entry(part.to_string())
                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
            current = map.get_mut(part).unwrap();
        }
    }
}

fn extract_keys_json(root: &serde_json::Value, keys: &[String]) -> serde_json::Value {
    let mut result = serde_json::Value::Object(serde_json::Map::new());
    for key_path in keys {
        if let Some(val) = get_at_path_json(root, key_path) {
            set_at_path_json(&mut result, key_path, val.clone());
        }
    }
    result
}

fn get_at_path_yaml<'a>(
    val: &'a serde_yaml::Value,
    path: &str,
) -> Option<&'a serde_yaml::Value> {
    let mut current = val;
    for part in path.split('.') {
        match current {
            serde_yaml::Value::Mapping(map) => {
                let str_key = serde_yaml::Value::String(part.to_string());
                if let Some(v) = map.get(&str_key) {
                    current = v;
                } else if let Ok(n) = part.parse::<i64>() {
                    let num_key = serde_yaml::Value::Number(n.into());
                    current = map.get(&num_key)?;
                } else {
                    return None;
                }
            }
            _ => return None,
        }
    }
    Some(current)
}

fn set_at_path_yaml(target: &mut serde_yaml::Value, path: &str, value: serde_yaml::Value) {
    let parts: Vec<&str> = path.split('.').collect();
    let mut current = target;
    for (i, &part) in parts.iter().enumerate() {
        let key = serde_yaml::Value::String(part.to_string());
        if i == parts.len() - 1 {
            if let serde_yaml::Value::Mapping(map) = current {
                map.insert(key, value);
            }
            return;
        }
        if let serde_yaml::Value::Mapping(map) = current {
            if !map.contains_key(&key) {
                map.insert(key.clone(), serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
            }
            current = map.get_mut(&key).unwrap();
        }
    }
}

fn extract_keys_yaml(root: &serde_yaml::Value, keys: &[String]) -> serde_yaml::Value {
    let mut result = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
    for key_path in keys {
        if let Some(val) = get_at_path_yaml(root, key_path) {
            set_at_path_yaml(&mut result, key_path, val.clone());
        }
    }
    result
}

/// Decrypt all (or a filtered subset of) vault-encrypted values in a JSON or YAML file.
///
/// - `input`:    path to the input file
/// - `output`:   path to write the result, or `"stdout"` to print to stdout
/// - `password`: vault password
/// - `keys`:     dot-notation key paths to decrypt; empty means all encrypted values
pub fn decrypt(
    input: &str,
    output: &str,
    password: &str,
    keys: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
    check_key_conflicts(keys)?;
    let mut processor = make_decrypt_processor(password);
    match detect_format(input)? {
        FileFormat::Json => crate::json::process_file(input, output, keys, &mut processor),
        FileFormat::Yaml => crate::yaml::process_file(input, output, keys, &mut processor),
    }
}

/// Like [`decrypt`] but outputs only the specified keys as a minimal nested JSON/YAML document.
/// Requires at least one key to be specified.
pub fn decrypt_value_only(
    input: &str,
    output: &str,
    password: &str,
    keys: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
    check_key_conflicts(keys)?;
    let mut processor = make_decrypt_processor(password);
    match detect_format(input)? {
        FileFormat::Json => {
            let tree = crate::json::process_in_memory(input, keys, &mut processor)?;
            let minimal = extract_keys_json(&tree, keys);
            let output_str = serde_json::to_string_pretty(&minimal)?;
            if output == "stdout" {
                println!("{}", output_str);
            } else {
                std::fs::write(output, &output_str)
                    .map_err(|e| format!("error writing '{}': {}", output, e))?;
            }
        }
        FileFormat::Yaml => {
            let tree = crate::yaml::process_in_memory(input, keys, &mut processor)?;
            let minimal = extract_keys_yaml(&tree, keys);
            let yaml_str = serde_yaml::to_string(&minimal)?;
            if output == "stdout" {
                print!("{}", yaml_str);
            } else {
                std::fs::write(output, &yaml_str)
                    .map_err(|e| format!("error writing '{}': {}", output, e))?;
            }
        }
    }
    Ok(())
}

/// Fuzzy-select which encrypted keys to decrypt, then run [`decrypt`] or [`decrypt_value_only`].
///
/// Shows a `MultiSelect` over all encrypted key paths in the file.
/// If `value_only` is true, only the selected keys are emitted as a minimal document.
pub fn decrypt_fuzzy(
    input: &str,
    output: &str,
    password: &str,
    value_only: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let keys = match detect_format(input)? {
        FileFormat::Json => crate::json::get_encrypted_keys(input)?,
        FileFormat::Yaml => crate::yaml::get_encrypted_keys(input)?,
    };

    if keys.is_empty() {
        println!("No encrypted values found in '{}'.", input);
        return Ok(());
    }

    let selected = match inquire::MultiSelect::new(
        "Select keys to decrypt (Space to select, Enter to confirm):",
        keys,
    )
    .prompt()
    {
        Ok(s) => s,
        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
            println!("Cancelled.");
            return Ok(());
        }
        Err(e) => return Err(e.into()),
    };

    if selected.is_empty() {
        println!("No keys selected.");
        return Ok(());
    }

    if value_only {
        decrypt_value_only(input, output, password, &selected)
    } else {
        decrypt(input, output, password, &selected)
    }
}

pub(crate) fn make_decrypt_processor(
    password: &str,
) -> impl FnMut(
    TypedValue,
    ValueType,
    &str,
) -> Result<(TypedValue, ValueType, ProcessHandling), Box<dyn std::error::Error>>
+ '_ {
    move |typed, vt, key_path| {
        let (encrypted, vault_str) = is_encrypted(&typed);
        if !encrypted {
            return Ok((typed, vt, ProcessHandling::Skip));
        }
        let vault_str = vault_str.unwrap();
        match vaultfunc::decrypt(&vault_str, password) {
            Ok((decrypted, new_vt)) => Ok((decrypted, new_vt, ProcessHandling::Process)),
            Err(e) => {
                eprintln!("error decrypting key '{}': {}", key_path, e);
                Ok((typed, vt, ProcessHandling::Skip))
            }
        }
    }
}

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

    fn resources(filename: &str) -> String {
        format!(
            "{}/resources/tests/{}",
            env!("CARGO_MANIFEST_DIR"),
            filename
        )
    }

    // --- YAML decrypt tests ---

    struct YamlDecryptCase {
        input: &'static str,
        reference: &'static str,
        keys: &'static [&'static str],
    }

    fn run_yaml_decrypt(case: &YamlDecryptCase) {
        let input = resources(case.input);
        let reference = resources(case.reference);
        let keys: Vec<String> = case.keys.iter().map(|s| s.to_string()).collect();

        let output = tempfile::NamedTempFile::new().unwrap();
        let output_path = output.path().to_str().unwrap().to_string();

        decrypt(&input, &output_path, "test999", &keys).unwrap();

        let got: serde_yaml::Value =
            serde_yaml::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap();
        let expected: serde_yaml::Value =
            serde_yaml::from_str(&std::fs::read_to_string(&reference).unwrap()).unwrap();
        assert_eq!(got, expected, "YAML decrypt '{}' failed", case.input);
    }

    #[test]
    fn test_decrypt_yaml_all_keys() {
        run_yaml_decrypt(&YamlDecryptCase {
            input: "partial_encrypted_example.yaml",
            reference: "partial_encrypted_example_decrypted_01.yaml",
            keys: &[],
        });
    }

    #[test]
    fn test_decrypt_yaml_filtered_key() {
        run_yaml_decrypt(&YamlDecryptCase {
            input: "partial_encrypted_example.yaml",
            reference: "partial_encrypted_example_decrypted_03.yaml",
            keys: &["third.carrot"],
        });
    }

    #[test]
    fn test_decrypt_yaml_multiple_keys() {
        run_yaml_decrypt(&YamlDecryptCase {
            input: "partial_encrypted_example.yaml",
            reference: "partial_encrypted_example_decrypted_04.yaml",
            keys: &["first.a", "first.z", "second.b.2", "fourth.list"],
        });
    }

    #[test]
    fn test_decrypt_yaml_02() {
        run_yaml_decrypt(&YamlDecryptCase {
            input: "partial_encrypted_example_02.yaml",
            reference: "partial_encrypted_example_decrypted_01.yaml",
            keys: &[],
        });
    }

    #[test]
    fn test_decrypt_yaml_03() {
        run_yaml_decrypt(&YamlDecryptCase {
            input: "partial_encrypted_example_03.yaml",
            reference: "partial_encrypted_example_decrypted_01.yaml",
            keys: &[],
        });
    }

    // --- JSON decrypt tests ---

    struct JsonDecryptCase {
        input: &'static str,
        reference: &'static str,
        keys: &'static [&'static str],
    }

    fn run_json_decrypt(case: &JsonDecryptCase) {
        let input = resources(case.input);
        let reference = resources(case.reference);
        let keys: Vec<String> = case.keys.iter().map(|s| s.to_string()).collect();

        let output = tempfile::NamedTempFile::new().unwrap();
        let output_path = output.path().to_str().unwrap().to_string();

        decrypt(&input, &output_path, "test999", &keys).unwrap();

        let got: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap();
        let expected: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&reference).unwrap()).unwrap();
        assert_eq!(got, expected, "JSON decrypt '{}' failed", case.input);
    }

    #[test]
    fn test_decrypt_json_all_keys() {
        run_json_decrypt(&JsonDecryptCase {
            input: "partial_encrypted_example.json",
            reference: "partial_encrypted_example_decrypted_01.json",
            keys: &[],
        });
    }

    #[test]
    fn test_decrypt_json_filtered_key() {
        run_json_decrypt(&JsonDecryptCase {
            input: "partial_encrypted_example.json",
            reference: "partial_encrypted_example_decrypted_03.json",
            keys: &["third.carrot"],
        });
    }

    #[test]
    fn test_decrypt_json_multiple_keys() {
        run_json_decrypt(&JsonDecryptCase {
            input: "partial_encrypted_example.json",
            reference: "partial_encrypted_example_decrypted_04.json",
            keys: &["first.a", "first.z", "second.b.2", "fourth.list"],
        });
    }

    #[test]
    fn test_decrypt_json_02() {
        run_json_decrypt(&JsonDecryptCase {
            input: "partial_encrypted_example_02.json",
            reference: "partial_encrypted_example_decrypted_01.json",
            keys: &[],
        });
    }

    #[test]
    fn test_decrypt_json_03() {
        run_json_decrypt(&JsonDecryptCase {
            input: "partial_encrypted_example_03.json",
            reference: "partial_encrypted_example_decrypted_01.json",
            keys: &[],
        });
    }
}