gunnar-grpc 2.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **The directory's words**: how a user name and a domain are compared, and
//! the CSV an account without SCIM imports.

/// A directory user name as it is compared: trimmed, lowercase, at most 256
/// characters, no control characters. `None` when nothing is left.
pub fn normalize_user_name(raw: &str) -> Option<String> {
    let name = raw.trim().to_lowercase();
    if name.is_empty() || name.chars().count() > 256 || name.chars().any(char::is_control) {
        return None;
    }
    Some(name)
}

/// An email domain as the login page compares it: lowercase, no `@`, a dot
/// inside, the characters of a host name.
pub fn normalize_domain(raw: &str) -> Option<String> {
    let domain = raw
        .trim()
        .trim_start_matches('@')
        .trim_end_matches('.')
        .to_ascii_lowercase();
    let ok = domain.len() <= 253
        && domain.contains('.')
        && !domain.starts_with('.')
        && !domain.contains("..")
        && domain
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-');
    ok.then_some(domain)
}

/// One row of a CSV import: `user_name,display_name,email,active`.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CsvRow {
    /// 1-based line number, for the error a row earns.
    pub line: usize,
    pub user_name: String,
    pub display_name: String,
    pub email: String,
    pub active: bool,
}

/// The most rows one import takes.
pub const CSV_MAX_ROWS: usize = 10_000;
/// The most bytes one import takes.
pub const CSV_MAX_BYTES: usize = 4 << 20;

/// **Parse a CSV import.** The header row names the columns (`user_name` is
/// required — `username` and `email` are read as it too; `display_name` or
/// `name`, `email` and `active` are optional, in any order); fields may be
/// double-quoted (a `""` inside is a quote). `active` reads
/// `true/false/yes/no/1/0` (empty: true). Returns the rows and one sentence
/// per row that could not be read (the import goes on without it).
pub fn parse_csv(text: &str) -> Result<(Vec<CsvRow>, Vec<String>), String> {
    if text.len() > CSV_MAX_BYTES {
        return Err(format!(
            "the CSV is larger than {} MiB",
            CSV_MAX_BYTES >> 20
        ));
    }
    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
    let mut lines = text
        .lines()
        .enumerate()
        .filter(|(_, l)| !l.trim().is_empty());
    let Some((_, header)) = lines.next() else {
        return Err("the CSV is empty: the first line names the columns (user_name,display_name,email,active)".to_owned());
    };
    let columns: Vec<String> = split_csv_line(header)?
        .into_iter()
        .map(|c| c.trim().to_ascii_lowercase())
        .collect();
    let at = |name: &str| columns.iter().position(|c| c == name);
    let Some(user_col) = at("user_name")
        .or_else(|| at("username"))
        .or_else(|| at("email"))
    else {
        return Err("the header names no `user_name` column".to_owned());
    };
    let name_col = at("display_name").or_else(|| at("name"));
    let email_col = at("email");
    let active_col = at("active");
    let mut rows = Vec::new();
    let mut errors = Vec::new();
    let mut seen = std::collections::BTreeSet::new();
    for (index, line) in lines {
        let number = index + 1;
        if rows.len() >= CSV_MAX_ROWS {
            errors.push(format!(
                "line {number}: more than {CSV_MAX_ROWS} rows; the rest are not read"
            ));
            break;
        }
        let fields = match split_csv_line(line) {
            Ok(fields) => fields,
            Err(why) => {
                errors.push(format!("line {number}: {why}"));
                continue;
            }
        };
        let get = |col: Option<usize>| {
            col.and_then(|c| fields.get(c))
                .map(|s| s.trim().to_owned())
                .unwrap_or_default()
        };
        let Some(user_name) = normalize_user_name(&get(Some(user_col))) else {
            errors.push(format!("line {number}: no user name"));
            continue;
        };
        if !seen.insert(user_name.clone()) {
            errors.push(format!("line {number}: {user_name} is named twice"));
            continue;
        }
        let active = match get(active_col).to_ascii_lowercase().as_str() {
            "" | "true" | "yes" | "1" | "active" => true,
            "false" | "no" | "0" | "inactive" => false,
            other => {
                errors.push(format!(
                    "line {number}: `active` is {other:?}, not true or false"
                ));
                continue;
            }
        };
        rows.push(CsvRow {
            line: number,
            user_name,
            display_name: get(name_col),
            email: get(email_col),
            active,
        });
    }
    Ok((rows, errors))
}

fn split_csv_line(line: &str) -> Result<Vec<String>, String> {
    let mut out = Vec::new();
    let mut field = String::new();
    let mut quoted = false;
    let mut chars = line.chars().peekable();
    let mut at_start = true;
    while let Some(c) = chars.next() {
        if quoted {
            if c == '"' {
                if chars.peek() == Some(&'"') {
                    chars.next();
                    field.push('"');
                } else {
                    quoted = false;
                }
            } else {
                field.push(c);
            }
        } else if c == '"' && at_start {
            quoted = true;
            at_start = false;
        } else if c == ',' {
            out.push(std::mem::take(&mut field));
            at_start = true;
        } else {
            field.push(c);
            at_start = false;
        }
    }
    if quoted {
        return Err("a quoted field is not closed".to_owned());
    }
    out.push(field);
    Ok(out)
}

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

    #[test]
    fn domains_and_names() {
        assert_eq!(
            normalize_domain("@ACME.example").as_deref(),
            Some("acme.example")
        );
        assert_eq!(normalize_domain("localhost"), None);
        assert_eq!(normalize_domain("a..b"), None);
        assert_eq!(
            normalize_user_name("  Bob@Acme.Example "),
            Some("bob@acme.example".to_owned())
        );
        assert_eq!(normalize_user_name(" \t "), None);
    }

    #[test]
    fn csv_reads_quotes_and_reports_bad_rows() {
        let (rows, errors) = parse_csv(
            "\u{feff}user_name,display_name,email,active\nbob@acme.example,\"Smith, Bob\",bob@acme.example,true\n\ncarol,Carol,,no\n,,,\ndave,Dave,,maybe\nBOB@acme.example,,,\n",
        )
        .unwrap();
        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].display_name, "Smith, Bob");
        assert!(!rows[1].active);
        assert_eq!(errors.len(), 3, "{errors:?}");
        assert!(parse_csv("").is_err());
        assert!(parse_csv("name,email\nx,y").is_ok());
        assert!(parse_csv("display_name\nx").is_err());
        assert!(parse_csv("user_name\n\"open").map(|(_, e)| e.len()) == Ok(1));
    }
}