Skip to content

Commit 43018a6

Browse files
DarthStromeasonysliuc-tonneslan
committed
tac: read regular files instead of mmap to avoid SIGBUS on truncation
tac memory-mapped regular files. If another process truncated such a file while it was mapped (e.g. during log rotation), accessing the now-invalid pages raised SIGBUS and killed the process. Read regular files into memory up front instead, so a concurrent truncation can no longer crash tac. The stdin path had the same hole by a different route: `tac < file` mapped the raw stdin fd -- the caller's regular file -- directly. Remove that direct-stdin mmap and always route stdin through a process-owned temp file (already used to bound memory on large stdin, see #10094). The temp file is created unlinked, so no other process can truncate it and mapping it stays sound. Adds regression tests that truncate a file mid-read, via both an argument and stdin redirection, and assert tac is not killed by a signal. Fixes #9748 Co-authored-by: easonysliu <easonysliu@tencent.com> Co-authored-by: Charlie Tonneslan <cst0520@gmail.com>
1 parent eae191c commit 43018a6

2 files changed

Lines changed: 113 additions & 53 deletions

File tree

src/uu/tac/src/tac.rs

Lines changed: 34 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -352,25 +352,22 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) ->
352352
set_exit_code(1);
353353
continue;
354354
}
355-
if let Some(mmap1) = try_mmap_stdin() {
356-
mmap = mmap1;
357-
&mmap
358-
} else {
359-
// Copy stdin to a temp file (respects TMPDIR), then mmap it.
360-
// Falls back to Vec buffer if temp file creation fails (e.g., bad TMPDIR).
361-
match buffer_stdin() {
362-
Ok(StdinData::Mmap(mmap1)) => {
363-
mmap = mmap1;
364-
&mmap
365-
}
366-
Ok(StdinData::Vec(buf1)) => {
367-
buf = buf1;
368-
&buf
369-
}
370-
Err(e) => {
371-
show!(TacError::ReadError(OsString::from("stdin"), e));
372-
continue;
373-
}
355+
// Spool stdin to a temp file and mmap that (buffer_stdin explains
356+
// why mapping the temp file is sound). Mapping the raw stdin fd
357+
// would expose `tac < file` to the same truncation SIGBUS as #9748,
358+
// and the temp file also bounds memory for huge stdin (#10094).
359+
match buffer_stdin() {
360+
Ok(StdinData::Mmap(mmap1)) => {
361+
mmap = mmap1;
362+
&mmap
363+
}
364+
Ok(StdinData::Vec(buf1)) => {
365+
buf = buf1;
366+
&buf
367+
}
368+
Err(e) => {
369+
show!(TacError::ReadError(OsString::from("stdin"), e));
370+
continue;
374371
}
375372
}
376373
} else {
@@ -383,20 +380,20 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) ->
383380
}
384381
};
385382

386-
if let Some(mmap1) = try_mmap_file(&file) {
387-
mmap = mmap1;
388-
&mmap
389-
} else {
390-
let mut contents = Vec::new();
391-
match file.read_to_end(&mut contents) {
392-
Ok(_) => {
393-
buf = contents;
394-
&buf
395-
}
396-
Err(e) => {
397-
show!(TacError::ReadError(filename.clone(), e));
398-
continue;
399-
}
383+
// Read the file into memory rather than memory-mapping it: a
384+
// concurrent truncation of a mapped file raises SIGBUS and kills the
385+
// process (e.g. during log rotation; #9748). This holds the whole
386+
// file in memory, unlike the stdin path; reading seekable files
387+
// backwards in blocks could bound memory without copying (future work).
388+
let mut contents = Vec::new();
389+
match file.read_to_end(&mut contents) {
390+
Ok(_) => {
391+
buf = contents;
392+
&buf
393+
}
394+
Err(e) => {
395+
show!(TacError::ReadError(filename.clone(), e));
396+
continue;
400397
}
401398
}
402399
};
@@ -416,16 +413,6 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) ->
416413
Ok(())
417414
}
418415

