envoy-cli 0.5.0

A Git-like CLI for managing encrypted environment files
use std::collections::{BTreeMap, BTreeSet};
use std::fs;

use anyhow::Result;
use console::style;
use sha2::{Digest, Sha256};
use similar::{ChangeTag, TextDiff};

use crate::{
    commands::crypto::decrypt_file_bytes,
    utils::{
        commit::get_head_manifest_hash,
        manifest::{FileEntry, Manifest, get_project_key, load_manifest, load_manifest_by_hash},
        paths::{to_native_path, validate_project_path},
        ui::{print_info, print_warn},
    },
};

#[derive(Clone, Copy)]
enum ChangeKind {
    Added,
    Modified,
    Deleted,
}

struct FileChange {
    path: String,
    kind: ChangeKind,
    old: Option<Vec<u8>>,
    new: Option<Vec<u8>>,
    access_changed: bool,
}

pub fn diff(cached: bool, show_secrets: bool) -> Result<()> {
    let index = load_manifest()?;
    let changes = if cached {
        cached_changes(&index)?
    } else {
        working_tree_changes(&index)?
    };

    if changes.is_empty() {
        print_info(if cached {
            "No staged changes."
        } else {
            "No unstaged changes."
        });
        return Ok(());
    }

    if show_secrets {
        print_warn("Showing decrypted secret values in terminal output.");
    }

    for change in changes {
        print_change(&change, show_secrets);
    }

    Ok(())
}

fn cached_changes(index: &Manifest) -> Result<Vec<FileChange>> {
    let head = match get_head_manifest_hash() {
        Some(hash) => load_manifest_by_hash(&hash)?,
        None => Manifest::new(),
    };
    let paths = all_paths(&head, index);
    let mut changes = Vec::new();

    for path in paths {
        let old_entry = head.files.get(&path);
        let new_entry = index.files.get(&path);
        if entries_match(old_entry, new_entry) {
            continue;
        }

        changes.push(FileChange {
            path,
            kind: match (old_entry, new_entry) {
                (None, Some(_)) => ChangeKind::Added,
                (Some(_), None) => ChangeKind::Deleted,
                _ => ChangeKind::Modified,
            },
            old: old_entry.and_then(decrypt_managed_entry),
            new: new_entry.and_then(decrypt_managed_entry),
            access_changed: access_changed(old_entry, new_entry),
        });
    }

    Ok(changes)
}

fn working_tree_changes(index: &Manifest) -> Result<Vec<FileChange>> {
    let mut changes = Vec::new();

    for (path, entry) in &index.files {
        let Ok(safe_path) = validate_project_path(path) else {
            continue;
        };
        let native_path = to_native_path(&safe_path);
        let current = fs::read(&native_path).ok();
        let kind = match current.as_ref() {
            None => Some(ChangeKind::Deleted),
            Some(bytes) => entry
                .content_hash()
                .is_some_and(|hash| hash != hex::encode(Sha256::digest(bytes)))
                .then_some(ChangeKind::Modified),
        };

        if let Some(kind) = kind {
            changes.push(FileChange {
                path: path.clone(),
                kind,
                old: decrypt_managed_entry(entry),
                new: current,
                access_changed: false,
            });
        }
    }

    Ok(changes)
}

fn all_paths(left: &Manifest, right: &Manifest) -> BTreeSet<String> {
    left.files
        .keys()
        .chain(right.files.keys())
        .cloned()
        .collect()
}

fn entries_match(left: Option<&FileEntry>, right: Option<&FileEntry>) -> bool {
    match (left, right) {
        (Some(FileEntry::Managed(left)), Some(FileEntry::Managed(right))) => {
            left.content_hash == right.content_hash
                && left.encryption == right.encryption
                && left.members == right.members
        }
        (Some(FileEntry::Legacy(left)), Some(FileEntry::Legacy(right))) => left == right,
        (None, None) => true,
        _ => false,
    }
}

fn access_changed(left: Option<&FileEntry>, right: Option<&FileEntry>) -> bool {
    match (left, right) {
        (Some(FileEntry::Managed(left)), Some(FileEntry::Managed(right))) => {
            left.members != right.members
        }
        _ => false,
    }
}

fn decrypt_managed_entry(entry: &FileEntry) -> Option<Vec<u8>> {
    if !matches!(entry, FileEntry::Managed(_)) {
        return None;
    }

    let encrypted = fs::read(format!(".envoy/cache/{}.blob", entry.blob_hash())).ok()?;
    let project_key = get_project_key().ok()?;
    decrypt_file_bytes(&encrypted, &project_key).ok()
}

