Skip to content

Commit b08532a

Browse files
authored
fix: eliminate TOCTOU races in ln and tac by deferring is_dir() checks (#10991)
* fix: improve error handling for file operations in tac and ln commands * fix: eliminate TOCTOU races in ln and tac by deferring is_dir() checks ln: call hard_link() first, check is_dir() only on failure for diagnostics tac: remove is_dir()/metadata() pre-checks, open file directly and let OS report errors (EISDIR, ENOENT) Closes #9450 * fix(tac): update tests for cross-platform error messages and strip_errno - Remove `(os error 28)` from test_failed_write_is_reported expectation to match the intentional strip_errno() behavior * fix(tac): improve error message formatting for file not found scenario
2 parents dc3a9a5 + e8b98bc commit b08532a

File tree

6 files changed

+57
-70
lines changed

6 files changed

+57
-70
lines changed

src/uu/ln/src/ln.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -430,24 +430,20 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> UResult<()> {
430430
if settings.symbolic {
431431
symlink(&source, dst)?;
432432
} else {
433-
// Cannot create hard link to a directory directly
434-
// We can however create hard link to a symlink that points to a directory, so long as -L is not passed
435-
if src.is_dir() && (!src.is_symlink() || settings.logical) {
436-
return Err(LnError::FailedToCreateHardLinkDir(source.to_path_buf()).into());
437-
}
438-
439433
let p = if settings.logical && source.is_symlink() {
440-
// if we want to have an hard link,
441-
// source is a symlink and -L is passed
442-
// we want to resolve the symlink to create the hardlink
443434
fs::canonicalize(&source)
444435
.map_err_context(|| translate!("ln-failed-to-access", "file" => source.quote()))?
445436
} else {
446437
source.to_path_buf()
447438
};
448-
fs::hard_link(p, dst).map_err_context(|| {
449-
translate!("ln-failed-to-create-hard-link", "source" => source.quote(), "dest" => dst.quote())
450-
})?;
439+
if let Err(e) = fs::hard_link(&p, dst) {
440+
if p.is_dir() {
441+
return Err(LnError::FailedToCreateHardLinkDir(source.to_path_buf()).into());
442+
}
443+
return Err(e).map_err_context(|| {
444+
translate!("ln-failed-to-create-hard-link", "source" => source.quote(), "dest" => dst.quote())
445+
});
446+
}
451447
}
452448

453449
if settings.verbose {

src/uu/tac/locales/en-US.ftl

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ tac-help-separator = use STRING as the separator instead of newline
66
77
# Error messages
88
tac-error-invalid-regex = invalid regular expression: { $error }
9-
tac-error-invalid-directory-argument = { $argument }: read error: Is a directory
10-
tac-error-file-not-found = failed to open { $filename } for reading: No such file or directory
11-
tac-error-read-error = failed to read from { $filename }: { $error }
9+
tac-error-open-error = failed to open { $filename } for reading: { $error }
10+
tac-error-read-error = { $filename }: read error: { $error }
1211
tac-error-write-error = failed to write to stdout: { $error }

src/uu/tac/locales/fr-FR.ftl

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ tac-help-separator = utiliser CHAÎNE comme séparateur au lieu du saut de ligne
66
77
# Messages d'erreur
88
tac-error-invalid-regex = expression régulière invalide : { $error }
9-
tac-error-file-not-found = échec de l'ouverture de { $filename } en lecture : Aucun fichier ou répertoire de ce type
10-
tac-error-read-error = échec de la lecture depuis { $filename } : { $error }
9+
tac-error-open-error = échec de l'ouverture de { $filename } en lecture : { $error }
10+
tac-error-read-error = { $filename } : erreur de lecture : { $error }
1111
tac-error-write-error = échec de l'écriture vers stdout : { $error }
12-
tac-error-invalid-directory-argument = { $argument } : erreur de lecture : Est un répertoire

src/uu/tac/src/error.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,31 @@
77
use std::ffi::OsString;
88
use thiserror::Error;
99
use uucore::display::Quotable;
10-
use uucore::error::UError;
10+
use uucore::error::{UError, strip_errno};
1111
use uucore::translate;
1212

1313
#[derive(Debug, Error)]
1414
pub enum TacError {
1515
/// A regular expression given by the user is invalid.
1616
#[error("{}", translate!("tac-error-invalid-regex", "error" => .0))]
1717
InvalidRegex(regex::Error),
18-
/// The argument to tac is a directory.
19-
#[error("{}", translate!("tac-error-invalid-directory-argument", "argument" => .0.maybe_quote()))]
20-
InvalidDirectoryArgument(OsString),
21-
/// The specified file is not found on the filesystem.
22-
#[error("{}", translate!("tac-error-file-not-found", "filename" => .0.quote()))]
23-
FileNotFound(OsString),
18+
/// An error opening a file for reading.
19+
///
20+
/// The parameters are the name of the file and the underlying
21+
/// [`std::io::Error`] that caused this error.
22+
#[error("{}", translate!("tac-error-open-error", "filename" => .0.quote(), "error" => strip_errno(.1)))]
23+
OpenError(OsString, std::io::Error),
2424
/// An error reading the contents of a file or stdin.
2525
///
2626
/// The parameters are the name of the file and the underlying
2727
/// [`std::io::Error`] that caused this error.
28-
#[error("{}", translate!("tac-error-read-error", "filename" => .0.quote(), "error" => .1))]
28+
#[error("{}", translate!("tac-error-read-error", "filename" => .0.maybe_quote(), "error" => strip_errno(.1)))]
2929
ReadError(OsString, std::io::Error),
3030
/// An error writing the (reversed) contents of a file or stdin.
3131
///
3232
/// The parameter is the underlying [`std::io::Error`] that caused
3333
/// this error.
34-
#[error("{}", translate!("tac-error-write-error", "error" => .0))]
34+
#[error("{}", translate!("tac-error-write-error", "error" => strip_errno(.0)))]
3535
WriteError(std::io::Error),
3636
}
3737

src/uu/tac/src/tac.rs

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,12 @@ use memchr::memmem;
1212
use memmap2::Mmap;
1313
use std::ffi::{OsStr, OsString};
1414
use std::io::{BufWriter, Read, Write, stdin, stdout};
15-
use std::{
16-
fs::{File, read},
17-
io::copy,
18-
path::Path,
19-
};
20-
15+
use std::{fs::File, io::copy, path::Path};
16+
#[cfg(unix)]
17+
use uucore::error::UError;
18+
use uucore::error::UResult;
2119
#[cfg(unix)]
2220
use uucore::error::set_exit_code;
23-
use uucore::error::{UError, UResult};
2421
use uucore::{format_usage, show};
2522

2623
use crate::error::TacError;
@@ -378,31 +375,26 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) ->
378375
}
379376
} else {
380377
let path = Path::new(filename);
381-
if path.is_dir() {
382-
let e: Box<dyn UError> =
383-
TacError::InvalidDirectoryArgument(filename.clone()).into();
384-
show!(e);
385-
continue;
386-
}
387-
388-
if path.metadata().is_err() {
389-
let e: Box<dyn UError> = TacError::FileNotFound(filename.clone()).into();
390-
show!(e);
391-
continue;
392-
}
378+
let mut file = match File::open(path) {
379+
Ok(f) => f,
380+
Err(e) => {
381+
show!(TacError::OpenError(filename.clone(), e));
382+
continue;
383+
}
384+
};
393385

394-
if let Some(mmap1) = try_mmap_path(path) {
386+
if let Some(mmap1) = try_mmap_file(&file) {
395387
mmap = mmap1;
396388
&mmap
397389
} else {
398-
match read(path) {
399-
Ok(buf1) => {
400-
buf = buf1;
390+
let mut contents = Vec::new();
391+
match file.read_to_end(&mut contents) {
392+
Ok(_) => {
393+
buf = contents;
401394
&buf
402395
}
403396
Err(e) => {
404-
let e: Box<dyn UError> = TacError::ReadError(filename.clone(), e).into();
405-
show!(e);
397+
show!(TacError::ReadError(filename.clone(), e));
406398
continue;
407399
}
408400
}
@@ -458,14 +450,10 @@ fn buffer_stdin() -> std::io::Result<StdinData> {
458450
}
459451
}
460452

461-
fn try_mmap_path(path: &Path) -> Option<Mmap> {
462-
let file = File::open(path).ok()?;
463-
453+
fn try_mmap_file(file: &File) -> Option<Mmap> {
464454
// SAFETY: If the file is truncated while we map it, SIGBUS will be raised
465455
// and our process will be terminated, thus preventing access of invalid memory.
466-
let mmap = unsafe { Mmap::map(&file).ok()? };
467-
468-
Some(mmap)
456+
unsafe { Mmap::map(file).ok() }
469457
}
470458

471459
#[cfg(test)]

tests/by-util/test_tac.rs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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
5+
// spell-checker:ignore axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa EISDIR
66
#[cfg(target_os = "linux")]
77
use uutests::at_and_ucmd;
88
use uutests::new_ucmd;
@@ -83,18 +83,23 @@ fn test_invalid_input() {
8383
let scene = TestScenario::new(util_name!());
8484
let at = &scene.fixtures;
8585

86-
scene
87-
.ucmd()
88-
.arg("b")
89-
.fails()
90-
.stderr_contains("failed to open 'b' for reading: No such file or directory");
86+
#[cfg(not(windows))]
87+
let not_found_err = "failed to open 'b' for reading: No such file or directory";
88+
#[cfg(windows)]
89+
let not_found_err =
90+
"failed to open 'b' for reading: The system cannot find the file specified.";
91+
92+
scene.ucmd().arg("b").fails().stderr_contains(not_found_err);
9193

9294
at.mkdir("a");
93-
scene
94-
.ucmd()
95-
.arg("a")
96-
.fails()
97-
.stderr_contains("a: read error: Is a directory");
95+
// On Unix, File::open succeeds on directories but read_to_end fails with EISDIR.
96+
// On Windows, File::open on a directory fails with "Access is denied".
97+
#[cfg(not(windows))]
98+
let dir_err = "a: read error: Is a directory";
99+
#[cfg(windows)]
100+
let dir_err = "failed to open 'a' for reading: Access is denied";
101+
102+
scene.ucmd().arg("a").fails().stderr_contains(dir_err);
98103
}
99104

100105
#[test]
@@ -387,7 +392,7 @@ fn test_failed_write_is_reported() {
387392
.pipe_in("hello")
388393
.set_stdout(std::fs::File::create("/dev/full").unwrap())
389394
.fails()
390-
.stderr_is("tac: failed to write to stdout: No space left on device (os error 28)\n");
395+
.stderr_is("tac: failed to write to stdout: No space left on device\n");
391396
}
392397

393398
#[cfg(target_os = "linux")]

0 commit comments

Comments
 (0)