Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 106 additions & 8 deletions src/uu/tar/src/operations/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,123 @@ 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)?;
}
}

out.flush().map_err(TarError::Io)?;
Ok(())
}

fn format_verbose_entry<R: Read>(entry: &tar::Entry<'_, R>) -> Result<String, TarError> {
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<R: Read>(
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();
(
Expand Down Expand Up @@ -83,10 +182,9 @@ fn format_verbose_entry<R: Read>(entry: &tar::Entry<'_, R>) -> Result<String, Ta
.unwrap_or_else(Utc::now);
let date_str = dt.format("%Y-%m-%d %H:%M");

Ok(format!(
"{permissions} {owner}/{group} {size:>8} {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)]
Expand Down
57 changes: 57 additions & 0 deletions src/uu/tar/src/operations/list_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
109 changes: 109 additions & 0 deletions tests/by-util/test_tar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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!();
Expand Down
Loading