Skip to content

Commit

Permalink
Add <? input redirection
Browse files Browse the repository at this point in the history
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
  • Loading branch information
faho committed Mar 21, 2024
1 parent a5156e9 commit df8b9b7
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 40 deletions.
6 changes: 6 additions & 0 deletions doc_src/language.rst
Expand Up @@ -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``.
Expand All @@ -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)
Expand All @@ -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>`.
Expand Down
2 changes: 1 addition & 1 deletion src/highlight.rs
Expand Up @@ -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) {
Expand Down
90 changes: 52 additions & 38 deletions src/io.rs
Expand Up @@ -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 => {
Expand All @@ -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).
self.push(Arc::new(IoClose::new(spec.fd)));
have_error = true;
continue;
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;
}
}
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/redirection.rs
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/tokenizer.rs
Expand Up @@ -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;
}
Expand Down
22 changes: 22 additions & 0 deletions tests/checks/redirect.fish
Expand Up @@ -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: ^

0 comments on commit df8b9b7

Please sign in to comment.