dove-core 0.1.1

The shared library behind dove — client-side-encrypted, expiring file sharing from a cloud you own.
Documentation
//! The named-backend registry: where dove looks up *which* cloud a share goes
//! to. Today there's one kind of backend (`self-hosted` — your own S3 bucket,
//! the fields `dove provision` writes), but the registry format supports many,
//! named and switchable (`dove backend use <name>`, a later CLI feature).
//!
//! Lives at `~/.config/dove/config.toml`, honoring `$XDG_CONFIG_HOME` (and, for
//! tests, a `DOVE_CONFIG` override that pins the exact path). A registry file
//! from before this format existed — bare `bucket`/`region`/… fields, no
//! `active`/`backends` — is transparently migrated into a `"default"`
//! self-hosted backend and rewritten in the new format the first time it's
//! loaded.

use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// The self-hosted backend's fields — this *is* the CLI's original bare
/// `Config` struct, moved here verbatim so behavior is preserved exactly.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct SelfHostedConfig {
    /// The S3 bucket dove uploads shares to.
    pub bucket: String,
    pub region: String,
    /// AWS profile whose credentials sign presigned URLs. `None` → the default
    /// credential chain (env / default profile / instance role).
    #[serde(default)]
    pub profile: Option<String>,
    /// Optional S3-compatible endpoint (MinIO, R2, …); omitted → real AWS S3.
    #[serde(default)]
    pub endpoint: Option<String>,
    /// DynamoDB table holding share policies — full tier only.
    #[serde(default)]
    pub table: Option<String>,
    /// The access-gate base URL. Its presence marks the config as full-tier:
    /// `share` registers a policy and points links at the gate instead of a raw
    /// presigned URL. This is the CloudFront domain (or a custom domain), which
    /// signs requests to the IAM-private Lambda Function URL behind it.
    #[serde(default)]
    pub gate_url: Option<String>,
    /// The CloudFront distribution fronting the gate (full tier). `domain add`
    /// updates this distribution to attach a custom domain.
    #[serde(default)]
    pub distribution_id: Option<String>,
}

impl SelfHostedConfig {
    /// Whether this config is provisioned for the full (gated, encrypted) tier.
    pub fn is_full(&self) -> bool {
        self.gate_url.is_some()
    }
}

/// One named backend in the registry. `config` is kind-specific — for
/// `kind == "self-hosted"` it deserializes into [`SelfHostedConfig`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Backend {
    pub name: String,
    /// `"self-hosted"` today; other kinds arrive with future backends.
    pub kind: String,
    #[serde(flatten)]
    pub config: toml::Table,
}

impl Backend {
    /// Build a `self-hosted` backend named `name` from a [`SelfHostedConfig`].
    pub fn self_hosted(name: &str, cfg: &SelfHostedConfig) -> Result<Backend> {
        let value = toml::Value::try_from(cfg)
            .map_err(|e| Error::Config(format!("serializing self-hosted config: {e}")))?;
        let config = match value {
            toml::Value::Table(t) => t,
            _ => {
                return Err(Error::Config(
                    "self-hosted config did not serialize to a table".into(),
                ))
            }
        };
        Ok(Backend {
            name: name.to_string(),
            kind: "self-hosted".to_string(),
            config,
        })
    }
}

/// The registry: which backend is active, and the full list dove knows about.
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct Registry {
    pub active: String,
    pub backends: Vec<Backend>,
}