419-
fn try_mmap_stdin() -> Option<Mmap> {
420-
// SAFETY: If the file is truncated while we map it, SIGBUS will be raised
421-
// and our process will be terminated, thus preventing access of invalid memory.
422-
let mmap = unsafe { Mmap::map(&stdin()).ok()? };
423-
// On Windows, mmap on a pipe handle can "succeed" but return 0 bytes
424-
// (the file size of a pipe is reported as 0). When that happens, return
425-
// None so we fall through to buffer_stdin() which reads the pipe properly.
426-
if mmap.is_empty() { None } else { Some(mmap) }
427-
}
428-
429416
enum StdinData {
430417
Mmap(Mmap),
431418
Vec(Vec<u8>),
@@ -438,8 +425,10 @@ fn buffer_stdin() -> std::io::Result<StdinData> {
438425
if let Ok(mut tmp) = tempfile::tempfile() {
439426
// Temp file created - copy stdin to it, then read back
440427
copy(&mut stdin(), &mut tmp)?;
441-
// SAFETY: If the file is truncated while we map it, SIGBUS will be raised
442-
// and our process will be terminated, thus preventing access of invalid memory.
428+
// SAFETY: `tmp` is an unlinked file owned by this process, so no other
429+
// process can open and truncate it. The mapping therefore stays valid
430+
// for its whole lifetime and cannot trigger SIGBUS (unlike mapping a
431+
// caller-provided file; see #9748).
443432
let mmap = unsafe { Mmap::map(&tmp)? };
444433
Ok(StdinData::Mmap(mmap))
445434
} else {
@@ -450,12 +439,6 @@ fn buffer_stdin() -> std::io::Result<StdinData> {
450439
}
451440
}
452441

453-
fn try_mmap_file(file: &File) -> Option<Mmap> {
454-
// SAFETY: If the file is truncated while we map it, SIGBUS will be raised
455-
// and our process will be terminated, thus preventing access of invalid memory.
456-
unsafe { Mmap::map(file).ok() }
457-
}
458-
459442
#[cfg(test)]
460443
mod tests_hybrid_flavor {
461444
use super::translate_regex_flavor;

tests/by-util/test_tac.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
//
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
5-
// spell-checker:ignore axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa EISDIR
6-
#[cfg(target_os = "linux")]
5+
// spell-checker:ignore axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa EISDIR SIGBUS mmap
6+
#[cfg(unix)]
77
use uutests::at_and_ucmd;
88
use uutests::new_ucmd;
99
use uutests::util::TestScenario;
@@ -475,3 +475,80 @@ fn test_regular_end_anchor() {
475475
.succeeds()
476476
.stdout_is("\nccc\nbbaaa\nb");
477477
}
478+
479+
/// Regression test for <https://github.com/uutils/coreutils/issues/9748>.
480+
///
481+
/// `tac` used to mmap regular files, so truncating a file mid-read raised
482+
/// SIGBUS and killed the process. It now reads files into memory up front, so a
483+
/// concurrent truncation can no longer crash it. The assertion only checks that
484+
/// no signal killed `tac`, so it is stable regardless of how the race lands.
485+
#[test]
486+
#[cfg(unix)]
487+
fn test_tac_file_truncated_during_read_does_not_crash() {
488+
use std::fs::OpenOptions;
489+
use std::time::Duration;
490+
491+
let (at, mut ucmd) = at_and_ucmd!();
492+
493+
// A large sparse file, so the read overlaps with the truncation below.
494+
// `set_len` keeps it sparse, so creation stays cheap.
495+
let name = "input";
496+
at.make_file(name).set_len(64 * 1024 * 1024).unwrap();
497+
498+
let child = ucmd.arg(name).run_no_wait();
499+
500+
// Give tac a moment to start reading, then truncate the file out from
501+
// under it.
502+
std::thread::sleep(Duration::from_millis(2));
503+
OpenOptions::new()
504+
.write(true)
505+
.open(at.plus(name))
506+
.unwrap()
507+
.set_len(0)
508+
.unwrap();
509+
510+
let result = child.wait().unwrap();
511+
assert!(
512+
result.signal().is_none(),
513+
"tac was killed by signal {:?} (SIGBUS regression, see #9748)",
514+
result.signal()
515+
);
516+
}
517+
518+
/// Companion to the test above for the `tac < file` path. `tac` used to mmap the
519+
/// raw stdin fd, exposing a redirected file to the same SIGBUS-on-truncation
520+
/// race; it now copies stdin to an unlinked temp file before mapping.
521+
#[test]
522+
#[cfg(unix)]
523+
fn test_tac_stdin_redirected_file_truncated_during_read_does_not_crash() {
524+
use std::fs::{File, OpenOptions};
525+
use std::time::Duration;
526+
527+
let (at, mut ucmd) = at_and_ucmd!();
528+
529+
// A large sparse file, so the read overlaps with the truncation below.
530+
let name = "input";
531+
at.make_file(name).set_len(64 * 1024 * 1024).unwrap();
532+
533+
// Redirect the regular file in as stdin (`tac < input`).
534+
let child = ucmd
535+
.set_stdin(File::open(at.plus(name)).unwrap())
536+
.run_no_wait();
537+
538+
// Give tac a moment to start reading, then truncate the file out from
539+
// under it.
540+
std::thread::sleep(Duration::from_millis(2));
541+
OpenOptions::new()
542+
.write(true)
543+
.open(at.plus(name))
544+
.unwrap()
545+
.set_len(0)
546+
.unwrap();
547+
548+
let result = child.wait().unwrap();
549+
assert!(
550+
result.signal().is_none(),
551+
"tac was killed by signal {:?} (SIGBUS regression, see #9748)",
552+
result.signal()
553+
);
554+
}

0 commit comments

Comments
 (0)