From 1b87d6d415a7bd374e752b1e7d224b64576d2883 Mon Sep 17 00:00:00 2001 From: Logic769 Date: Fri, 30 Oct 2026 05:51:47 +0800 Subject: [PATCH 1/5] tail: do not panic when the file is truncated while scanning backwards --- src/uu/tail/src/chunks.rs | 41 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 27c9625f8da..6f28a32e2c5 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -90,7 +90,12 @@ impl Iterator for ReverseChunks<'_> { .file .seek(SeekFrom::Current(-(block_size as i64))) .unwrap(); - self.file.read_exact(&mut buf).unwrap(); + // The file may have been truncated after its size was measured. In + // that case treat the end of the data as the start of the file instead + // of panicking; GNU tail exits successfully in the same situation. + if self.file.read_exact(&mut buf).is_err() { + return None; + } let pos2 = self .file .seek(SeekFrom::Current(-(block_size as i64))) @@ -623,7 +628,39 @@ impl LinesChunkBuffer { #[cfg(test)] mod tests { - use crate::chunks::{BUFFER_SIZE, BytesChunk}; + use crate::chunks::{BLOCK_SIZE, BUFFER_SIZE, BytesChunk, ReverseChunks}; + use std::io::Write; + + #[test] + fn test_reverse_chunks_stops_gracefully_when_file_is_truncated() { + let path = std::env::temp_dir().join(format!( + "uutils-tail-reverse-chunks-truncated-{}", + std::process::id() + )); + { + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(&vec![b'a'; BLOCK_SIZE as usize + 1]) + .unwrap(); + } + + let mut file = std::fs::File::open(&path).unwrap(); + let mut chunks = ReverseChunks::new(&mut file); + assert!(chunks.next().is_some()); + + // Truncate through a second handle while the iterator still holds its + // previously measured size, then make sure the next read stops instead + // of panicking. + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_len(0) + .unwrap(); + assert!(chunks.next().is_none()); + + drop(chunks); + let _ = std::fs::remove_file(&path); + } #[test] fn test_bytes_chunk_from_when_offset_is_zero() { From 68bc1bc0d39fab249fe2a0b969d1493bf4dec428 Mon Sep 17 00:00:00 2001 From: Logic769 Date: Fri, 30 Oct 2026 21:16:00 +0800 Subject: [PATCH 2/5] tail: keep the truncation test's file handle scoped --- src/uu/tail/src/chunks.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 6f28a32e2c5..ebf3912849e 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -643,22 +643,23 @@ mod tests { .unwrap(); } - let mut file = std::fs::File::open(&path).unwrap(); - let mut chunks = ReverseChunks::new(&mut file); - assert!(chunks.next().is_some()); - - // Truncate through a second handle while the iterator still holds its - // previously measured size, then make sure the next read stops instead - // of panicking. - std::fs::OpenOptions::new() - .write(true) - .open(&path) - .unwrap() - .set_len(0) - .unwrap(); - assert!(chunks.next().is_none()); + { + let mut file = std::fs::File::open(&path).unwrap(); + let mut chunks = ReverseChunks::new(&mut file); + assert!(chunks.next().is_some()); + + // Truncate through a second handle while the iterator still holds its + // previously measured size, then make sure the next read stops instead + // of panicking. + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_len(0) + .unwrap(); + assert!(chunks.next().is_none()); + } - drop(chunks); let _ = std::fs::remove_file(&path); } From 5315766851cabb76aaa65a21428b680935cc36bb Mon Sep 17 00:00:00 2001 From: Logic769 Date: Sat, 31 Oct 2026 06:01:08 +0800 Subject: [PATCH 3/5] tail: skip the truncation test on WASI --- src/uu/tail/src/chunks.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index ebf3912849e..23833254811 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -631,6 +631,8 @@ mod tests { use crate::chunks::{BLOCK_SIZE, BUFFER_SIZE, BytesChunk, ReverseChunks}; use std::io::Write; + // WASI has no temporary directory, so this file-backed case is skipped there. + #[cfg(not(target_os = "wasi"))] #[test] fn test_reverse_chunks_stops_gracefully_when_file_is_truncated() { let path = std::env::temp_dir().join(format!( From 24e863f20486fa0ea95cec3cfc8ad7d3c266f1dd Mon Sep 17 00:00:00 2001 From: Logic769 Date: Mon, 2 Nov 2026 10:08:56 +0800 Subject: [PATCH 4/5] fix(tail): handle truncated files during reverse scan --- Cargo.lock | 1 + src/uu/tail/Cargo.toml | 1 + src/uu/tail/src/chunks.rs | 28 ++++++++++++++++------------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a65cfbb6e7f..05ddacdf6f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4188,6 +4188,7 @@ dependencies = [ "notify", "rstest", "rustix", + "tempfile", "uucore", "windows-sys", ] diff --git a/src/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index f1a1fdbf3ee..e4a17f89a73 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -46,6 +46,7 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] rstest = { workspace = true } +tempfile = { workspace = true } [features] diagnostics = ["uucore/diagnostics"] diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 23833254811..74b859dfd24 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -12,7 +12,7 @@ use std::collections::VecDeque; use std::fs::File; -use std::io::{BufRead, Read, Seek, SeekFrom, Write}; +use std::io::{BufRead, ErrorKind, Read, Seek, SeekFrom, Write}; use uucore::error::UResult; /// When reading files in reverse in `bounded_tail`, this is the size of each @@ -90,11 +90,18 @@ impl Iterator for ReverseChunks<'_> { .file .seek(SeekFrom::Current(-(block_size as i64))) .unwrap(); - // The file may have been truncated after its size was measured. In - // that case treat the end of the data as the start of the file instead - // of panicking; GNU tail exits successfully in the same situation. - if self.file.read_exact(&mut buf).is_err() { - return None; + match self.file.read_exact(&mut buf) { + Ok(()) => {} + // The file may have been truncated after its size was measured. + // Treat the remaining bytes as the start of the file instead of + // panicking; GNU tail exits successfully in the same situation. + // Rewind so the caller prints whatever is left of the file. + Err(err) if err.kind() == ErrorKind::UnexpectedEof => { + self.file.seek(SeekFrom::Start(0)).unwrap(); + return None; + } + // Do not silently swallow real I/O errors (EIO, etc.). + Err(err) => panic!("failed to read file while scanning backwards: {err}"), } let pos2 = self .file @@ -630,15 +637,14 @@ impl LinesChunkBuffer { mod tests { use crate::chunks::{BLOCK_SIZE, BUFFER_SIZE, BytesChunk, ReverseChunks}; use std::io::Write; + use tempfile::tempdir; // WASI has no temporary directory, so this file-backed case is skipped there. #[cfg(not(target_os = "wasi"))] #[test] fn test_reverse_chunks_stops_gracefully_when_file_is_truncated() { - let path = std::env::temp_dir().join(format!( - "uutils-tail-reverse-chunks-truncated-{}", - std::process::id() - )); + let dir = tempdir().unwrap(); + let path = dir.path().join("truncated"); { let mut file = std::fs::File::create(&path).unwrap(); file.write_all(&vec![b'a'; BLOCK_SIZE as usize + 1]) @@ -661,8 +667,6 @@ mod tests { .unwrap(); assert!(chunks.next().is_none()); } - - let _ = std::fs::remove_file(&path); } #[test] From d9f7d18a0cdafc967e8007b9fe838f64e185bca0 Mon Sep 17 00:00:00 2001 From: Logic769 Date: Tue, 3 Nov 2026 08:09:24 +0800 Subject: [PATCH 5/5] tail: propagate errors from reverse chunk reads --- src/uu/tail/src/chunks.rs | 58 ++++++++++++++++++++------------------- src/uu/tail/src/tail.rs | 16 ++++++----- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 74b859dfd24..5c6f99f3fb6 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -12,7 +12,7 @@ use std::collections::VecDeque; use std::fs::File; -use std::io::{BufRead, ErrorKind, Read, Seek, SeekFrom, Write}; +use std::io::{self, BufRead, ErrorKind, Read, Seek, SeekFrom, Write}; use uucore::error::UResult; /// When reading files in reverse in `bounded_tail`, this is the size of each @@ -46,31 +46,27 @@ pub struct ReverseChunks<'a> { } impl<'a> ReverseChunks<'a> { - pub fn new(file: &'a mut File) -> Self { + pub fn new(file: &'a mut File) -> io::Result { let current = if cfg!(unix) { - file.stream_position().unwrap() + file.stream_position()? } else { 0 }; - let size = file.seek(SeekFrom::End(0)).unwrap() - current; + let size = file.seek(SeekFrom::End(0))? - current; let max_blocks_to_read = (size as f64 / BLOCK_SIZE as f64).ceil() as usize; let block_idx = 0; - ReverseChunks { + Ok(ReverseChunks { file, size, max_blocks_to_read, block_idx, - } + }) } -} - -impl Iterator for ReverseChunks<'_> { - type Item = Vec; - fn next(&mut self) -> Option { + fn next_chunk(&mut self) -> io::Result>> { // If there are no more chunks to read, terminate the iterator. if self.block_idx >= self.max_blocks_to_read { - return None; + return Ok(None); } // The chunk size is `BLOCK_SIZE` for all but the last chunk @@ -86,10 +82,7 @@ impl Iterator for ReverseChunks<'_> { // Seek backwards by the next chunk, read the full chunk into // `buf`, and then seek back to the start of the chunk again. let mut buf = vec![0; block_size as usize]; - let pos = self - .file - .seek(SeekFrom::Current(-(block_size as i64))) - .unwrap(); + let pos = self.file.seek(SeekFrom::Current(-(block_size as i64)))?; match self.file.read_exact(&mut buf) { Ok(()) => {} // The file may have been truncated after its size was measured. @@ -97,21 +90,26 @@ impl Iterator for ReverseChunks<'_> { // panicking; GNU tail exits successfully in the same situation. // Rewind so the caller prints whatever is left of the file. Err(err) if err.kind() == ErrorKind::UnexpectedEof => { - self.file.seek(SeekFrom::Start(0)).unwrap(); - return None; + self.file.seek(SeekFrom::Start(0))?; + return Ok(None); } - // Do not silently swallow real I/O errors (EIO, etc.). - Err(err) => panic!("failed to read file while scanning backwards: {err}"), + // Propagate real I/O errors (EIO, etc.) to the caller. + Err(err) => return Err(err), } - let pos2 = self - .file - .seek(SeekFrom::Current(-(block_size as i64))) - .unwrap(); + let pos2 = self.file.seek(SeekFrom::Current(-(block_size as i64)))?; assert_eq!(pos, pos2); self.block_idx += 1; - Some(buf) + Ok(Some(buf)) + } +} + +impl Iterator for ReverseChunks<'_> { + type Item = io::Result>; + + fn next(&mut self) -> Option { + self.next_chunk().transpose() } } @@ -636,7 +634,7 @@ impl LinesChunkBuffer { #[cfg(test)] mod tests { use crate::chunks::{BLOCK_SIZE, BUFFER_SIZE, BytesChunk, ReverseChunks}; - use std::io::Write; + use std::io::{Seek, Write}; use tempfile::tempdir; // WASI has no temporary directory, so this file-backed case is skipped there. @@ -653,8 +651,8 @@ mod tests { { let mut file = std::fs::File::open(&path).unwrap(); - let mut chunks = ReverseChunks::new(&mut file); - assert!(chunks.next().is_some()); + let mut chunks = ReverseChunks::new(&mut file).unwrap(); + assert!(matches!(chunks.next(), Some(Ok(_)))); // Truncate through a second handle while the iterator still holds its // previously measured size, then make sure the next read stops instead @@ -666,6 +664,10 @@ mod tests { .set_len(0) .unwrap(); assert!(chunks.next().is_none()); + + // The rewind must leave the file at the start so the caller can + // print whatever is left of the truncated file. + assert_eq!(file.stream_position().unwrap(), 0); } } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index ad794bfd392..6a6d96b9b11 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -406,16 +406,17 @@ fn forwards_thru_file( /// Iterate over bytes in the file, in reverse, until we find the /// `num_delimiters` instance of `delimiter`. The `file` is left seek'd to the /// position just after that delimiter. -fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) { +fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) -> UResult<()> { if num_delimiters == 0 { - file.seek(SeekFrom::End(0)).unwrap(); - return; + file.seek(SeekFrom::End(0))?; + return Ok(()); } // This variable counts the number of delimiters found in the file // so far (reading from the end of the file toward the beginning). let mut counter = 0; let mut first_slice = true; - for slice in ReverseChunks::new(file) { + for slice in ReverseChunks::new(file)? { + let slice = slice?; // Iterate over each byte in the slice in reverse order. let mut iter = memrchr_iter(delimiter, &slice); @@ -440,11 +441,12 @@ fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) { // cursor in the file is at the *beginning* of the // block, so seeking forward by `i + 1` bytes puts // us right after the found delimiter. - file.seek(SeekFrom::Current((i + 1) as i64)).unwrap(); - return; + file.seek(SeekFrom::Current((i + 1) as i64))?; + return Ok(()); } } } + Ok(()) } /// When tail'ing a file, we do not need to read the whole file from start to @@ -459,7 +461,7 @@ fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> { // Find the position in the file to start printing from. match &settings.mode { FilterMode::Lines(Signum::Negative(count), delimiter) => { - backwards_thru_file(file, *count, *delimiter); + backwards_thru_file(file, *count, *delimiter)?; } FilterMode::Lines(Signum::Positive(count), delimiter) if count > &1 => { let i = forwards_thru_file(file, *count - 1, *delimiter).unwrap();