diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b3e37d..2fbccad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,11 @@ hashes are given where a change is easiest to understand by reading the commit. `--rsh-dir DIR`) answers TCP 514 at the gateway with `cat`, `dd`, `ls`, `echo` and `pwd` on DIR, read-only, so the guest can pull files with `rsh 192.168.0.1 cat /file` without a host `rshd`. It is not a shell and runs - nothing on the host. It speaks the BSD `rcmd` protocol, including the + nothing on the host. `exec /bin/sh` (what `inst` runs) is accepted: stdin is + run line by line against the same commands, including the + `( status=$? ; ... echo 'MARK\c' ; echo 'MARK'$status 1>&2 )` tail `inst` + waits for, `trap`, `echo X | dd`, `dd ... | fgrep STR` and the IRIX + `ls -inld` format; with `IRIS_RSH_TRACE` each line is logged. It speaks the BSD `rcmd` protocol, including the stderr connection back to the guest from a reserved port. `src/net/rsh.rs` is the protocol and the sandbox; `TcpService` gained `pull` (stream output instead of holding it) and `side_connect` (a service asks for a connection diff --git a/HELP.md b/HELP.md index 5457c85b..bb6f4f73 100644 --- a/HELP.md +++ b/HELP.md @@ -220,7 +220,17 @@ guest side of the NAT. IRIX's `rsh` asks for a second connection for stderr; the gateway opens it back to the guest from a reserved port, which `rcmd` insists on. With `rsh_dir` set, guest connections to gateway port 514 are answered here instead of being -relayed to a host `rshd`. NAT mode only. Run `rsh -n` if the command should not +relayed to a host `rshd`. NAT mode only. + +To see what a client (IRIX `inst`, say) asks for, start iris with +`IRIS_RSH_TRACE=1`. Each request is written to stderr when it arrives and again +when it ends, with the bytes moved, the time taken, and whether it completed, +was cut short by the guest, or never sent a whole request. This works in any +build; the `net debug` logging needs a `developer` build. +At startup iris prints `iris: rsh server ... serving DIR` when `rsh_dir` is on. +Every guest connection to port 514 is also reported with where it went, so a +trace with no requests means the guest never reached the built-in server (the +`rsh_dir` is unset, or `inst` is pointed at an address other than the gateway). Run `rsh -n` if the command should not read the terminal. ### NAT diff --git a/src/net/engine.rs b/src/net/engine.rs index 9e3c5265..8c33c717 100644 --- a/src/net/engine.rs +++ b/src/net/engine.rs @@ -96,6 +96,9 @@ impl NatEngine { } udp_services.push(svc); } + if let Some(dir) = &config.rsh_dir { + eprintln!("iris: rsh server (read-only: cat, dd, ls, echo, pwd) serving {}", dir.display()); + } Self { config, host_dns: None, tx_cons, rx_prod, rx_wake, tx_wake, running, ctl, udp_nat: HashMap::new(), tcp_nat: HashMap::new(), icmp_nat: HashMap::new(), icmp_unavailable: false, diff --git a/src/net/nat_tcp.rs b/src/net/nat_tcp.rs index 6c94f5b1..2cf2127e 100644 --- a/src/net/nat_tcp.rs +++ b/src/net/nat_tcp.rs @@ -125,6 +125,18 @@ impl NatEngine { let ends = Endpoints { guest_mac: client_mac, guest_ip: src_ip, guest_port: syn.sport(), remote_ip: dst_ip, remote_port: dport }; let to_gw = dst_ip == self.config.gateway_ip; + if dport == RSH_PORT && super::rsh::trace_enabled() { + // Where did this guest rsh go? "No trace from the built-in server" + // usually means it never got the connection. + let verdict = if to_gw && self.config.rsh_dir.is_some() { + "answered by the built-in rsh server" + } else if to_gw { + "relayed to the host's port 514: rsh_dir is not set" + } else { + "relayed to that address: it is not the gateway" + }; + eprintln!("iris: rsh: guest {}:{} -> {}:{}: {}", src_ip, syn.sport(), dst_ip, dport, verdict); + } let backend = if to_gw && dport == TIME_PORT { Backend::Service(ServiceConn::new(Box::new(TimeService(super::services::rfc868_time())))) } else if to_gw && dport == PORTMAP_PORT && self.config.nfs.is_some() { @@ -188,6 +200,10 @@ impl NatEngine { }; dlog_dev!(LogModule::Net, "NAT TCP side connection {}:{} → guest {}:{}", main.remote_ip, sport, main.guest_ip, side.guest_port); + if super::rsh::trace_enabled() { + eprintln!("iris: rsh: connecting back to guest {}:{} (stderr) from {}:{}", + main.guest_ip, side.guest_port, main.remote_ip, sport); + } let ends = Endpoints { guest_port: side.guest_port, remote_port: sport, ..main }; let conn = TcpConn::open(ends, Backend::Service(ServiceConn::new(side.service))); let syn = conn.syn(self.config.gateway_mac); diff --git a/src/net/rsh.rs b/src/net/rsh.rs index ed0de6e0..b45d1ec7 100644 --- a/src/net/rsh.rs +++ b/src/net/rsh.rs @@ -26,10 +26,16 @@ //! ``` //! //! A stderr port of `0` means the client wants no second connection; error -//! text then goes down the main connection. `rcmd` blocks accepting the second -//! connection, so a client that asked for one must get it. That connection is -//! requested through [`TcpService::side_connect`]. The protocol carries no exit -//! status, so a failing command is reported only in its error text. +//! text then goes down the main connection. That connection is requested +//! through [`TcpService::side_connect`]. The protocol carries no exit status, +//! so a failing command is reported only in its error text. +//! +//! **The connection back must not wait for the rest of the request.** `rcmd` +//! sends *only* the stderr port, then blocks accepting the second connection, +//! and sends the user names and the command only after that. A server that +//! waits for the whole request before connecting back deadlocks with it until +//! the client gives up (inst's 120 s timeout). So the connection is requested +//! as soon as the first field has arrived. //! //! `dd if=FILE [bs=N] [ibs=N] [count=N] [iseek=N|skip=N]` is what IRIX `inst` //! uses to read a distribution from a remote host: it restarts the remote `dd` @@ -40,9 +46,11 @@ use std::collections::VecDeque; use std::fs::{self, File}; -use std::io::{self, Read, Seek, SeekFrom}; +use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Instant; use parking_lot::Mutex; @@ -137,6 +145,17 @@ pub fn parse_request(buf: &[u8]) -> Parse { }) } +/// The stderr port field: empty (rcmd's "none") or up to five decimal digits. +fn parse_port(field: &[u8]) -> Option { + if field.len() > 5 || !field.iter().all(u8::is_ascii_digit) { + return None; + } + match std::str::from_utf8(field).ok()? { + "" => Some(0), + s => s.parse::().ok(), + } +} + /// Split a command line into words: whitespace separates, single and double /// quotes group, backslash escapes. Anything that would need a real shell /// (pipes, redirects, expansion, wildcards) is refused instead of being @@ -297,9 +316,29 @@ enum Job { /// `dd`: `left` input bytes still to copy (None = to the end of the file), /// `total` copied so far, `ibs` for the records report. Dd { file: File, left: Option, total: u64, ibs: u64 }, + /// `dd ... | fgrep PATTERN`: the lines of the input holding the fixed string. + Grep { rd: BufReader, pat: Vec, line: Vec, hits: u64 }, } +/// With `IRIS_RSH_TRACE` set, every request and how it ended is written to +/// stderr, in any build (`dlog_dev!` output exists only in `developer` builds). +/// It shows what a client asks for, how many bytes each request moved and how +/// long it took, and requests that never finished, which is what slow or hung +/// installs need. +pub(super) fn trace_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("IRIS_RSH_TRACE").is_some()) +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + pub struct RshSession { + id: u64, + trace: bool, + started: Instant, + command: String, + /// Stdout bytes handed to the connection so far. + sent: u64, root: PathBuf, phase: Phase, inbuf: Vec, @@ -309,19 +348,59 @@ pub struct RshSession { chan: Option>>, side: Option, finished: bool, + /// The stderr port field has arrived and been acted on. + port_seen: bool, + /// A whole request has arrived and the command was started. + requested: bool, + /// The stderr port the client named, for the trace. + stderr_port: u16, + /// Shell mode: `exec /bin/sh` and friends. The client's stdin is a script, + /// run line by line against the built-in commands. + shell: bool, + /// The current shell command (if any) is over; the next line may run. + cmd_done: bool, + /// Stdin received and not yet run. + stdin: Vec, + /// The client closed its side of stdin. + stdin_eof: bool, + /// `2>/dev/null` on the running command: error text is dropped. + quiet: bool, + /// Exit status of the last command (0, 1, or 127 for not found). + status: u8, + /// Commands of the current line still to run. + cmds: VecDeque, + /// The `( status=$? ; ... echo 'A' ; echo 'B'$status 1>&2 )` tail of the + /// current line: the stdout and stderr arguments, run after the commands. + epilogue: Option<(String, String)>, } impl RshSession { /// Serve the built-in commands out of `root`. Nothing outside it is reachable. pub fn new(root: PathBuf) -> Self { Self { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), trace: trace_enabled(), + started: Instant::now(), command: String::new(), sent: 0, root, phase: Phase::Header, inbuf: Vec::new(), out: VecDeque::new(), job: Job::None, chan: None, side: None, finished: false, + port_seen: false, requested: false, stderr_port: 0, + shell: false, cmd_done: true, stdin: Vec::new(), stdin_eof: false, quiet: false, + status: 0, cmds: VecDeque::new(), epilogue: None, } } /// The command is over: no more output, and the stderr channel can close. fn finish(&mut self) { + if self.shell && !self.finished { + // A command in a shell session: the shell carries on. + self.job = Job::None; + self.cmd_done = true; + return; + } + self.end_session(); + } + + /// The whole session is over. + fn end_session(&mut self) { self.finished = true; self.job = Job::None; if let Some(c) = &self.chan { @@ -332,6 +411,12 @@ impl RshSession { /// Error text for the client: on the stderr connection when it has one, /// else down the main connection. fn err(&mut self, msg: &str) { + if self.status == 0 { + self.status = 1; + } + if self.quiet { + return; + } match &self.chan { Some(c) => c.lock().data.extend(msg.as_bytes()), None => self.out.extend(msg.as_bytes()), @@ -345,18 +430,31 @@ impl RshSession { self.finish(); } + /// Ask for the connection back to the client's stderr port, once. + fn open_stderr(&mut self, port: u16) { + self.stderr_port = port; + if port == 0 || self.chan.is_some() { + return; + } + if self.trace { + eprintln!("iris: rsh #{} stderr port {}: connecting back to the guest", self.id, port); + } + let chan = Arc::new(Mutex::new(Chan::default())); + self.side = Some(SideConnect { guest_port: port, service: Box::new(StderrService(chan.clone())) }); + self.chan = Some(chan); + } + fn start(&mut self, req: Request) { dlog_dev!(LogModule::Net, "NAT rsh local user {:?}, remote user {:?}: {:?}", req.local_user, req.remote_user, req.command); self.phase = Phase::Running; - if req.stderr_port != 0 { - let chan = Arc::new(Mutex::new(Chan::default())); - self.side = Some(SideConnect { - guest_port: req.stderr_port, - service: Box::new(StderrService(chan.clone())), - }); - self.chan = Some(chan); + if self.trace { + eprintln!("iris: rsh #{} {}@{}: {:?} (stderr port {})", self.id, req.remote_user, + req.local_user, req.command, req.stderr_port); } + self.command = req.command.clone(); + self.requested = true; + self.open_stderr(req.stderr_port); // a no-op: the first field already did self.out.push_back(0); // accepted let words = match split_words(&req.command) { Ok(w) => w, @@ -365,11 +463,34 @@ impl RshSession { return self.finish(); } }; + if is_shell(&words) { + // The installer runs `exec /bin/sh` and sends its commands as + // stdin; there is no shell to give it, but the read-only commands + // can be run from what it sends. + self.shell = true; + self.cmd_done = true; + return; + } + self.run_words(&words); + } + + /// Run one command (already split into words). + fn run_words(&mut self, words: &[String]) { + let words = match words.split_first() { + Some((e, rest)) if e == "exec" => rest, + _ => words, + }; let Some((name, args)) = words.split_first() else { + if self.shell { + return self.finish(); + } self.err("rshd: no command given (interactive login is not supported)\n"); return self.finish(); }; - match name.as_str() { + let name = name.strip_prefix("/bin/").unwrap_or(name); + match name { + "true" | ":" | "cd" | "export" | "set" | "umask" | "unset" | "trap" => self.finish(), + "exit" | "logout" => self.end_session(), "cat" => self.cmd_cat(args), "dd" => self.cmd_dd(args), "ls" => self.cmd_ls(args), @@ -380,6 +501,116 @@ impl RshSession { } other => { self.err(&format!("{other}: Command not found.\n")); + self.status = 127; + self.finish(); + } + } + } + + /// Run the complete lines of stdin, one command at a time, while the + /// previous one is over. At end of stdin the session ends. + fn pump_shell(&mut self) { + while self.shell && !self.finished && self.cmd_done { + if let Some(cmd) = self.cmds.pop_front() { + self.run_shell_command(&cmd); + continue; + } + if let Some((o, e)) = self.epilogue.take() { + // The marker `inst` waits for: on stdout, and on stderr with + // the exit status in it. + let status = self.status.to_string(); + let (text, nl) = echo_escapes(&expand_arg(&o, &status)); + self.out.extend(text.bytes()); + if nl { + self.out.push_back(b'\n'); + } + let (text, nl) = echo_escapes(&expand_arg(&e, &status)); + let tail = if nl { "\n" } else { "" }; + if let Some(c) = &self.chan { + c.lock().data.extend(format!("{text}{tail}").bytes()); + } + continue; + } + let Some(nl) = self.stdin.iter().position(|&b| b == b'\n') else { + if self.stdin_eof { + self.end_session(); + } + return; + }; + let raw: Vec = self.stdin.drain(..=nl).collect(); + let line = String::from_utf8_lossy(&raw).trim().to_string(); + if self.trace { + eprintln!("iris: rsh #{} stdin: {:?}", self.id, line); + } + if line.is_empty() || line.starts_with('#') { + continue; + } + // `CMD ; CMD ; ( status=$? ; trap '' 2 ; echo A ; echo B$status 1>&2 )` + let (body, tail) = match line.find("( status=$?") { + Some(i) => (&line[..i], Some(&line[i..])), + None => (&line[..], None), + }; + self.cmds = split_top(body, ';').into_iter().filter(|c| !c.is_empty()).collect(); + self.epilogue = tail.and_then(parse_epilogue); + self.status = 0; + } + } + + /// One command of a script line, with its redirections and pipe. + fn run_shell_command(&mut self, cmd: &str) { + self.cmd_done = false; + self.status = 0; + self.quiet = false; + let mut cmd = cmd.to_string(); + for pat in ["2>/dev/null", "2> /dev/null"] { + if cmd.contains(pat) { + cmd = cmd.replace(pat, " "); + self.quiet = true; + } + } + let cmd = cmd.replace("2>&1", " ").replace(">, _> = parts.iter().map(|p| split_words(p)).collect(); + let words = match words { + Ok(w) => w, + Err(e) => { + self.err(&format!("sh: {e}\n")); + return self.finish(); + } + }; + match words.as_slice() { + [one] => self.run_words(one), + // inst probes the remote dd with `echo abc | dd iseek=0`. + [l, r] if l.first().is_some_and(|w| w == "echo") && r.first().is_some_and(|w| w == "dd") => { + self.cmd_echo(&l[1..]); + if let Some(c) = &self.chan { + c.lock().data.extend(b"0+1 records in\n0+1 records out\n"); + } + self.finish(); + } + [l, r] if l.first().is_some_and(|w| w == "dd") && r.first().is_some_and(|w| w == "fgrep" || w == "grep") => { + self.cmd_dd_fgrep(&l[1..], &r[1..]); + } + _ => { + self.err("sh: only 'echo | dd' and 'dd | fgrep' pipelines are supported\n"); + self.finish(); + } + } + } + + /// `dd if=FILE ... | fgrep PATTERN`: the matching lines of FILE. + fn cmd_dd_fgrep(&mut self, dd_args: &[String], grep_args: &[String]) { + let Some(pat) = grep_args.iter().rev().find(|a| !a.starts_with('-')) else { + self.err("fgrep: no pattern\n"); + return self.finish(); + }; + match self.parse_dd(dd_args) { + Ok(Job::Dd { file, .. }) => { + self.job = Job::Grep { rd: BufReader::new(file), pat: pat.clone().into_bytes(), line: Vec::new(), hits: 0 }; + } + Ok(_) => self.finish(), + Err(msg) => { + self.err(&format!("dd: {msg}\n")); self.finish(); } } @@ -390,8 +621,10 @@ impl RshSession { Some((f, rest)) if f == "-n" => (false, rest), _ => (true, args), }; - self.out.extend(args.join(" ").bytes()); - if newline { + let text = args.join(" "); + let (text, nl) = if self.shell { echo_escapes(&text) } else { (text, true) }; + self.out.extend(text.bytes()); + if newline && nl { self.out.push_back(b'\n'); } self.finish(); @@ -461,7 +694,7 @@ impl RshSession { } fn cmd_ls(&mut self, args: &[String]) { - let (mut all, mut long) = (false, false); + let (mut all, mut long, mut irix, mut dir_only) = (false, false, false, false); let mut operands = Vec::new(); let mut opts_done = false; for a in args { @@ -472,9 +705,11 @@ impl RshSession { match c { 'a' => all = true, 'l' => long = true, - '1' => {} + 'i' | 'n' => irix = true, + 'd' => dir_only = true, + '1' | 'L' => {} _ => { - self.err(&format!("ls: illegal option -- {c}\nusage: ls [-1al] [file ...]\n")); + self.err(&format!("ls: illegal option -- {c}\nusage: ls [-1alinLd] [file ...]\n")); return self.finish(); } } @@ -486,7 +721,8 @@ impl RshSession { if operands.is_empty() { operands.push("."); } - let headers = operands.len() > 1; + let fmt = if irix { LsFmt::Irix } else if long { LsFmt::Long } else { LsFmt::Short }; + let headers = operands.len() > 1 && !irix; let mut text = String::new(); for (i, name) in operands.iter().enumerate() { let path = match resolve(&self.root, name) { @@ -496,9 +732,9 @@ impl RshSession { continue; } }; - if !path.is_dir() { - match fs::symlink_metadata(&path) { - Ok(m) => text.push_str(&ls_line(name, &m, long)), + if !path.is_dir() || dir_only { + match fs::metadata(&path) { + Ok(m) => text.push_str(&ls_line(name, &m, fmt)), Err(e) => self.err(&format!("ls: {name}: {}\n", errstr(&e))), } continue; @@ -523,8 +759,15 @@ impl RshSession { .take(MAX_LS_ENTRIES) .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); + if all { + for dot in [".", ".."] { + if let Ok(m) = fs::metadata(&path.join(dot)) { + text.push_str(&ls_line(dot, &m, fmt)); + } + } + } for (n, m) in &rows { - text.push_str(&ls_line(n, m, long)); + text.push_str(&ls_line(n, m, fmt)); } } self.out.extend(text.bytes()); @@ -537,6 +780,37 @@ impl RshSession { Job::None => {} Job::Cat { .. } => self.advance_cat(want), Job::Dd { .. } => self.advance_dd(want), + Job::Grep { .. } => self.advance_grep(want), + } + } + + fn advance_grep(&mut self, want: usize) { + let mut moved = 0; + while moved < want { + let Job::Grep { rd, pat, line, hits } = &mut self.job else { return }; + line.clear(); + match rd.read_until(b'\n', line) { + Ok(0) => { + // grep's status: 1 when nothing matched. + if *hits == 0 && self.status == 0 { + self.status = 1; + } + return self.finish(); + } + Ok(_) => { + if line.windows(pat.len().max(1)).any(|w| w == pat.as_slice()) { + *hits += 1; + moved += line.len(); + self.out.extend(line.iter()); + } + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => { + let msg = format!("dd: read error: {}\n", errstr(&e)); + self.err(&msg); + return self.finish(); + } + } } } @@ -617,13 +891,148 @@ impl RshSession { } } +/// `sh`, `/bin/ksh`, `-sh` ...: a command that wants a shell on stdin. +fn is_shell(words: &[String]) -> bool { + let words = match words.split_first() { + Some((e, rest)) if e == "exec" => rest, + _ => words, + }; + words.first().is_some_and(|w| { + matches!(w.rsplit('/').next().unwrap_or(""), "sh" | "ksh" | "bash" | "csh" | "tcsh" | "-sh") + }) +} + +/// Split at every `sep` outside quotes; the pieces are trimmed. +fn split_top(line: &str, sep: char) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + for c in line.chars() { + match (quote, c) { + (None, '\'' | '"') => { + quote = Some(c); + cur.push(c); + } + (Some(q), c) if c == q => { + quote = None; + cur.push(c); + } + (None, c) if c == sep => parts.push(std::mem::take(&mut cur).trim().to_string()), + _ => cur.push(c), + } + } + parts.push(cur.trim().to_string()); + parts +} + +/// The tail `( status=$? ; trap '' 2 ; echo A ; echo B$status 1>&2 )`: the +/// arguments of its stdout echo and its stderr echo. +fn parse_epilogue(tail: &str) -> Option<(String, String)> { + let (mut out, mut err) = (None, None); + for seg in split_top(tail, ';') { + let seg = seg.trim_matches(|c| c == '(' || c == ')' || c == ' '); + let Some(rest) = seg.strip_prefix("echo ") else { continue }; + let rest = rest.trim(); + match rest.strip_suffix("1>&2") { + Some(a) => err = Some(a.trim().to_string()), + None => out = Some(rest.to_string()), + } + } + Some((out?, err?)) +} + +/// An echo argument as the shell would pass it: quotes removed, `$status` +/// replaced. +fn expand_arg(arg: &str, status: &str) -> String { + let mut out = String::new(); + let mut quote: Option = None; + let mut rest = arg; + while let Some(c) = rest.chars().next() { + match (quote, c) { + (None, '\'' | '"') => quote = Some(c), + (Some(q), c) if c == q => quote = None, + (q, '$') if q != Some('\'') && rest.starts_with("$status") => { + out.push_str(status); + rest = &rest["$status".len()..]; + continue; + } + _ => out.push(c), + } + rest = &rest[c.len_utf8()..]; + } + out +} + +/// What the System V `echo` makes of backslash escapes. The flag is false when +/// `\c` asked for no trailing newline. +fn echo_escapes(s: &str) -> (String, bool) { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('c') => return (out, false), + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('\\') => out.push('\\'), + Some(o) => { + out.push('\\'); + out.push(o); + } + None => out.push('\\'), + } + } + (out, true) +} + +/// `ctime`-style "Mon D HH:MM" (UTC), as the IRIX `ls` shows a modification time. +fn ls_time(m: &fs::Metadata) -> String { + let secs = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs()); + let (days, rem) = ((secs / 86400) as i64, secs % 86400); + // civil-from-days (Howard Hinnant) + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let _ = era; + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + format!("{} {} {:02}:{:02}", MONTHS[(month - 1) as usize], day, rem / 3600, rem % 3600 / 60) +} + /// One `ls` output line: the bare name, or a long line with type/permissions /// and size (there is no owner or date: the host's are meaningless to IRIX). -fn ls_line(name: &str, m: &fs::Metadata, long: bool) -> String { - if !long { - return format!("{name}\n"); +fn ls_line(name: &str, m: &fs::Metadata, fmt: LsFmt) -> String { + match fmt { + LsFmt::Short => format!("{name}\n"), + LsFmt::Long => format!("{} {:>10} {name}\n", mode_string(m), m.len()), + // `ls -inld`: what `inst` parses. Inode, mode, links, owner, group, + // size, time, name. + LsFmt::Irix => { + #[cfg(unix)] + let (ino, nlink, uid, gid) = { + use std::os::unix::fs::MetadataExt; + (m.ino(), m.nlink(), m.uid(), m.gid()) + }; + #[cfg(not(unix))] + let (ino, nlink, uid, gid) = (0u64, 1u64, 0u32, 0u32); + format!("{ino} {} {nlink} {uid} {gid} {} {} {name}\n", mode_string(m), m.len(), ls_time(m)) + } } - format!("{} {:>10} {name}\n", mode_string(m), m.len()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LsFmt { + Short, + Long, + Irix, } fn mode_string(m: &fs::Metadata) -> String { @@ -646,30 +1055,99 @@ fn mode_string(m: &fs::Metadata) -> String { s } +impl Drop for RshSession { + fn drop(&mut self) { + if !self.trace { + return; + } + let ms = self.started.elapsed().as_millis(); + let how = if !self.requested { + format!("the guest never sent a whole request ({} bytes received, stderr port {})", + self.inbuf.len(), if self.port_seen { self.stderr_port.to_string() } else { "not yet seen".into() }) + } else if self.finished && self.out.is_empty() { + "complete".to_string() + } else if self.finished { + format!("closed with {} bytes unsent", self.out.len()) + } else { + "closed by the guest before the command finished".to_string() + }; + eprintln!("iris: rsh #{} {:?}: {} bytes in {} ms, {}", self.id, self.command, self.sent, ms, how); + } +} + impl TcpService for RshSession { fn input(&mut self, data: &[u8]) -> Vec { // Anything after the header is the client's stdin: there is no process // to feed it to. + if self.shell && !self.finished { + self.stdin.extend_from_slice(data); + self.pump_shell(); + return Vec::new(); + } if self.phase != Phase::Header || self.finished { return Vec::new(); } self.inbuf.extend_from_slice(data); + // rcmd sends the stderr port alone and waits for us to connect back + // before it sends anything else: act on it as soon as it is here. + if !self.port_seen { + match self.inbuf.iter().position(|&b| b == 0) { + Some(end) => match parse_port(&self.inbuf[..end]) { + Some(port) => { + self.port_seen = true; + self.open_stderr(port); + } + None => { + self.phase = Phase::Running; + self.reject(PROTOCOL_FAILURE); + return Vec::new(); + } + }, + None if self.inbuf.len() > 5 => { + self.phase = Phase::Running; + self.reject(PROTOCOL_FAILURE); + return Vec::new(); + } + None => return Vec::new(), + } + } match parse_request(&self.inbuf) { Parse::Need => {} Parse::Bad(msg) => { self.phase = Phase::Running; self.reject(msg); } - Parse::Done(req) => self.start(req), + Parse::Done(req) => { + // Whatever followed the fourth NUL is already stdin. + let mut nuls = 0; + let end = self.inbuf.iter().position(|&b| { + nuls += usize::from(b == 0); + nuls == 4 + }); + let extra = end.map(|e| self.inbuf[e + 1..].to_vec()).unwrap_or_default(); + self.start(req); + if self.shell { + self.stdin.extend_from_slice(&extra); + self.pump_shell(); + } + } } Vec::new() } fn pull(&mut self, max: usize) -> Vec { - if self.out.len() < max { + for _ in 0..4 { + if self.out.len() >= max { + break; + } self.advance_job(max - self.out.len()); + self.pump_shell(); + if matches!(self.job, Job::None) { + break; + } } let n = max.min(self.out.len()); + self.sent += n as u64; self.out.drain(..n).collect() } @@ -683,6 +1161,12 @@ impl TcpService for RshSession { if self.phase == Phase::Header { self.phase = Phase::Running; self.finish(); + } else if self.shell { + self.stdin_eof = true; + if !self.stdin.is_empty() && !self.stdin.ends_with(b"\n") { + self.stdin.push(b'\n'); + } + self.pump_shell(); } } @@ -729,6 +1213,91 @@ mod tests { (out, side.map(|_| err)) } + #[test] + fn exec_sh_runs_stdin_as_a_script() { + let root = tmp_root("shell"); + fs::write(root.join("a"), b"AAA").unwrap(); + let mut s = RshSession::new(root.clone()); + // Header and the first script lines arrive together; the rest later. + let mut first = req("1000", "exec /bin/sh"); + first.extend_from_slice(b"cat a; echo hi 2>/dev/null\nnosuch\n"); + s.input(&first); + s.input(b"echo -n x"); + s.guest_closed(); + let mut out = Vec::new(); + for _ in 0..100 { + out.extend(s.pull(4)); + if s.done() { break; } + } + assert!(s.done()); + assert_eq!(out, b"\0AAAhi\nx"); + fs::remove_dir_all(root).ok(); + } + + /// Drive a shell session with `script` as stdin; returns (stdout, stderr). + fn shell(root: &Path, script: &[u8]) -> (Vec, Vec) { + let mut s = RshSession::new(root.to_path_buf()); + let mut first = req("1000", "exec /bin/sh"); + first.extend_from_slice(script); + s.input(&first); + let mut side = s.side_connect().unwrap(); + s.guest_closed(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + for _ in 0..1000 { + out.extend(s.pull(16)); + err.extend(side.service.pull(16)); + if s.done() && side.service.done() { break; } + } + assert!(s.done()); + (out, err) + } + + const TAIL: &str = " ; ( status=$? ; trap '' 2 ; echo 'o?_InstProc7IsDone\\c' ; echo 'o?_InstProc7IsDone'$status'\\c' 1>&2 )\n"; + + #[test] + fn inst_commands_end_with_the_marker_on_both_streams() { + let root = tmp_root("marker"); + fs::write(root.join("a"), b"AAA").unwrap(); + let script = format!("trap : 2{TAIL}cat a{TAIL}cat nosuch{TAIL}nosuch{TAIL}"); + let (out, err) = shell(&root, script.as_bytes()); + let m = "o?_InstProc7IsDone"; + assert_eq!(String::from_utf8_lossy(&out), format!("\0{m}AAA{m}{m}{m}")); + let err = String::from_utf8_lossy(&err).into_owned(); + assert!(err.starts_with(&format!("{m}0{m}0")), "{err}"); + assert!(err.contains("No such file") && err.contains(&format!("{m}1")), "{err}"); + assert!(err.contains("nosuch: Command not found.") && err.contains(&format!("{m}127")), "{err}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn irix_ls_dd_probe_and_fgrep() { + let root = tmp_root("inst"); + fs::write(root.join("x.idb"), b"f 0 a mach(CPU=R4000)\nf 0 b other\nf 0 c mach(CPU=R5000)").unwrap(); + let script = format!( + "ls -inld x.idb{TAIL}/bin/ls -a .{TAIL}echo abc|dd iseek=0{TAIL}echo flush{TAIL}dd if=x.idb bs=512 | fgrep ' mach('{TAIL}"); + let (out, err) = shell(&root, script.as_bytes()); + let out = String::from_utf8_lossy(&out).into_owned(); + let err = String::from_utf8_lossy(&err).into_owned(); + let first = out.lines().nth(0).unwrap(); + let f: Vec<&str> = first.trim_start_matches('\0').split(' ').collect(); + assert_eq!(f.len(), 10, "{first}"); // ino mode nlink uid gid size Mon D HH:MM name + assert!(f[1].starts_with('-') && f[5] == "55" && f[9] == "x.idb", "{first}"); + assert!(out.contains(".\n..\nx.idb\n"), "{out}"); + assert!(out.contains("abc\n") && out.contains("flush\n"), "{out}"); + assert!(err.contains("0+1 records in\n0+1 records out\n"), "{err}"); + assert!(out.contains("f 0 a mach(CPU=R4000)\nf 0 c mach(CPU=R5000)"), "{out}"); + assert!(!out.contains("other"), "{out}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn echo_escapes_and_quoting() { + assert_eq!(echo_escapes("a\\cb"), ("a".to_string(), false)); + assert_eq!(echo_escapes("a\\nb"), ("a\nb".to_string(), true)); + assert_eq!(expand_arg("'M'$status'\\c'", "7"), "M7\\c"); + assert_eq!(split_top("a 'x;y' ; b", ';'), vec!["a 'x;y'", "b"]); + } + #[test] fn parses_a_whole_request() { let r = parse_request(&req("1023", "cat /x")); @@ -739,6 +1308,55 @@ mod tests { assert!(matches!(parse_request(&req("", "pwd")), Parse::Done(Request { stderr_port: 0, .. }))); } + /// rcmd sends the stderr port alone and waits for the connection back + /// before it sends the rest. Waiting for the whole request deadlocks. + #[test] + fn the_connection_back_does_not_wait_for_the_command() { + let root = tmp_root("handshake"); + fs::write(root.join("f"), b"data").unwrap(); + let mut s = RshSession::new(root.clone()); + assert!(s.input(b"40").is_empty()); + assert!(s.side_connect().is_none(), "half a port number is not a port"); + s.input(b"00\0"); + let side = s.side_connect().expect("asked for as soon as the port is complete"); + assert_eq!(side.guest_port, 4000); + assert!(s.pull(64).is_empty() && !s.done(), "nothing is accepted before the command"); + s.input(b"guest\0root\0cat f\0"); + assert!(s.side_connect().is_none(), "asked for once"); + let mut out = Vec::new(); + while !s.done() { + out.extend(s.pull(64)); + } + assert_eq!(out, b"\0data"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rcmd_without_a_stderr_connection_sends_an_empty_port() { + let root = tmp_root("noerr"); + let mut s = RshSession::new(root.clone()); + s.input(b"\0"); + assert!(s.side_connect().is_none()); + s.input(b"a\0b\0pwd\0"); + let mut out = Vec::new(); + while !s.done() { + out.extend(s.pull(64)); + } + assert_eq!(out, b"\0/\n"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn a_bad_port_is_refused_at_once() { + for bad in [&b"12x\0"[..], b"123456\0", b"99999\0", b"abcdefg"] { + let mut s = RshSession::new(std::env::temp_dir()); + s.input(bad); + assert!(s.side_connect().is_none()); + assert_eq!(s.pull(100)[0], 1, "{bad:?}"); + assert!(s.done()); + } + } + #[test] fn a_partial_request_waits() { let full = req("5000", "ls"); @@ -866,7 +1484,7 @@ mod tests { fs::create_dir(root.join("sub")).unwrap(); fs::write(root.join("sub/x"), b"").unwrap(); assert_eq!(run(&root, &req("0", "ls")).0, b"\0a\nb\nc\nsub\n"); - assert_eq!(run(&root, &req("0", "ls -a /")).0, b"\0.hidden\na\nb\nc\nsub\n"); + assert_eq!(run(&root, &req("0", "ls -a /")).0, b"\0.\n..\n.hidden\na\nb\nc\nsub\n"); assert_eq!(run(&root, &req("0", "ls sub")).0, b"\0x\n"); assert_eq!(run(&root, &req("0", "ls a")).0, b"\0a\n"); assert_eq!(run(&root, &req("0", "ls . sub")).0, b"\0.:\na\nb\nc\nsub\n\nsub:\nx\n"); diff --git a/src/net/testnet.rs b/src/net/testnet.rs index d8eb4daa..ce9b963d 100644 --- a/src/net/testnet.rs +++ b/src/net/testnet.rs @@ -1307,8 +1307,11 @@ fn rsh_cat_with_a_stderr_connection() { let gw = net.gw_ip; let (mut out, flags) = GuestTcp::connect(&mut net, gw, 514, 1023); assert!(flags.syn(), "the gateway answers on 514"); - out.send_all(&mut net, b"5000\0guest\0root\0cat /hello.txt nope ../hello.txt\0"); + // rcmd sends the port alone and waits for the connection back before it + // sends the user names and the command. + out.send_all(&mut net, b"5000\0"); let mut err = accept_side_connection(&mut net, 5000); + out.send_all(&mut net, b"guest\0root\0cat /hello.txt nope ../hello.txt\0"); let want_err = b"cat: nope: No such file or directory\ncat: ../hello.txt: Permission denied\n"; out.recv(&mut net, Some(1 + data.len()));