fundaia 0.30.1

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

use crate::exit::Refused;

/// Where the tool remembers which instance it talks to, and how.
///
/// One file, several profiles, because the interesting case is not one server —
/// it is a laptop that reaches production and a staging box and wants `--profile
/// staging` rather than a second config file and a wrapper script.
///
/// The token lives here in the clear, the same way `gh` and `docker` keep
/// theirs. Encrypting it would need a key, and the only place to put the key is
/// next to the file it protects.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
    /// Which profile a bare invocation uses.
    #[serde(default)]
    pub current: Option<String>,
    #[serde(default)]
    pub profiles: std::collections::BTreeMap<String, Profile>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
    /// Base address, with no trailing slash: `https://infrastructure.fundaia.com`.
    pub url: String,
    pub token: String,
}

impl Config {
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }

        let text = fs::read_to_string(&path)
            .with_context(|| format!("could not read {}", path.display()))?;

        toml::from_str(&text).with_context(|| format!("{} is not valid TOML", path.display()))
    }

    /// Writes the file with owner-only permissions, creating the directory.
    ///
    /// The mode is set before the token is written rather than after: a file
    /// that is briefly world-readable while holding a credential is a file that
    /// was world-readable.
    pub fn save(&self) -> Result<PathBuf> {
        let path = config_path()?;
        let directory = path
            .parent()
            .ok_or_else(|| anyhow!("the configuration path has no directory"))?;

        create_private_directory(directory)
            .with_context(|| format!("could not create {}", directory.display()))?;

        let text = toml::to_string_pretty(self).context("could not serialise the configuration")?;
        write_private(&path, text.as_bytes())?;

        Ok(path)
    }

    pub fn profile(&self, name: Option<&str>) -> Result<(&str, &Profile)> {
        let chosen = name
            .map(str::to_owned)
            .or_else(|| self.current.clone())
            .ok_or_else(|| {
                Refused::not_signed_in("no profile configured — run `fundaia login` first")
            })?;

        let profile = self.profiles.get(&chosen).ok_or_else(|| {
            Refused::not_signed_in(format!("there is no profile called `{chosen}`"))
        })?;

        // The key is returned rather than the caller's argument so a lookup
        // through `current` still reports which profile it actually used.
        let key = self
            .profiles
            .keys()
            .find(|candidate| *candidate == &chosen)
            .map(String::as_str)
            .unwrap_or("default");

        Ok((key, profile))
    }

    pub fn set(&mut self, name: &str, profile: Profile) {
        self.profiles.insert(name.to_owned(), profile);
        if self.current.is_none() {
            self.current = Some(name.to_owned());
        }
    }
}

pub fn config_path() -> Result<PathBuf> {
    if let Ok(explicit) = std::env::var("FUNDAIA_CONFIG") {
        return Ok(PathBuf::from(explicit));
    }

    let base = dirs::config_dir()
        .ok_or_else(|| anyhow!("could not work out where configuration files live"))?;

    Ok(settled_at(
        &base.join("fundaia").join("config.toml"),
        &base.join("fundaia-infra").join("config.toml"),
        |path| path.exists(),
    ))
}

/// Where this tool's things actually live, which is not always where a fresh
/// install would put them.
///
/// The command used to be called `fundaia-infra` and kept its profiles under
/// that name. Renaming the binary must not sign anybody out, so an existing
/// directory keeps being used and only a machine with neither gets the new one.
/// Nothing is moved: a rename in a function everything calls is a side effect
/// hiding inside a getter, and there is no reason to pay it — the file works
/// perfectly well where it is.
fn settled_at(current: &Path, legacy: &Path, exists: impl Fn(&Path) -> bool) -> PathBuf {
    if !exists(current) && exists(legacy) {
        return legacy.to_path_buf();
    }

    current.to_path_buf()
}

#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

    /*
     * Written beside the file and renamed over it, so a save that dies halfway
     * leaves the previous configuration rather than an empty one — and the
     * token with it. `mode` only applies to a file being created, which is
     * what the temporary always is; the rename carries its bits across, so a
     * config that was `0644` before (copied from another machine, or written
     * by an older version) ends up `0600` too.
     */
    let staged = path.with_extension("toml.tmp");
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&staged)
        .with_context(|| format!("could not write {}", staged.display()))?;

    let written = file
        .write_all(bytes)
        .and_then(|()| file.set_permissions(fs::Permissions::from_mode(0o600)))
        .and_then(|()| file.sync_all())
        .and_then(|()| fs::rename(&staged, path));

    if let Err(error) = written {
        let _ = fs::remove_file(&staged);
        return Err(error).with_context(|| format!("could not write {}", path.display()));
    }

    Ok(())
}

#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
    fs::write(path, bytes).with_context(|| format!("could not write {}", path.display()))
}

/// Trims a trailing slash so every joined path has exactly one separator.
pub fn normalise_url(url: &str) -> String {
    url.trim().trim_end_matches('/').to_owned()
}

