dotlock-bin 0.1.5

Encrypted project-local environment variables manager
use std::{
    collections::{BTreeSet, HashMap},
    path::Path,
};

use crate::{
    crypto::VaultKeyMetadata,
    domain::{error::DotLockError, model::DotLockResult},
    storage::{
        cache::read_cached_dek,
        project::{SECRETS_FILE, VAULT_FILE},
        secrets_lock::{SecretRecord, SecretsFile, load_secrets_file, refresh_vault_hash},
        secure_fs,
        unlock_file::unlock_vault,
        vault_file::load_vault_metadata,
    },
};

pub fn run_merge_driver(ours: &Path, theirs: &Path, base: &Path) -> DotLockResult<()> {
    match merge_target(ours) {
        MergeTarget::Secrets => merge_secrets_lock(ours, theirs, base),
        MergeTarget::Vault => merge_vault_metadata(ours, theirs, base),
    }
}

enum MergeTarget {
    Secrets,
    Vault,
}

fn merge_target(path: &Path) -> MergeTarget {
    if path.file_name().and_then(|name| name.to_str()) == Some("vault.toml") {
        MergeTarget::Vault
    } else {
        MergeTarget::Secrets
    }
}

fn merge_secrets_lock(ours: &Path, theirs: &Path, base: &Path) -> DotLockResult<()> {
    let ours_file = load_secrets_file(ours)?;
    let theirs_file = load_secrets_file(theirs)?;
    let base_file = load_secrets_file(base).unwrap_or_default();
    let merged = merge_secrets(ours_file, theirs_file, base_file)?;

    let content =
        toml::to_string_pretty(&merged).map_err(|err| DotLockError::Crypto(err.to_string()))?;
    secure_fs::write_string_atomic(ours, &content, 0o700, 0o600)?;

    let dek = read_cached_dek().or_else(|| unlock_vault(VAULT_FILE).ok());
    if let Some(dek) = dek {
        refresh_vault_hash(Path::new(SECRETS_FILE), &dek, VAULT_FILE)?;
    }

    Ok(())
}

pub fn merge_secrets(
    ours: SecretsFile,
    theirs: SecretsFile,
    base: SecretsFile,
) -> DotLockResult<SecretsFile> {
    let version = ours.version.max(theirs.version);
    let ours_by_name: HashMap<String, SecretRecord> = ours
        .secrets
        .into_iter()
        .map(|secret| (secret.name.clone(), secret))
        .collect();
    let theirs_by_name: HashMap<String, SecretRecord> = theirs
        .secrets
        .into_iter()
        .map(|secret| (secret.name.clone(), secret))
        .collect();
    let base_by_name: HashMap<String, SecretRecord> = base
        .secrets
        .into_iter()
        .map(|secret| (secret.name.clone(), secret))
        .collect();

    let mut names = BTreeSet::new();
    names.extend(ours_by_name.keys().cloned());
    names.extend(theirs_by_name.keys().cloned());
    names.extend(base_by_name.keys().cloned());

    let mut secrets = Vec::new();
    for name in names {
        if let Some(secret) = choose_secret(
            &name,
            ours_by_name.get(&name),
            theirs_by_name.get(&name),
            base_by_name.get(&name),
        )? {
            secrets.push(secret.clone());
        }
    }

    Ok(SecretsFile { version, secrets })
}

fn choose_secret<'a>(
    name: &str,
    ours: Option<&'a SecretRecord>,
    theirs: Option<&'a SecretRecord>,
    base: Option<&SecretRecord>,
) -> DotLockResult<Option<&'a SecretRecord>> {
    match (ours, theirs, base) {
        (Some(ours), Some(theirs), _) => choose_latest(name, ours, theirs).map(Some),
        (Some(ours), None, Some(base)) => {
            if same_secret_revision(ours, base) {
                Ok(None)
            } else {
                Ok(Some(ours))
            }
        }
        (None, Some(theirs), Some(base)) => {
            if same_secret_revision(theirs, base) {
                Ok(None)
            } else {
                Ok(Some(theirs))
            }
        }
        (Some(ours), None, None) => Ok(Some(ours)),
        (None, Some(theirs), None) => Ok(Some(theirs)),
        (None, None, _) => Ok(None),
    }
}

fn choose_latest<'a>(
    name: &str,
    ours: &'a SecretRecord,
    theirs: &'a SecretRecord,
) -> DotLockResult<&'a SecretRecord> {
    if ours.updated_at > theirs.updated_at {
        return Ok(ours);
    }
    if theirs.updated_at > ours.updated_at {
        return Ok(theirs);
    }
    if ours.data != theirs.data {
        return Err(DotLockError::Io(format!(
            "manual merge required for secret `{name}`; both sides changed it at the same timestamp"
        )));
    }
    Ok(ours)
}

