use async_trait::async_trait;
use std::path::Path;
use super::{Builtin, Context, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Mkdir;
#[async_trait]
impl Builtin for Mkdir {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.is_empty() {
return Ok(ExecResult::err("mkdir: missing operand\n".to_string(), 1));
}
let recursive = ctx.args.iter().any(|a| a == "-p");
let dirs: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if dirs.is_empty() {
return Ok(ExecResult::err("mkdir: missing operand\n".to_string(), 1));
}
for dir in dirs {
let path = resolve_path(ctx.cwd, dir);
if ctx.fs.exists(&path).await.unwrap_or(false) {
if let Ok(meta) = ctx.fs.stat(&path).await
&& meta.file_type.is_dir()
{
if !recursive {
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': File exists\n", dir),
1,
));
}
continue;
}
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': File exists\n", dir),
1,
));
}
if let Err(e) = ctx.fs.mkdir(&path, recursive).await {
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': {}\n", dir, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Rm;
#[async_trait]
impl Builtin for Rm {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.is_empty() {
return Ok(ExecResult::err("rm: missing operand\n".to_string(), 1));
}
let recursive = ctx.args.iter().any(|a| {
a == "-r"
|| a == "-R"
|| a == "-rf"
|| a == "-fr"
|| a.contains('r') && a.starts_with('-')
});
let force = ctx.args.iter().any(|a| {
a == "-f" || a == "-rf" || a == "-fr" || a.contains('f') && a.starts_with('-')
});
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.is_empty() {
return Ok(ExecResult::err("rm: missing operand\n".to_string(), 1));
}
for file in files {
let path = resolve_path(ctx.cwd, file);
let exists = ctx.fs.exists(&path).await.unwrap_or(false);
if !exists {
if !force {
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': No such file or directory\n", file),
1,
));
}
continue;
}
let metadata = ctx.fs.stat(&path).await;
if let Ok(meta) = metadata
&& meta.file_type.is_dir()
&& !recursive
{
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': Is a directory\n", file),
1,
));
}
if let Err(e) = ctx.fs.remove(&path, recursive).await
&& !force
{
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': {}\n", file, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Cp;
#[async_trait]
impl Builtin for Cp {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.len() < 2 {
return Ok(ExecResult::err("cp: missing file operand\n".to_string(), 1));
}
let _recursive = ctx.args.iter().any(|a| a == "-r" || a == "-R");
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.len() < 2 {
return Ok(ExecResult::err(
"cp: missing destination file operand\n".to_string(),
1,
));
}
let dest = files
.last()
.expect("files.last() valid: guarded by files.len() < 2 check above");
let sources = &files[..files.len() - 1];
let dest_path = resolve_path(ctx.cwd, dest);
let dest_is_dir = if let Ok(meta) = ctx.fs.stat(&dest_path).await {
meta.file_type.is_dir()
} else {
false
};
if sources.len() > 1 && !dest_is_dir {
return Ok(ExecResult::err(
format!("cp: target '{}' is not a directory\n", dest),
1,
));
}
for source in sources {
let src_path = resolve_path(ctx.cwd, source);
let final_dest = if dest_is_dir {
let filename = Path::new(source)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| source.to_string());
dest_path.join(&filename)
} else {
dest_path.clone()
};
if let Err(e) = ctx.fs.copy(&src_path, &final_dest).await {
return Ok(ExecResult::err(
format!("cp: cannot copy '{}': {}\n", source, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Mv;
#[async_trait]
impl Builtin for Mv {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.len() < 2 {
return Ok(ExecResult::err("mv: missing file operand\n".to_string(), 1));
}
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.len() < 2 {
return Ok(ExecResult::err(
"mv: missing destination file operand\n".to_string(),
1,
));
}
let dest = files
.last()
.expect("files.last() valid: guarded by files.len() < 2 check above");
let sources = &files[..files.len() - 1];
let dest_path = resolve_path(ctx.cwd, dest);
let dest_is_dir = if let Ok(meta) = ctx.fs.stat(&dest_path).await {
meta.file_type.is_dir()
} else {
false
};
if sources.len() > 1 && !dest_is_dir {
return Ok(ExecResult::err(
format!("mv: target '{}' is not a directory\n", dest),
1,
));
}
for source in sources {
let src_path = resolve_path(ctx.cwd, source);
let final_dest = if dest_is_dir {
let filename = Path::new(source)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| source.to_string());
dest_path.join(&filename)
} else {
dest_path.clone()
};
if let Err(e) = ctx.fs.rename(&src_path, &final_dest).await {
return Ok(ExecResult::err(
format!("mv: cannot move '{}': {}\n", source, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Touch;
#[async_trait]
impl Builtin for Touch {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.is_empty() {
return Ok(ExecResult::err(
"touch: missing file operand\n".to_string(),
1,
));
}
for file in ctx.args.iter().filter(|a| !a.starts_with('-')) {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false) {
if let Err(e) = ctx.fs.write_file(&path, &[]).await {
return Ok(ExecResult::err(
format!("touch: cannot touch '{}': {}\n", file, e),
1,
));
}
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Chmod;
fn apply_symbolic_mode(mode_str: &str, current_mode: u32) -> Option<u32> {
let mut mode = current_mode;
for clause in mode_str.split(',') {
let clause = clause.trim();
if clause.is_empty() {
return None;
}
let mut chars = clause.chars().peekable();
let mut who_u = false;
let mut who_g = false;
let mut who_o = false;
let mut has_who = false;
while let Some(&c) = chars.peek() {
match c {
'u' => {
who_u = true;
has_who = true;
chars.next();
}
'g' => {
who_g = true;
has_who = true;
chars.next();
}
'o' => {
who_o = true;
has_who = true;
chars.next();
}
'a' => {
who_u = true;
who_g = true;
who_o = true;
has_who = true;
chars.next();
}
_ => break,
}
}
if !has_who {
who_u = true;
who_g = true;
who_o = true;
}
let op = chars.next()?;
if op != '+' && op != '-' && op != '=' {
return None;
}
let mut perm_bits: u32 = 0;
for c in chars {
match c {
'r' => perm_bits |= 0o4,
'w' => perm_bits |= 0o2,
'x' => perm_bits |= 0o1,
'X' => {
if current_mode & 0o111 != 0 {
perm_bits |= 0o1;
}
}
's' | 't' => {} _ => return None,
}
}
let mut mask: u32 = 0;
let mut bits: u32 = 0;
if who_u {
mask |= 0o700;
bits |= perm_bits << 6;
}
if who_g {
mask |= 0o070;
bits |= perm_bits << 3;
}
if who_o {
mask |= 0o007;
bits |= perm_bits;
}
match op {
'+' => mode |= bits,
'-' => mode &= !bits,
'=' => mode = (mode & !mask) | bits,
_ => unreachable!(),
}
}
Some(mode)
}
#[async_trait]
impl Builtin for Chmod {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.len() < 2 {
return Ok(ExecResult::err("chmod: missing operand\n".to_string(), 1));
}
let mode_str = &ctx.args[0];
let files = &ctx.args[1..];
let is_octal = u32::from_str_radix(mode_str, 8).is_ok();
for file in files.iter().filter(|a| !a.starts_with('-')) {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false) {
return Ok(ExecResult::err(
format!(
"chmod: cannot access '{}': No such file or directory\n",
file
),
1,
));
}
let mode = if is_octal {
u32::from_str_radix(mode_str, 8)
.expect("from_str_radix valid: is_octal confirmed by is_ok() check above")
} else {
let current_mode = match ctx.fs.stat(&path).await {
Ok(meta) => meta.mode,
Err(_) => 0o644, };
match apply_symbolic_mode(mode_str, current_mode) {
Some(m) => m,
None => {
return Ok(ExecResult::err(
format!("chmod: invalid mode: '{}'\n", mode_str),
1,
));
}
}
};
if let Err(e) = ctx.fs.chmod(&path, mode).await {
return Ok(ExecResult::err(
format!("chmod: changing permissions of '{}': {}\n", file, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Ln;
#[async_trait]
impl Builtin for Ln {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut force = false;
let mut files: Vec<&str> = Vec::new();
for arg in ctx.args.iter() {
if arg.starts_with('-') && arg.len() > 1 {
for c in arg[1..].chars() {
match c {
's' => {} 'f' => force = true,
_ => {
return Ok(ExecResult::err(
format!("ln: invalid option -- '{}'\n", c),
1,
));
}
}
}
} else {
files.push(arg);
}
}
if files.len() < 2 {
return Ok(ExecResult::err("ln: missing file operand\n".to_string(), 1));
}
let target = files[0];
let link_name = files[1];
let link_path = resolve_path(ctx.cwd, link_name);
if ctx.fs.exists(&link_path).await.unwrap_or(false) {
if force {
let _ = ctx.fs.remove(&link_path, false).await;
} else {
return Ok(ExecResult::err(
format!(
"ln: failed to create symbolic link '{}': File exists\n",
link_name
),
1,
));
}
}
let target_path = Path::new(target);
if let Err(e) = ctx.fs.symlink(target_path, &link_path).await {
return Ok(ExecResult::err(
format!(
"ln: failed to create symbolic link '{}': {}\n",
link_name, e
),
1,
));
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Chown;
#[async_trait]
impl Builtin for Chown {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut recursive = false;
let mut positional: Vec<&str> = Vec::new();
for arg in ctx.args {
match arg.as_str() {
"-R" | "--recursive" => recursive = true,
_ if arg.starts_with('-') => {} _ => positional.push(arg),
}
}
let _ = recursive;
if positional.len() < 2 {
return Ok(ExecResult::err("chown: missing operand\n".to_string(), 1));
}
let _owner = positional[0]; for file in &positional[1..] {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false) {
return Ok(ExecResult::err(
format!(
"chown: cannot access '{}': No such file or directory\n",
file
),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Kill;
#[async_trait]
impl Builtin for Kill {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut pids: Vec<&str> = Vec::new();
for arg in ctx.args {
if arg == "-l" || arg == "-L" {
return Ok(ExecResult::ok(
"HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM\n"
.to_string(),
));
}
if arg.starts_with('-') {
continue; }
pids.push(arg);
}
if pids.is_empty() {
return Ok(ExecResult::err(
"kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ...\n"
.to_string(),
2,
));
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Mktemp;
#[async_trait]
impl Builtin for Mktemp {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut create_dir = false;
let mut prefix_dir = "/tmp".to_string();
let mut template: Option<String> = None;
let mut use_tmpdir = false;
let mut i = 0;
while i < ctx.args.len() {
match ctx.args[i].as_str() {
"-d" => create_dir = true,
"-p" => {
i += 1;
if i < ctx.args.len() {
prefix_dir = ctx.args[i].clone();
}
}
"-t" => use_tmpdir = true,
arg if !arg.starts_with('-') => {
template = Some(arg.to_string());
}
_ => {} }
i += 1;
}
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let random = RandomState::new().build_hasher().finish();
let suffix = format!("{:010x}", random % 0xFF_FFFF_FFFF);
let name = if let Some(tmpl) = &template {
if tmpl.contains("XXXXXX") {
tmpl.replacen("XXXXXX", &suffix[..6], 1)
} else {
format!("{}.{}", tmpl, &suffix[..6])
}
} else {
format!("tmp.{}", &suffix[..10])
};
let path = if use_tmpdir || template.is_none() || !name.contains('/') {
format!("{}/{}", prefix_dir, name)
} else {
let p = resolve_path(ctx.cwd, &name);
p.to_string_lossy().to_string()
};
let full_path = std::path::PathBuf::from(&path);
if let Some(parent) = full_path.parent()
&& !ctx.fs.exists(parent).await.unwrap_or(false)
{
let _ = ctx.fs.mkdir(parent, true).await;
}
if create_dir {
if let Err(e) = ctx.fs.mkdir(&full_path, true).await {
return Ok(ExecResult::err(
format!("mktemp: failed to create directory '{}': {}\n", path, e),
1,
));
}
} else if let Err(e) = ctx.fs.write_file(&full_path, &[]).await {
return Ok(ExecResult::err(
format!("mktemp: failed to create file '{}': {}\n", path, e),
1,
));
}
Ok(ExecResult::ok(format!("{}\n", path)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::{FileSystem, InMemoryFs};
async fn create_test_ctx() -> (Arc<InMemoryFs>, PathBuf, HashMap<String, String>) {
let fs = Arc::new(InMemoryFs::new());
let cwd = PathBuf::from("/home/user");
let variables = HashMap::new();
fs.mkdir(&cwd, true).await.unwrap();
(fs, cwd, variables)
}
#[tokio::test]
async fn test_mkdir_simple() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["testdir".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mkdir.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("testdir")).await.unwrap());
}
#[tokio::test]
async fn test_mkdir_recursive() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-p".to_string(), "a/b/c".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mkdir.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("a/b/c")).await.unwrap());
}
#[tokio::test]
async fn test_touch_create() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["newfile.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Touch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("newfile.txt")).await.unwrap());
}
#[tokio::test]
async fn test_rm_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("testfile.txt"), b"content")
.await
.unwrap();
let args = vec!["testfile.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Rm.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!fs.exists(&cwd.join("testfile.txt")).await.unwrap());
}
#[tokio::test]
async fn test_rm_force_nonexistent() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-f".to_string(), "nonexistent".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Rm.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0); }
#[tokio::test]
async fn test_cp_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("source.txt"), b"content")
.await
.unwrap();
let args = vec!["source.txt".to_string(), "dest.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Cp.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("dest.txt")).await.unwrap());
let content = fs.read_file(&cwd.join("dest.txt")).await.unwrap();
assert_eq!(content, b"content");
}
#[tokio::test]
async fn test_mv_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("source.txt"), b"content")
.await
.unwrap();
let args = vec!["source.txt".to_string(), "dest.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!fs.exists(&cwd.join("source.txt")).await.unwrap());
assert!(fs.exists(&cwd.join("dest.txt")).await.unwrap());
}
#[tokio::test]
async fn test_chmod_octal() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("script.sh"), b"#!/bin/bash")
.await
.unwrap();
let args = vec!["755".to_string(), "script.sh".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Chmod.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
let meta = fs.stat(&cwd.join("script.sh")).await.unwrap();
assert_eq!(meta.mode, 0o755);
}
}