implemented '<>' redirects, and the 'seek' builtin

'seek' is a wrapper around the lseek() syscall

added noclobber to core shopts and implemented '>|' redirection syntax

properly implemented fd close syntax

fixed saved fds being leaked into exec'd programs
This commit is contained in:
2026-03-14 20:04:20 -04:00
parent 5173e1908d
commit 9bd9c66b92
11 changed files with 576 additions and 162 deletions

View File

@@ -24,13 +24,14 @@ pub mod source;
pub mod test; // [[ ]] thing pub mod test; // [[ ]] thing
pub mod trap; pub mod trap;
pub mod varcmds; pub mod varcmds;
pub mod seek;
pub const BUILTINS: [&str; 49] = [ pub const BUILTINS: [&str; 50] = [
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin", "disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek"
]; ];
pub fn true_builtin() -> ShResult<()> { pub fn true_builtin() -> ShResult<()> {

253
src/builtin/seek.rs Normal file
View File

@@ -0,0 +1,253 @@
use nix::{libc::STDOUT_FILENO, unistd::{Whence, lseek, write}};
use crate::{getopt::{Opt, OptSpec, get_opts_from_tokens}, libsh::error::{ShErr, ShErrKind, ShResult}, parse::{NdRule, Node, execute::prepare_argv}, procio::borrow_fd, state};
pub const LSEEK_OPTS: [OptSpec;2] = [
OptSpec {
opt: Opt::Short('c'),
takes_arg: false
},
OptSpec {
opt: Opt::Short('e'),
takes_arg: false
},
];
pub struct LseekOpts {
cursor_rel: bool,
end_rel: bool
}
pub fn seek(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
argv,
} = node.class else { unreachable!() };
let (argv, opts) = get_opts_from_tokens(argv, &LSEEK_OPTS)?;
let lseek_opts = get_lseek_opts(opts)?;
let mut argv = prepare_argv(argv)?.into_iter();
argv.next(); // drop 'seek'
let Some(fd) = argv.next() else {
return Err(ShErr::simple(
ShErrKind::ExecFail,
"lseek: Missing required argument 'fd'",
));
};
let Ok(fd) = fd.0.parse::<u32>() else {
return Err(ShErr::at(
ShErrKind::ExecFail,
fd.1,
"Invalid file descriptor",
).with_note("file descriptors are integers"));
};
let Some(offset) = argv.next() else {
return Err(ShErr::simple(
ShErrKind::ExecFail,
"lseek: Missing required argument 'offset'",
));
};
let Ok(offset) = offset.0.parse::<i64>() else {
return Err(ShErr::at(
ShErrKind::ExecFail,
offset.1,
"Invalid offset",
).with_note("offset can be a positive or negative integer"));
};
let whence = if lseek_opts.cursor_rel {
Whence::SeekCur
} else if lseek_opts.end_rel {
Whence::SeekEnd
} else {
Whence::SeekSet
};
match lseek(fd as i32, offset, whence) {
Ok(new_offset) => {
let stdout = borrow_fd(STDOUT_FILENO);
let buf = new_offset.to_string() + "\n";
write(stdout, buf.as_bytes())?;
}
Err(e) => {
state::set_status(1);
return Err(e.into())
}
}
state::set_status(0);
Ok(())
}
pub fn get_lseek_opts(opts: Vec<Opt>) -> ShResult<LseekOpts> {
let mut lseek_opts = LseekOpts {
cursor_rel: false,
end_rel: false,
};
for opt in opts {
match opt {
Opt::Short('c') => lseek_opts.cursor_rel = true,
Opt::Short('e') => lseek_opts.end_rel = true,
_ => {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("lseek: Unexpected flag '{opt}'"),
));
}
}
}
Ok(lseek_opts)
}
#[cfg(test)]
mod tests {
use crate::testutil::{TestGuard, test_input};
use pretty_assertions::assert_eq;
#[test]
fn seek_set_beginning() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 0").unwrap();
let out = g.read_output();
assert_eq!(out, "0\n");
}
#[test]
fn seek_set_offset() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
let out = g.read_output();
assert_eq!(out, "6\n");
}
#[test]
fn seek_then_read() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
// Clear the seek output
g.read_output();
test_input("read line <&9").unwrap();
let val = crate::state::read_vars(|v| v.get_var("line"));
assert_eq!(val, "world");
}
#[test]
fn seek_cur_relative() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "abcdefghij\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 3").unwrap();
test_input("seek -c 9 4").unwrap();
let out = g.read_output();
assert_eq!(out, "3\n7\n");
}
#[test]
fn seek_end() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello\n").unwrap(); // 6 bytes
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek -e 9 0").unwrap();
let out = g.read_output();
assert_eq!(out, "6\n");
}
#[test]
fn seek_end_negative() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello\n").unwrap(); // 6 bytes
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek -e 9 -2").unwrap();
let out = g.read_output();
assert_eq!(out, "4\n");
}
#[test]
fn seek_write_overwrite() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let _g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
test_input("echo -n 'WORLD' >&9").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "hello WORLD\n");
}
#[test]
fn seek_rewind_full_read() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "abc\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
// Read moves cursor to EOF
test_input("read line <&9").unwrap();
// Rewind
test_input("seek 9 0").unwrap();
// Clear output from seek
g.read_output();
// Read again from beginning
test_input("read line <&9").unwrap();
let val = crate::state::read_vars(|v| v.get_var("line"));
assert_eq!(val, "abc");
}
#[test]
fn seek_bad_fd() {
let _g = TestGuard::new();
let result = test_input("seek 99 0");
assert!(result.is_err());
}
#[test]
fn seek_missing_args() {
let _g = TestGuard::new();
let result = test_input("seek");
assert!(result.is_err());
let result = test_input("seek 9");
assert!(result.is_err());
}
}

