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) };
}
}