pub(crate) mod complete;
pub(crate) mod ops;
pub(crate) mod v2;
pub(crate) mod v3;
use ops::{ShellDeviceType, ShellEntry, ShellFileInfo, ShellFileType, ShellHandle, ShellOps};
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use sha2::{Digest, Sha256};
use colored::Colorize as _;
use crate::engine::file_handle::FileHandleAnalyzer;
use crate::proto::nfs3::types::FileHandle;
use crate::util::utmp::{LastlogRecord, UTMP_RECORD_SIZE, UtType, UtmpRecord, parse_lastlog, parse_passwd, parse_utmp};
const CAT_MAX_BYTES: u32 = 1_048_576;
const CHUNK_SIZE: u32 = 65_536;
pub(crate) const V3_SHELL_COMMANDS: &[&str] = &[
"ls",
"ll",
"dir",
"cd",
"pwd",
"tree",
"find",
"cat",
"type",
"get",
"download",
"put",
"upload",
"rm",
"del",
"mkdir",
"rmdir",
"mv",
"rename",
"cp",
"copy",
"chmod",
"chown",
"stat",
"readlink",
"symlink",
"link",
"uid",
"gid",
"hostname",
"whoami",
"id",
"impersonate",
"su",
"mknod",
"suid-scan",
"world-writable",
"secrets-scan",
"last",
"lastb",
"lastlog",
"escape-root",
"mount-handle",
"handle",
"verifier",
"lcd",
"lls",
"lpwd",
"lmkdir",
"history",
"help",
"exit",
"quit",
];
pub(crate) const V2_SHELL_COMMANDS: &[&str] = &[
"ls",
"ll",
"dir",
"cd",
"pwd",
"tree",
"find",
"cat",
"type",
"get",
"download",
"put",
"upload",
"rm",
"del",
"mkdir",
"rmdir",
"mv",
"rename",
"cp",
"copy",
"chmod",
"chown",
"stat",
"readlink",
"symlink",
"link",
"uid",
"gid",
"hostname",
"whoami",
"id",
"impersonate",
"su",
"suid-scan",
"world-writable",
"secrets-scan",
"last",
"lastb",
"lastlog",
"escape-root",
"mount-handle",
"handle",
"root",
"lcd",
"lls",
"lpwd",
"lmkdir",
"history",
"help",
"exit",
"quit",
];
pub(crate) struct NfsShell<O: ShellOps> {
ops: O,
export_root: ShellHandle,
cwd: ShellHandle,
cwd_path: String,
allow_write: bool,
hostname: String,
history: Vec<String>,
tab_cache: Arc<Mutex<complete::TabCache>>,
commands: &'static [&'static str],
}
impl<O: ShellOps> NfsShell<O> {
#[must_use]
pub(crate) fn new(ops: O, root: ShellHandle, allow_write: bool, hostname: String, commands: &'static [&'static str]) -> Self {
let tab_cache = Arc::new(Mutex::new(complete::TabCache { cwd: root.as_bytes().to_vec(), entries: Vec::new() }));
Self { ops, export_root: root.clone(), cwd: root, cwd_path: "/".to_owned(), allow_write, hostname, history: Vec::new(), tab_cache, commands }
}
#[must_use]
pub(crate) fn cwd_path(&self) -> &str {
&self.cwd_path
}
#[must_use]
pub(crate) fn current_uid(&self) -> u32 {
self.ops.uid()
}
#[must_use]
pub(crate) fn current_gid(&self) -> u32 {
self.ops.gid()
}
pub(crate) fn make_completer(&self) -> complete::ShellCompleter {
complete::ShellCompleter::new(self.ops.make_completer(), self.export_root.as_bytes().to_vec(), Arc::clone(&self.tab_cache), self.commands)
}
pub(crate) async fn refresh_tab_cache(&self) {
let entries = self.ops.list_dir(&self.cwd).await.map_or_else(|_| Vec::new(), |es| es.into_iter().filter(|e| e.name != "." && e.name != "..").map(|e| e.name).collect());
if let Ok(mut cache) = self.tab_cache.lock() {
cache.cwd = self.cwd.as_bytes().to_vec();
cache.entries = entries;
}
}
#[expect(clippy::cognitive_complexity, reason = "shell dispatch table")]
pub(crate) async fn dispatch(&mut self, line: &str) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return;
}
if self.history.last().map(String::as_str) != Some(line) {
self.history.push(line.to_owned());
}
let mut parts = line.splitn(2, ' ');
let cmd = parts.next().unwrap_or("");
let arg = parts.next().unwrap_or("").trim();
match cmd {
"ls" | "dir" => self.cmd_ls(arg).await,
"ll" => {
let ll_arg = format!("-a {arg}");
self.cmd_ls(ll_arg.trim_end()).await;
},
"cd" => self.cmd_cd(arg).await,
"pwd" => println!("{}", self.cwd_path),
"tree" => self.cmd_tree(arg).await,
"find" => self.cmd_find(arg).await,
"cat" | "type" => self.cmd_cat(arg).await,
"get" | "download" => self.cmd_get(arg).await,
"put" | "upload" => self.cmd_put(arg).await,
"rm" | "del" => self.cmd_rm(arg).await,
"mkdir" => self.cmd_mkdir(arg).await,
"rmdir" => self.cmd_rmdir(arg).await,
"mv" | "rename" => self.cmd_mv(arg).await,
"cp" | "copy" => self.cmd_cp(arg).await,
"chmod" => self.cmd_chmod(arg).await,
"chown" => self.cmd_chown(arg).await,
"stat" => self.cmd_stat(arg).await,
"readlink" => self.cmd_readlink(arg).await,
"symlink" => self.cmd_symlink(arg).await,
"link" => self.cmd_link(arg).await,
"uid" => self.cmd_uid(arg),
"gid" => self.cmd_gid(arg),
"hostname" => self.cmd_hostname(arg),
"whoami" | "id" => self.cmd_whoami(),
"impersonate" | "su" => self.cmd_impersonate(arg),
"mknod" => self.cmd_mknod(arg).await,
"suid-scan" => self.cmd_suid_scan().await,
"world-writable" => self.cmd_world_writable().await,
"secrets-scan" => self.cmd_secrets_scan().await,
"last" => self.cmd_last(arg, false).await,
"lastb" => self.cmd_last(arg, true).await,
"lastlog" => self.cmd_lastlog(arg).await,
"escape-root" => self.cmd_escape_root().await,
"mount-handle" => self.cmd_mount_handle(arg).await,
"handle" => println!("{}", self.cwd.to_hex()),
"root" => self.cmd_root().await,
"verifier" => self.cmd_verifier().await,
"lcd" => self.cmd_lcd(arg),
"lls" => self.cmd_lls(arg),
"lpwd" => self.cmd_lpwd(),
"lmkdir" => self.cmd_lmkdir(arg),
"history" => self.cmd_history(),
"help" | "?" => self.print_help(),
"exit" | "quit" => {},
_ => eprintln!("{}", format!("unknown command: {cmd} (try 'help')").yellow()),
}
}
async fn cmd_ls(&self, raw: &str) {
let (sort, reverse, all_cols, path_str) = parse_ls_args(raw);
let target = if path_str.is_empty() { None } else { Some(path_str) };
let all_entries: Vec<ShellEntry> = match target {
None => match self.ops.list_dir(&self.cwd).await {
Ok(v) => v,
Err(e) => {
eprintln!("{}", format!("ls: {e}").red());
return;
},
},
Some(p) => {
let (fh, info) = match self.lookup_path(p).await {
Ok(pair) => pair,
Err(e) => {
eprintln!("{}", format!("ls: {p}: {e}").red());
return;
},
};
if info.file_type == ShellFileType::Directory {
match self.ops.list_dir(&fh).await {
Ok(v) => v,
Err(e) => {
eprintln!("{}", format!("ls: {p}: {e}").red());
return;
},
}
} else {
let name = p.rsplit('/').find(|s| !s.is_empty()).unwrap_or(p).to_owned();
vec![ShellEntry { name, info: Some(info), handle: Some(fh) }]
}
},
};
let mut dot: Option<&ShellEntry> = None;
let mut dotdot: Option<&ShellEntry> = None;
let mut rest: Vec<&ShellEntry> = Vec::with_capacity(all_entries.len());
for e in &all_entries {
if e.name == "." {
dot = Some(e);
} else if e.name == ".." {
dotdot = Some(e);
} else {
rest.push(e);
}
}
rest.sort_by(|a, b| ls_cmp_shell(a, b, sort));
if reverse {
rest.reverse();
}
if all_cols {
println!("{:>10} {:<10} {:>4} {:>8} {:>8} {:>12} {:>12} {:>11} {:<19} {:<19} {:<19} name", "inode", "mode", "nlink", "uid", "gid", "size", "used", "rdev", "atime", "mtime", "ctime");
println!("{}", "-".repeat(148).dimmed());
} else {
let time_label = match sort {
LsSort::Ctime => "ctime",
LsSort::Atime => "atime",
_ => "mtime",
};
println!("{:<10} {:>8} {:>8} {:>12} {:<19} name", "mode", "uid", "gid", "size", time_label);
println!("{}", "-".repeat(75).dimmed());
}
let print_entry = |entry: &ShellEntry| {
let tc = entry.info.as_ref().map_or('?', |a| a.file_type.letter());
let mode_str = entry.info.as_ref().map_or_else(|| "?????????".to_owned(), |a| format_mode(a.mode));
let uid = entry.info.as_ref().map_or(0u32, |a| a.uid);
let gid = entry.info.as_ref().map_or(0u32, |a| a.gid);
let size = entry.info.as_ref().map_or(0u64, |a| a.size);
let name_str = colorize_name(&entry.name, tc);
if all_cols {
let nlink = entry.info.as_ref().map_or(0u32, |a| a.nlink);
let inode = entry.info.as_ref().map_or(0u64, |a| a.fileid);
let used = entry.info.as_ref().map_or(0u64, |a| a.used);
let rdev = entry.info.as_ref().map(|a| if a.file_type == ShellFileType::Block || a.file_type == ShellFileType::Character { format!("{}:{}", a.rdev.0, a.rdev.1) } else { "-".to_owned() });
let rdev_str = rdev.unwrap_or_else(|| "-".to_owned());
let atime = entry.info.as_ref().map_or_else(|| "????-??-?? ??:??:??".to_owned(), |a| fmt_unix_time(a.atime_secs));
let mtime = entry.info.as_ref().map_or_else(|| "????-??-?? ??:??:??".to_owned(), |a| fmt_unix_time(a.mtime_secs));
let ctime = entry.info.as_ref().map_or_else(|| "????-??-?? ??:??:??".to_owned(), |a| fmt_unix_time(a.ctime_secs));
println!("{inode:>10} {tc}{mode_str} {nlink:4} {uid:8} {gid:8} {size:12} {used:12} {rdev_str:>11} {atime} {mtime} {ctime} {name_str}");
} else {
let time_secs = entry.info.as_ref().map(|a| match sort {
LsSort::Ctime => a.ctime_secs,
LsSort::Atime => a.atime_secs,
_ => a.mtime_secs,
});
let time_str = time_secs.map_or_else(|| "????-??-?? ??:??:??".to_owned(), fmt_unix_time);
println!("{tc}{mode_str} {uid:8} {gid:8} {size:12} {time_str} {name_str}");
}
};
if let Some(e) = dot {
print_entry(e);
}
if let Some(e) = dotdot {
print_entry(e);
}
for e in &rest {
print_entry(e);
}
}
async fn cmd_cd(&mut self, target: &str) {
let target = if target.is_empty() { "/" } else { target };
if target == "/" {
self.cwd.clone_from(&self.export_root);
"/".clone_into(&mut self.cwd_path);
self.refresh_tab_cache().await;
return;
}
let (start_fh, path_base, rel) = if target.starts_with('/') { (self.export_root.clone(), "/", target.trim_start_matches('/')) } else { (self.cwd.clone(), self.cwd_path.as_str(), target) };
if rel.is_empty() {
self.cwd.clone_from(&self.export_root);
"/".clone_into(&mut self.cwd_path);
self.refresh_tab_cache().await;
return;
}
match self.ops.lookup_path(&start_fh, rel).await {
Ok((fh, info)) => {
if info.file_type != ShellFileType::Directory {
eprintln!("{}", format!("cd: {target}: not a directory").red());
return;
}
self.cwd = fh;
self.cwd_path = build_path(path_base, rel);
self.refresh_tab_cache().await;
},
Err(e) => eprintln!("{}", format!("cd: {target}: {e}").red()),
}
}
async fn cmd_tree(&self, arg: &str) {
let max_depth = arg.split_whitespace().find_map(|t| t.parse::<usize>().ok()).unwrap_or(3);
println!("{}", self.cwd_path.bold());
tree_recursive(&self.ops, self.cwd.clone(), String::new(), 0, max_depth).await;
}
async fn cmd_find(&self, pattern: &str) {
if pattern.is_empty() {
eprintln!("{}", "usage: find <pattern>".yellow());
return;
}
let pattern_lower = pattern.to_ascii_lowercase();
let filter = |entry: &ShellEntry, path: &str| -> Option<String> {
if entry.name.to_ascii_lowercase().contains(&pattern_lower) {
let tc = entry.info.as_ref().map_or('?', |a| a.file_type.letter());
Some(colorize_name(path, tc))
} else {
None
}
};
walk_recursive(&self.ops, self.cwd.clone(), self.cwd_path.clone(), &filter).await;
}
async fn cmd_cat(&self, name: &str) {
if name.is_empty() {
eprintln!("{}", "usage: cat <file>".yellow());
return;
}
let (fh, _) = match self.lookup_path(name).await {
Ok(pair) => pair,
Err(e) => {
eprintln!("{}", format!("cat: {name}: {e}").red());
return;
},
};
match self.ops.read_file(&fh).await {
Ok(data) => {
let mut stdout = std::io::stdout();
let len = data.len().min(CAT_MAX_BYTES as usize);
drop(stdout.write_all(data.get(..len).unwrap_or(&data)));
if data.last().is_some_and(|&b| b != b'\n') {
println!();
}
if data.len() > CAT_MAX_BYTES as usize {
eprintln!("{}", format!("[!] output truncated at {CAT_MAX_BYTES} bytes -- use `get` for the full file").yellow());
}
},
Err(e) => eprintln!("{}", format!("cat: {name}: {e}").red()),
}
}
async fn cmd_get(&self, line: &str) {
let tokens: Vec<&str> = line.split_whitespace().collect();
let mut recursive = false;
let mut verify_hash: Option<String> = None;
let mut positional: Vec<&str> = Vec::with_capacity(2);
let mut iter = tokens.iter().copied();
while let Some(tok) = iter.next() {
match tok {
"-r" => recursive = true,
"--verify" => {
if let Some(h) = iter.next() {
verify_hash = Some(h.to_owned());
} else {
eprintln!("{}", "get: --verify requires a hex SHA-256 hash".red());
return;
}
},
t if t.starts_with("--") => {
eprintln!("{}", format!("get: unknown flag {t}").red());
return;
},
t => positional.push(t),
}
}
let Some(&remote) = positional.first() else {
eprintln!("{}", "usage: get [-r] [--verify <sha256>] <remote> [local]".yellow());
return;
};
let local = positional.get(1).copied().unwrap_or("");
let (fh, info) = match self.lookup_path(remote).await {
Ok(pair) => pair,
Err(e) => {
eprintln!("{}", format!("get: {remote}: {e}").red());
return;
},
};
let local_is_dir = Path::new(local).is_dir();
let dest = resolve_get_dest(remote, local, local_is_dir);
if !local.is_empty() && (local.ends_with('/') || local_is_dir) {
eprintln!("{}", format!("get: {local} is a directory -> saving as {dest}").yellow());
}
let dest = dest.as_str();
if recursive && info.file_type == ShellFileType::Directory {
let mp = MultiProgress::new();
match download_tree(&self.ops, &fh, dest, &mp).await {
Ok(bytes) => println!("{}", format!("saved {bytes} bytes -> {dest}/").green()),
Err(e) => eprintln!("{}", format!("get -r: {e}").red()),
}
} else {
match download_file(&self.ops, &fh, dest).await {
Ok((bytes, hash)) => {
println!("{}", format!("saved {bytes} bytes -> {dest} sha256:{hash}").green());
if let Some(ref expected) = verify_hash {
if hash.eq_ignore_ascii_case(expected) {
println!("{}", "sha256 verified".green());
} else {
eprintln!("{}", format!("get: sha256 mismatch expected:{expected} got:{hash}").red());
}
}
},
Err(e) => eprintln!("{}", format!("get: {e}").red()),
}
}
}
async fn cmd_put(&self, line: &str) {
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let mut recursive = false;
let mut rest = line.trim();
if let Some(r) = rest.strip_prefix("-r") {
recursive = true;
rest = r.trim_start();
}
let (local, remote) = split2(rest);
if local.is_empty() || remote.is_empty() {
eprintln!("{}", "usage: put [-r] <local> <remote>".yellow());
return;
}
let local_path = Path::new(local);
if recursive && local_path.is_dir() {
let (remote_parent_fh, remote_dir_name) = match self.resolve_parent(remote).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("put -r: {e}").red());
return;
},
};
#[cfg(unix)]
let dir_mode = std::fs::metadata(local_path).map_or(0o755, |m| m.permissions().mode() & 0o7777);
#[cfg(not(unix))]
let dir_mode = 0o755;
let dir_fh = match self.ops.mkdir(&remote_parent_fh, &remote_dir_name, dir_mode).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("put -r: create dir {remote}: {e}").red());
return;
},
};
let mp = MultiProgress::new();
match upload_tree(&self.ops, local_path, &dir_fh, &mp).await {
Ok(bytes) => println!("{}", format!("put -r: {bytes} bytes -> {remote}/").green()),
Err(e) => eprintln!("{}", format!("put -r: {e}").red()),
}
return;
}
let local_meta = match std::fs::metadata(local) {
Ok(m) => m,
Err(e) => {
eprintln!("{}", format!("put: cannot stat {local}: {e}").red());
return;
},
};
let data = match std::fs::read(local) {
Ok(d) => d,
Err(e) => {
eprintln!("{}", format!("put: cannot read {local}: {e}").red());
return;
},
};
let (parent_fh, filename) = match self.resolve_parent(remote).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("put: {e}").red());
return;
},
};
#[cfg(unix)]
let mode = local_meta.permissions().mode() & 0o7777;
#[cfg(not(unix))]
let mode = if local_meta.permissions().readonly() { 0o444 } else { 0o644 };
let file_fh = match self.ops.create_file(&parent_fh, &filename, mode).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("put: create {remote}: {e}").red());
return;
},
};
match upload_data(&self.ops, &file_fh, &data).await {
Ok(n) => println!("{}", format!("put: {n} bytes -> {remote}").green()),
Err(e) => eprintln!("{}", format!("put: write error: {e}").red()),
}
}
async fn cmd_rm(&self, path: &str) {
if path.is_empty() {
eprintln!("{}", "usage: rm <file>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (parent_fh, filename) = match self.resolve_parent(path).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("rm: {e}").red());
return;
},
};
match self.ops.remove(&parent_fh, &filename).await {
Ok(()) => println!("{}", format!("removed {path}").green()),
Err(e) => eprintln!("{}", format!("rm: {e}").red()),
}
}
async fn cmd_mkdir(&self, path: &str) {
if path.is_empty() {
eprintln!("{}", "usage: mkdir <dir>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (parent_fh, dirname) = match self.resolve_parent(path).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("mkdir: {e}").red());
return;
},
};
match self.ops.mkdir(&parent_fh, &dirname, 0o755).await {
Ok(_) => println!("{}", format!("created {path}").green()),
Err(e) => eprintln!("{}", format!("mkdir: {e}").red()),
}
}
async fn cmd_rmdir(&self, path: &str) {
if path.is_empty() {
eprintln!("{}", "usage: rmdir <dir>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (parent_fh, dirname) = match self.resolve_parent(path).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("rmdir: {e}").red());
return;
},
};
match self.ops.rmdir(&parent_fh, &dirname).await {
Ok(()) => println!("{}", format!("removed {path}").green()),
Err(e) => eprintln!("{}", format!("rmdir: {e}").red()),
}
}
async fn cmd_mv(&self, line: &str) {
let (src, dst) = split2(line);
if src.is_empty() || dst.is_empty() {
eprintln!("{}", "usage: mv <src> <dst>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let ((from_fh, from_name), (to_fh, to_name)) = match (self.resolve_parent(src).await, self.resolve_parent(dst).await) {
(Ok(a), Ok(b)) => (a, b),
(Err(e), _) | (_, Err(e)) => {
eprintln!("{}", format!("mv: {e}").red());
return;
},
};
match self.ops.rename(&from_fh, &from_name, &to_fh, &to_name).await {
Ok(()) => println!("{}", format!("{src} -> {dst}").green()),
Err(e) => eprintln!("{}", format!("mv: {e}").red()),
}
}
async fn cmd_cp(&self, line: &str) {
let (src, dst) = split2(line);
if src.is_empty() || dst.is_empty() {
eprintln!("{}", "usage: cp <src> <dst>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (src_fh, src_info) = match self.lookup_path(src).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("cp: {src}: {e}").red());
return;
},
};
let data = match self.ops.read_file(&src_fh).await {
Ok(d) => d,
Err(e) => {
eprintln!("{}", format!("cp: read {src}: {e}").red());
return;
},
};
let (parent_fh, filename) = match self.resolve_parent(dst).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("cp: {e}").red());
return;
},
};
let dst_fh = match self.ops.create_file(&parent_fh, &filename, src_info.mode).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("cp: create {dst}: {e}").red());
return;
},
};
match upload_data(&self.ops, &dst_fh, &data).await {
Ok(n) => println!("{}", format!("copied {n} bytes {src} -> {dst}").green()),
Err(e) => eprintln!("{}", format!("cp: write {dst}: {e}").red()),
}
}
async fn cmd_chmod(&self, line: &str) {
let (mode_str, path) = split2(line);
if mode_str.is_empty() || path.is_empty() {
eprintln!("{}", "usage: chmod <octal-mode> <path>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let Ok(mode) = u32::from_str_radix(mode_str, 8) else {
eprintln!("{}", format!("chmod: invalid mode {mode_str}").red());
return;
};
let fh = match self.resolve_handle(Some(path)).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("chmod: {e}").red());
return;
},
};
match self.ops.set_mode(&fh, mode).await {
Ok(()) => println!("{}", format!("mode set to {mode_str} on {path}").green()),
Err(e) => eprintln!("{}", format!("chmod: {e}").red()),
}
}
async fn cmd_chown(&self, line: &str) {
let (spec, path) = split2(line);
if spec.is_empty() || path.is_empty() {
eprintln!("{}", "usage: chown <uid>[:<gid>] <path>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (uid_opt, gid_opt) = parse_uid_gid(spec);
if uid_opt.is_none() && gid_opt.is_none() {
eprintln!("{}", format!("chown: cannot parse {spec}").red());
return;
}
let fh = match self.resolve_handle(Some(path)).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("chown: {e}").red());
return;
},
};
match self.ops.set_owner(&fh, uid_opt, gid_opt).await {
Ok(()) => println!("{}", format!("ownership set on {path}").green()),
Err(e) => eprintln!("{}", format!("chown: {e}").red()),
}
}
async fn cmd_stat(&self, name: &str) {
let fh = match self.resolve_handle(if name.is_empty() { None } else { Some(name) }).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("stat: {e}").red());
return;
},
};
match self.ops.getattr(&fh).await {
Ok(info) => print_stat(if name.is_empty() { "." } else { name }, &info),
Err(e) => eprintln!("{}", format!("stat: {e}").red()),
}
}
async fn cmd_readlink(&self, name: &str) {
if name.is_empty() {
eprintln!("{}", "usage: readlink <symlink>".yellow());
return;
}
let (fh, _) = match self.lookup_path(name).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("readlink: {name}: {e}").red());
return;
},
};
match self.ops.readlink(&fh).await {
Ok(target) => println!("{target}"),
Err(e) => eprintln!("{}", format!("readlink: {e}").red()),
}
}
async fn cmd_symlink(&self, line: &str) {
let (target, linkname) = split2(line);
if target.is_empty() || linkname.is_empty() {
eprintln!("{}", "usage: symlink <target> <linkname>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let (parent_fh, link_filename) = match self.resolve_parent(linkname).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("symlink: {e}").red());
return;
},
};
match self.ops.symlink(&parent_fh, &link_filename, target).await {
Ok(()) => println!("{}", format!("{linkname} -> {target}").green()),
Err(e) => eprintln!("{}", format!("symlink: {e}").red()),
}
}
async fn cmd_link(&self, line: &str) {
let (existing, linkname) = split2(line);
if existing.is_empty() || linkname.is_empty() {
eprintln!("{}", "usage: link <existing> <linkname>".yellow());
return;
}
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
let target_fh = match self.lookup_path(existing).await {
Ok((fh, _)) => fh,
Err(e) => {
eprintln!("{}", format!("link: {existing}: {e}").red());
return;
},
};
let (parent_fh, link_filename) = match self.resolve_parent(linkname).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("link: {linkname}: {e}").red());
return;
},
};
match self.ops.hard_link(&target_fh, &parent_fh, &link_filename).await {
Ok(()) => println!("{}", format!("{linkname} => {existing}").green()),
Err(e) => eprintln!("{}", format!("link: {e}").red()),
}
}
fn cmd_uid(&mut self, arg: &str) {
if !self.ops.supports_identity_change() {
eprintln!("{}", format!("uid: identity changes not supported on {}", self.ops.version_name()).red());
return;
}
match arg.parse::<u32>() {
Ok(uid) => {
let gid = self.ops.gid();
if let Err(e) = self.ops.change_identity(uid, gid, &self.hostname) {
eprintln!("{}", format!("uid: {e}").red());
return;
}
println!("{}", format!("uid={uid} gid={gid} hostname={}", self.hostname).green());
},
Err(_) => eprintln!("{}", format!("uid: invalid number: {arg}").red()),
}
}
fn cmd_gid(&mut self, arg: &str) {
if !self.ops.supports_identity_change() {
eprintln!("{}", format!("gid: identity changes not supported on {}", self.ops.version_name()).red());
return;
}
match arg.parse::<u32>() {
Ok(gid) => {
let uid = self.ops.uid();
if let Err(e) = self.ops.change_identity(uid, gid, &self.hostname) {
eprintln!("{}", format!("gid: {e}").red());
return;
}
println!("{}", format!("uid={uid} gid={gid} hostname={}", self.hostname).green());
},
Err(_) => eprintln!("{}", format!("gid: invalid number: {arg}").red()),
}
}
fn cmd_hostname(&mut self, arg: &str) {
if arg.is_empty() {
println!("{}", self.hostname);
return;
}
if !self.ops.supports_identity_change() {
eprintln!("{}", format!("hostname: identity changes not supported on {}", self.ops.version_name()).red());
return;
}
arg.clone_into(&mut self.hostname);
let uid = self.ops.uid();
let gid = self.ops.gid();
if let Err(e) = self.ops.change_identity(uid, gid, &self.hostname) {
eprintln!("{}", format!("hostname: {e}").red());
return;
}
println!("{}", format!("hostname={}", self.hostname).green());
}
fn cmd_whoami(&self) {
println!("uid={} gid={} hostname={}", self.ops.uid(), self.ops.gid(), self.ops.machinename());
}
fn cmd_impersonate(&mut self, arg: &str) {
if !self.ops.supports_identity_change() {
eprintln!("{}", format!("impersonate: identity changes not supported on {}", self.ops.version_name()).red());
return;
}
let (uid_opt, gid_opt) = parse_uid_gid(arg);
match (uid_opt, gid_opt) {
(Some(uid), Some(gid)) => {
if let Err(e) = self.ops.change_identity(uid, gid, &self.hostname) {
eprintln!("{}", format!("impersonate: {e}").red());
return;
}
println!("{}", format!("impersonating uid={uid} gid={gid}").green());
},
_ => eprintln!("{}", format!("impersonate: expected uid:gid (got {arg:?})").red()),
}
}
async fn cmd_mknod(&self, line: &str) {
if !self.allow_write {
eprintln!("{}", "write disabled -- rerun with --allow-write".red());
return;
}
if !self.ops.supports_mknod() {
eprintln!("{}", format!("mknod: not supported on {}", self.ops.version_name()).red());
return;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 4 {
eprintln!("{}", "usage: mknod <name> c|b <major> <minor>".yellow());
return;
}
let (name, dev_type_str, major, minor) = if let (Some(&n), Some(&t), Some(&maj), Some(&min)) = (parts.first(), parts.get(1), parts.get(2), parts.get(3)) {
(n, t, maj.parse::<u32>().unwrap_or(0), min.parse::<u32>().unwrap_or(0))
} else {
eprintln!("{}", "usage: mknod <name> c|b <major> <minor>".yellow());
return;
};
let dev_type = match dev_type_str {
"c" => ShellDeviceType::Char,
"b" => ShellDeviceType::Block,
_ => {
eprintln!("{}", "mknod: type must be 'c' (char) or 'b' (block)".red());
return;
},
};
let (parent_fh, node_name) = match self.resolve_parent(name).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("mknod: {e}").red());
return;
},
};
match self.ops.mknod(&parent_fh, &node_name, dev_type, major, minor, 0o666).await {
Ok(_) => println!("{}", format!("mknod: created {name} ({dev_type_str} {major}:{minor})").green()),
Err(e) => eprintln!("{}", format!("mknod: {e}").red()),
}
}
async fn cmd_suid_scan(&self) {
eprintln!("{}", "[*] scanning for SUID/SGID binaries...".blue());
let filter = |entry: &ShellEntry, path: &str| -> Option<String> {
let a = entry.info.as_ref()?;
if a.file_type == ShellFileType::Regular && (a.mode & 0o6000 != 0) {
let tag = match a.mode & 0o6000 {
0o6000 => "SUID+SGID",
0o4000 => "SUID",
_ => "SGID",
};
Some(format!("{} {:04o} uid={} {path}", format!("[!] {tag}").yellow().bold(), a.mode & 0o7777, a.uid))
} else {
None
}
};
walk_recursive(&self.ops, self.cwd.clone(), self.cwd_path.clone(), &filter).await;
}
async fn cmd_world_writable(&self) {
eprintln!("{}", "[*] scanning for world-writable entries...".blue());
let filter = |entry: &ShellEntry, path: &str| -> Option<String> {
let a = entry.info.as_ref()?;
if a.mode & 0o002 != 0 {
let tc = a.file_type.letter();
Some(format!("{} {:04o} uid={} {path}", format!("[!] world-writable ({tc})").yellow(), a.mode & 0o7777, a.uid))
} else {
None
}
};
walk_recursive(&self.ops, self.cwd.clone(), self.cwd_path.clone(), &filter).await;
}
async fn cmd_secrets_scan(&self) {
eprintln!("{}", "[*] scanning for secrets and credentials...".blue());
let filter = |entry: &ShellEntry, path: &str| -> Option<String> {
let lower = entry.name.to_ascii_lowercase();
if SECRET_PATTERNS.iter().any(|pat| lower.contains(pat)) {
let size = entry.info.as_ref().map_or(0, |a| a.size);
let mode = entry.info.as_ref().map_or(0, |a| a.mode);
let exec_implies_read = (mode & 0o111 != 0) && (mode & 0o444 == 0);
let suffix = if exec_implies_read { " [execute-implies-read: readable via NFS despite mode]" } else { "" };
Some(format!("{} {path} ({size} bytes {:04o}){suffix}", "[!] potential secret:".yellow().bold(), mode & 0o7777))
} else {
None
}
};
walk_recursive(&self.ops, self.cwd.clone(), self.cwd_path.clone(), &filter).await;
}
async fn cmd_last(&self, arg: &str, lastb: bool) {
let max_recs: Option<usize> = arg.split_whitespace().next().and_then(|s| s.parse().ok());
let path = if lastb { "/var/log/btmp" } else { "/var/log/wtmp" };
let label = if lastb { "lastb" } else { "last" };
let (fh, info) = match self.lookup_path(path).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("{label}: {path}: {e}").red());
eprintln!("{}", format!(" (escape-root first if you're inside a sub-export; {path} lives on the underlying filesystem)").yellow());
return;
},
};
if info.size == 0 {
println!("{}", format!("{path} is empty -- no records to show").yellow());
return;
}
let bytes = match self.ops.read_file(&fh).await {
Ok(b) => b,
Err(e) => {
eprintln!("{}", format!("{label}: read {path}: {e}").red());
return;
},
};
let recs = parse_utmp(&bytes);
if recs.is_empty() {
println!("{}", format!("{path}: no parseable records ({} bytes / {UTMP_RECORD_SIZE} per record)", bytes.len()).yellow());
return;
}
if lastb {
render_lastb(&recs, max_recs);
} else {
render_last(&recs, max_recs);
}
}
async fn cmd_lastlog(&self, _arg: &str) {
let lastlog_path = "/var/log/lastlog";
let (fh, info) = match self.lookup_path(lastlog_path).await {
Ok(p) => p,
Err(e) => {
eprintln!("{}", format!("lastlog: {lastlog_path}: {e}").red());
self.lastlog_hint_lastlog2().await;
return;
},
};
if info.size == 0 {
println!("{}", format!("{lastlog_path} is empty -- no users have logged in interactively").yellow());
self.lastlog_hint_lastlog2().await;
return;
}
let bytes = match self.ops.read_file(&fh).await {
Ok(b) => b,
Err(e) => {
eprintln!("{}", format!("lastlog: read {lastlog_path}: {e}").red());
return;
},
};
let recs = parse_lastlog(&bytes);
let uid_to_user: Vec<(u32, String)> = match self.lookup_path("/etc/passwd").await {
Ok((pfh, _)) => match self.ops.read_file(&pfh).await {
Ok(b) => parse_passwd(&b),
Err(e) => {
eprintln!("{}", format!("lastlog: /etc/passwd unreadable ({e}); rendering UIDs numerically").yellow());
Vec::new()
},
},
Err(e) => {
eprintln!("{}", format!("lastlog: /etc/passwd lookup failed ({e}); rendering UIDs numerically").yellow());
Vec::new()
},
};
render_lastlog(&recs, &uid_to_user);
}
async fn lastlog_hint_lastlog2(&self) {
let candidate = "/var/lib/lastlog/lastlog2.db";
if let Ok((_fh, attrs)) = self.lookup_path(candidate).await {
eprintln!(
"{}",
format!(
"[*] note: {candidate} is present ({} bytes); util-linux 2.42 uses an SQLite database here. \
Run 'get {candidate} ./lastlog2.db' and read it with `sqlite3 lastlog2.db 'select * from Lastlog2'`.",
attrs.size
)
.cyan()
);
}
}
async fn cmd_escape_root(&mut self) {
let export_fh = FileHandle::from_bytes(self.export_root.as_bytes());
let candidates = FileHandleAnalyzer::construct_root_candidates(&export_fh);
if candidates.is_empty() {
eprintln!("{}", "escape-root: unsupported filesystem type (ext4/xfs/btrfs only)".red());
return;
}
let mut last_err: Option<String> = None;
for result in candidates {
let candidate = ShellHandle(result.root_handle.as_bytes().to_vec());
match self.ops.getattr(&candidate).await {
Ok(info) => {
if info.file_type != ShellFileType::Directory {
continue;
}
eprintln!("{}", format!("[+] escaped to filesystem root ({:?}, inode {})", result.fs_type, result.inode_number).green().bold());
eprintln!("{}", format!(" handle: {}", candidate.to_hex()).cyan());
eprintln!("{}", format!(" inode: {} type: {} mode: {:04o}", info.fileid, info.type_name(), info.mode & 0o7777).cyan());
self.export_root = candidate.clone();
self.cwd = candidate;
self.cwd_path = String::from("/ [escaped]");
self.refresh_tab_cache().await;
return;
},
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("NFS3ERR_ACCES") || msg.contains("NFS3ERR_PERM") || msg.contains("ACCES") || msg.contains("PERM") {
eprintln!("{}", format!("[+] escaped to filesystem root ({:?}, inode {}) -- root_squash active, GETATTR denied", result.fs_type, result.inode_number).green().bold());
eprintln!("{}", format!(" handle: {}", candidate.to_hex()).cyan());
self.export_root = candidate.clone();
self.cwd = candidate;
self.cwd_path = String::from("/ [escaped]");
self.refresh_tab_cache().await;
return;
}
last_err = Some(msg);
},
}
}
eprintln!("{}", format!("[!] no escape candidate resolved to the filesystem root (last error: {}) -- try `nfswolf escape` for the full inode scan", last_err.as_deref().unwrap_or("none")).yellow());
}
async fn cmd_mount_handle(&mut self, hex: &str) {
if hex.is_empty() {
eprintln!("{}", "usage: mount-handle <hex>".yellow());
return;
}
let fh = match ShellHandle::from_hex(hex) {
Ok(fh) => fh,
Err(e) => {
eprintln!("{}", format!("mount-handle: invalid hex: {e}").red());
return;
},
};
match self.ops.getattr(&fh).await {
Ok(info) => {
eprintln!("{}", format!("[+] handle OK type={} inode={}", info.type_name(), info.fileid).green());
self.cwd = fh;
self.cwd_path = String::from("<handle>");
self.refresh_tab_cache().await;
},
Err(e) => eprintln!("{}", format!("mount-handle: {e}").red()),
}
}
async fn cmd_root(&mut self) {
match self.ops.probe_root().await {
Ok(Some(fh)) => {
eprintln!("{}", "[!] Server returned a root handle via NFSPROC_ROOT -- MOUNT bypass!".red().bold());
eprintln!(" handle: {}", fh.to_hex());
eprintln!(" This is obsolete per RFC 1094 sec. 2.2.3. A server that");
eprintln!(" responds to it gives any client the root handle without");
eprintln!(" going through MOUNT's export ACL checks.");
self.cwd = fh;
},
Ok(None) => println!("{}", "NFSPROC_ROOT: server did not return a handle (expected -- procedure is obsolete since RFC 1094)".yellow()),
Err(e) => eprintln!("{}", format!("NFSPROC_ROOT: {e}").yellow()),
}
}
async fn cmd_verifier(&self) {
match self.ops.write_verifier(&self.cwd).await {
Ok(Some(verf)) => {
use std::fmt::Write as _;
let hex = verf.iter().fold(String::with_capacity(16), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
});
println!("writeverf3: {hex}");
},
Ok(None) => println!("{}", "verifier: COMMIT not available on this NFS version".yellow()),
Err(e) => eprintln!("{}", format!("verifier: {e}").red()),
}
}
fn cmd_lcd(&mut self, dir: &str) {
let _ = self;
let d = if dir.is_empty() { "." } else { dir };
match std::env::set_current_dir(d) {
Ok(()) => println!("{}", std::env::current_dir().map_or_else(|_| d.to_owned(), |p| p.display().to_string()).green()),
Err(e) => eprintln!("{}", format!("lcd: {e}").red()),
}
}
fn cmd_lls(&mut self, path: &str) {
let _ = self;
let target = if path.is_empty() { "." } else { path };
match std::fs::read_dir(target) {
Ok(iter) => {
let mut names: Vec<String> = iter.filter_map(Result::ok).map(|e| e.file_name().to_string_lossy().into_owned()).collect::<Vec<_>>();
names.sort();
for n in &names {
println!("{n}");
}
},
Err(e) => eprintln!("{}", format!("lls: {e}").red()),
}
}
fn cmd_lpwd(&mut self) {
let _ = self;
match std::env::current_dir() {
Ok(p) => println!("{}", p.display()),
Err(e) => eprintln!("{}", format!("lpwd: {e}").red()),
}
}
fn cmd_lmkdir(&mut self, dir: &str) {
let _ = self;
if dir.is_empty() {
eprintln!("{}", "usage: lmkdir <dir>".yellow());
return;
}
match std::fs::create_dir_all(dir) {
Ok(()) => println!("{}", format!("created {dir}").green()),
Err(e) => eprintln!("{}", format!("lmkdir: {e}").red()),
}
}
fn cmd_history(&self) {
for (i, line) in self.history.iter().enumerate() {
println!("{:4} {line}", i + 1);
}
}
async fn resolve_handle(&self, name: Option<&str>) -> anyhow::Result<ShellHandle> {
match name {
None => Ok(self.cwd.clone()),
Some(n) => self.lookup_path(n).await.map(|(fh, _)| fh),
}
}
async fn lookup_path(&self, path: &str) -> anyhow::Result<(ShellHandle, ShellFileInfo)> {
let start = if path.starts_with('/') { &self.export_root } else { &self.cwd };
self.ops.lookup_path(start, path).await
}
async fn resolve_parent(&self, path: &str) -> anyhow::Result<(ShellHandle, String)> {
match path.rfind('/') {
None => Ok((self.cwd.clone(), path.to_owned())),
Some(pos) => {
let parent_str = if pos == 0 { "/" } else { &path[..pos] };
let name = path[pos + 1..].to_owned();
if name.is_empty() {
anyhow::bail!("path must not end with '/'");
}
let (fh, _) = self.lookup_path(parent_str).await?;
Ok((fh, name))
},
}
}
fn print_help(&self) {
let ver = self.ops.version_name();
println!("{}", format!("{ver} shell commands:").bold());
println!();
println!("{}", "Navigation:".bold().underline());
println!(" ls [-a] [--sort=FIELD] [-r] [path] list directory (aliases: ll = ls -a, dir)");
println!(" cd <path> change directory (/ = export root, /abs = absolute)");
println!(" pwd print current path");
println!(" tree [depth] recursive tree (default depth 3; hidden dirs always shown)");
println!(" find <pattern> find filenames containing pattern");
println!();
println!("{}", "File operations:".bold().underline());
println!(" cat <file> print file contents (alias: type)");
println!(" get [-r] <remote> [local | dir/] download file/tree (alias: download)");
println!(" put [-r] <local> <remote> upload file/tree (alias: upload) [--allow-write]");
println!(" rm <file> remove file (alias: del) [--allow-write]");
println!(" mkdir <dir> create directory [--allow-write]");
println!(" rmdir <dir> remove directory [--allow-write]");
println!(" mv <src> <dst> rename/move (alias: rename) [--allow-write]");
println!(" cp <src> <dst> copy file (alias: copy) [--allow-write]");
println!(" symlink <target> <name> create symlink [--allow-write]");
println!(" link <existing> <name> create hard link [--allow-write]");
println!(" readlink <path> read symlink target");
println!();
println!("{}", "Attributes:".bold().underline());
println!(" stat [path] show file attributes");
println!(" chmod <mode> <path> set mode (octal) [--allow-write]");
println!(" chown <uid>[:<gid>] <path> set owner [--allow-write]");
println!();
if self.ops.supports_identity_change() {
println!("{}", "Identity (AUTH_SYS is client-asserted, RFC 5531 sec. 14):".bold().underline());
} else {
println!("{}", "Identity (read-only on this version):".bold().underline());
}
println!(" whoami show current uid:gid (alias: id)");
if self.ops.supports_identity_change() {
println!(" uid <n> switch UID");
println!(" gid <n> switch GID");
println!(" hostname <name> spoof AUTH_SYS machinename");
println!(" impersonate <uid>:<gid> switch both (alias: su)");
}
println!();
if self.ops.supports_mknod() {
println!("{}", "Devices:".bold().underline());
println!(" mknod <name> c|b <maj> <min> create device node [--allow-write]");
println!();
}
println!("{}", "Analysis:".bold().underline());
println!(" suid-scan find SUID/SGID binaries");
println!(" world-writable find world-writable files");
println!(" secrets-scan find credential/secret files");
println!(" last [N] decode /var/log/wtmp (login history; per util-linux 2.42 last.c)");
println!(" lastb [N] decode /var/log/btmp (failed-login history)");
println!(" lastlog decode /var/log/lastlog (last login per UID)");
println!();
println!("{}", "Escape (F-2.1 -- construct filesystem root handle):".bold().underline());
println!(" escape-root build and switch to FS root handle");
println!(" mount-handle <hex> jump to arbitrary file handle");
println!(" handle print current dir handle (hex)");
if self.ops.version_name() == "NFSv2" {
println!(" root probe NFSPROC_ROOT (obsolete MOUNT bypass)");
}
if self.ops.version_name() == "NFSv3" {
println!(" verifier probe write verifier (reboot oracle, RFC 1813 S3.3.21)");
}
println!();
println!("{}", "Local:".bold().underline());
println!(" lcd [dir] lls [dir] lpwd lmkdir <dir>");
println!();
println!("{}", "Session:".bold().underline());
println!(" history help exit");
}
}
const READ_ALL_MAX_BYTES: u64 = 256 * 1024 * 1024;
async fn download_file<O: ShellOps>(ops: &O, fh: &ShellHandle, dest_path: &str) -> anyhow::Result<(u64, String)> {
let mut file = std::fs::File::create(dest_path).map_err(|e| anyhow::anyhow!("create {dest_path}: {e}"))?;
let mut hasher = Sha256::new();
let mut offset = 0u64;
loop {
let data = ops.read_chunk(fh, offset, CHUNK_SIZE).await?;
if data.is_empty() {
break;
}
file.write_all(&data).map_err(|e| anyhow::anyhow!("write: {e}"))?;
hasher.update(&data);
offset = offset.saturating_add(data.len() as u64);
if offset > READ_ALL_MAX_BYTES {
anyhow::bail!("download aborted at {offset} bytes: exceeds {READ_ALL_MAX_BYTES}-byte cap (untrusted server returning endless non-EOF data)");
}
}
let hash = hasher.finalize().iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
s
});
Ok((offset, hash))
}
async fn upload_data<O: ShellOps>(ops: &O, fh: &ShellHandle, data: &[u8]) -> anyhow::Result<u64> {
let chunk_size = usize::try_from(CHUNK_SIZE).unwrap_or(65536);
let mut pos = 0usize;
while pos < data.len() {
let end = pos.saturating_add(chunk_size).min(data.len());
let Some(slice) = data.get(pos..end) else { break };
let written = ops.write_chunk(fh, pos as u64, slice).await?;
let adv = usize::try_from(written).unwrap_or(0).min(slice.len());
if adv == 0 {
anyhow::bail!("WRITE at {pos}: server acknowledged 0 bytes (no progress)");
}
pos = pos.saturating_add(adv);
}
Ok(pos as u64)
}
fn download_tree<'a, O: ShellOps>(ops: &'a O, dir_fh: &'a ShellHandle, local_root: &'a str, mp: &'a MultiProgress) -> Pin<Box<dyn Future<Output = anyhow::Result<u64>> + Send + 'a>> {
Box::pin(async move {
std::fs::create_dir_all(local_root).map_err(|e| anyhow::anyhow!("mkdir {local_root}: {e}"))?;
let bar = mp.add(ProgressBar::new_spinner());
bar.set_style(ProgressStyle::default_spinner().template("{spinner} {msg}").unwrap_or_else(|_| ProgressStyle::default_spinner()));
bar.set_message(local_root.to_owned());
let entries = ops.list_dir(dir_fh).await?;
let mut total = 0u64;
for entry in &entries {
if !is_safe_local_name(&entry.name) {
tracing::warn!("skipping entry with unsafe server-supplied name {:?} (path-traversal guard)", entry.name);
continue;
}
let local_entry = format!("{local_root}/{}", entry.name);
let is_dir = entry.info.as_ref().is_some_and(|a| a.file_type == ShellFileType::Directory);
if is_dir {
if let Some(ref fh) = entry.handle {
total += download_tree(ops, fh, &local_entry, mp).await?;
}
} else {
let fh = match &entry.handle {
Some(fh) => fh.clone(),
None => match ops.lookup(dir_fh, &entry.name).await {
Ok((fh, _)) => fh,
Err(e) => {
tracing::warn!("skip {}: {e}", entry.name);
continue;
},
},
};
bar.set_message(format!("{local_root}/{}", entry.name));
match download_file(ops, &fh, &local_entry).await {
Ok((bytes, _hash)) => {
total += bytes;
bar.inc(1);
},
Err(e) => tracing::warn!("download {}: {e}", entry.name),
}
}
}
bar.finish_and_clear();
Ok(total)
})
}
fn upload_tree<'a, O: ShellOps>(ops: &'a O, local_dir: &'a Path, remote_fh: &'a ShellHandle, mp: &'a MultiProgress) -> Pin<Box<dyn Future<Output = anyhow::Result<u64>> + Send + 'a>> {
Box::pin(async move {
let bar = mp.add(ProgressBar::new_spinner());
bar.set_style(ProgressStyle::default_spinner().template("{spinner} {msg}").unwrap_or_else(|_| ProgressStyle::default_spinner()));
bar.set_message(local_dir.display().to_string());
let mut total = 0u64;
let read_dir = std::fs::read_dir(local_dir).map_err(|e| anyhow::anyhow!("read_dir {}: {e}", local_dir.display()))?;
for entry_result in read_dir {
let entry = match entry_result {
Ok(e) => e,
Err(e) => {
tracing::warn!("read_dir entry: {e}");
continue;
},
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
let local_path = entry.path();
bar.set_message(name_str.as_ref().to_owned());
if local_path.is_dir() {
#[cfg(unix)]
let dir_mode = entry.metadata().map_or(0o755, |m| m.permissions().mode() & 0o7777);
#[cfg(not(unix))]
let dir_mode = 0o755;
let sub_fh = match ops.mkdir(remote_fh, name_str.as_ref(), dir_mode).await {
Ok(fh) => fh,
Err(e) => {
tracing::warn!("mkdir {name_str}: {e}");
continue;
},
};
total += upload_tree(ops, &local_path, &sub_fh, mp).await?;
} else {
#[cfg(unix)]
let file_mode = entry.metadata().map_or(0o644, |m| m.permissions().mode() & 0o7777);
#[cfg(not(unix))]
let file_mode = 0o644;
let data = match std::fs::read(&local_path) {
Ok(d) => d,
Err(e) => {
tracing::warn!("read {}: {e}", local_path.display());
continue;
},
};
let file_fh = match ops.create_file(remote_fh, name_str.as_ref(), file_mode).await {
Ok(fh) => fh,
Err(e) => {
tracing::warn!("create {name_str}: {e}");
continue;
},
};
match upload_data(ops, &file_fh, &data).await {
Ok(n) => {
total += n;
bar.inc(1);
},
Err(e) => tracing::warn!("write {name_str}: {e}"),
}
}
}
bar.finish_and_clear();
Ok(total)
})
}
fn tree_recursive<'a, O: ShellOps>(ops: &'a O, dir_fh: ShellHandle, prefix: String, depth: usize, max_depth: usize) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
if depth >= max_depth {
return;
}
let entries = match ops.list_dir(&dir_fh).await {
Ok(v) => v,
Err(e) => {
eprintln!("{prefix} [error: {e}]");
return;
},
};
let real: Vec<_> = entries.iter().filter(|e| e.name != "." && e.name != "..").collect();
let total = real.len();
for (i, entry) in real.iter().enumerate() {
let is_last = i + 1 == total;
let branch = if is_last { "\\-- " } else { "+-- " };
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "| " });
let tc = entry.info.as_ref().map_or('?', |a| a.file_type.letter());
let name = colorize_name(&entry.name, tc);
println!("{prefix}{branch}{name}");
if let Some(ref fh) = entry.handle {
let is_dir = match tc {
'd' => true,
'?' => ops.getattr(fh).await.is_ok_and(|a| a.file_type == ShellFileType::Directory),
_ => false,
};
if is_dir {
tree_recursive(ops, fh.clone(), child_prefix, depth + 1, max_depth).await;
}
}
}
})
}
fn walk_recursive<'a, O: ShellOps, F>(ops: &'a O, handle: ShellHandle, base_path: String, filter: &'a F) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
where
F: Fn(&ShellEntry, &str) -> Option<String> + Send + Sync,
{
Box::pin(async move {
let Ok(entries) = ops.list_dir(&handle).await else { return };
for entry in &entries {
if entry.name == "." || entry.name == ".." {
continue;
}
let full_path = format!("{}/{}", base_path.trim_end_matches('/'), entry.name);
if let Some(line) = filter(entry, &full_path) {
println!("{line}");
}
if entry.info.as_ref().is_some_and(|a| a.file_type == ShellFileType::Directory)
&& let Some(ref fh) = entry.handle
{
walk_recursive(ops, fh.clone(), full_path, filter).await;
}
}
})
}
fn is_safe_local_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
}
const SECRET_PATTERNS: &[&str] = &[
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
".env",
"shadow",
"passwd",
".htpasswd",
"credentials",
"secret",
"password",
"token",
"apikey",
"api_key",
"private_key",
"privkey",
".pem",
".p12",
".pfx",
".kdbx",
"authorized_keys",
"known_hosts",
"docker-compose",
".npmrc",
".pypirc",
".git-credentials",
"wp-config.php",
"settings.py",
"database.yml",
"config.php",
"secrets.yaml",
"secrets.json",
".aws",
".ssh",
];
fn format_mode(mode: u32) -> String {
ops::format_rwx(mode)
}
fn colorize_name(name: &str, tc: char) -> String {
match tc {
'd' => name.blue().bold().to_string(),
'l' => name.cyan().to_string(),
'b' | 'c' => name.yellow().to_string(),
_ => name.to_owned(),
}
}
fn print_stat(name: &str, a: &ShellFileInfo) {
println!(" File: {name}");
println!(" Type: {}", a.type_name());
println!(" Mode: {:04o} ({}) Links: {}", a.mode & 0o7777, a.mode_string(), a.nlink);
println!(" UID: {} GID: {}", a.uid, a.gid);
println!(" Inode: {} FSID: {}", a.fileid, a.fsid);
println!(" Size: {} bytes Used: {} bytes (disk)", a.size, a.used);
if a.file_type == ShellFileType::Block || a.file_type == ShellFileType::Character {
println!(" Rdev: {}:{}", a.rdev.0, a.rdev.1);
}
println!(" atime: {} ({})", fmt_unix_time(a.atime_secs), a.atime_secs);
println!(" mtime: {} ({})", fmt_unix_time(a.mtime_secs), a.mtime_secs);
println!(" ctime: {} ({})", fmt_unix_time(a.ctime_secs), a.ctime_secs);
}
fn build_path(cwd: &str, target: &str) -> String {
let mut components: Vec<&str> = cwd.split('/').filter(|c| !c.is_empty()).collect();
for part in target.split('/').filter(|c| !c.is_empty()) {
if part == ".." {
_ = components.pop();
} else if part != "." {
components.push(part);
}
}
if components.is_empty() { "/".to_owned() } else { format!("/{}", components.join("/")) }
}
fn split2(line: &str) -> (&str, &str) {
let line = line.trim();
match line.find(|c: char| c.is_whitespace()) {
Some(pos) => (line[..pos].trim(), line[pos..].trim()),
None => (line, ""),
}
}
fn resolve_get_dest(remote: &str, local: &str, local_is_dir: bool) -> String {
let basename = remote.rsplit('/').find(|s| !s.is_empty()).unwrap_or(remote);
if local.is_empty() {
basename.to_owned()
} else if local.ends_with('/') || local_is_dir {
let dir = local.trim_end_matches('/');
if dir.is_empty() { format!("/{basename}") } else { format!("{dir}/{basename}") }
} else {
local.to_owned()
}
}
#[derive(Default, Clone, Copy, Debug)]
enum LsSort {
#[default]
Name,
Inode,
Size,
Uid,
Gid,
Mode,
Mtime,
Ctime,
Atime,
}
fn strip_flag<'a>(s: &'a str, flag: &str) -> Option<&'a str> {
let after = s.strip_prefix(flag)?;
if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) { Some(after.trim_start()) } else { None }
}
fn parse_ls_args(raw: &str) -> (LsSort, bool, bool, &str) {
let mut rest = raw.trim();
let mut sort = LsSort::default();
let mut reverse = false;
let mut all_cols = false;
loop {
if let Some(after_sort) = rest.strip_prefix("--sort=") {
let (field, remainder) = split2(after_sort);
sort = match field {
"inode" | "fileid" => LsSort::Inode,
"size" => LsSort::Size,
"uid" => LsSort::Uid,
"gid" => LsSort::Gid,
"mode" | "perms" => LsSort::Mode,
"mtime" | "time" => LsSort::Mtime,
"ctime" | "change" => LsSort::Ctime,
"atime" | "access" => LsSort::Atime,
_ => LsSort::Name,
};
rest = remainder;
} else if let Some(r) = strip_flag(rest, "--reverse") {
reverse = true;
rest = r;
} else if let Some(r) = strip_flag(rest, "-r") {
reverse = true;
rest = r;
} else if let Some(r) = strip_flag(rest, "-a") {
all_cols = true;
rest = r;
} else {
break;
}
}
(sort, reverse, all_cols, rest)
}
fn ls_cmp_shell(a: &ShellEntry, b: &ShellEntry, sort: LsSort) -> std::cmp::Ordering {
let name_ord = a.name.cmp(&b.name);
match sort {
LsSort::Name => name_ord,
LsSort::Inode => {
let ia = a.info.as_ref().map_or(0u64, |x| x.fileid);
let ib = b.info.as_ref().map_or(0u64, |x| x.fileid);
ia.cmp(&ib).then(name_ord)
},
LsSort::Size => {
let sa = a.info.as_ref().map_or(0u64, |x| x.size);
let sb = b.info.as_ref().map_or(0u64, |x| x.size);
sa.cmp(&sb).then(name_ord)
},
LsSort::Uid => {
let ua = a.info.as_ref().map_or(0u32, |x| x.uid);
let ub = b.info.as_ref().map_or(0u32, |x| x.uid);
ua.cmp(&ub).then(name_ord)
},
LsSort::Gid => {
let ga = a.info.as_ref().map_or(0u32, |x| x.gid);
let gb = b.info.as_ref().map_or(0u32, |x| x.gid);
ga.cmp(&gb).then(name_ord)
},
LsSort::Mode => {
let ma = a.info.as_ref().map_or(0u32, |x| x.mode);
let mb = b.info.as_ref().map_or(0u32, |x| x.mode);
ma.cmp(&mb).then(name_ord)
},
LsSort::Mtime => {
let ta = a.info.as_ref().map_or(0u32, |x| x.mtime_secs);
let tb = b.info.as_ref().map_or(0u32, |x| x.mtime_secs);
ta.cmp(&tb).then(name_ord)
},
LsSort::Ctime => {
let ta = a.info.as_ref().map_or(0u32, |x| x.ctime_secs);
let tb = b.info.as_ref().map_or(0u32, |x| x.ctime_secs);
ta.cmp(&tb).then(name_ord)
},
LsSort::Atime => {
let ta = a.info.as_ref().map_or(0u32, |x| x.atime_secs);
let tb = b.info.as_ref().map_or(0u32, |x| x.atime_secs);
ta.cmp(&tb).then(name_ord)
},
}
}
const MONTH_DAYS_NORMAL: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_DAYS_LEAP: [u64; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
fn fmt_unix_time(secs: u32) -> String {
let s = u64::from(secs);
let sec = s % 60;
let min = (s / 60) % 60;
let hour = (s / 3600) % 24;
let mut days = s / 86400;
let mut year = 1970u64;
let mut yd = if is_leap_year(year) { 366u64 } else { 365u64 };
while days >= yd {
days -= yd;
year += 1;
yd = if is_leap_year(year) { 366 } else { 365 };
}
let months = if is_leap_year(year) { &MONTH_DAYS_LEAP } else { &MONTH_DAYS_NORMAL };
let mut month = 1u64;
for &md in months {
if days < md {
break;
}
days -= md;
month += 1;
}
let day = days + 1;
format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
}
const fn is_leap_year(y: u64) -> bool {
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
}
fn parse_uid_gid(spec: &str) -> (Option<u32>, Option<u32>) {
if let Some(pos) = spec.find(':') {
let uid = spec[..pos].parse::<u32>().ok();
let gid = spec[pos + 1..].parse::<u32>().ok();
(uid, gid)
} else {
(spec.parse::<u32>().ok(), None)
}
}
fn sanitize_term(s: &str) -> String {
s.chars().map(|c| if c.is_control() { '.' } else { c }).collect()
}
fn fmt_ctime(tv_sec: i32) -> String {
let secs = u32::try_from(tv_sec).unwrap_or(0);
fmt_unix_time(secs)
}
fn pick_host(rec: &UtmpRecord) -> String {
let ip = rec.addr_string();
if !ip.is_empty() {
return ip;
}
rec.host.clone()
}
struct LastEntry {
user: String,
line: String,
host: String,
login: i32,
outcome: LastOutcome,
}
enum LastOutcome {
Logout(i32),
StillLoggedIn,
Down(i32),
Crash(i32),
}
fn render_last(recs: &[UtmpRecord], max_recs: Option<usize>) {
let entries = pair_wtmp(recs);
let mut count = 0usize;
for entry in &entries {
if max_recs.is_some_and(|n| count >= n) {
break;
}
let login = fmt_ctime(entry.login);
let (logout, length) = match entry.outcome {
LastOutcome::Logout(t) => {
let dur = t.saturating_sub(entry.login);
(fmt_ctime(t), fmt_duration(dur))
},
LastOutcome::StillLoggedIn => ("still running".to_owned(), String::new()),
LastOutcome::Down(t) => {
let dur = t.saturating_sub(entry.login);
(format!("down {}", fmt_ctime(t)), fmt_duration(dur))
},
LastOutcome::Crash(t) => {
let dur = t.saturating_sub(entry.login);
(format!("crash {}", fmt_ctime(t)), fmt_duration(dur))
},
};
println!("{:<8.8} {:<12.12} {:<16.16} {:<19} - {:<25} {}", sanitize_term(&entry.user), sanitize_term(&entry.line), sanitize_term(&entry.host), login, logout, length);
count += 1;
}
if count == 0 {
println!("{}", "no completed sessions in wtmp".yellow());
}
}
fn render_lastb(recs: &[UtmpRecord], max_recs: Option<usize>) {
let mut count = 0usize;
for r in recs.iter().rev() {
if max_recs.is_some_and(|n| count >= n) {
break;
}
if matches!(r.ut_type, UtType::Empty) {
continue;
}
let host = sanitize_term(&pick_host(r));
let user = if r.user.is_empty() { "(unknown)".to_owned() } else { sanitize_term(&r.user) };
println!("{:<8.8} {:<12.12} {:<16.16} {:<24} (failed login)", user, sanitize_term(&r.line), host, fmt_ctime(r.tv_sec));
count += 1;
}
if count == 0 {
println!("{}", "no failed-login records in btmp".green());
}
}
fn render_lastlog(recs: &[LastlogRecord], uid_to_user: &[(u32, String)]) {
println!("{:<16} {:<8} {:<24} Latest", "Username", "Port", "From");
let mut shown = 0usize;
for r in recs {
if r.ll_time == 0 {
continue;
}
let user = uid_to_user.iter().find(|(u, _)| *u == r.uid).map_or_else(|| format!("uid={}", r.uid), |(_, n)| n.clone());
let when = fmt_ctime(r.ll_time);
println!("{:<16} {:<8.8} {:<24.24} {}", sanitize_term(&user), sanitize_term(&r.ll_line), sanitize_term(&r.ll_host), when);
shown += 1;
}
if shown == 0 {
println!("{}", "no recorded interactive logins (every slot has ll_time=0)".yellow());
}
}
struct OpenSession {
user: String,
host: String,
login: i32,
}
struct PendingBoot {
host: String,
login: i32,
}
const fn clone_outcome(o: &LastOutcome) -> LastOutcome {
match o {
LastOutcome::Logout(t) => LastOutcome::Logout(*t),
LastOutcome::StillLoggedIn => LastOutcome::StillLoggedIn,
LastOutcome::Down(t) => LastOutcome::Down(*t),
LastOutcome::Crash(t) => LastOutcome::Crash(*t),
}
}
fn close_all(open: &mut std::collections::HashMap<String, OpenSession>, entries: &mut Vec<LastEntry>, outcome: &LastOutcome) {
let drained: Vec<(String, OpenSession)> = open.drain().collect();
for (line, sess) in drained {
entries.push(LastEntry { user: sess.user, line, host: sess.host, login: sess.login, outcome: clone_outcome(outcome) });
}
}
fn pair_wtmp(recs: &[UtmpRecord]) -> Vec<LastEntry> {
use std::collections::HashMap;
let mut open: HashMap<String, OpenSession> = HashMap::new();
let mut entries: Vec<LastEntry> = Vec::new();
let mut pending_boot: Option<PendingBoot> = None;
let mut clean_shutdown_since_boot: Option<i32> = None;
for r in recs {
let mut rec = r.clone();
if rec.line == "~" {
if rec.user == "shutdown" {
rec.ut_type = UtType::Other(254); } else if rec.user == "reboot" {
rec.ut_type = UtType::BootTime;
} else if rec.user == "runlevel" {
rec.ut_type = UtType::RunLvl;
}
} else if !rec.user.is_empty() && !rec.line.is_empty() && rec.user != "LOGIN" && !matches!(rec.ut_type, UtType::DeadProcess) {
rec.ut_type = UtType::UserProcess;
}
if rec.user.is_empty() {
rec.ut_type = UtType::DeadProcess;
}
match rec.ut_type {
UtType::Other(254) => {
close_all(&mut open, &mut entries, &LastOutcome::Down(rec.tv_sec));
clean_shutdown_since_boot = Some(rec.tv_sec);
},
UtType::RunLvl => {
let lvl = rec.pid & 0xff;
if lvl == i32::from(b'0') || lvl == i32::from(b'6') {
close_all(&mut open, &mut entries, &LastOutcome::Down(rec.tv_sec));
clean_shutdown_since_boot = Some(rec.tv_sec);
}
},
UtType::BootTime => {
if let Some(pb) = pending_boot.take() {
let outcome = clean_shutdown_since_boot.map_or(LastOutcome::Crash(rec.tv_sec), LastOutcome::Down);
entries.push(LastEntry { user: "reboot".to_owned(), line: "system boot".to_owned(), host: pb.host, login: pb.login, outcome });
}
close_all(&mut open, &mut entries, &LastOutcome::Crash(rec.tv_sec));
pending_boot = Some(PendingBoot { host: rec.host.clone(), login: rec.tv_sec });
clean_shutdown_since_boot = None;
},
UtType::UserProcess => {
drop(open.insert(rec.line.clone(), OpenSession { user: rec.user.clone(), host: pick_host(&rec), login: rec.tv_sec }));
},
UtType::DeadProcess => {
if let Some(sess) = open.remove(&rec.line) {
entries.push(LastEntry { user: sess.user, line: rec.line.clone(), host: sess.host, login: sess.login, outcome: LastOutcome::Logout(rec.tv_sec) });
}
},
_ => {},
}
}
if let Some(pb) = pending_boot {
entries.push(LastEntry { user: "reboot".to_owned(), line: "system boot".to_owned(), host: pb.host, login: pb.login, outcome: LastOutcome::StillLoggedIn });
}
let mut still_open: Vec<(String, OpenSession)> = open.into_iter().collect();
still_open.sort_by_key(|(_, s)| s.login);
for (line, sess) in still_open {
entries.push(LastEntry { user: sess.user, line, host: sess.host, login: sess.login, outcome: LastOutcome::StillLoggedIn });
}
entries.reverse();
entries
}
fn fmt_duration(secs: i32) -> String {
if secs <= 0 {
return String::new();
}
let days = secs / 86_400;
let hours = (secs / 3600) % 24;
let mins = (secs / 60) % 60;
if days > 0 { format!("({days}+{hours:02}:{mins:02})") } else { format!("({hours:02}:{mins:02})") }
}
#[cfg(test)]
mod tests {
use super::{is_safe_local_name, resolve_get_dest};
#[test]
fn safe_local_name_accepts_normal_entries() {
assert!(is_safe_local_name("passwd"));
assert!(is_safe_local_name(".hidden"));
assert!(is_safe_local_name("..."));
assert!(is_safe_local_name("file.tar.gz"));
}
#[test]
fn safe_local_name_rejects_traversal_and_separators() {
assert!(!is_safe_local_name(""));
assert!(!is_safe_local_name("."));
assert!(!is_safe_local_name(".."));
assert!(!is_safe_local_name("../etc/passwd"));
assert!(!is_safe_local_name("../../root/.ssh/authorized_keys"));
assert!(!is_safe_local_name("sub/dir"));
assert!(!is_safe_local_name("/etc/cron.d/pwn"));
assert!(!is_safe_local_name("a\0b"));
}
#[test]
fn get_dest_uses_basename_when_local_empty() {
assert_eq!(resolve_get_dest("/etc/passwd", "", false), "passwd");
assert_eq!(resolve_get_dest("passwd", "", false), "passwd");
}
#[test]
fn get_dest_keeps_plain_local_filename() {
assert_eq!(resolve_get_dest("/etc/passwd", "/home/test/out", false), "/home/test/out");
assert_eq!(resolve_get_dest("/etc/passwd", "copy", false), "copy");
}
#[test]
fn get_dest_appends_basename_for_trailing_slash() {
assert_eq!(resolve_get_dest("/etc/passwd", "/home/test/", false), "/home/test/passwd");
assert_eq!(resolve_get_dest("/etc/passwd", "/", false), "/passwd");
}
#[test]
fn get_dest_appends_basename_for_existing_dir() {
assert_eq!(resolve_get_dest("/etc/shadow", "/home/test", true), "/home/test/shadow");
}
#[test]
fn get_dest_handles_trailing_slash_on_remote() {
assert_eq!(resolve_get_dest("/var/log/", "", false), "log");
}
}