Compare commits

..
2 changed files with 26 additions and 27 deletions
+3 -20
View File
@@ -148,19 +148,7 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
// Messages can embed document-controlled text (a part name, a relationship id): control
// characters other than tab are escaped and each continuation line is marked, so such text
// cannot start a line that looks like a record of its own.
let mut text = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => text.push_str("\n | "),
'\t' => text.push(c),
c if c.is_control() => text.extend(c.escape_default()),
c => text.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -458,13 +446,8 @@ mod tests {
),
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
);
// A multi-line message keeps its lines, each continuation marked; the record still ends in
// exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
// Injected line breaks and escape sequences cannot forge a record.
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
assert_eq!(line.matches('\n').count(), 2);
assert!(!line.contains('\r') && !line.contains('\x1b'));
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
+23 -7
View File
@@ -61,16 +61,23 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
files.iter().filter(|f| is_asset(f) && !patterns.iter().any(|p| glob(p, f))).cloned().collect()
}
/// `*` matches any run of characters except `/`; everything else literally.
/// `*` matches any run of characters except `/`; everything else literally. Matched with a
/// table over the pattern, so a pattern with many `*` costs pattern length times name length
/// rather than exploring every way to split the name.
fn glob(pat: &str, s: &str) -> bool {
match pat.split_once('*') {
None => pat == s,
Some((head, tail)) => {
let Some(rest) = s.strip_prefix(head) else { return false };
let ends = rest.char_indices().map(|(i, _)| i).chain(std::iter::once(rest.len()));
ends.take_while(|&i| !rest[..i].contains('/')).any(|i| glob(tail, &rest[i..]))
let t: Vec<char> = s.chars().collect();
// `row[k]`: the pattern so far matches the first k characters of the name.
let mut row = vec![false; t.len() + 1];
row[0] = true;
for pc in pat.chars() {
let mut next = vec![false; t.len() + 1];
next[0] = pc == '*' && row[0];
for k in 1..=t.len() {
next[k] = if pc == '*' { row[k] || (next[k - 1] && t[k - 1] != '/') } else { row[k - 1] && t[k - 1] == pc };
}
row = next;
}
row[t.len()]
}
/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters).
@@ -120,6 +127,15 @@ mod tests {
assert_eq!(missing(&["assets/é.png".into()], "`assets/*.svg`"), vec!["assets/é.png".to_string()]);
}
#[test]
fn many_wildcards_on_a_failing_name_finish_at_once() {
let pat = format!("{}b", "a*".repeat(40));
assert!(!glob(&pat, &"a".repeat(60)));
assert!(glob(&pat, &format!("{}b", "a".repeat(60))));
assert!(!glob("a*b", "a/b"));
assert!(glob("a*b", "ab"));
}
#[test]
fn ls_files_output_is_nul_separated_and_unquoted() {
let names = parse_ls_files("assets/zażółć.png\0docs/images/a b\nc.png\0".as_bytes());