/// The host an address names, lower-cased, port included, userinfo dropped.
///
/// Hand-rolled over the six characters that matter rather than pulling a URL
/// parser into the hot path: a base address is `scheme://host[:port][/path]`
/// and nothing else is accepted anywhere in this tool.
pub fn host_of(url: &str) -> String {
    let rest = url.trim();
    let rest = rest.split_once("://").map_or(rest, |(_, after)| after);
    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
    let host = authority
        .rsplit_once('@')
        .map_or(authority, |(_, host)| host);
    host.to_ascii_lowercase()
}

/// Whether two addresses name the same host, which is the question that decides
/// if a token saved for one may be sent to the other.
pub fn same_host(left: &str, right: &str) -> bool {
    let (left, right) = (host_of(left), host_of(right));
    !left.is_empty() && left == right
}

/// The configuration directory, readable by its owner and nobody else.
///
/// `create_dir_all` takes the umask's word for the mode, and the umask's word
/// is usually `0755`: a directory anybody can list, holding one file whose name
/// says what is in it. Made private on creation, and made private again on
/// every save, because a directory that already existed was never asked.
#[cfg(unix)]
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};

    fs::DirBuilder::new()
        .recursive(true)
        .mode(0o700)
        .create(directory)?;
    fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
}

#[cfg(not(unix))]
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
    fs::create_dir_all(directory)
}

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

    #[test]
    fn it_should_read_the_host_out_of_an_address() {
        assert_eq!(
            host_of("https://Infra.Example.com:8443/api/"),
            "infra.example.com:8443"
        );
    }

    #[test]
    fn it_should_ignore_userinfo_when_reading_the_host() {
        assert_eq!(host_of("https://user:pw@evil.example/"), "evil.example");
    }

    #[test]
    fn it_should_treat_two_addresses_on_one_host_as_the_same() {
        assert!(same_host(
            "https://infra.example.com/",
            "https://infra.example.com/a"
        ));
    }

    #[test]
    fn it_should_treat_a_different_port_as_a_different_host() {
        assert!(!same_host(
            "https://infra.example.com",
            "https://infra.example.com:8443"
        ));
    }

    #[test]
    fn it_should_never_match_an_empty_host() {
        assert!(!same_host("", ""));
    }

    #[test]
    fn it_should_drop_a_trailing_slash_from_a_base_address() {
        assert_eq!(normalise_url("https://example.com/"), "https://example.com");
    }

    #[test]
    fn it_should_leave_an_address_without_a_trailing_slash_alone() {
        assert_eq!(normalise_url("https://example.com"), "https://example.com");
    }

    #[test]
    fn it_should_trim_surrounding_whitespace_from_an_address() {
        assert_eq!(
            normalise_url("  https://example.com  "),
            "https://example.com"
        );
    }

    #[test]
    fn it_should_report_a_missing_profile_by_name() {
        let config = Config::default();
        let error = config.profile(Some("staging")).unwrap_err().to_string();
        assert!(error.contains("staging"));
    }

    #[test]
    fn it_should_ask_for_a_login_when_nothing_is_configured() {
        let config = Config::default();
        let error = config.profile(None).unwrap_err().to_string();
        assert!(error.contains("login"));
    }

    #[test]
    fn it_should_make_the_first_profile_the_current_one() {
        let mut config = Config::default();
        config.set(
            "production",
            Profile {
                url: "https://example.com".into(),
                token: "fdi_x".into(),
            },
        );
        assert_eq!(config.current.as_deref(), Some("production"));
    }

    #[test]
    fn it_should_not_move_the_current_profile_when_another_is_added() {
        let mut config = Config::default();
        config.set(
            "production",
            Profile {
                url: "https://example.com".into(),
                token: "fdi_x".into(),
            },
        );
        config.set(
            "staging",
            Profile {
                url: "https://staging.example.com".into(),
                token: "fdi_y".into(),
            },
        );
        assert_eq!(config.current.as_deref(), Some("production"));
    }

    #[test]
    fn it_should_keep_the_profiles_where_the_old_name_left_them() {
        assert_eq!(
            settled_at(
                Path::new("/home/alex/.config/fundaia/config.toml"),
                Path::new("/home/alex/.config/fundaia-infra/config.toml"),
                |path| path.ends_with("fundaia-infra/config.toml"),
            ),
            PathBuf::from("/home/alex/.config/fundaia-infra/config.toml"),
        );
    }

    #[test]
    fn it_should_use_the_current_name_on_a_machine_that_has_neither() {
        assert_eq!(
            settled_at(
                Path::new("/home/alex/.config/fundaia/config.toml"),
                Path::new("/home/alex/.config/fundaia-infra/config.toml"),
                |_| false,
            ),
            PathBuf::from("/home/alex/.config/fundaia/config.toml"),
        );
    }

    #[test]
    fn it_should_ignore_the_old_directory_once_the_new_one_exists() {
        assert_eq!(
            settled_at(
                Path::new("/home/alex/.config/fundaia/config.toml"),
                Path::new("/home/alex/.config/fundaia-infra/config.toml"),
                |_| true,
            ),
            PathBuf::from("/home/alex/.config/fundaia/config.toml"),
        );
    }
}