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)
}
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)
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CsvRow {
pub line: usize,
pub user_name: String,
pub display_name: String,
pub email: String,
pub active: bool,
}
pub const CSV_MAX_ROWS: usize = 10_000;
pub const CSV_MAX_BYTES: usize = 4 << 20;
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));
}
}