const MAX_TOTAL_BYTES: usize = 64 * 1024;
const MAX_TOKEN_BYTES: usize = 4096;
const MAX_PATHS: usize = 512;
pub fn classify(text: &str) -> Option<Vec<String>> {
if text.is_empty() || text.len() > MAX_TOTAL_BYTES {
return None;
}
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let mut out = Vec::new();
for line in trimmed.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line).trim_matches(' ');
if line.is_empty() {
return None;
}
classify_line(line, &mut out)?;
}
if out.is_empty() || out.len() > MAX_PATHS {
return None;
}
Some(out)
}
fn classify_line(line: &str, out: &mut Vec<String>) -> Option<()> {
let mut rest = line;
loop {
rest = rest.trim_start_matches(' ');
if rest.is_empty() {
return Some(());
}
let (token, remainder) = take_token(rest)?;
out.push(accept_token(token)?);
rest = remainder;
}
}
fn windows_path_ahead(s: &str) -> bool {
let b = s.as_bytes();
(b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/'))
|| (b.len() >= 3 && b[0] == b'\\' && b[1] == b'\\' && b[2] != b'\\')
}
fn take_token(s: &str) -> Option<(String, &str)> {
if windows_path_ahead(s) {
let end = s.find(' ').unwrap_or(s.len());
let tok = &s[..end];
if tok.chars().any(char::is_control) {
return None;
}
return Some((tok.to_string(), &s[end..]));
}
if let Some(inner) = s.strip_prefix('"') {
if windows_path_ahead(inner) {
let close = inner.find('"')?;
let tok = &inner[..close];
if tok.chars().any(char::is_control) {
return None;
}
let rest = &inner[close + 1..];
if !rest.is_empty() && !rest.starts_with(' ') {
return None;
}
return Some((tok.to_string(), rest));
}
}
take_posix_token(s)
}
fn take_posix_token(s: &str) -> Option<(String, &str)> {
#[derive(PartialEq)]
enum Mode {
Plain,
Single,
Double,
}
let mut tok = String::new();
let mut mode = Mode::Plain;
let mut chars = s.char_indices();
while let Some((i, c)) = chars.next() {
match mode {
Mode::Plain => match c {
' ' => {
if tok.is_empty() {
return None; }
return Some((tok, &s[i..]));
}
'\\' => {
let (_, next) = chars.next()?; if next.is_control() {
return None;
}
tok.push(next);
}
'\'' => mode = Mode::Single,
'"' => mode = Mode::Double,
c if c.is_control() => return None,
c => tok.push(c),
},
Mode::Single => match c {
'\'' => mode = Mode::Plain,
c if c.is_control() => return None,
c => tok.push(c),
},
Mode::Double => match c {
'"' => mode = Mode::Plain,
'\\' => {
let (_, next) = chars.next()?;
match next {
'$' | '`' | '"' | '\\' => tok.push(next),
c if c.is_control() => return None,
c => {
tok.push('\\');
tok.push(c);
}
}
}
c if c.is_control() => return None,
c => tok.push(c),
},
}
}
if mode != Mode::Plain || tok.is_empty() {
return None; }
Some((tok, ""))
}
fn accept_token(tok: String) -> Option<String> {
if tok.len() > MAX_TOKEN_BYTES {
return None;
}
if let Some(scheme_len) = url_scheme_len(&tok) {
if tok[..scheme_len].eq_ignore_ascii_case("file") {
return decode_file_url(&tok[scheme_len + 3..]);
}
return None;
}
let pathish = tok.starts_with('/')
|| (tok.starts_with("~/") && tok.len() > 2)
|| windows_path_ahead(&tok);
pathish.then_some(tok)
}
fn url_scheme_len(s: &str) -> Option<usize> {
let b = s.as_bytes();
if b.is_empty() || !b[0].is_ascii_alphabetic() {
return None;
}
let mut i = 1;
while i < b.len()
&& (b[i].is_ascii_alphanumeric() || b[i] == b'+' || b[i] == b'-' || b[i] == b'.')
{
i += 1;
}
(s[i..].starts_with("://")).then_some(i)
}
fn decode_file_url(rest: &str) -> Option<String> {
let slash = rest.find('/')?;
let (host, path) = rest.split_at(slash);
if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) {
return None;
}
let decoded = percent_decode(path)?;
if decoded.chars().any(char::is_control) {
return None;
}
let b = decoded.as_bytes();
let windows_drive = b.len() >= 3
&& b[0] == b'/'
&& b[1].is_ascii_alphabetic()
&& b[2] == b':'
&& (b.len() == 3 || b[3] == b'/' || b[3] == b'\\');
let out = if windows_drive {
decoded[1..].to_string()
} else {
decoded
};
if out.is_empty() || out.len() > MAX_TOKEN_BYTES {
return None;
}
Some(out)
}
fn percent_decode(s: &str) -> Option<String> {
if !s.contains('%') {
return Some(s.to_string());
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hi = hex_val(*bytes.get(i + 1)?)?;
let lo = hex_val(*bytes.get(i + 2)?)?;
out.push(hi * 16 + lo);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
#[path = "paste_tests.rs"]
mod tests;