fn same_secret_revision(left: &SecretRecord, right: &SecretRecord) -> bool {
    left.id == right.id
        && left.name == right.name
        && left.alg == right.alg
        && left.data == right.data
        && left.updated_at == right.updated_at
}

fn merge_vault_metadata(ours: &Path, theirs: &Path, base: &Path) -> DotLockResult<()> {
    let ours_metadata = load_vault_metadata(ours)?;
    let theirs_metadata = load_vault_metadata(theirs)?;
    let base_metadata = load_vault_metadata(base).ok();
    let merged = merge_metadata(ours_metadata, theirs_metadata, base_metadata)?;
    let content =
        toml::to_string_pretty(&merged).map_err(|err| DotLockError::Crypto(err.to_string()))?;
    secure_fs::write_string_atomic(ours, &content, 0o700, 0o600)
}

pub fn merge_metadata(
    mut ours: VaultKeyMetadata,
    theirs: VaultKeyMetadata,
    base: Option<VaultKeyMetadata>,
) -> DotLockResult<VaultKeyMetadata> {
    if ours.project_uuid != theirs.project_uuid {
        return Err(DotLockError::Io(
            "manual merge required for vault.toml; project_uuid differs".to_string(),
        ));
    }

    if ours.kek_version != theirs.kek_version {
        return Err(DotLockError::Io(
            "manual merge required for vault.toml; kek_version differs".to_string(),
        ));
    }

    ours.version = ours.version.max(theirs.version).max(2);
    merge_recipients(&mut ours, theirs, base);
    Ok(ours)
}

fn merge_recipients(
    ours: &mut VaultKeyMetadata,
    theirs: VaultKeyMetadata,
    base: Option<VaultKeyMetadata>,
) {
    let base_fingerprints: Vec<String> = base
        .map(|metadata| {
            metadata
                .recipients
                .into_iter()
                .map(|recipient| recipient.public_key_fingerprint)
                .collect()
        })
        .unwrap_or_default();

    for recipient in theirs.recipients {
        let exists = ours
            .recipients
            .iter()
            .any(|existing| existing.public_key_fingerprint == recipient.public_key_fingerprint);
        if exists {
            continue;
        }

        let was_in_base = base_fingerprints
            .iter()
            .any(|fingerprint| fingerprint == &recipient.public_key_fingerprint);
        if !was_in_base {
            ours.recipients.push(recipient);
        }
    }

    ours.recipients
        .sort_by(|a, b| a.public_key_fingerprint.cmp(&b.public_key_fingerprint));
}

#[cfg(test)]
mod tests {
    use crate::storage::secrets_lock::{SecretKind, SecretRecord, SecretsFile};

    use super::merge_secrets;

    fn secret(name: &str, data: &str, updated_at: i64) -> SecretRecord {
        SecretRecord {
            id: name.to_string(),
            name: name.to_string(),
            alg: "xchacha20-poly1305".to_string(),
            data: data.to_string(),
            updated_at,
            kind: SecretKind::Static,
        }
    }

    fn file(secrets: Vec<SecretRecord>) -> SecretsFile {
        SecretsFile {
            version: 1,
            secrets,
        }
    }

    #[test]
    fn merges_different_secret_changes() {
        let merged = merge_secrets(
            file(vec![secret("A", "ours", 10)]),
            file(vec![secret("B", "theirs", 11)]),
            file(Vec::new()),
        )
        .expect("merge");

        assert_eq!(merged.secrets.len(), 2);
        assert_eq!(merged.secrets[0].name, "A");
        assert_eq!(merged.secrets[1].name, "B");
    }

    #[test]
    fn latest_timestamp_wins_for_same_secret() {
        let merged = merge_secrets(
            file(vec![secret("A", "old", 10)]),
            file(vec![secret("A", "new", 20)]),
            file(Vec::new()),
        )
        .expect("merge");

        assert_eq!(merged.secrets[0].data, "new");
    }

    #[test]
    fn same_timestamp_conflict_requires_manual_merge() {
        let result = merge_secrets(
            file(vec![secret("A", "ours", 10)]),
            file(vec![secret("A", "theirs", 10)]),
            file(Vec::new()),
        );

        assert!(result.is_err());
    }

    #[test]
    fn deletion_wins_when_other_side_did_not_change_secret() {
        let base = secret("A", "base", 10);
        let merged = merge_secrets(file(Vec::new()), file(vec![base.clone()]), file(vec![base]))
            .expect("merge");

        assert!(merged.secrets.is_empty());
    }

    #[test]
    fn update_wins_when_other_side_deleted_older_base_secret() {
        let merged = merge_secrets(
            file(Vec::new()),
            file(vec![secret("A", "updated", 20)]),
            file(vec![secret("A", "base", 10)]),
        )
        .expect("merge");

        assert_eq!(merged.secrets[0].data, "updated");
    }
}