View File

@@ -201,6 +201,7 @@ impl ShErr {
pub fn is_flow_control(&self) -> bool { pub fn is_flow_control(&self) -> bool {
self.kind.is_flow_control() self.kind.is_flow_control()
} }
/// Promotes a shell error from a simple error to an error that blames a span
pub fn promote(mut self, span: Span) -> Self { pub fn promote(mut self, span: Span) -> Self {
if self.notes.is_empty() { if self.notes.is_empty() {
return self; return self;
@@ -208,7 +209,9 @@ impl ShErr {
let first = self.notes[0].clone(); let first = self.notes[0].clone();
if self.notes.len() > 1 { if self.notes.len() > 1 {
self.notes = self.notes[1..].to_vec(); self.notes = self.notes[1..].to_vec();
} } else {
self.notes = vec![];
}
self.labeled(span, first) self.labeled(span, first)
} }

View File

@@ -147,11 +147,9 @@ impl RawModeGuard {
let orig = ORIG_TERMIOS let orig = ORIG_TERMIOS
.with(|cell| cell.borrow().clone()) .with(|cell| cell.borrow().clone())
.expect("with_cooked_mode called before raw_mode()"); .expect("with_cooked_mode called before raw_mode()");
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig) tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig).ok();
.expect("Failed to restore cooked mode");
let res = f(); let res = f();
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &current) tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &current).ok();
.expect("Failed to restore raw mode");
res res
} }
} }

View File

@@ -2,6 +2,14 @@ use std::sync::LazyLock;
use crate::prelude::*; use crate::prelude::*;
/// Minimum fd number for shell-internal file descriptors.
const MIN_INTERNAL_FD: RawFd = 10;
pub static TTY_FILENO: LazyLock<RawFd> = LazyLock::new(|| { pub static TTY_FILENO: LazyLock<RawFd> = LazyLock::new(|| {
open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty") let fd = open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty");
// Move the tty fd above the user-accessible range so that
// `exec 3>&-` and friends don't collide with shell internals.
let high = fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).expect("Failed to dup /dev/tty high");
close(fd).ok();
high
}); });

View File

