From 892562c9921456fc8fdf4703ecdd12b78a8e395c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:52:47 +0200 Subject: [PATCH 1/5] net/rsh: IRIS_RSH_TRACE request/throughput log Writes each rsh request and how it ended (bytes, ms, complete / cut short / never sent a whole request) to stderr in any build, for diagnosing slow or hung network installs. Off unless the variable is set. Standalone rsh.rs tests pass (20); the full crate is still unbuilt here. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NJtD8EfBuugQ7a2pv4vhsU --- HELP.md | 8 +++++++- src/net/rsh.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/HELP.md b/HELP.md index 5457c85b..5e9df859 100644 --- a/HELP.md +++ b/HELP.md @@ -220,7 +220,13 @@ 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. Run `rsh -n` if the command should not read the terminal. ### NAT diff --git a/src/net/rsh.rs b/src/net/rsh.rs index ed0de6e0..5dea2bbd 100644 --- a/src/net/rsh.rs +++ b/src/net/rsh.rs @@ -42,7 +42,9 @@ use std::collections::VecDeque; use std::fs::{self, File}; use std::io::{self, 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; @@ -299,7 +301,25 @@ enum Job { Dd { file: File, left: Option, total: u64, ibs: 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. +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, @@ -315,6 +335,8 @@ 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, } @@ -349,6 +371,11 @@ impl RshSession { dlog_dev!(LogModule::Net, "NAT rsh local user {:?}, remote user {:?}: {:?}", req.local_user, req.remote_user, req.command); self.phase = Phase::Running; + 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(); if req.stderr_port != 0 { let chan = Arc::new(Mutex::new(Chan::default())); self.side = Some(SideConnect { @@ -646,6 +673,22 @@ 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 = match (&self.phase, self.finished) { + (Phase::Header, _) => format!("never sent a whole request ({} bytes of header)", self.inbuf.len()), + (_, true) if self.out.is_empty() => "complete".to_string(), + (_, true) => format!("closed with {} bytes unsent", self.out.len()), + (_, false) => "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 @@ -670,6 +713,7 @@ impl TcpService for RshSession { self.advance_job(max - self.out.len()); } let n = max.min(self.out.len()); + self.sent += n as u64; self.out.drain(..n).collect() } From 94c0ad1044b5b7b02c77a4f508f107c5e5b969e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:43:53 +0200 Subject: [PATCH 2/5] net/rsh: trace where guest port-514 connections go; startup line With IRIS_RSH_TRACE set, report each guest connection to port 514 and whether the built-in server answered it or it was relayed to the host (rsh_dir unset, or not the gateway address), and the stderr connect-back. Print a startup line when rsh_dir is on. "No trace" now distinguishes "never reached the server" from "no requests". Not built in full here (no crates.io access); rsh.rs tests pass standalone. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NJtD8EfBuugQ7a2pv4vhsU --- HELP.md | 6 +++++- src/net/engine.rs | 3 +++ src/net/nat_tcp.rs | 16 ++++++++++++++++ src/net/rsh.rs | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/HELP.md b/HELP.md index 5e9df859..bb6f4f73 100644 --- a/HELP.md +++ b/HELP.md @@ -226,7 +226,11 @@ 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. Run `rsh -n` if the command should not +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 5dea2bbd..9f56528a 100644 --- a/src/net/rsh.rs +++ b/src/net/rsh.rs @@ -306,7 +306,7 @@ enum Job { /// 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. -fn trace_enabled() -> bool { +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()) } From ab78678f523d3b720137a345c3778e4c8aa72c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:51:06 +0200 Subject: [PATCH 3/5] net/rsh: connect back for stderr as soon as the port arrives BSD rcmd sends only the stderr port, then blocks accepting the connection back, and sends the user names and command only after that. The server waited for the whole request before connecting back, so both sides waited until the client's timeout (inst: 120 s) and the guest then closed with nothing sent. Request the connection back when the first field is complete. Also stop the trace calling such a session "complete". Standalone rsh.rs tests pass (23); the full crate is not built here, so the updated testnet.rs test is unrun. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NJtD8EfBuugQ7a2pv4vhsU --- src/net/rsh.rs | 142 +++++++++++++++++++++++++++++++++++++++------ src/net/testnet.rs | 5 +- 2 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/net/rsh.rs b/src/net/rsh.rs index 9f56528a..866f3cc8 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` @@ -139,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 @@ -329,6 +346,12 @@ 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, } impl RshSession { @@ -339,6 +362,7 @@ impl RshSession { 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, } } @@ -367,6 +391,20 @@ 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); @@ -376,14 +414,8 @@ impl RshSession { req.local_user, req.command, req.stderr_port); } self.command = req.command.clone(); - 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); - } + 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, @@ -679,11 +711,15 @@ impl Drop for RshSession { return; } let ms = self.started.elapsed().as_millis(); - let how = match (&self.phase, self.finished) { - (Phase::Header, _) => format!("never sent a whole request ({} bytes of header)", self.inbuf.len()), - (_, true) if self.out.is_empty() => "complete".to_string(), - (_, true) => format!("closed with {} bytes unsent", self.out.len()), - (_, false) => "closed by the guest before the command finished".to_string(), + 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); } @@ -697,6 +733,29 @@ impl TcpService for RshSession { 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) => { @@ -783,6 +842,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"); 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())); From b61f488663faf51f9b4d691c667091c66346bc15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 12:36:50 +0200 Subject: [PATCH 4/5] rsh: accept exec /bin/sh and run stdin lines against the built-in commands inst runs 'exec /bin/sh' and drives it over stdin. Shell mode runs each stdin line (with ';', 2>/dev/null) against the read-only built-ins and logs every line under IRIS_RSH_TRACE. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NJtD8EfBuugQ7a2pv4vhsU --- CHANGELOG.md | 4 +- src/net/rsh.rs | 175 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b3e37d..78e2e8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ 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 (`;`, `2>/dev/null`, `cd`/`exit` + accepted), and 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/src/net/rsh.rs b/src/net/rsh.rs index 866f3cc8..1a7cbc2d 100644 --- a/src/net/rsh.rs +++ b/src/net/rsh.rs @@ -352,6 +352,17 @@ pub struct RshSession { 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 line: error text is dropped. + quiet: bool, } impl RshSession { @@ -363,11 +374,23 @@ impl RshSession { 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, } } /// 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 { @@ -378,6 +401,9 @@ 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.quiet { + return; + } match &self.chan { Some(c) => c.lock().data.extend(msg.as_bytes()), None => self.out.extend(msg.as_bytes()), @@ -424,11 +450,33 @@ 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() { + "true" | ":" | "cd" | "export" | "set" | "umask" | "unset" => self.finish(), + "exit" | "logout" => self.end_session(), "cat" => self.cmd_cat(args), "dd" => self.cmd_dd(args), "ls" => self.cmd_ls(args), @@ -444,6 +492,52 @@ impl RshSession { } } + /// 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 { + 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 mut line = String::from_utf8_lossy(&raw).trim().to_string(); + if self.trace { + eprintln!("iris: rsh #{} stdin: {:?}", self.id, line); + } + self.quiet = false; + for pat in ["2>/dev/null", "2> /dev/null"] { + if line.contains(pat) { + line = line.replace(pat, " "); + self.quiet = true; + } + } + line = line.replace("2>&1", " ").replace(" self.run_words(&w), + Err(e) => { + self.err(&format!("sh: {e}\n")); + self.finish(); + } + } + } + } + fn cmd_echo(&mut self, args: &[String]) { let (newline, args) = match args.split_first() { Some((f, rest)) if f == "-n" => (false, rest), @@ -676,6 +770,31 @@ 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 a line at its first `;` outside quotes. +fn split_semicolon(line: &str) -> (String, Option) { + let mut quote = None; + for (i, c) in line.char_indices() { + match (quote, c) { + (None, '\'' | '"') => quote = Some(c), + (Some(q), c) if c == q => quote = None, + (None, ';') => return (line[..i].to_string(), Some(line[i + 1..].to_string())), + _ => {} + } + } + (line.to_string(), None) +} + /// 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 { @@ -729,6 +848,11 @@ 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(); } @@ -762,14 +886,34 @@ impl TcpService for RshSession { 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; @@ -786,6 +930,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(); } } @@ -832,6 +982,27 @@ 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(); + } + #[test] fn parses_a_whole_request() { let r = parse_request(&req("1023", "cat /x")); From 38645c9cedb9d899e39f0b36912ed922b4985ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:26:18 +0200 Subject: [PATCH 5/5] rsh: run inst's script lines with the IsDone marker protocol inst sends each command as one line ending in ( status=$? ; trap '' 2 ; echo 'MARK\c' ; echo 'MARK'$status'\c' 1>&2 ). Run the commands, then write the marker to stdout and, with the exit status, to the stderr connection. Add trap (no-op), echo X | dd, dd | fgrep and the IRIX ls -inld[L] / /bin/ls -a formats, as the reference server does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NJtD8EfBuugQ7a2pv4vhsU --- CHANGELOG.md | 6 +- src/net/rsh.rs | 393 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 348 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e2e8f5..2fbccad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,10 @@ hashes are given where a change is easiest to understand by reading the commit. 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. `exec /bin/sh` (what `inst` runs) is accepted: stdin is - run line by line against the same commands (`;`, `2>/dev/null`, `cd`/`exit` - accepted), and with `IRIS_RSH_TRACE` each line is logged. It speaks the BSD `rcmd` protocol, including the + 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/src/net/rsh.rs b/src/net/rsh.rs index 1a7cbc2d..b45d1ec7 100644 --- a/src/net/rsh.rs +++ b/src/net/rsh.rs @@ -46,7 +46,7 @@ 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; @@ -316,6 +316,8 @@ 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 @@ -361,8 +363,15 @@ pub struct RshSession { stdin: Vec, /// The client closed its side of stdin. stdin_eof: bool, - /// `2>/dev/null` on the running line: error text is dropped. + /// `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 { @@ -375,6 +384,7 @@ impl RshSession { 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, } } @@ -401,6 +411,9 @@ 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; } @@ -474,8 +487,9 @@ impl RshSession { self.err("rshd: no command given (interactive login is not supported)\n"); return self.finish(); }; - match name.as_str() { - "true" | ":" | "cd" | "export" | "set" | "umask" | "unset" => self.finish(), + 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), @@ -487,6 +501,7 @@ impl RshSession { } other => { self.err(&format!("{other}: Command not found.\n")); + self.status = 127; self.finish(); } } @@ -496,6 +511,26 @@ impl RshSession { /// 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(); @@ -503,37 +538,80 @@ impl RshSession { return; }; let raw: Vec = self.stdin.drain(..=nl).collect(); - let mut line = String::from_utf8_lossy(&raw).trim().to_string(); + let line = String::from_utf8_lossy(&raw).trim().to_string(); if self.trace { eprintln!("iris: rsh #{} stdin: {:?}", self.id, line); } - self.quiet = false; - for pat in ["2>/dev/null", "2> /dev/null"] { - if line.contains(pat) { - line = line.replace(pat, " "); - self.quiet = true; - } - } - line = line.replace("2>&1", " ").replace("&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; } - match split_words(&first) { - Ok(w) => self.run_words(&w), - Err(e) => { - self.err(&format!("sh: {e}\n")); - self.finish(); + } + 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(); } } } @@ -543,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(); @@ -614,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 { @@ -625,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(); } } @@ -639,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) { @@ -649,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; @@ -676,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()); @@ -690,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(); + } + } } } @@ -781,27 +902,137 @@ fn is_shell(words: &[String]) -> bool { }) } -/// Split a line at its first `;` outside quotes. -fn split_semicolon(line: &str) -> (String, Option) { - let mut quote = None; - for (i, c) in line.char_indices() { +/// 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, - (None, ';') => return (line[..i].to_string(), Some(line[i + 1..].to_string())), - _ => {} + (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()..]; } - (line.to_string(), None) + 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 { @@ -1003,6 +1234,70 @@ mod tests { 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")); @@ -1189,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");