From ed1e1e2b95e4c04a7fba8cc69ee615ef9f5d76e7 Mon Sep 17 00:00:00 2001 From: Sichen Date: Mon, 28 Sep 2026 20:52:37 +0000 Subject: [PATCH] tar: quote member names consistently in listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use one writer for plain and verbose listings. Quote invalid UTF-8 bytes, backslashes and control characters. In C, POSIX, or when all locale variables are unset or empty, quote every non-ASCII byte: café is printed as caf\303\251. Windows keeps ordinary non-ASCII names as text regardless of locale variables. Locale precedence skips empty LC_ALL, LC_CTYPE and LANG values. Other locale names use UTF-8 quoting, so non-UTF-8 locales and some Unicode printability cases remain outside this change. --- src/uu/tar/src/operations/list.rs | 114 ++++++++++++++++++++++-- src/uu/tar/src/operations/list_tests.rs | 57 ++++++++++++ tests/by-util/test_tar.rs | 109 ++++++++++++++++++++++ 3 files changed, 272 insertions(+), 8 deletions(-) diff --git a/src/uu/tar/src/operations/list.rs b/src/uu/tar/src/operations/list.rs index 8f88b7b..89a9ab2 100644 --- a/src/uu/tar/src/operations/list.rs +++ b/src/uu/tar/src/operations/list.rs @@ -24,16 +24,17 @@ pub fn list_archive( let reader = open_archive_reader(input, compression)?; let mut archive = Archive::new(reader); let mut out = BufWriter::new(io::stdout().lock()); + let c_locale = member_name_c_locale(); for entry_result in archive.entries().map_err(TarError::CannotReadEntries)? { let entry = entry_result.map_err(TarError::CannotReadEntry)?; if verbose { - let formatted = format_verbose_entry(&entry)?; - writeln!(out, "{formatted}").map_err(TarError::Io)?; + write_verbose_entry(&entry, &mut out, c_locale)?; } else { let path = entry.path().map_err(TarError::CannotReadEntryPath)?; - writeln!(out, "{}", path.display()).map_err(TarError::Io)?; + write_member_name(&mut out, &path, c_locale).map_err(TarError::Io)?; + writeln!(out).map_err(TarError::Io)?; } } @@ -41,7 +42,105 @@ pub fn list_archive( Ok(()) } -fn format_verbose_entry(entry: &tar::Entry<'_, R>) -> Result { +fn member_name_c_locale() -> bool { + // Windows keeps ordinary non-ASCII names as text regardless of locale variables. + if cfg!(windows) { + return false; + } + member_name_c_locale_for( + std::env::var_os("LC_ALL").as_deref(), + std::env::var_os("LC_CTYPE").as_deref(), + std::env::var_os("LANG").as_deref(), + ) +} + +fn member_name_c_locale_for( + all: Option<&std::ffi::OsStr>, + ctype: Option<&std::ffi::OsStr>, + lang: Option<&std::ffi::OsStr>, +) -> bool { + let locale = [all, ctype, lang] + .into_iter() + .flatten() + .find(|value| !value.is_empty()); + locale.is_none_or(|value| value == "C" || value == "POSIX") +} + +fn plain_character(ch: char) -> bool { + // Line and paragraph separators can split a displayed member name. + !ch.is_control() && !matches!(ch, '\\' | '\u{2028}' | '\u{2029}') +} + +fn write_member_name(out: &mut impl Write, path: &Path, c_locale: bool) -> io::Result<()> { + let bytes = path.as_os_str().as_encoded_bytes(); + if bytes + .iter() + .all(|&byte| byte.is_ascii() && plain_character(byte as char)) + || (!c_locale + && std::str::from_utf8(bytes).is_ok_and(|name| name.chars().all(plain_character))) + { + return out.write_all(bytes); + } + if c_locale { + for &byte in bytes { + write_quoted_byte(out, byte)?; + } + return Ok(()); + } + for chunk in bytes.utf8_chunks() { + write_valid_text(out, chunk.valid())?; + for byte in chunk.invalid() { + write_octal(out, *byte)?; + } + } + Ok(()) +} + +fn write_valid_text(out: &mut impl Write, valid: &str) -> io::Result<()> { + for ch in valid.chars() { + if ch.is_ascii() { + write_quoted_byte(out, ch as u8)?; + } else { + let mut buffer = [0; 4]; + let bytes = ch.encode_utf8(&mut buffer).as_bytes(); + if !plain_character(ch) { + for &byte in bytes { + write_octal(out, byte)?; + } + } else { + out.write_all(bytes)?; + } + } + } + Ok(()) +} + +fn write_quoted_byte(out: &mut impl Write, byte: u8) -> io::Result<()> { + if byte.is_ascii() && plain_character(byte as char) { + return out.write_all(&[byte]); + } + match byte { + b'\\' => out.write_all(b"\\\\"), + 7 => out.write_all(b"\\a"), + 8 => out.write_all(b"\\b"), + b'\t' => out.write_all(b"\\t"), + b'\n' => out.write_all(b"\\n"), + 11 => out.write_all(b"\\v"), + 12 => out.write_all(b"\\f"), + b'\r' => out.write_all(b"\\r"), + _ => write_octal(out, byte), + } +} + +fn write_octal(out: &mut impl Write, byte: u8) -> io::Result<()> { + write!(out, "\\{byte:03o}") +} + +fn write_verbose_entry( + entry: &tar::Entry<'_, R>, + out: &mut impl Write, + c_locale: bool, +) -> Result<(), TarError> { let (mode, entry_type, owner, group, size, mtime) = { let header = entry.header(); ( @@ -83,10 +182,9 @@ fn format_verbose_entry(entry: &tar::Entry<'_, R>) -> Result8} {date_str} {}", - path.display() - )) + write!(out, "{permissions} {owner}/{group} {size:>8} {date_str} ").map_err(TarError::Io)?; + write_member_name(out, &path, c_locale).map_err(TarError::Io)?; + writeln!(out).map_err(TarError::Io) } #[cfg(test)] diff --git a/src/uu/tar/src/operations/list_tests.rs b/src/uu/tar/src/operations/list_tests.rs index 0bdb442..201692c 100644 --- a/src/uu/tar/src/operations/list_tests.rs +++ b/src/uu/tar/src/operations/list_tests.rs @@ -45,3 +45,60 @@ fn test_list_archive_with_zstd_verbose() { let input = fs::File::open(&archive_path).unwrap(); list_archive(input, &archive_path, true, CompressionMode::Zstd).unwrap(); } + +#[cfg(unix)] +#[test] +fn test_write_member_name_quoting() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let c_cases: &[(&[u8], &[u8])] = &[ + (b"plain name", b"plain name"), + (b"bad-\xff\xc3\xa9\xfe", b"bad-\\377\\303\\251\\376"), + ("é".as_bytes(), b"\\303\\251"), + ]; + let utf8_cases: &[(&[u8], &[u8])] = &[ + (b"bad-\xff", b"bad-\\377"), + (b"bad-\xc3", b"bad-\\303"), + (b"a\xffz", b"a\\377z"), + (b"\xff\xc3\xa9\xfe", "\\377é\\376".as_bytes()), + (b"x\xe2\x82", b"x\\342\\202"), + ("normal-é".as_bytes(), "normal-é".as_bytes()), + (b"bad-\\377", b"bad-\\\\377"), + (b"bad-\n\t\r\x1b", b"bad-\\n\\t\\r\\033"), + (b"\x07\x08\x0b\x0c\x7f", b"\\a\\b\\v\\f\\177"), + (b"bad-\\n\\t", b"bad-\\\\n\\\\t"), + (b"bad-\xe2\x80\xa8", b"bad-\\342\\200\\250"), + (b"bad-\xe2\x80\xa9", b"bad-\\342\\200\\251"), + ]; + for (c_locale, cases) in [(true, c_cases), (false, utf8_cases)] { + for &(input, expected) in cases { + let mut output = Vec::new(); + write_member_name(&mut output, Path::new(OsStr::from_bytes(input)), c_locale).unwrap(); + assert_eq!(output, expected, "{input:?} C={c_locale}"); + } + } +} + +#[test] +fn test_member_name_locale_precedence() { + use std::ffi::OsStr; + + for (all, ctype, lang, expected) in [ + (None, None, None, true), + (Some("POSIX"), None, None, true), + (Some("C"), Some("C.UTF-8"), Some("C"), true), + (Some(""), Some("C.UTF-8"), Some("C"), false), + (Some(""), Some(""), Some("C"), true), + (Some(""), Some(""), Some("C.UTF-8"), false), + ] { + assert_eq!( + member_name_c_locale_for( + all.map(OsStr::new), + ctype.map(OsStr::new), + lang.map(OsStr::new), + ), + expected + ); + } +} diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 23cefa3..050fc54 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -406,6 +406,115 @@ fn test_list_zstd_archive_created_outside_tar() { .stdout_contains("external.txt"); } +fn archive_with_raw_members(members: &[(&[u8], &[u8])]) -> Vec { + let mut tar_bytes = Vec::new(); + { + let mut builder = TarRsBuilder::new(&mut tar_bytes); + for &(name, content) in members { + let mut header = TarRsHeader::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.as_old_mut().name[..name.len()].copy_from_slice(name); + header.set_cksum(); + builder.append(&header, Cursor::new(content)).unwrap(); + } + builder.finish().unwrap(); + } + tar_bytes +} + +#[cfg(unix)] +#[test] +fn test_list_locale_changes_valid_utf8_member_name() { + let (at, _ucmd) = at_and_ucmd!(); + at.write_bytes( + "unicode-name.tar", + &archive_with_raw_members(&[("é".as_bytes(), b"x")]), + ); + for (locale, expected) in [("C", "\\303\\251\n"), ("C.UTF-8", "é\n")] { + for flag in ["-tf", "-tvf"] { + let output = new_ucmd!() + .args(&[flag, "unicode-name.tar"]) + .env("LC_ALL", locale) + .current_dir(at.as_string()) + .succeeds() + .stdout_str() + .to_owned(); + if flag == "-tf" { + assert_eq!(output, expected, "{locale}"); + } else { + assert_eq!(output.lines().count(), 1, "{locale}: {output:?}"); + assert!(output.ends_with(expected), "{locale}: {output:?}"); + } + } + } +} + +#[cfg(windows)] +#[test] +fn test_list_windows_keeps_non_ascii_name_with_c_locale() { + let (at, _ucmd) = at_and_ucmd!(); + at.write_bytes( + "unicode-name.tar", + &archive_with_raw_members(&[("é".as_bytes(), b"x")]), + ); + let output = new_ucmd!() + .args(&["-tf", "unicode-name.tar"]) + .current_dir(at.as_string()) + .succeeds(); + assert_eq!(output.stdout_str(), "é\n"); +} + +#[cfg(windows)] +#[test] +fn test_list_windows_quotes_newline_in_plain_and_verbose_output() { + let (at, _ucmd) = at_and_ucmd!(); + at.write_bytes( + "newline-name.tar", + &archive_with_raw_members(&[(b"line\nname", b"x")]), + ); + for flag in ["-tf", "-tvf"] { + let output = new_ucmd!() + .args(&[flag, "newline-name.tar"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_str() + .to_owned(); + if flag == "-tf" { + assert_eq!(output, "line\\nname\n"); + } else { + assert_eq!(output.lines().count(), 1, "{flag}: {output:?}"); + assert!(output.ends_with("line\\nname\n"), "{flag}: {output:?}"); + } + } +} + +#[cfg(unix)] +#[test] +fn test_plain_and_verbose_list_quote_the_same_member_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write_bytes( + "mixed-name.tar", + &archive_with_raw_members(&[(b"bad-\xff\\tab\t", b"x")]), + ); + + let plain = ucmd + .args(&["-tf", "mixed-name.tar"]) + .env("LC_ALL", "C.UTF-8") + .succeeds() + .stdout_str() + .to_owned(); + let verbose = new_ucmd!() + .args(&["-tvf", "mixed-name.tar"]) + .env("LC_ALL", "C.UTF-8") + .current_dir(at.as_string()) + .succeeds() + .stdout_str() + .to_owned(); + assert_eq!(plain, "bad-\\377\\\\tab\\t\n"); + assert!(verbose.ends_with(&plain), "verbose output: {verbose:?}"); +} + #[test] fn test_extract_zstd_archive() { let (at, mut ucmd) = at_and_ucmd!();