@@ -8,28 +8,7 @@ use ariadne::Fmt;
use crate::{ use crate::{
builtin::{ builtin::{
alias::{alias, unalias}, alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, seek::seek, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}
arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate},
autocmd::autocmd,
cd::cd,
complete::{compgen_builtin, complete_builtin},
dirstack::{dirs, popd, pushd},
echo::echo,
eval, exec,
flowctl::flowctl,
getopts::getopts,
intro,
jobctl::{self, JobBehavior, continue_job, disown, jobs},
keymap, map,
pwd::pwd,
read::{self, read_builtin},
resource::{ulimit, umask_builtin},
shift::shift,
shopt::shopt,
source::source,
test::double_bracket_test,
trap::{TrapTarget, trap},
varcmds::{export, local, readonly, unset},
}, },
expand::{expand_aliases, expand_case_pattern, glob_to_regex}, expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
@@ -888,7 +867,10 @@ impl Dispatcher {
if fork_builtins { if fork_builtins {
log::trace!("Forking builtin: {}", cmd_raw); log::trace!("Forking builtin: {}", cmd_raw);
let _guard = self.io_stack.pop_frame().redirect()?; let guard = self.io_stack.pop_frame().redirect()?;
if cmd_raw.as_str() == "exec" {
guard.persist();
}
self.run_fork(&cmd_raw, |s| { self.run_fork(&cmd_raw, |s| {
if let Err(e) = s.dispatch_builtin(cmd) { if let Err(e) = s.dispatch_builtin(cmd) {
e.print_error(); e.print_error();
@@ -1013,6 +995,7 @@ impl Dispatcher {
"autocmd" => autocmd(cmd), "autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd), "ulimit" => ulimit(cmd),
"umask" => umask_builtin(cmd), "umask" => umask_builtin(cmd),
"seek" => seek(cmd),
"true" | ":" => { "true" | ":" => {
state::set_status(0); state::set_status(0);
Ok(()) Ok(())

View File

@@ -411,37 +411,51 @@ impl LexStream {
return None; // It's a process sub return None; // It's a process sub
} }
pos += 1; pos += 1;
if let Some('|') = chars.peek() {
// noclobber force '>|'
chars.next();
pos += 1;
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break
}
if let Some('>') = chars.peek() { if let Some('>') = chars.peek() {
chars.next(); chars.next();
pos += 1; pos += 1;
} }
if let Some('&') = chars.peek() { let Some('&') = chars.peek() else {
chars.next();
pos += 1;
let mut found_fd = false;
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir); tk = self.get_token(self.cursor..pos, TkRule::Redir);
break; break;
} };
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
} }
'<' => { '<' => {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
@@ -449,53 +463,92 @@ impl LexStream {
} }
pos += 1; pos += 1;
if let Some('<') = chars.peek() { match chars.peek() {
chars.next(); Some('<') => {
pos += 1; chars.next();
pos += 1;
match chars.peek() { match chars.peek() {
Some('<') => { Some('<') => {
chars.next(); chars.next();
pos += 1; pos += 1;
}
Some(ch) => {
let mut ch = *ch;
while is_field_sep(ch) {
let Some(next_ch) = chars.next() else {
// Incomplete input — fall through to emit << as Redir
break;
};
pos += next_ch.len_utf8();
ch = next_ch;
} }
if is_field_sep(ch) { Some(ch) => {
// Ran out of input while skipping whitespace — fall through let mut ch = *ch;
} else { while is_field_sep(ch) {
let saved_cursor = self.cursor; let Some(next_ch) = chars.next() else {
match self.read_heredoc(pos) { // Incomplete input — fall through to emit << as Redir
Ok(Some(heredoc_tk)) => {
// cursor is set to after the delimiter word;
// heredoc_skip is set to after the body
pos = self.cursor;
self.cursor = saved_cursor;
tk = heredoc_tk;
break; break;
};
pos += next_ch.len_utf8();
ch = next_ch;
}
if is_field_sep(ch) {
// Ran out of input while skipping whitespace — fall through
} else {
let saved_cursor = self.cursor;
match self.read_heredoc(pos) {
Ok(Some(heredoc_tk)) => {
// cursor is set to after the delimiter word;
// heredoc_skip is set to after the body
pos = self.cursor;
self.cursor = saved_cursor;
tk = heredoc_tk;
break;
}
Ok(None) => {
// Incomplete heredoc — restore cursor and fall through
self.cursor = saved_cursor;
}
Err(e) => return Some(Err(e)),
} }
Ok(None) => {
// Incomplete heredoc — restore cursor and fall through
self.cursor = saved_cursor;
}
Err(e) => return Some(Err(e)),
} }
} }
} _ => {
_ => { // No delimiter yet — input is incomplete
// No delimiter yet — input is incomplete // Fall through to emit the << as a Redir token
// Fall through to emit the << as a Redir token }
} }
} }
Some('>') => {
chars.next();
pos += 1;
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
Some('&') => {
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
}
_ => {}
} }
tk = self.get_token(self.cursor..pos, TkRule::Redir); tk = self.get_token(self.cursor..pos, TkRule::Redir);
@@ -1049,11 +1102,10 @@ impl Iterator for LexStream {
// If a heredoc was parsed on this line, skip past the body // If a heredoc was parsed on this line, skip past the body
// Only on newline — ';' is a command separator within the same line // Only on newline — ';' is a command separator within the same line
if ch == '\n' || ch == '\r' { if (ch == '\n' || ch == '\r')
if let Some(skip) = self.heredoc_skip.take() { && let Some(skip) = self.heredoc_skip.take() {
self.cursor = skip; self.cursor = skip;
} }
}
while let Some(ch) = get_char(&self.source, self.cursor) { while let Some(ch) = get_char(&self.source, self.cursor) {
match ch { match ch {

View File

@@ -12,7 +12,7 @@ use crate::{
}, },
parse::lex::clean_input, parse::lex::clean_input,
prelude::*, prelude::*,
procio::IoMode, procio::IoMode, state::read_shopts,
}; };
pub mod execute; pub mod execute;
@@ -280,12 +280,17 @@ bitflags! {
pub struct Redir { pub struct Redir {
pub io_mode: IoMode, pub io_mode: IoMode,
pub class: RedirType, pub class: RedirType,
pub span: Option<Span>
} }
impl Redir { impl Redir {
pub fn new(io_mode: IoMode, class: RedirType) -> Self { pub fn new(io_mode: IoMode, class: RedirType) -> Self {
Self { io_mode, class } Self { io_mode, class, span: None }
} }
pub fn with_span(mut self, span: Span) -> Self {
self.span = Some(span);
self
}
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -293,6 +298,7 @@ pub struct RedirBldr {
pub io_mode: Option<IoMode>, pub io_mode: Option<IoMode>,
pub class: Option<RedirType>, pub class: Option<RedirType>,
pub tgt_fd: Option<RawFd>, pub tgt_fd: Option<RawFd>,
pub span: Option<Span>,
} }
impl RedirBldr { impl RedirBldr {
@@ -300,43 +306,36 @@ impl RedirBldr {
Default::default() Default::default()
} }
pub fn with_io_mode(self, io_mode: IoMode) -> Self { pub fn with_io_mode(self, io_mode: IoMode) -> Self {
let Self { Self {
io_mode: _, io_mode: Some(io_mode),
class, ..self
tgt_fd, }
} = self;
Self {
io_mode: Some(io_mode),
class,
tgt_fd,
}
} }
pub fn with_class(self, class: RedirType) -> Self { pub fn with_class(self, class: RedirType) -> Self {
let Self { Self {
io_mode, class: Some(class),
class: _, ..self
tgt_fd, }
} = self;
Self {
io_mode,
class: Some(class),
tgt_fd,
}
} }
pub fn with_tgt(self, tgt_fd: RawFd) -> Self { pub fn with_tgt(self, tgt_fd: RawFd) -> Self {
let Self { Self {
io_mode, tgt_fd: Some(tgt_fd),
class, ..self
tgt_fd: _, }
} = self;
Self {
io_mode,
class,
tgt_fd: Some(tgt_fd),
}
} }
pub fn with_span(self, span: Span) -> Self {
Self {
span: Some(span),
..self
}
}
pub fn build(self) -> Redir { pub fn build(self) -> Redir {
Redir::new(self.io_mode.unwrap(), self.class.unwrap()) let new = Redir::new(self.io_mode.unwrap(), self.class.unwrap());
if let Some(span) = self.span {
new.with_span(span)
} else {
new
}
} }
} }
@@ -355,16 +354,24 @@ impl FromStr for RedirBldr {
if let Some('>') = chars.peek() { if let Some('>') = chars.peek() {
chars.next(); chars.next();
redir = redir.with_class(RedirType::Append); redir = redir.with_class(RedirType::Append);
} } else if let Some('|') = chars.peek() {
chars.next();
redir = redir.with_class(RedirType::OutputForce);
}
} }
'<' => { '<' => {
redir = redir.with_class(RedirType::Input); redir = redir.with_class(RedirType::Input);
let mut count = 0; let mut count = 0;
while count < 2 && matches!(chars.peek(), Some('<')) { if chars.peek() == Some(&'>') {
chars.next(); chars.next(); // consume the '>'
count += 1; redir = redir.with_class(RedirType::ReadWrite);
} } else {
while count < 2 && matches!(chars.peek(), Some('<')) {
chars.next();
count += 1;
}
}
redir = match count { redir = match count {
1 => redir.with_class(RedirType::HereDoc), 1 => redir.with_class(RedirType::HereDoc),
@@ -373,13 +380,18 @@ impl FromStr for RedirBldr {
}; };
} }
'&' => { '&' => {
while let Some(next_ch) = chars.next() { if chars.peek() == Some(&'-') {
if next_ch.is_ascii_digit() { chars.next();
src_fd.push(next_ch) src_fd.push('-');
} else { } else {
break; while let Some(next_ch) = chars.next() {
} if next_ch.is_ascii_digit() {
} src_fd.push(next_ch)
} else {
break;
}
}
}
if src_fd.is_empty() { if src_fd.is_empty() {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::ParseErr, ShErrKind::ParseErr,
@@ -405,15 +417,20 @@ impl FromStr for RedirBldr {
} }
} }
// FIXME: I am 99.999999999% sure that tgt_fd and src_fd are backwards here
let tgt_fd = tgt_fd let tgt_fd = tgt_fd
.parse::<i32>() .parse::<i32>()
.unwrap_or_else(|_| match redir.class.unwrap() { .unwrap_or_else(|_| match redir.class.unwrap() {
RedirType::Input | RedirType::HereDoc | RedirType::HereString => 0, RedirType::Input |
RedirType::ReadWrite |
RedirType::HereDoc |
RedirType::HereString => 0,
_ => 1, _ => 1,
}); });
redir = redir.with_tgt(tgt_fd); redir = redir.with_tgt(tgt_fd);
if let Ok(src_fd) = src_fd.parse::<i32>() { if src_fd.as_str() == "-" {
let io_mode = IoMode::Close { tgt_fd };
redir = redir.with_io_mode(io_mode);
} else if let Ok(src_fd) = src_fd.parse::<i32>() {
let io_mode = IoMode::fd(tgt_fd, src_fd); let io_mode = IoMode::fd(tgt_fd, src_fd);
redir = redir.with_io_mode(io_mode); redir = redir.with_io_mode(io_mode);
} }
@@ -424,6 +441,7 @@ impl FromStr for RedirBldr {
impl TryFrom<Tk> for RedirBldr { impl TryFrom<Tk> for RedirBldr {
type Error = ShErr; type Error = ShErr;
fn try_from(tk: Tk) -> Result<Self, Self::Error> { fn try_from(tk: Tk) -> Result<Self, Self::Error> {
let span = tk.span.clone();
if tk.flags.contains(TkFlags::IS_HEREDOC) { if tk.flags.contains(TkFlags::IS_HEREDOC) {
let flags = tk.flags; let flags = tk.flags;
let mut heredoc_body = if flags.contains(TkFlags::LIT_HEREDOC) { let mut heredoc_body = if flags.contains(TkFlags::LIT_HEREDOC) {
@@ -466,10 +484,14 @@ impl TryFrom<Tk> for RedirBldr {
Ok(RedirBldr { Ok(RedirBldr {
io_mode: Some(IoMode::loaded_pipe(0, heredoc_body.as_bytes())?), io_mode: Some(IoMode::loaded_pipe(0, heredoc_body.as_bytes())?),
class: Some(RedirType::HereDoc), class: Some(RedirType::HereDoc),
tgt_fd: Some(0) tgt_fd: Some(0),
span: Some(span)
}) })
} else { } else {
Self::from_str(tk.as_str()) match Self::from_str(tk.as_str()) {
Ok(bldr) => Ok(bldr.with_span(span)),
Err(e) => Err(e.promote(span)),
}
} }
} }
} }
@@ -481,10 +503,12 @@ pub enum RedirType {
PipeAnd, // |&, redirs stderr and stdout PipeAnd, // |&, redirs stderr and stdout
Input, // < Input, // <
Output, // > Output, // >
OutputForce,// >|
Append, // >> Append, // >>
HereDoc, // << HereDoc, // <<
IndentHereDoc, // <<-, strips leading tabs IndentHereDoc, // <<-, strips leading tabs
HereString, // <<< HereString, // <<<
ReadWrite, // <>, fd is opened for reading and writing
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -1881,11 +1905,34 @@ pub fn get_redir_file<P: AsRef<Path>>(class: RedirType, path: P) -> ShResult<Fil
let path = path.as_ref(); let path = path.as_ref();
let result = match class { let result = match class {
RedirType::Input => OpenOptions::new().read(true).open(Path::new(&path)), RedirType::Input => OpenOptions::new().read(true).open(Path::new(&path)),
RedirType::Output => OpenOptions::new() RedirType::Output => {
.write(true) if read_shopts(|o| o.core.noclobber) && path.is_file() {
.create(true) return Err(ShErr::simple(
.truncate(true) ShErrKind::ExecFail,
.open(path), format!("shopt core.noclobber is set, refusing to overwrite existing file `{}`", path.display()),
));
}
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
},
RedirType::ReadWrite => {
OpenOptions::new()
.write(true)
.read(true)
.create(true)
.truncate(false)
.open(path)
}
RedirType::OutputForce => {
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
}
RedirType::Append => OpenOptions::new().create(true).append(true).open(path), RedirType::Append => OpenOptions::new().create(true).append(true).open(path),
_ => unimplemented!("Unimplemented redir type: {:?}", class), _ => unimplemented!("Unimplemented redir type: {:?}", class),
}; };

View File

@@ -19,7 +19,7 @@ pub use std::os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd,
pub use bitflags::bitflags; pub use bitflags::bitflags;
pub use nix::{ pub use nix::{
errno::Errno, errno::Errno,
fcntl::{OFlag, open}, fcntl::{FcntlArg, OFlag, fcntl, open},
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}, libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
sys::{ sys::{
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal}, signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},

View File

@@ -8,6 +8,7 @@ use crate::{
expand::Expander, expand::Expander,
libsh::{ libsh::{
error::{ShErr, ShErrKind, ShResult}, error::{ShErr, ShErrKind, ShResult},
sys::TTY_FILENO,
utils::RedirVecUtils, utils::RedirVecUtils,
}, },
parse::{Redir, RedirType, get_redir_file, lex::TkFlags}, parse::{Redir, RedirType, get_redir_file, lex::TkFlags},
@@ -17,6 +18,16 @@ use crate::{
// Credit to fish-shell for many of the implementation ideas present in this // Credit to fish-shell for many of the implementation ideas present in this
// module https://fishshell.com/ // module https://fishshell.com/
/// Minimum fd number for shell-internal file descriptors.
/// User-visible fds (0-9) are kept clear so `exec 3>&-` etc. work as expected.
const MIN_INTERNAL_FD: RawFd = 10;
/// Like `dup()`, but places the new fd at `MIN_INTERNAL_FD` or above so it
/// doesn't collide with user-managed fds.
fn dup_high(fd: RawFd) -> nix::Result<RawFd> {
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum IoMode { pub enum IoMode {
Fd { Fd {
@@ -84,9 +95,16 @@ impl IoMode {
let expanded_pathbuf = PathBuf::from(expanded_path); let expanded_pathbuf = PathBuf::from(expanded_path);
let file = get_redir_file(mode, expanded_pathbuf)?; let file = get_redir_file(mode, expanded_pathbuf)?;
// Move the opened fd above the user-accessible range so it never
// collides with the target fd (e.g. `3>/tmp/foo` where open() returns 3,
// causing dup2(3,3) to be a no-op and then OwnedFd drop closes it).
let raw = file.as_raw_fd();
let high = fcntl(raw, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD))
.map_err(ShErr::from)?;
drop(file); // closes the original low fd
self = IoMode::OpenedFile { self = IoMode::OpenedFile {
tgt_fd, tgt_fd,
file: Arc::new(OwnedFd::from(file)), file: Arc::new(unsafe { OwnedFd::from_raw_fd(high) }),
} }
} }
Ok(self) Ok(self)
@@ -210,23 +228,53 @@ impl<'e> IoFrame {
) )
} }
pub fn save(&'e mut self) { pub fn save(&'e mut self) {
let saved_in = dup(STDIN_FILENO).unwrap(); let saved_in = dup_high(STDIN_FILENO).unwrap();
let saved_out = dup(STDOUT_FILENO).unwrap(); let saved_out = dup_high(STDOUT_FILENO).unwrap();
let saved_err = dup(STDERR_FILENO).unwrap(); let saved_err = dup_high(STDERR_FILENO).unwrap();
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err)); self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
} }
pub fn redirect(mut self) -> ShResult<RedirGuard> { pub fn redirect(mut self) -> ShResult<RedirGuard> {
self.save(); self.save();
if let Err(e) = self.apply_redirs() {
// Restore saved fds before propagating the error so they don't leak.
self.restore().ok();
return Err(e);
}
Ok(RedirGuard::new(self))
}
fn apply_redirs(&mut self) -> ShResult<()> {
for redir in &mut self.redirs { for redir in &mut self.redirs {
let io_mode = &mut redir.io_mode; let io_mode = &mut redir.io_mode;
if let IoMode::Close { tgt_fd } = io_mode {
if *tgt_fd == *TTY_FILENO {
// Don't let user close the shell's tty fd.
continue;
}
close(*tgt_fd).ok();
continue;
}
if let IoMode::File { .. } = io_mode { if let IoMode::File { .. } = io_mode {
*io_mode = io_mode.clone().open_file()?; match io_mode.clone().open_file() {
Ok(file) => *io_mode = file,
Err(e) => {
if let Some(span) = redir.span.as_ref() {
return Err(e.promote(span.clone()));
}
return Err(e)
}
}
}; };
let tgt_fd = io_mode.tgt_fd(); let tgt_fd = io_mode.tgt_fd();
let src_fd = io_mode.src_fd(); let src_fd = io_mode.src_fd();
dup2(src_fd, tgt_fd)?; if let Err(e) = dup2(src_fd, tgt_fd) {
if let Some(span) = redir.span.as_ref() {
return Err(ShErr::from(e).promote(span.clone()));
} else {
return Err(e.into());
}
}
} }
Ok(RedirGuard::new(self)) Ok(())
} }
pub fn restore(&mut self) -> ShResult<()> { pub fn restore(&mut self) -> ShResult<()> {
if let Some(saved) = self.saved_io.take() { if let Some(saved) = self.saved_io.take() {
@@ -338,6 +386,8 @@ pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
} }
type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>; type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>;
/// An iterator that lazily creates a specific number of pipes.
pub struct PipeGenerator { pub struct PipeGenerator {
num_cmds: usize, num_cmds: usize,
cursor: usize, cursor: usize,

View File

@@ -146,6 +146,7 @@ pub struct ShOptCore {
pub bell_enabled: bool, pub bell_enabled: bool,
pub max_recurse_depth: usize, pub max_recurse_depth: usize,
pub xpg_echo: bool, pub xpg_echo: bool,
pub noclobber: bool,
} }
impl ShOptCore { impl ShOptCore {
@@ -238,6 +239,15 @@ impl ShOptCore {
}; };
self.xpg_echo = val; self.xpg_echo = val;
} }
"noclobber" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for noclobber value",
));
};
self.noclobber = val;
}
_ => { _ => {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
@@ -304,6 +314,11 @@ impl ShOptCore {
output.push_str(&format!("{}", self.xpg_echo)); output.push_str(&format!("{}", self.xpg_echo));
Ok(Some(output)) Ok(Some(output))
} }
"noclobber" => {
let mut output = String::from("Prevent > from overwriting existing files (use >| to override)\n");
output.push_str(&format!("{}", self.noclobber));
Ok(Some(output))
}
_ => Err(ShErr::simple( _ => Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{query}'"), format!("shopt: Unexpected 'core' option '{query}'"),
@@ -327,6 +342,7 @@ impl Display for ShOptCore {
output.push(format!("bell_enabled = {}", self.bell_enabled)); output.push(format!("bell_enabled = {}", self.bell_enabled));
output.push(format!("max_recurse_depth = {}", self.max_recurse_depth)); output.push(format!("max_recurse_depth = {}", self.max_recurse_depth));
output.push(format!("xpg_echo = {}", self.xpg_echo)); output.push(format!("xpg_echo = {}", self.xpg_echo));
output.push(format!("noclobber = {}", self.noclobber));
let final_output = output.join("\n"); let final_output = output.join("\n");
@@ -346,6 +362,7 @@ impl Default for ShOptCore {
bell_enabled: true, bell_enabled: true,
max_recurse_depth: 1000, max_recurse_depth: 1000,
xpg_echo: false, xpg_echo: false,
noclobber: false,
} }
} }
} }
@@ -589,6 +606,7 @@ mod tests {
bell_enabled, bell_enabled,
max_recurse_depth, max_recurse_depth,
xpg_echo, xpg_echo,
noclobber,
} = ShOptCore::default(); } = ShOptCore::default();
// If a field is added to the struct, this destructure fails to compile. // If a field is added to the struct, this destructure fails to compile.
let _ = ( let _ = (
@@ -601,6 +619,7 @@ mod tests {
bell_enabled, bell_enabled,
max_recurse_depth, max_recurse_depth,
xpg_echo, xpg_echo,
noclobber,
); );
} }