Skip to content

Commit b4cfc93

Browse files
authored
feat(vfs): add mkfifo and named pipe (FIFO) support (#591)
## Summary - Add `FileType::Fifo` variant to VFS with full read/write/stat support - Implement `mkfifo` builtin with `-m mode` flag and multiple path support - Update `test -p` / `[ -p ]` to detect FIFOs via metadata - Update `file`, `stat`, `ls -l` to identify FIFOs (type `p`) - FIFOs simulated as buffered files, preserving type across writes ## Test plan - [x] 9 unit tests for mkfifo builtin (mode flags, errors, multiple paths) - [x] 15 integration tests (FIFO creation, test -p, file/stat, read/write, type preservation) - [x] `cargo fmt --check` passes - [x] `cargo clippy --all-targets -- -D warnings` passes - [x] `cargo test --all-features` passes (1939+ unit + integration tests) Closes #557
1 parent 08d4a33 commit b4cfc93

10 files changed

Lines changed: 604 additions & 48 deletions

File tree

crates/bashkit-cli/src/mcp.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ impl McpServer {
202202
}
203203

204204
fn handle_tools_list(&self, id: serde_json::Value) -> JsonRpcResponse {
205+
#[allow(unused_mut)]
205206
let mut tools = vec![McpTool {
206207
name: "bash".to_string(),
207208
description: "Execute a bash script in a virtual environment".to_string(),

crates/bashkit/src/builtins/inspect.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ impl Builtin for File {
125125
determine_file_content_type(&content)
126126
}
127127
}
128+
FileType::Fifo => "fifo (named pipe)".to_string(),
128129
};
129130

130131
output.push_str(&format!("{}: {}\n", file, file_type_str));
@@ -344,6 +345,7 @@ fn format_file_type(file_type: FileType) -> String {
344345
FileType::File => "regular file".to_string(),
345346
FileType::Directory => "directory".to_string(),
346347
FileType::Symlink => "symbolic link".to_string(),
348+
FileType::Fifo => "fifo (named pipe)".to_string(),
347349
}
348350
}
349351

@@ -352,6 +354,7 @@ fn format_permissions(metadata: &crate::fs::Metadata) -> String {
352354
let file_type = match metadata.file_type {
353355
FileType::Directory => 'd',
354356
FileType::Symlink => 'l',
357+
FileType::Fifo => 'p',
355358
FileType::File => '-',
356359
};
357360

crates/bashkit/src/builtins/ls.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ fn format_long_entry(name: &str, metadata: &crate::fs::Metadata, human: bool) ->
226226
let file_type = match metadata.file_type {
227227
FileType::Directory => 'd',
228228
FileType::Symlink => 'l',
229+
FileType::Fifo => 'p',
229230
FileType::File => '-',
230231
};
231232

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! mkfifo builtin - create named pipes in the virtual filesystem.
2+
//!
3+
//! Creates FIFO entries in the VFS. `test -p` returns true for these.
4+
//! In VFS mode, data written/read behaves like a regular file buffer.
5+
6+
use async_trait::async_trait;
7+
8+
use super::{Builtin, Context, resolve_path};
9+
use crate::error::Result;
10+
use crate::interpreter::ExecResult;
11+
12+
/// The mkfifo builtin - create named pipes.
13+
///
14+
/// Usage: mkfifo [-m MODE] NAME...
15+
///
16+
/// Creates FIFO entries at the given paths via `FileSystem::mkfifo`.
17+
/// The `-m` flag sets the permission mode (octal, default 0o666).
18+
pub struct Mkfifo;
19+
20+
/// Parse an octal mode string (e.g. "0644", "755") to u32.
21+
/// Returns None if the string is not valid octal.
22+
fn parse_mode(s: &str) -> Option<u32> {
23+
let trimmed = s.trim_start_matches('0');
24+
let trimmed = if trimmed.is_empty() { "0" } else { trimmed };
25+
u32::from_str_radix(trimmed, 8).ok()
26+
}
27+
28+
#[async_trait]
29+
impl Builtin for Mkfifo {
30+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
31+
if ctx.args.is_empty() {
32+
return Ok(ExecResult::err("mkfifo: missing operand\n".to_string(), 1));
33+
}
34+
35+
// Parse arguments: extract -m mode, collect paths
36+
let mut paths = Vec::new();
37+
let mut mode: u32 = 0o666;
38+
let mut i = 0;
39+
while i < ctx.args.len() {
40+
let arg = &ctx.args[i];
41+
if arg == "-m" {
42+
if i + 1 >= ctx.args.len() {
43+
return Ok(ExecResult::err(
44+
"mkfifo: option requires an argument -- 'm'\n".to_string(),
45+
1,
46+
));
47+
}
48+
if let Some(m) = parse_mode(&ctx.args[i + 1]) {
49+
mode = m;
50+
} else {
51+
return Ok(ExecResult::err(
52+
format!("mkfifo: invalid mode '{}'\n", ctx.args[i + 1]),
53+
1,
54+
));
55+
}
56+
i += 2;
57+
} else if let Some(mode_str) = arg.strip_prefix("-m") {
58+
// -mMODE (combined form)
59+
if let Some(m) = parse_mode(mode_str) {
60+
mode = m;
61+
} else {
62+
return Ok(ExecResult::err(
63+
format!("mkfifo: invalid mode '{}'\n", mode_str),
64+
1,
65+
));
66+
}
67+
i += 1;
68+
} else if arg.starts_with('-') && arg != "-" {
69+
return Ok(ExecResult::err(
70+
format!("mkfifo: invalid option -- '{}'\n", &arg[1..]),
71+
1,
72+
));
73+
} else {
74+
paths.push(arg.clone());
75+
i += 1;
76+
}
77+
}
78+
79+
if paths.is_empty() {
80+
return Ok(ExecResult::err("mkfifo: missing operand\n".to_string(), 1));
81+
}
82+
83+
let mut stderr = String::new();
84+
let mut failed = false;
85+
86+
for name in &paths {
87+
let path = resolve_path(ctx.cwd, name);
88+
89+
if let Err(e) = ctx.fs.mkfifo(&path, mode).await {
90+
let msg = e.to_string();
91+
// Map error messages to mkfifo-style output
92+
if msg.contains("lready") || msg.contains("exists") {
93+
stderr.push_str(&format!(
94+
"mkfifo: cannot create fifo '{}': File exists\n",
95+
name
96+
));
97+
} else if msg.contains("ot found") || msg.contains("o such") {
98+
stderr.push_str(&format!(
99+
"mkfifo: cannot create fifo '{}': No such file or directory\n",
100+
name
101+
));
102+
} else {
103+
stderr.push_str(&format!("mkfifo: cannot create fifo '{}': {}\n", name, e));
104+
}
105+
failed = true;
106+
}
107+
}
108+
109+
if failed {
110+
Ok(ExecResult::err(stderr, 1))
111+
} else {
112+
Ok(ExecResult::ok(String::new()))
113+
}
114+
}
115+
}
116+
117+
#[cfg(test)]
118+
#[allow(clippy::unwrap_used)]
119+
mod tests {
120+
use super::*;
121+
use crate::fs::{FileSystem, InMemoryFs};
122+
use std::collections::HashMap;
123+
use std::path::{Path, PathBuf};
124+
use std::sync::Arc;
125+
126+
async fn run_mkfifo(args: &[&str]) -> ExecResult {
127+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
128+
let env = HashMap::new();
129+
let mut variables = HashMap::new();
130+
let mut cwd = PathBuf::from("/");
131+
let fs = Arc::new(InMemoryFs::new());
132+
133+
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
134+
Mkfifo.execute(ctx).await.unwrap()
135+
}
136+
137+
async fn run_mkfifo_with_fs(args: &[&str], fs: Arc<InMemoryFs>) -> ExecResult {
138+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
139+
let env = HashMap::new();
140+
let mut variables = HashMap::new();
141+
let mut cwd = PathBuf::from("/");
142+
143+
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
144+
Mkfifo.execute(ctx).await.unwrap()
145+
}
146+
147+
#[tokio::test]
148+
async fn mkfifo_creates_fifo() {
149+
let fs = Arc::new(InMemoryFs::new());
150+
let result = run_mkfifo_with_fs(&["mypipe"], fs.clone()).await;
151+
assert_eq!(result.exit_code, 0);
152+
assert!(fs.exists(Path::new("/mypipe")).await.unwrap());
153+
// Verify it's a FIFO
154+
let meta = fs.stat(Path::new("/mypipe")).await.unwrap();
155+
assert!(meta.file_type.is_fifo());
156+
}
157+
158+
#[tokio::test]
159+
async fn mkfifo_multiple_paths() {
160+
let fs = Arc::new(InMemoryFs::new());
161+
let result = run_mkfifo_with_fs(&["pipe1", "pipe2", "pipe3"], fs.clone()).await;
162+
assert_eq!(result.exit_code, 0);
163+
assert!(fs.exists(Path::new("/pipe1")).await.unwrap());
164+
assert!(fs.exists(Path::new("/pipe2")).await.unwrap());
165+
assert!(fs.exists(Path::new("/pipe3")).await.unwrap());
166+
}
167+
168+
#[tokio::test]
169+
async fn mkfifo_existing_file_error() {
170+
let fs = Arc::new(InMemoryFs::new());
171+
fs.write_file(Path::new("/existing"), b"data")
172+
.await
173+
.unwrap();
174+
175+
let result = run_mkfifo_with_fs(&["existing"], fs).await;
176+
assert_eq!(result.exit_code, 1);
177+
assert!(result.stderr.contains("File exists"));
178+
}
179+
180+
#[tokio::test]
181+
async fn mkfifo_missing_operand() {
182+
let result = run_mkfifo(&[]).await;
183+
assert_eq!(result.exit_code, 1);
184+
assert!(result.stderr.contains("missing operand"));
185+
}
186+
187+
#[tokio::test]
188+
async fn mkfifo_mode_flag_accepted() {
189+
let fs = Arc::new(InMemoryFs::new());
190+
let result = run_mkfifo_with_fs(&["-m", "0644", "mypipe"], fs.clone()).await;
191+
assert_eq!(result.exit_code, 0);
192+
let meta = fs.stat(Path::new("/mypipe")).await.unwrap();
193+
assert_eq!(meta.mode, 0o644);
194+
}
195+
196+
#[tokio::test]
197+
async fn mkfifo_mode_flag_combined() {
198+
let fs = Arc::new(InMemoryFs::new());
199+
let result = run_mkfifo_with_fs(&["-m0755", "mypipe"], fs.clone()).await;
200+
assert_eq!(result.exit_code, 0);
201+
let meta = fs.stat(Path::new("/mypipe")).await.unwrap();
202+
assert_eq!(meta.mode, 0o755);
203+
}
204+
205+
#[tokio::test]
206+
async fn mkfifo_mode_missing_arg() {
207+
let result = run_mkfifo(&["-m"]).await;
208+
assert_eq!(result.exit_code, 1);
209+
assert!(result.stderr.contains("option requires an argument"));
210+
}
211+
212+
#[tokio::test]
213+
async fn mkfifo_nonexistent_parent() {
214+
let fs = Arc::new(InMemoryFs::new());
215+
let result = run_mkfifo_with_fs(&["/no/such/dir/pipe"], fs).await;
216+
assert_eq!(result.exit_code, 1);
217+
assert!(result.stderr.contains("No such file or directory"));
218+
}
219+
220+
#[tokio::test]
221+
async fn mkfifo_default_mode() {
222+
let fs = Arc::new(InMemoryFs::new());
223+
let result = run_mkfifo_with_fs(&["mypipe"], fs.clone()).await;
224+
assert_eq!(result.exit_code, 0);
225+
let meta = fs.stat(Path::new("/mypipe")).await.unwrap();
226+
assert_eq!(meta.mode, 0o666);
227+
}
228+
}

crates/bashkit/src/builtins/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ mod jq;
6464
mod json;
6565
mod log;
6666
mod ls;
67+
mod mkfifo;
6768
mod navigation;
6869
mod nl;
6970
mod parallel;
@@ -147,6 +148,7 @@ pub use json::Json;
147148
pub use log::Log;
148149
pub(crate) use ls::glob_match;
149150
pub use ls::{Find, Ls, Rmdir};
151+
pub use mkfifo::Mkfifo;
150152
pub use navigation::{Cd, Pwd};
151153
pub use nl::Nl;
152154
pub use parallel::Parallel;

crates/bashkit/src/builtins/test.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,15 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc<dyn FileSystem>, cwd: &Pat
200200
false
201201
}
202202
}
203-
"-p" => false, // named pipe (not supported)
203+
"-p" => {
204+
// named pipe (FIFO)
205+
let path = resolve_file_path(cwd, arg);
206+
if let Ok(meta) = fs.stat(&path).await {
207+
meta.file_type.is_fifo()
208+
} else {
209+
false
210+
}
211+
}
204212
"-S" => false, // socket (not supported)
205213
"-b" => false, // block device (not supported)
206214
"-c" => false, // character device (not supported)

0 commit comments

Comments
 (0)