Add <? input redirection

This tries to open the given file to use as stdin, and if it fails,
for any reason, it uses /dev/null instead.

This is useful in cases where we would otherwise do either of these:

```fish
test -r /path/to/file
and string match foo < /path/to/file

cat /path/to/file 2>/dev/null | string match foo
```

This both makes it nicer and shorter, *and* helps with TOCTTOU - what if the file is removed/changed after the check?

The reason for reading /dev/null instead of a closed fd is that a closed fd will often cause an error.

In case opening /dev/null fails, it still skips the command.
That's really a last resort for when the operating system
has turned out to be a platypus and not a unix.

Fixes #4865
This commit is contained in:
Fabian Boehm 2024-03-20 16:44:59 +01:00
parent a5156e9e0e
commit df8b9b7095
6 changed files with 86 additions and 40 deletions

View file

@ -168,6 +168,7 @@ Each stream has a number called the file descriptor (FD): 0 for stdin, 1 for std
The destination of a stream can be changed using something called *redirection*. For example, ``echo hello > output.txt``, redirects the standard output of the ``echo`` command to a text file.
- To read standard input from a file, use ``<SOURCE_FILE``.
- To read standard input from a file or /dev/null if it can't be read, use ``<?SOURCE_FILE``.
- To write standard output to a file, use ``>DESTINATION``.
- To write standard error to a file, use ``2>DESTINATION``. [#]_
- To append standard output to a file, use ``>>DESTINATION_FILE``.
@ -188,6 +189,8 @@ Any arbitrary file descriptor can be used in a redirection by prefixing the redi
- To redirect the output of descriptor N, use ``N>DESTINATION``.
- To append the output of descriptor N to a file, use ``N>>DESTINATION_FILE``.
File descriptors cannot be used with a ``<?`` input redirection, only a regular ``<`` one.
For example::
# Write `foo`'s standard error (file descriptor 2)
@ -213,6 +216,9 @@ For example::
echo stderr >&2 # <- this goes to stderr!
end >/dev/null # ignore stdout, so this prints "stderr"
# print all lines that include "foo" from myfile, or nothing if it doesn't exist.
string match '*foo*' <?myfile
It is an error to redirect a builtin, function, or block to a file descriptor above 2. However this is supported for external commands.
.. [#] Previous versions of fish also allowed specifying this as ``^DESTINATION``, but that made another character special so it was deprecated and removed. See :ref:`feature flags<featureflags>`.

View file

@ -1235,7 +1235,7 @@ impl<'s> Highlighter<'s> {
};
}
}
RedirectionMode::input => {
RedirectionMode::input | RedirectionMode::try_input => {
// Input redirections must have a readable non-directory.
target_is_valid = waccess(&target_path, R_OK) == 0
&& match wstat(&target_path) {

View file

@ -646,6 +646,35 @@ impl IoChain {
#[allow(clippy::collapsible_else_if)]
pub fn append_from_specs(&mut self, specs: &RedirectionSpecList, pwd: &wstr) -> bool {
let mut have_error = false;
let print_error = |err, target: &wstr| {
// If the error is that the file doesn't exist
// or there's a non-directory component,
// find the first problematic component for a better message.
if [ENOENT, ENOTDIR].contains(&err) {
FLOGF!(warning, FILE_ERROR, target);
let mut dname: &wstr = target;
while !dname.is_empty() {
let next: &wstr = wdirname(dname);
if let Ok(md) = wstat(next) {
if !md.is_dir() {
FLOGF!(warning, "Path '%ls' is not a directory", next);
} else {
FLOGF!(warning, "Path '%ls' does not exist", dname);
}
break;
}
dname = next;
}
} else if err != EINTR {
// If we get EINTR we had a cancel signal.
// That's expected (ctrl-c on the commandline),
// so no warning.
FLOGF!(warning, FILE_ERROR, target);
perror("open");
}
};
for spec in specs {
match spec.mode {
RedirectionMode::fd => {
@ -671,50 +700,35 @@ impl IoChain {
Err(err) => {
if oflags.intersects(OFlag::O_EXCL) && err == nix::Error::EEXIST {
FLOGF!(warning, NOCLOB_ERROR, spec.target);
} else {
} else if spec.mode != RedirectionMode::try_input {
if should_flog!(warning) {
let err = errno::errno().0;
// If the error is that the file doesn't exist
// or there's a non-directory component,
// find the first problematic component for a better message.
if [ENOENT, ENOTDIR].contains(&err) {
FLOGF!(warning, FILE_ERROR, spec.target);
let mut dname: &wstr = &spec.target;
while !dname.is_empty() {
let next: &wstr = wdirname(dname);
if let Ok(md) = wstat(next) {
if !md.is_dir() {
FLOGF!(
warning,
"Path '%ls' is not a directory",
next
);
} else {
FLOGF!(
warning,
"Path '%ls' does not exist",
dname
);
}
break;
}
dname = next;
}
} else if err != EINTR {
// If we get EINTR we had a cancel signal.
// That's expected (ctrl-c on the commandline),
// so no warning.
FLOGF!(warning, FILE_ERROR, spec.target);
perror("open");
}
print_error(errno::errno().0, &spec.target);
}
}
// If opening a file fails, insert a closed FD instead of the file redirection
// and return false. This lets execution potentially recover and at least gives
// the shell a chance to gracefully regain control of the shell (see #7038).
if spec.mode != RedirectionMode::try_input {
self.push(Arc::new(IoClose::new(spec.fd)));
have_error = true;
continue;
} else {
// If we're told to try via `<?`, we use /dev/null
match wopen_cloexec(L!("/dev/null"), oflags, OPEN_MASK) {
Ok(fd) => {
self.push(Arc::new(IoFile::new(spec.fd, fd)));
}
_ => {
// /dev/null can't be opened???
if should_flog!(warning) {
print_error(errno::errno().0, L!("/dev/null"));
}
self.push(Arc::new(IoClose::new(spec.fd)));
have_error = true;
continue;
}
}
}
}
}
}

View file

@ -11,6 +11,7 @@ pub enum RedirectionMode {
overwrite, // normal redirection: > file.txt
append, // appending redirection: >> file.txt
input, // input redirection: < file.txt
try_input, // try-input redirection: <? file.txt
fd, // fd redirection: 2>&1
noclob, // noclobber redirection: >? file.txt
}
@ -38,7 +39,7 @@ impl RedirectionMode {
RedirectionMode::append => Some(OFlag::O_CREAT | OFlag::O_APPEND | OFlag::O_WRONLY),
RedirectionMode::overwrite => Some(OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_TRUNC),
RedirectionMode::noclob => Some(OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_WRONLY),
RedirectionMode::input => Some(OFlag::O_RDONLY),
RedirectionMode::input | RedirectionMode::try_input => Some(OFlag::O_RDONLY),
_ => None,
}
}

View file

@ -990,6 +990,9 @@ impl TryFrom<&wstr> for PipeOrRedir {
consume(&mut cursor, '<');
if try_consume(&mut cursor, '&') {
result.mode = RedirectionMode::fd;
} else if try_consume(&mut cursor, '?') {
// <? foo try-input redirection (uses /dev/null if file can't be used).
result.mode = RedirectionMode::try_input;
} else {
result.mode = RedirectionMode::input;
}

View file

@ -142,3 +142,25 @@ echo "/bin/echo pipe 12 <&12 12<&-" | source 12<&0
echo foo >/bin/echo/file
#CHECKERR: warning: An error occurred while redirecting file '/bin/echo/file'
#CHECKERR: warning: Path '/bin/echo' is not a directory
echo foo <?nonexistent
#CHECK: foo
echo $status
#CHECK: 0
read -l foo <?nonexistent
echo $status
#CHECK: 1
set -S foo
#CHECK: $foo: set in local scope, unexported, with 0 elements
set -l fish (status fish-path)
$fish --no-config -c 'true <&?fail'
#CHECKERR: fish: Requested redirection to '?fail', which is not a valid file descriptor
#CHECKERR: true <&?fail
#CHECKERR: ^~~~~~^
$fish --no-config -c 'true <?&fail'
#CHECKERR: fish: Expected a string, but found a '&'
#CHECKERR: true <?&fail
#CHECKERR: ^