dotlock-bin 0.1.1

Encrypted project-local environment variables manager
use std::path::Path;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{
    crypto::integrity::build_encrypted_hash_fields,
    domain::{error::DotLockError, model::DataEncrypted, model::DotLockResult},
    storage::{
        project::SECRETS_FILE,
        secure_fs,
        vault_file::{load_vault_metadata, save_vault_metadata},
    },
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsFile {
    pub version: u32,
    #[serde(default)]
    pub secrets: Vec<SecretRecord>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretRecord {
    pub id: String,
    pub name: String,
    pub alg: String,
    pub data: String,
}

impl Default for SecretsFile {
    fn default() -> Self {
        Self {
            version: 1,
            secrets: Vec::new(),
        }
    }
}

pub fn load_secrets_file<P: AsRef<Path>>(path: P) -> DotLockResult<SecretsFile> {
    let path = path.as_ref();

    if !path.exists() {
        return Ok(SecretsFile::default());
    }

    let content = secure_fs::read_to_string(path)?;

    if content.trim().is_empty() {
        return Ok(SecretsFile::default());
    }

    let file =
        toml::from_str::<SecretsFile>(&content).map_err(|_| DotLockError::LegacyVaultFormat)?;
    Ok(file)
}

fn write_secrets_file<P: AsRef<Path>>(path: P, file: &SecretsFile) -> DotLockResult<()> {
    let path = path.as_ref();

    let content = toml::to_string_pretty(file).map_err(|e| DotLockError::Crypto(e.to_string()))?;
    secure_fs::write_string_atomic(path, &content, 0o700, 0o600)
}

pub fn save_secrets_file<P: AsRef<Path>>(
    path: P,
    file: &SecretsFile,
    dek: &[u8; 32],
    vault_path: &str,
) -> DotLockResult<()> {
    write_secrets_file(path.as_ref(), file)?;
    refresh_vault_hash(path.as_ref(), dek, vault_path)
}

pub fn refresh_vault_hash(
    secrets_path: &Path,
    dek: &[u8; 32],
    vault_path: &str,
) -> DotLockResult<()> {
    let mut metadata = load_vault_metadata(vault_path)?;
    let (nonce_b64, hash_b64) = build_encrypted_hash_fields(secrets_path, dek)?;
    metadata.secrets_hash_nonce_b64 = nonce_b64;
    metadata.secrets_hash_b64 = hash_b64;
    save_vault_metadata(vault_path, &metadata)
}

pub fn upsert_secret<P: AsRef<Path>>(
    path: P,
    encrypted: DataEncrypted<'_>,
    dek: &[u8; 32],
    vault_path: &str,
) -> DotLockResult<()> {
    let path = path.as_ref();
    let mut file = load_secrets_file(path)?;

    let data_str =
        String::from_utf8(encrypted.data).map_err(|e| DotLockError::Crypto(e.to_string()))?;

    if let Some(existing) = file
        .secrets
        .iter_mut()
        .find(|secret| secret.name == encrypted.name)
    {
        existing.alg = encrypted.alg.to_string();
        existing.data = data_str;
    } else {
        file.secrets.push(SecretRecord {
            id: Uuid::new_v4().to_string(),
            name: encrypted.name,
            alg: encrypted.alg.to_string(),
            data: data_str,
        });
    }

    save_secrets_file(path, &file, dek, vault_path)
}

pub fn find_secret_by_name(name: &str) -> DotLockResult<SecretRecord> {
    let file = load_secrets_file(SECRETS_FILE)?;
    file.secrets
        .into_iter()
        .find(|secret| secret.name == name)
        .ok_or_else(|| DotLockError::SecretNotFound {
            name: name.to_string(),
        })
}

pub fn list_secrets() -> DotLockResult<Vec<SecretRecord>> {
    let file = load_secrets_file(SECRETS_FILE)?;
    Ok(file.secrets)
}

pub struct EncryptedEntry {
    pub name: String,
    pub alg: String,
    pub data: String,
}

pub struct UpsertSummary {
    pub created: usize,
    pub updated: usize,
}

pub fn upsert_many<P: AsRef<Path>>(
    path: P,
    entries: Vec<EncryptedEntry>,
    dek: &[u8; 32],
    vault_path: &str,
) -> DotLockResult<UpsertSummary> {
    let path = path.as_ref();
    let mut file = load_secrets_file(path)?;
    let mut summary = UpsertSummary {
        created: 0,
        updated: 0,
    };

    for entry in entries {
        if let Some(existing) = file.secrets.iter_mut().find(|s| s.name == entry.name) {
            existing.alg = entry.alg;
            existing.data = entry.data;
            summary.updated += 1;
        } else {
            file.secrets.push(SecretRecord {
                id: Uuid::new_v4().to_string(),
                name: entry.name,
                alg: entry.alg,
                data: entry.data,
            });
            summary.created += 1;
        }
    }

    save_secrets_file(path, &file, dek, vault_path)?;
    Ok(summary)
}

pub fn remove_secret_by_name(name: &str, dek: &[u8; 32], vault_path: &str) -> DotLockResult<()> {
    let mut file = load_secrets_file(SECRETS_FILE)?;
    let before = file.secrets.len();
    file.secrets.retain(|secret| secret.name != name);

    if file.secrets.len() == before {
        return Err(DotLockError::SecretNotFound {
            name: name.to_string(),
        });
    }

    save_secrets_file(SECRETS_FILE, &file, dek, vault_path)
}