fn print_change(change: &FileChange, show_secrets: bool) {
    println!();
    println!(
        "{}",
        style(format!("diff --envoy a/{0} b/{0}", change.path))
            .bold()
            .cyan()
    );

    match change.kind {
        ChangeKind::Added => println!("{}", style("new file").green()),
        ChangeKind::Modified => println!("{}", style("modified").yellow()),
        ChangeKind::Deleted => println!("{}", style("deleted file").red()),
    }
    if change.access_changed {
        println!("{}", style("access policy changed").magenta());
    }

    match (&change.old, &change.new) {
        (old, new) if show_secrets && (old.is_some() || new.is_some()) => {
            print_text_diff(
                old.as_deref().unwrap_or_default(),
                new.as_deref().unwrap_or_default(),
            );
        }
        (old, new) if !show_secrets => print_redacted_diff(old.as_deref(), new.as_deref()),
        _ => {
            print_info("Encrypted content changed; plaintext diff is unavailable for legacy data.")
        }
    }
}

fn print_text_diff(old: &[u8], new: &[u8]) {
    let (Ok(old), Ok(new)) = (std::str::from_utf8(old), std::str::from_utf8(new)) else {
        print_info("Binary content changed.");
        return;
    };

    for change in TextDiff::from_lines(old, new).iter_all_changes() {
        let (sign, color) = match change.tag() {
            ChangeTag::Delete => ("-", console::Color::Red),
            ChangeTag::Insert => ("+", console::Color::Green),
            ChangeTag::Equal => (" ", console::Color::White),
        };
        print!("{}{}", style(sign).fg(color), style(change).fg(color));
    }
}

fn print_redacted_diff(old: Option<&[u8]>, new: Option<&[u8]>) {
    let old = match old {
        Some(content) => parse_env(content),
        None => Some(BTreeMap::new()),
    };
    let new = match new {
        Some(content) => parse_env(content),
        None => Some(BTreeMap::new()),
    };
    let (Some(old), Some(new)) = (old, new) else {
        print_info("Content changed (values hidden; use --show-secrets to reveal text).");
        return;
    };

    let keys: BTreeSet<_> = old.keys().chain(new.keys()).cloned().collect();
    for key in keys {
        match (old.get(&key), new.get(&key)) {
            (None, Some(_)) => println!("{}", style(format!("+{}=<redacted>", key)).green()),
            (Some(_), None) => println!("{}", style(format!("-{}=<redacted>", key)).red()),
            (Some(left), Some(right)) if left != right => {
                println!("{}", style(format!("-{}=<redacted>", key)).red());
                println!("{}", style(format!("+{}=<redacted>", key)).green());
            }
            _ => {}
        }
    }
}

fn parse_env(content: &[u8]) -> Option<BTreeMap<String, String>> {
    let content = std::str::from_utf8(content).ok()?;
    let mut values = BTreeMap::new();

    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let line = line.strip_prefix("export ").unwrap_or(line);
        let (key, value) = line.split_once('=')?;
        let key = key.trim();
        if key.is_empty()
            || !key
                .chars()
                .all(|character| character == '_' || character.is_ascii_alphanumeric())
        {
            return None;
        }
        values.insert(key.to_string(), value.to_string());
    }

    Some(values)
}

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

    #[test]
    fn parses_env_without_exposing_values() {
        let parsed = parse_env(b"# ignored\nexport API_KEY=secret=value\nEMPTY=\n").unwrap();

        assert_eq!(
            parsed.get("API_KEY").map(String::as_str),
            Some("secret=value")
        );
        assert_eq!(parsed.get("EMPTY").map(String::as_str), Some(""));
    }

    #[test]
    fn rejects_non_env_text_for_redacted_output() {
        assert!(parse_env(b"not an env file").is_none());
        assert!(parse_env(&[0xff]).is_none());
    }

    #[test]
    fn managed_entries_match_by_plaintext_hash() {
        let entry = |blob: &str| {
            FileEntry::Managed(crate::utils::manifest::ManagedFile {
                blob_hash: blob.to_string(),
                content_hash: "same-content".to_string(),
                encryption: crate::utils::manifest::FileEncryption::ProjectKey,
                members: None,
            })
        };

        assert!(entries_match(Some(&entry("one")), Some(&entry("two"))));
    }
}