Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-directory/src/csv.rs

//! **The directory's words** (moved from gunnar-grpc `sso/directory.rs`, A34
//! M-6): how a user name and a domain are compared, and the CSV an account
//! without SCIM imports.

pub use crate::names::{normalize_domain, normalize_user_name};

/// 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: THE limit (A34-44), the server's, which
/// every console uses for its upload too ([`REQUEST_MAX_BYTES`]) and refuses
/// past with [`too_large`]'s sentence.
pub const CSV_MAX_BYTES: usize = 4 << 20;

/// The most bytes a console's import request may carry: the CSV as a JSON
/// string (each byte at most two once escaped) and the envelope around it.
pub const REQUEST_MAX_BYTES: usize = 2 * CSV_MAX_BYTES + (64 << 10);

/// The named refusal of a CSV past [`CSV_MAX_BYTES`], one sentence for the
/// server and every console.
pub fn too_large() -> String {
    format!(
        "[csv_too_large] the CSV is larger than {} MiB: split it and import the parts",
        CSV_MAX_BYTES >> 20
    )
}

/// **One field of a CSV the directory writes out** (A34-45): quoted when it
/// holds a comma, a quote or a line break, and a field a spreadsheet would
/// read as a formula (a leading `=`, `+`, `-`, `@`, tab or carriage return)
/// gets a leading `'` inside the quotes, so an exported name never runs.
pub fn export_field(value: &str) -> String {
    let formula = value.starts_with(['=', '+', '-', '@', '\t', '\r']);
    let needs_quotes = formula || value.contains([',', '"', '\n', '\r']);
    if !needs_quotes {
        return value.to_owned();
    }
    let body = value.replace('"', "\"\"");
    if formula {
        format!("\"'{body}\"")
    } else {
        format!("\"{body}\"")
    }
}

/// One exported row: the fields through [`export_field`], comma-joined.
pub fn export_row(fields: &[&str]) -> String {
    fields.iter().map(|f| export_field(f)).collect::<Vec<_>>().join(",")
}

/// **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(too_large());
    }
    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;
            }
        };
        let (display_name, email) = (get(name_col), get(email_col));
        // A34-45: only the user name was checked; a display name or e-mail
        // with a control character (a newline, an escape) is refused too.
        if display_name.chars().chain(email.chars()).any(char::is_control) {
            errors.push(format!("line {number}: a display name or e-mail holds a control character"));
            continue;
        }
        rows.push(CsvRow {
            line: number,
            user_name,
            display_name,
            email,
            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 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));
    }

    /// ★ **A34-45: a display name or an e-mail with control characters is
    /// refused**, not stored: only the user name was checked, so a CSV could
    /// carry a newline or an escape into the directory (and into any export).
    #[test]
    fn a_display_name_or_email_with_control_characters_is_refused() {
        let (rows, errors) = parse_csv(
            "user_name,display_name,email\nbob,\"Bob\u{1b}[31m\",bob@acme.example\ncarol,Carol,\"carol@acme.example\u{7}\"\ndave,Dave,dave@acme.example\n",
        )
        .unwrap();
        assert_eq!(rows.iter().map(|r| r.user_name.as_str()).collect::<Vec<_>>(), vec!["dave"], "{errors:?}");
        assert_eq!(errors.len(), 2, "{errors:?}");
        assert!(errors.iter().all(|e| e.contains("control character")), "{errors:?}");
    }

    /// ★ **A34-45, the export half: a field that a spreadsheet would run is
    /// quoted and defused**, and an ordinary one is left alone.
    #[test]
    fn exported_fields_never_run_as_formulas() {
        assert_eq!(export_field("Bob"), "Bob");
        assert_eq!(export_field("Smith, Bob"), "\"Smith, Bob\"");
        assert_eq!(export_field("=HYPERLINK(\"x\")"), "\"'=HYPERLINK(\"\"x\"\")\"");
        for lead in ["+1", "-1", "@SUM(A1)", "\tx", "\rx"] {
            assert!(export_field(lead).starts_with("\"'"), "{lead:?}");
        }
        assert_eq!(export_row(&["bob", "=1+1", "a\"b"]), "bob,\"'=1+1\",\"a\"\"b\"");
    }

    /// ★ **A34-44: one limit, one named refusal.**
    #[test]
    fn a_csv_past_the_limit_is_refused_by_name() {
        let big = format!("user_name\n{}", "x".repeat(CSV_MAX_BYTES));
        let err = parse_csv(&big).unwrap_err();
        assert!(err.starts_with("[csv_too_large]"), "{err}");
        const { assert!(REQUEST_MAX_BYTES > 2 * CSV_MAX_BYTES) };
    }
}