impl Registry {
    /// Load the registry, migrating an old bare-`Config` file in place.
    ///
    /// - New format (has `active` + `backends`): parsed and returned as-is.
    /// - Legacy format (bare self-hosted fields, no `active`/`backends`): wrapped
    ///   into a `"default"` backend, saved back in the new format, and returned.
    /// - No file yet: an empty registry (`active` empty, no backends) — the
    ///   "not provisioned yet" state. This does *not* itself surface the
    ///   `dove provision`-pointing error the old `Config::load()` gave on a
    ///   missing file; that guidance now lives in [`Registry::active_backend`],
    ///   which every real read path goes through.
    pub fn load() -> Result<Registry> {
        let path = config_path()?;
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Registry {
                    active: String::new(),
                    backends: Vec::new(),
                })
            }
            Err(e) => return Err(Error::Config(format!("reading {}: {e}", path.display()))),
        };

        // Parse once into a generic table so format detection doesn't depend on
        // serde's `#[serde(default)]` silently filling in a Registry shape for
        // what's actually a legacy file (every Registry field is defaultable,
        // so a naive "does it deserialize as Registry" check would say yes to
        // almost anything). Presence of the `active`/`backends` keys themselves
        // is what distinguishes the two formats.
        let table: toml::Table = toml::from_str(&text)
            .map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;

        if table.contains_key("active") && table.contains_key("backends") {
            let reg: Registry = toml::Value::Table(table)
                .try_into()
                .map_err(|e| Error::Config(format!("parsing dove config registry: {e}")))?;
            return Ok(reg);
        }

        // Legacy bare self-hosted config: migrate it into a "default" backend
        // and persist the new format immediately so this is a one-time upgrade.
        let legacy: SelfHostedConfig = toml::Value::Table(table)
            .try_into()
            .map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;
        let backend = Backend::self_hosted("default", &legacy)?;
        let reg = Registry {
            active: "default".to_string(),
            backends: vec![backend],
        };
        reg.save()?;
        Ok(reg)
    }

    /// Write the registry to `~/.config/dove/config.toml` (or `$DOVE_CONFIG`).
    pub fn save(&self) -> Result<()> {
        let path = config_path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| Error::Config(format!("creating {}: {e}", parent.display())))?;
        }
        let text = toml::to_string_pretty(self)
            .map_err(|e| Error::Config(format!("serializing dove config: {e}")))?;
        std::fs::write(&path, text)
            .map_err(|e| Error::Config(format!("writing {}: {e}", path.display())))
    }

    /// The currently-active backend. This is where "not provisioned yet" is
    /// surfaced — same guidance the old `Config::load()` gave on a missing file.
    pub fn active_backend(&self) -> Result<&Backend> {
        if self.active.is_empty() {
            return Err(Error::Config(
                "no dove config yet — run `dove provision` first".into(),
            ));
        }
        self.backends
            .iter()
            .find(|b| b.name == self.active)
            .ok_or_else(|| {
                Error::Config(format!(
                    "active backend '{}' not found in config — run `dove provision` first",
                    self.active
                ))
            })
    }

    /// Switch the active backend. Errors if no backend by that name exists.
    pub fn set_active(&mut self, name: &str) -> Result<()> {
        if !self.backends.iter().any(|b| b.name == name) {
            return Err(Error::Config(format!("no backend named '{name}'")));
        }
        self.active = name.to_string();
        Ok(())
    }

    /// Replace the backend with this name, or add it if none exists yet.
    pub fn upsert(&mut self, b: Backend) {
        if let Some(existing) = self.backends.iter_mut().find(|x| x.name == b.name) {
            *existing = b;
        } else {
            self.backends.push(b);
        }
    }

    /// The active backend's config, deserialized as a [`SelfHostedConfig`].
    /// Convenience for the CLI's self-hosted-only read paths.
    pub fn active_self_hosted(&self) -> Result<SelfHostedConfig> {
        let backend = self.active_backend()?;
        toml::Value::Table(backend.config.clone())
            .try_into()
            .map_err(|e| Error::Config(format!("reading '{}' backend config: {e}", backend.name)))
    }
}

/// `~/.config/dove/config.toml`, honoring `$XDG_CONFIG_HOME`. Tests (and
/// anything else that needs an isolated config) can pin the exact path with
/// `$DOVE_CONFIG`.
fn config_path() -> Result<PathBuf> {
    if let Ok(p) = std::env::var("DOVE_CONFIG") {
        if !p.is_empty() {
            return Ok(PathBuf::from(p));
        }
    }
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
        if !x.is_empty() {
            return Ok(PathBuf::from(x).join("dove/config.toml"));
        }
    }
    let home = std::env::var("HOME").map_err(|_| Error::Config("HOME is not set".into()))?;
    Ok(PathBuf::from(home).join(".config/dove/config.toml"))
}

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

    #[test]
    fn self_hosted_round_trips_through_backend() {
        let cfg = SelfHostedConfig {
            bucket: "dove-shares-example".into(),
            region: "us-east-1".into(),
            profile: Some("work".into()),
            endpoint: None,
            table: Some("dove-shares-example".into()),
            gate_url: Some("https://share.example.com".into()),
            distribution_id: Some("E123ABC".into()),
        };
        let backend = Backend::self_hosted("default", &cfg).unwrap();
        assert_eq!(backend.name, "default");
        assert_eq!(backend.kind, "self-hosted");
        assert!(cfg.is_full());

        let reg = Registry {
            active: "default".into(),
            backends: vec![backend],
        };
        let recovered = reg.active_self_hosted().unwrap();
        assert_eq!(recovered, cfg);
    }

    #[test]
    fn active_backend_missing_is_config_error() {
        let reg = Registry {
            active: String::new(),
            backends: vec![],
        };
        let err = reg.active_backend().unwrap_err();
        assert!(matches!(err, Error::Config(_)));
    }

    // Carried over from the CLI's original `Config` unit tests, so this
    // extraction doesn't lose coverage of the field-level TOML contract.

    #[test]
    fn optional_fields_default_to_none() {
        let cfg: SelfHostedConfig =
            toml::from_str("bucket = \"b\"\nregion = \"us-east-1\"\n").unwrap();
        assert_eq!(cfg.profile, None);
        assert_eq!(cfg.endpoint, None);
    }

    #[test]
    fn missing_required_field_is_an_error() {
        let result: std::result::Result<SelfHostedConfig, _> =
            toml::from_str("region = \"us-east-1\"\n"); // no bucket
        assert!(result.is_err());
    }
}