diff --git a/crates/bashkit/src/builtins/test.rs b/crates/bashkit/src/builtins/test.rs index 423a119d..68cdf234 100644 --- a/crates/bashkit/src/builtins/test.rs +++ b/crates/bashkit/src/builtins/test.rs @@ -1,6 +1,6 @@ //! test builtin command ([ and test) -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use async_trait::async_trait; @@ -21,8 +21,9 @@ impl Builtin for Test { return Ok(ExecResult::err(String::new(), 1)); } + let cwd = ctx.cwd.clone(); // Parse and evaluate the expression - let result = evaluate_expression(ctx.args, &ctx.fs).await; + let result = evaluate_expression(ctx.args, &ctx.fs, &cwd).await; if result { Ok(ExecResult::ok(String::new())) @@ -51,8 +52,9 @@ impl Builtin for Bracket { return Ok(ExecResult::err(String::new(), 1)); } + let cwd = ctx.cwd.clone(); // Parse and evaluate the expression - let result = evaluate_expression(&args, &ctx.fs).await; + let result = evaluate_expression(&args, &ctx.fs, &cwd).await; if result { Ok(ExecResult::ok(String::new())) @@ -62,10 +64,21 @@ impl Builtin for Bracket { } } +/// Resolve a file path against cwd (relative paths become absolute) +fn resolve_file_path(cwd: &Path, arg: &str) -> PathBuf { + let p = Path::new(arg); + if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(p) + } +} + /// Evaluate a test expression fn evaluate_expression<'a>( args: &'a [String], fs: &'a Arc, + cwd: &'a Path, ) -> std::pin::Pin + Send + 'a>> { Box::pin(async move { if args.is_empty() { @@ -74,12 +87,12 @@ fn evaluate_expression<'a>( // Handle negation if args[0] == "!" { - return !evaluate_expression(&args[1..], fs).await; + return !evaluate_expression(&args[1..], fs, cwd).await; } // Handle parentheses (basic support) if args[0] == "(" && args.last().map(|s| s.as_str()) == Some(")") { - return evaluate_expression(&args[1..args.len() - 1], fs).await; + return evaluate_expression(&args[1..args.len() - 1], fs, cwd).await; } // Look for binary operators @@ -87,12 +100,12 @@ fn evaluate_expression<'a>( match arg.as_str() { // Logical operators (lowest precedence) "-a" if i > 0 => { - return evaluate_expression(&args[..i], fs).await - && evaluate_expression(&args[i + 1..], fs).await; + return evaluate_expression(&args[..i], fs, cwd).await + && evaluate_expression(&args[i + 1..], fs, cwd).await; } "-o" if i > 0 => { - return evaluate_expression(&args[..i], fs).await - || evaluate_expression(&args[i + 1..], fs).await; + return evaluate_expression(&args[..i], fs, cwd).await + || evaluate_expression(&args[i + 1..], fs, cwd).await; } _ => {} } @@ -106,11 +119,11 @@ fn evaluate_expression<'a>( } 2 => { // Unary operators - evaluate_unary(&args[0], &args[1], fs).await + evaluate_unary(&args[0], &args[1], fs, cwd).await } 3 => { // Binary operators - evaluate_binary(&args[0], &args[1], &args[2], fs).await + evaluate_binary(&args[0], &args[1], &args[2], fs, cwd).await } _ => false, } @@ -118,7 +131,7 @@ fn evaluate_expression<'a>( } /// Evaluate a unary test expression -async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { +async fn evaluate_unary(op: &str, arg: &str, fs: &Arc, cwd: &Path) -> bool { match op { // String tests "-z" => arg.is_empty(), @@ -127,13 +140,13 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { // File tests using the virtual filesystem "-e" | "-a" => { // file exists - let path = Path::new(arg); - fs.exists(path).await.unwrap_or(false) + let path = resolve_file_path(cwd, arg); + fs.exists(&path).await.unwrap_or(false) } "-f" => { // regular file - let path = Path::new(arg); - if let Ok(meta) = fs.stat(path).await { + let path = resolve_file_path(cwd, arg); + if let Ok(meta) = fs.stat(&path).await { meta.file_type.is_file() } else { false @@ -141,8 +154,8 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { } "-d" => { // directory - let path = Path::new(arg); - if let Ok(meta) = fs.stat(path).await { + let path = resolve_file_path(cwd, arg); + if let Ok(meta) = fs.stat(&path).await { meta.file_type.is_dir() } else { false @@ -151,18 +164,18 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { "-r" => { // readable - in virtual fs, check if file exists // (permissions are stored but not enforced) - let path = Path::new(arg); - fs.exists(path).await.unwrap_or(false) + let path = resolve_file_path(cwd, arg); + fs.exists(&path).await.unwrap_or(false) } "-w" => { // writable - in virtual fs, check if file exists - let path = Path::new(arg); - fs.exists(path).await.unwrap_or(false) + let path = resolve_file_path(cwd, arg); + fs.exists(&path).await.unwrap_or(false) } "-x" => { // executable - in virtual fs, check if file exists and has executable permission - let path = Path::new(arg); - if let Ok(meta) = fs.stat(path).await { + let path = resolve_file_path(cwd, arg); + if let Ok(meta) = fs.stat(&path).await { // Check if any execute bit is set (u+x, g+x, o+x) (meta.mode & 0o111) != 0 } else { @@ -171,8 +184,8 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { } "-s" => { // file exists and has size > 0 - let path = Path::new(arg); - if let Ok(meta) = fs.stat(path).await { + let path = resolve_file_path(cwd, arg); + if let Ok(meta) = fs.stat(&path).await { meta.size > 0 } else { false @@ -180,8 +193,8 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { } "-L" | "-h" => { // symbolic link - let path = Path::new(arg); - if let Ok(meta) = fs.stat(path).await { + let path = resolve_file_path(cwd, arg); + if let Ok(meta) = fs.stat(&path).await { meta.file_type.is_symlink() } else { false @@ -198,7 +211,13 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { } /// Evaluate a binary test expression -async fn evaluate_binary(left: &str, op: &str, right: &str, fs: &Arc) -> bool { +async fn evaluate_binary( + left: &str, + op: &str, + right: &str, + fs: &Arc, + cwd: &Path, +) -> bool { match op { // String comparisons "=" | "==" => left == right, @@ -217,8 +236,8 @@ async fn evaluate_binary(left: &str, op: &str, right: &str, fs: &Arc { // file1 is newer than file2 - let left_meta = fs.stat(Path::new(left)).await; - let right_meta = fs.stat(Path::new(right)).await; + let left_meta = fs.stat(&resolve_file_path(cwd, left)).await; + let right_meta = fs.stat(&resolve_file_path(cwd, right)).await; match (left_meta, right_meta) { (Ok(lm), Ok(rm)) => lm.modified > rm.modified, (Ok(_), Err(_)) => true, // left exists, right doesn't → left is newer @@ -227,8 +246,8 @@ async fn evaluate_binary(left: &str, op: &str, right: &str, fs: &Arc { // file1 is older than file2 - let left_meta = fs.stat(Path::new(left)).await; - let right_meta = fs.stat(Path::new(right)).await; + let left_meta = fs.stat(&resolve_file_path(cwd, left)).await; + let right_meta = fs.stat(&resolve_file_path(cwd, right)).await; match (left_meta, right_meta) { (Ok(lm), Ok(rm)) => lm.modified < rm.modified, (Err(_), Ok(_)) => true, // left doesn't exist, right does → left is older @@ -238,8 +257,8 @@ async fn evaluate_binary(left: &str, op: &str, right: &str, fs: &Arc { // file1 and file2 refer to the same file (same path after resolution) // In VFS without inodes, compare canonical paths - let left_path = super::resolve_path(&std::path::PathBuf::from("/"), left); - let right_path = super::resolve_path(&std::path::PathBuf::from("/"), right); + let left_path = super::resolve_path(cwd, left); + let right_path = super::resolve_path(cwd, right); left_path == right_path } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 043c007d..4884f6ce 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1150,34 +1150,36 @@ impl Interpreter { 1 => !args[0].is_empty(), 2 => { // Unary operators + let resolve = |p: &str| -> std::path::PathBuf { + let path = std::path::Path::new(p); + if path.is_absolute() { + path.to_path_buf() + } else { + self.cwd.join(path) + } + }; match args[0].as_str() { "-z" => args[1].is_empty(), "-n" => !args[1].is_empty(), - "-e" | "-a" => self - .fs - .exists(std::path::Path::new(&args[1])) - .await - .unwrap_or(false), + "-e" | "-a" => self.fs.exists(&resolve(&args[1])).await.unwrap_or(false), "-f" => self .fs - .stat(std::path::Path::new(&args[1])) + .stat(&resolve(&args[1])) .await .map(|m| m.file_type.is_file()) .unwrap_or(false), "-d" => self .fs - .stat(std::path::Path::new(&args[1])) + .stat(&resolve(&args[1])) .await .map(|m| m.file_type.is_dir()) .unwrap_or(false), - "-r" | "-w" | "-x" => self - .fs - .exists(std::path::Path::new(&args[1])) - .await - .unwrap_or(false), + "-r" | "-w" | "-x" => { + self.fs.exists(&resolve(&args[1])).await.unwrap_or(false) + } "-s" => self .fs - .stat(std::path::Path::new(&args[1])) + .stat(&resolve(&args[1])) .await .map(|m| m.size > 0) .unwrap_or(false), diff --git a/crates/bashkit/tests/issue_291_test.rs b/crates/bashkit/tests/issue_291_test.rs new file mode 100644 index 00000000..c58f9415 --- /dev/null +++ b/crates/bashkit/tests/issue_291_test.rs @@ -0,0 +1,40 @@ +//! Regression test for #291: [ -f ] doesn't see VFS files after cd in script + +use bashkit::Bash; +use std::path::Path; + +#[tokio::test] +async fn issue_291_file_test_after_cd_in_script() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.mkdir(Path::new("/project"), true).await.unwrap(); + fs.write_file(Path::new("/project/test.txt"), b"hello") + .await + .unwrap(); + fs.write_file( + Path::new("/check.sh"), + b"#!/bin/bash\n[ -f test.txt ] && echo found || echo not-found", + ) + .await + .unwrap(); + fs.chmod(Path::new("/check.sh"), 0o755).await.unwrap(); + + let r = bash.exec("cd /project\n/check.sh").await.unwrap(); + assert_eq!(r.stdout.trim(), "found"); +} + +#[tokio::test] +async fn issue_291_double_bracket_file_test_after_cd() { + let mut bash = Bash::new(); + let fs = bash.fs(); + fs.mkdir(Path::new("/mydir"), true).await.unwrap(); + fs.write_file(Path::new("/mydir/data.json"), b"{}") + .await + .unwrap(); + + let r = bash + .exec("cd /mydir\n[[ -f data.json ]] && echo ok || echo no") + .await + .unwrap(); + assert_eq!(r.stdout.trim(), "ok"); +}