dotlock-bin 0.1.1

Encrypted project-local environment variables manager
use std::{
    fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use base64::{Engine, engine::general_purpose};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

use crate::{
    crypto::{AccessMode, VaultKeyMetadata},
    domain::{error::DotLockError, model::DotLockResult},
    storage::{secure_fs, vault_file::load_vault_metadata},
};

const CACHE_DIR_NAME: &str = "run";
const CACHE_SCOPE_DIR: &str = "sessions";
const CACHE_FILE_NAME: &str = "sessions.toml";
const LEGACY_CACHE_FILE_NAME: &str = "sessions.lock";
const APP_CACHE_DIR: &str = ".lock";
const DEFAULT_TTL_SECS: u64 = 30;
const VAULT_FILE: &str = ".lock/vault.toml";

#[derive(Debug, Serialize, Deserialize)]
struct SessionCache {
    expires_at: u64,
    dek_b64: String,
}

fn ttl_secs() -> u64 {
    std::env::var("DOTLOCK_CACHE_TTL")
        .ok()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(DEFAULT_TTL_SECS)
}

fn shared_cache_enabled() -> bool {
    std::env::var("DOTLOCK_SHARED_CACHE")
        .ok()
        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
        .unwrap_or(false)
}

pub fn cache_path() -> PathBuf {
    cache_dir().join(CACHE_FILE_NAME)
}

fn legacy_cache_path() -> PathBuf {
    cache_dir().join(LEGACY_CACHE_FILE_NAME)
}

fn cache_root() -> PathBuf {
    if let Ok(dir) = std::env::var("DOTLOCK_CACHE_DIR") {
        return PathBuf::from(dir);
    }

    #[cfg(not(windows))]
    {
        if let Ok(home) = std::env::var("HOME") {
            return Path::new(&home).join(APP_CACHE_DIR);
        }
    }

    #[cfg(windows)]
    {
        if let Ok(dir) = std::env::var("LOCALAPPDATA") {
            return Path::new(&dir).join("dotlock");
        }
    }

    PathBuf::from(".").join(APP_CACHE_DIR)
}

fn cache_dir() -> PathBuf {
    cache_root()
        .join(CACHE_DIR_NAME)
        .join(CACHE_SCOPE_DIR)
        .join(project_cache_dir_name())
}

fn project_cache_dir_name() -> String {
    read_project_uuid()
        .map(|uuid| short_uuid(&uuid))
        .unwrap_or_else(|_| "unknown".to_string())
}

fn read_project_uuid() -> DotLockResult<String> {
    let metadata: VaultKeyMetadata = load_vault_metadata(VAULT_FILE)?;
    Ok(metadata.project_uuid)
}

fn shared_mode_active() -> bool {
    load_vault_metadata(VAULT_FILE)
        .map(|metadata| metadata.access_mode == AccessMode::Shared)
        .unwrap_or(false)
}

fn short_uuid(uuid: &str) -> String {
    uuid.chars().take(8).collect()
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

pub fn read_cached_dek() -> Option<Zeroizing<[u8; 32]>> {
    if shared_mode_active() && !shared_cache_enabled() {
        let _ = invalidate_cache();
        return None;
    }

    let path = cache_path();
    let legacy_path = legacy_cache_path();
    let path = if path.exists() {
        path
    } else if legacy_path.exists() {
        legacy_path
    } else {
        return None;
    };

    let content = secure_fs::read_to_string(&path).ok()?;
    let cache: SessionCache = toml::from_str(&content).ok()?;

    if cache.expires_at <= now_secs() {
        let _ = fs::remove_file(&path);
        return None;
    }

    let bytes = general_purpose::STANDARD.decode(&cache.dek_b64).ok()?;
    let dek: [u8; 32] = bytes.try_into().ok()?;
    Some(Zeroizing::new(dek))
}

pub fn write_cached_dek(dek: &[u8; 32]) -> DotLockResult<()> {
    if shared_mode_active() && !shared_cache_enabled() {
        let _ = invalidate_cache();
        return Ok(());
    }

    let path = cache_path();

    let cache = SessionCache {
        expires_at: now_secs().saturating_add(ttl_secs()),
        dek_b64: general_purpose::STANDARD.encode(dek),
    };

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

    let legacy_path = legacy_cache_path();
    if legacy_path != path {
        let _ = fs::remove_file(legacy_path);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{read_cached_dek, shared_cache_enabled, write_cached_dek};
    use crate::{
        crypto::{AccessMode, VaultKeyMetadata},
        storage::vault_file::save_vault_metadata,
    };
    use std::{
        fs,
        time::{SystemTime, UNIX_EPOCH},
    };

    fn temp_dir(name: &str) -> std::path::PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("dotlock-{name}-{unique}"));
        fs::create_dir_all(&dir).expect("create dir");
        dir
    }

    fn shared_metadata() -> VaultKeyMetadata {
        VaultKeyMetadata {
            version: 1,
            project_uuid: "project".to_string(),
            project: "dotlock".to_string(),
            environment: "dev".to_string(),
            kdf: "argon2id".to_string(),
            salt_b64: "salt".to_string(),
            memory_kib: 1,
            iterations: 1,
            parallelism: 1,
            kek_version: 1,
            wrapped_dek_nonce_b64: "nonce".to_string(),
            wrapped_dek_b64: "wrapped".to_string(),
            access_mode: AccessMode::Shared,
            recipients: Vec::new(),
            secrets_hash_nonce_b64: "hash_nonce".to_string(),
            secrets_hash_b64: "hash".to_string(),
        }
    }

    #[test]
    fn shared_mode_does_not_cache_by_default() {
        let dir = temp_dir("cache");
        let project_dir = dir.join("project");
        fs::create_dir_all(project_dir.join(".lock")).expect("project dir");
        let cache_dir = dir.join("cache");
        unsafe {
            std::env::set_var("DOTLOCK_CACHE_DIR", &cache_dir);
            std::env::set_var("DOTLOCK_SHARED_CACHE", "false");
        }
        save_vault_metadata(project_dir.join(".lock/vault.toml"), &shared_metadata())
            .expect("save vault");
        let cwd = std::env::current_dir().expect("cwd");
        std::env::set_current_dir(&project_dir).expect("chdir");

        assert!(!shared_cache_enabled());
        write_cached_dek(&[5u8; 32]).expect("write cache");
        assert!(read_cached_dek().is_none());

        std::env::set_current_dir(cwd).expect("restore cwd");
        let _ = fs::remove_dir_all(dir);
        unsafe {
            std::env::remove_var("DOTLOCK_CACHE_DIR");
            std::env::remove_var("DOTLOCK_SHARED_CACHE");
        }
    }
}

pub fn invalidate_cache() -> DotLockResult<bool> {
    let mut removed = false;

    for path in [cache_path(), legacy_cache_path()] {
        match fs::remove_file(&path) {
            Ok(()) => removed = true,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => return Err(DotLockError::from(err)),
        }
    }

    Ok(removed)
}