esk 0.8.0

Encrypted Secrets Keeper with multi-target deploy
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncIndex {
    pub records: BTreeMap<String, SyncRecord>,
    #[serde(skip)]
    path: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncRecord {
    pub remote: String,
    pub env: String,
    pub pushed_version: u64,
    pub last_pushed_at: String,
    pub last_push_status: SyncStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_error: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SyncStatus {
    Success,
    Failed,
}

impl SyncIndex {
    pub fn new(path: &Path) -> Self {
        Self {
            records: BTreeMap::new(),
            path: path.to_path_buf(),
        }
    }

    pub fn load(path: &Path) -> Self {
        if !path.is_file() {
            return Self::new(path);
        }
        let contents = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("Warning: could not read sync index ({e}), starting fresh");
                return Self::new(path);
            }
        };
        match serde_json::from_str::<SyncIndex>(&contents) {
            Ok(mut index) => {
                index.path = path.to_path_buf();
                index
            }
            Err(e) => {
                eprintln!("Warning: sync index corrupted ({e}), starting fresh");
                Self::new(path)
            }
        }
    }

    pub fn save(&self) -> Result<()> {
        let json = serde_json::to_string_pretty(&self)?;
        let dir = self
            .path
            .parent()
            .context("sync index path has no parent")?;
        let tmp = NamedTempFile::new_in(dir)?;
        std::fs::write(tmp.path(), json)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
        }
        tmp.persist(&self.path)
            .with_context(|| format!("failed to persist sync index to {}", self.path.display()))?;
        Ok(())
    }

    /// Build a tracker key: "remote:env"
    pub fn tracker_key(remote: &str, env: &str) -> String {
        format!("{remote}:{env}")
    }

    pub fn record_success(&mut self, remote: &str, env: &str, version: u64) {
        let key = Self::tracker_key(remote, env);
        self.records.insert(
            key,
            SyncRecord {
                remote: remote.to_string(),
                env: env.to_string(),
                pushed_version: version,
                last_pushed_at: chrono::Utc::now().to_rfc3339(),
                last_push_status: SyncStatus::Success,
                last_error: None,
            },
        );
    }

    /// Returns the minimum `pushed_version` across all named remotes for an env,
    /// considering only successful pushes.
    ///
    /// Returns `None` if `remote_names` is empty or if any named remote lacks a
    /// successful push record for the env (blocks pruning to prevent resurrection
    /// attacks from stale/offline remotes).
    pub fn min_successful_push_version(&self, env: &str, remote_names: &[&str]) -> Option<u64> {
        if remote_names.is_empty() {
            return None;
        }

        let mut min_version: Option<u64> = None;
        for name in remote_names {
            let key = Self::tracker_key(name, env);
            match self.records.get(&key) {
                Some(record) if record.last_push_status == SyncStatus::Success => {
                    min_version = Some(match min_version {
                        Some(v) => v.min(record.pushed_version),
                        None => record.pushed_version,
                    });
                }
                _ => return None, // Missing or failed — block pruning
            }
        }

        min_version
    }

    pub fn record_failure(&mut self, remote: &str, env: &str, version: u64, error: String) {
        let key = Self::tracker_key(remote, env);
        self.records.insert(
            key,
            SyncRecord {
                remote: remote.to_string(),
                env: env.to_string(),
                pushed_version: version,
                last_pushed_at: chrono::Utc::now().to_rfc3339(),
                last_push_status: SyncStatus::Failed,
                last_error: Some(error),
            },
        );
    }
}

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

    #[test]
    fn new_empty() {
        let index = SyncIndex::new(Path::new("/tmp/test.json"));
        assert!(index.records.is_empty());
    }

    #[test]
    fn load_nonexistent_returns_empty() {
        let index = SyncIndex::load(Path::new("/nonexistent/path/test.json"));
        assert!(index.records.is_empty());
    }

    #[test]
    fn load_existing_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("index.json");
        let mut index = SyncIndex::new(&path);
        index.record_success("1password", "dev", 3);
        index.save().unwrap();

        let loaded = SyncIndex::load(&path);
        assert_eq!(loaded.records.len(), 1);
        assert!(loaded.records.contains_key("1password:dev"));
    }

    #[test]
    fn load_corrupted_returns_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("index.json");
        std::fs::write(&path, "not valid json").unwrap();
        let index = SyncIndex::load(&path);
        assert!(index.records.is_empty());
    }

    #[test]
    fn save_and_reload() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("index.json");
        let mut index = SyncIndex::new(&path);
        index.record_success("1password", "dev", 5);
        index.record_failure("dropbox", "prod", 3, "timeout".to_string());
        index.save().unwrap();

        let loaded = SyncIndex::load(&path);
        assert_eq!(loaded.records.len(), 2);
        assert_eq!(
            loaded.records["1password:dev"].last_push_status,
            SyncStatus::Success
        );
        assert_eq!(
            loaded.records["dropbox:prod"].last_push_status,
            SyncStatus::Failed
        );
    }

    #[test]
    fn save_atomic() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("index.json");
        let index = SyncIndex::new(&path);
        index.save().unwrap();
        assert!(path.is_file());
    }

    #[test]
    fn tracker_key_format() {
        assert_eq!(SyncIndex::tracker_key("1password", "dev"), "1password:dev");
        assert_eq!(SyncIndex::tracker_key("dropbox", "prod"), "dropbox:prod");
    }

    #[test]
    fn record_success_sets_fields() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_success("1password", "dev", 5);
        let record = &index.records["1password:dev"];
        assert_eq!(record.remote, "1password");
        assert_eq!(record.env, "dev");
        assert_eq!(record.pushed_version, 5);
        assert_eq!(record.last_push_status, SyncStatus::Success);
        assert!(record.last_error.is_none());
    }

    #[test]
    fn record_failure_sets_fields() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_failure("dropbox", "prod", 3, "timeout".to_string());
        let record = &index.records["dropbox:prod"];
        assert_eq!(record.remote, "dropbox");
        assert_eq!(record.env, "prod");
        assert_eq!(record.pushed_version, 3);
        assert_eq!(record.last_push_status, SyncStatus::Failed);
        assert_eq!(record.last_error.as_deref(), Some("timeout"));
    }

    #[test]
    fn record_overwrites_previous() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_failure("1password", "dev", 3, "err".to_string());
        index.record_success("1password", "dev", 5);
        let record = &index.records["1password:dev"];
        assert_eq!(record.last_push_status, SyncStatus::Success);
        assert_eq!(record.pushed_version, 5);
    }

    #[test]
    fn min_version_all_success() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_success("remote_a", "dev", 5);
        index.record_success("remote_b", "dev", 3);
        assert_eq!(
            index.min_successful_push_version("dev", &["remote_a", "remote_b"]),
            Some(3)
        );
    }

    #[test]
    fn min_version_one_missing() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_success("remote_a", "dev", 5);
        // remote_b has no record
        assert_eq!(
            index.min_successful_push_version("dev", &["remote_a", "remote_b"]),
            None
        );
    }

    #[test]
    fn min_version_one_failed() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_success("remote_a", "dev", 5);
        index.record_failure("remote_b", "dev", 3, "err".to_string());
        assert_eq!(
            index.min_successful_push_version("dev", &["remote_a", "remote_b"]),
            None
        );
    }

    #[test]
    fn min_version_empty_remotes() {
        let index = SyncIndex::new(Path::new("/tmp/test.json"));
        assert_eq!(index.min_successful_push_version("dev", &[]), None);
    }

    #[test]
    fn min_version_multi_env() {
        let mut index = SyncIndex::new(Path::new("/tmp/test.json"));
        index.record_success("remote_a", "dev", 5);
        index.record_success("remote_a", "prod", 2);
        assert_eq!(
            index.min_successful_push_version("dev", &["remote_a"]),
            Some(5)
        );
        assert_eq!(
            index.min_successful_push_version("prod", &["remote_a"]),
            Some(2)
        );
    }
}