fundaia 0.5.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Reading a `.env`, which is a format nobody ever specified.
//!
//! Every tool that parses one disagrees with every other about the edges, so
//! this one commits to the subset they all share and refuses to guess past it:
//! `KEY=value`, one per line, `#` starts a comment, quotes come off, and
//! `export ` in front is tolerated because half the files in the world have it.
//!
//! What it deliberately does **not** do is expand `${OTHER}`. On this platform
//! that syntax means something already — `${{ Postgres.DATABASE_URL }}` is a
//! reference the server resolves at deploy time — and a client that quietly
//! substituted a local shell variable into one would turn a reference into a
//! frozen copy of whatever this laptop happened to have exported.

use crate::api::models::VariableDraft;

/// Turns the text of a `.env` into variables, dropping what is not one.
///
/// A line that cannot be read is skipped rather than fatal: these files are
/// full of banners, blank lines and commented-out keys, and refusing the whole
/// import over one of them helps nobody.
pub fn variables_in(text: &str, secret: bool) -> Vec<VariableDraft> {
    text.lines()
        .filter_map(pair_in)
        .map(|(key, value)| VariableDraft {
            key: key.to_owned(),
            value,
            secret,
        })
        .collect()
}

fn pair_in(line: &str) -> Option<(&str, String)> {
    let line = line.trim();

    if line.is_empty() || line.starts_with('#') {
        return None;
    }

    let line = line.strip_prefix("export ").unwrap_or(line);
    let (key, value) = line.split_once('=')?;
    let key = key.trim();

    if !is_a_key(key) {
        return None;
    }

    Some((key, unquoted(value.trim())))
}

/// The names a shell will actually export: letters, digits and underscores, not
/// starting with a digit. Anything else came from a line that only looked like
/// an assignment.
fn is_a_key(key: &str) -> bool {
    !key.is_empty()
        && !key.starts_with(|c: char| c.is_ascii_digit())
        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// Takes off one matching pair of quotes, and only a matching pair.
///
/// An unquoted value keeps everything after the first `=`, including further
/// `=` signs — which is what a connection string is made of. A trailing comment
/// is only a comment on an unquoted value; inside quotes a `#` is a character
/// like any other, and passwords are full of them.
fn unquoted(value: &str) -> String {
    for quote in ['"', '\''] {
        if let Some(inner) = value
            .strip_prefix(quote)
            .and_then(|rest| rest.strip_suffix(quote))
        {
            return inner.to_owned();
        }
    }

    match value.split_once(" #") {
        Some((before, _)) => before.trim_end().to_owned(),
        None => value.to_owned(),
    }
}

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

    fn only(text: &str) -> VariableDraft {
        variables_in(text, false)
            .pop()
            .expect("the text holds exactly one variable")
    }

    #[test]
    fn it_should_read_a_plain_assignment() {
        assert_eq!(only("PORT=8080").key, "PORT");
    }

    #[test]
    fn it_should_read_the_value_of_a_plain_assignment() {
        assert_eq!(only("PORT=8080").value, "8080");
    }

    #[test]
    fn it_should_tolerate_the_export_people_write_in_front() {
        assert_eq!(only("export PORT=8080").key, "PORT");
    }

    #[test]
    fn it_should_keep_every_equals_sign_after_the_first() {
        assert_eq!(
            only("DATABASE_URL=postgres://u:p@host/db?a=b").value,
            "postgres://u:p@host/db?a=b",
        );
    }

    #[test]
    fn it_should_take_off_double_quotes() {
        assert_eq!(only(r#"GREETING="hola mundo""#).value, "hola mundo");
    }

    #[test]
    fn it_should_take_off_single_quotes() {
        assert_eq!(only("GREETING='hola mundo'").value, "hola mundo");
    }

    #[test]
    fn it_should_keep_a_hash_that_is_inside_quotes() {
        assert_eq!(only(r#"PASSWORD="a#b""#).value, "a#b");
    }

    #[test]
    fn it_should_drop_a_trailing_comment_from_an_unquoted_value() {
        assert_eq!(only("PORT=8080 # the http one").value, "8080");
    }

    #[test]
    fn it_should_skip_a_commented_line() {
        assert!(variables_in("# PORT=8080", false).is_empty());
    }

    #[test]
    fn it_should_skip_a_blank_line() {
        assert!(variables_in("\n   \n", false).is_empty());
    }

    #[test]
    fn it_should_skip_a_line_that_is_not_an_assignment() {
        assert!(variables_in("just some prose", false).is_empty());
    }

    #[test]
    fn it_should_refuse_a_key_a_shell_would_not_export() {
        assert!(variables_in("not-a-key=1", false).is_empty());
    }

    #[test]
    fn it_should_refuse_a_key_that_starts_with_a_digit() {
        assert!(variables_in("1PORT=8080", false).is_empty());
    }

    #[test]
    fn it_should_read_every_variable_in_a_file() {
        assert_eq!(variables_in("A=1\n\n# comment\nB=2\n", false).len(), 2);
    }

    #[test]
    fn it_should_mark_them_all_secret_when_asked_to() {
        assert!(variables_in("TOKEN=abc", true)[0].secret);
    }

    #[test]
    fn it_should_leave_them_plain_when_not_asked_to() {
        assert!(!variables_in("PORT=8080", false)[0].secret);
    }

    #[test]
    fn it_should_leave_a_platform_reference_exactly_as_written() {
        assert_eq!(
            only("DATABASE_URL=${{ Postgres.DATABASE_URL }}").value,
            "${{ Postgres.DATABASE_URL }}",
        );
    }
}