Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ describe("VFS API", () => {
bash.writeFile("/tmp/target.txt", "data");
bash.symlink("/tmp/target.txt", "/tmp/link.txt");
assert.equal(bash.readLink("/tmp/link.txt"), "/tmp/target.txt");
const meta = bash.stat("/tmp/link.txt");
assert.equal(meta.fileType, "symlink");
assert.equal(bash.lstat("/tmp/link.txt").fileType, "symlink");
// stat follows the link, like Node's fs.stat.
assert.equal(bash.stat("/tmp/link.txt").fileType, "file");
assert.equal(bash.fs().lstat("/tmp/link.txt").fileType, "symlink");
});

it("fs() accessor provides same operations", () => {
Expand Down
50 changes: 48 additions & 2 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,16 @@ impl NativeFileSystemState {
})
}

fn lstat(&self, path: String) -> napi::Result<FileMetadata> {
self.with_fs(|fs| async move {
let meta = fs
.lstat(Path::new(&path))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(metadata_to_js(&meta))
})
}

fn exists(&self, path: String) -> napi::Result<bool> {
self.with_fs(|fs| async move {
fs.exists(Path::new(&path))
Expand Down Expand Up @@ -661,6 +671,14 @@ pub fn file_system_stat(
fs.stat(path)
}

#[napi(js_name = "__fileSystemLstat")]
pub fn file_system_lstat(
fs: &External<NativeFileSystemState>,
path: String,
) -> napi::Result<FileMetadata> {
fs.lstat(path)
}

#[napi(js_name = "__fileSystemExists")]
pub fn file_system_exists(
fs: &External<NativeFileSystemState>,
Expand Down Expand Up @@ -2241,7 +2259,7 @@ impl Bash {
// VFS — direct filesystem access
// ========================================================================

/// Get metadata for a path in the virtual filesystem.
/// Get metadata for a path in the virtual filesystem, following symlinks.
#[napi]
pub fn stat(&self, path: String) -> napi::Result<FileMetadata> {
block_on_with(&self.state, |s| async move {
Expand All @@ -2255,6 +2273,20 @@ impl Bash {
})
}

/// Get metadata without following a symlink in the final component.
#[napi]
pub fn lstat(&self, path: String) -> napi::Result<FileMetadata> {
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let meta = bash
.fs()
.lstat(Path::new(&path))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(metadata_to_js(&meta))
})
}

/// Append content to a file in the virtual filesystem.
#[napi]
pub fn append_file(&self, path: String, content: String) -> napi::Result<()> {
Expand Down Expand Up @@ -2940,7 +2972,7 @@ impl BashTool {
// VFS — direct filesystem access (no shell command composition)
// ========================================================================

/// Get metadata for a path in the virtual filesystem.
/// Get metadata for a path in the virtual filesystem, following symlinks.
#[napi]
pub fn stat(&self, path: String) -> napi::Result<FileMetadata> {
block_on_with(&self.state, |s| async move {
Expand All @@ -2954,6 +2986,20 @@ impl BashTool {
})
}

/// Get metadata without following a symlink in the final component.
#[napi]
pub fn lstat(&self, path: String) -> napi::Result<FileMetadata> {
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let meta = bash
.fs()
.lstat(Path::new(&path))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(metadata_to_js(&meta))
})
}

/// Append content to a file in the virtual filesystem.
#[napi]
pub fn append_file(&self, path: String, content: String) -> napi::Result<()> {
Expand Down
38 changes: 36 additions & 2 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const nativeFileSystemStat: (
modified: number;
created: number;
} = native.__fileSystemStat;
const nativeFileSystemLstat: typeof nativeFileSystemStat =
native.__fileSystemLstat;
const nativeFileSystemExists: (fs: any, path: string) => boolean =
native.__fileSystemExists;
const nativeFileSystemReadDir: (
Expand Down Expand Up @@ -635,6 +637,16 @@ export class FileSystem {
return nativeFileSystemStat(this.native, path);
}

lstat(path: string): {
fileType: string;
size: number;
mode: number;
modified: number;
created: number;
} {
return nativeFileSystemLstat(this.native, path);
}

exists(path: string): boolean {
return nativeFileSystemExists(this.native, path);
}
Expand Down Expand Up @@ -1164,7 +1176,7 @@ export class Bash {
this.native.remove(path, recursive);
}

/** Get metadata for a path (fileType, size, mode, timestamps). */
/** Get metadata for a path (fileType, size, mode, timestamps), following symlinks. */
stat(path: string): {
fileType: string;
size: number;
Expand All @@ -1175,6 +1187,17 @@ export class Bash {
return this.native.stat(path);
}

/** Like `stat`, but a symlink reports itself (`fileType: "symlink"`). */
lstat(path: string): {
fileType: string;
size: number;
mode: number;
modified: number;
created: number;
} {
return this.native.lstat(path);
}

/** Append content to a file. */
appendFile(path: string, content: string): void {
this.native.appendFile(path, content);
Expand Down Expand Up @@ -1648,7 +1671,7 @@ export class BashTool {
this.native.remove(path, recursive);
}

/** Get metadata for a path (fileType, size, mode, timestamps). */
/** Get metadata for a path (fileType, size, mode, timestamps), following symlinks. */
stat(path: string): {
fileType: string;
size: number;
Expand All @@ -1659,6 +1682,17 @@ export class BashTool {
return this.native.stat(path);
}

/** Like `stat`, but a symlink reports itself (`fileType: "symlink"`). */
lstat(path: string): {
fileType: string;
size: number;
mode: number;
modified: number;
created: number;
} {
return this.native.lstat(path);
}

/** Append content to a file. */
appendFile(path: string, content: string): void {
this.native.appendFile(path, content);
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ Default limits (configurable):
|---------|--------|-------|
| Virtual filesystem | ✅ | InMemoryFs, OverlayFs, MountableFs |
| Real filesystem | ❌ | Virtual by default |
| Symlinks | ✅ | Stored but not followed |
| Symlinks | ✅ | Followed in the VFS (40-link cap); `..` resolves lexically |
| Permissions | ✅ | Metadata stored, not enforced |
| `/dev/null` | ✅ | Interpreter-level handling (cannot be bypassed) |
| `/dev/zero` | ✅ | 1 MiB of zeros per read |
Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ through configurable limits.
| Append flood (TM-DOS-010) | `while true; do echo x >> f; done` | FS + loop limits | MITIGATED |
| RealFs append memory exhaustion (TM-DOS-105) | Tiny append to a large writable host file | Stream existing bytes into atomic sibling staging with bounded memory | MITIGATED |
| Deep Agents VFS search amplification (TM-DOS-113) | Dense matching files or a broad recursive `grep`/`glob` bypass shell execution limits and amplify into host objects | Grep caps results at 1,000 matches and 100 KB of matched text; line scans stream; every direct walk carries a per-operation deadline, 10,000-file and 10 MB traversal budget, and a cancellation flag the async workers set | **MITIGATED** |
| Symlink loops (TM-DOS-011) | `ln -s /a /b; ln -s /b /a` | No symlink following | MITIGATED |
| Symlink loops (TM-DOS-011) | `ln -s /a /b; ln -s /b /a` | At most 40 links per lookup, then "Too many levels of symbolic links" | MITIGATED |
| Deep dirs (TM-DOS-012) | `mkdir -p a/b/c/.../z` (1000 levels) | `max_path_depth` (100) | MITIGATED |
| Long filenames (TM-DOS-013) | 10KB filename | `max_filename_length` (255) + `max_path_length` (4096) | MITIGATED |
| Many dir entries (TM-DOS-014) | 1M files in one dir | `max_file_count` | MITIGATED |
Expand Down Expand Up @@ -189,7 +189,7 @@ Scripts may attempt to break out of the sandbox to access the host system.
| Threat | Attack Example | Mitigation | Status |
|--------|---------------|------------|--------|
| Path traversal (TM-ESC-001) | `cat /../../../etc/passwd` | Path normalization | MITIGATED |
| Symlink escape (TM-ESC-002) | `ln -s /etc/passwd /tmp/x` | Symlinks not followed | MITIGATED |
| Symlink escape (TM-ESC-002) | `ln -s /etc/passwd /tmp/x` | Links resolve to VFS paths only; real-mount containment still applies to the result | MITIGATED |
| Real FS access (TM-ESC-003) | Direct syscalls | No real FS by default | MITIGATED |
| Mount escape (TM-ESC-004) | Mount real paths | MountableFs controlled by caller | MITIGATED |
| VFS limit bypass (TM-ESC-012) | `add_file()` skips limits | Restrict API visibility | **MITIGATED** |
Expand Down
23 changes: 16 additions & 7 deletions crates/bashkit/src/builtins/cpython/wasi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const FILETYPE_UNKNOWN: u8 = 0;
const FILETYPE_DIRECTORY: u8 = 3;
const FILETYPE_REGULAR_FILE: u8 = 4;
const FILETYPE_SYMBOLIC_LINK: u8 = 7;
/// WASI `lookupflags`: follow a symlink in the final path component.
const LOOKUPFLAGS_SYMLINK_FOLLOW: i32 = 1;

const OFLAGS_CREAT: u16 = 1;
const OFLAGS_DIRECTORY: u16 = 2;
Expand Down Expand Up @@ -533,7 +535,8 @@ fn encode_filestat(s: &Stat) -> [u8; 64] {
b
}

async fn stat_path(state: &GuestState, path: &Path) -> Result<Stat, Errno> {
/// `follow` = stat semantics; otherwise lstat (a final symlink reports itself).
async fn stat_path(state: &GuestState, path: &Path, follow: bool) -> Result<Stat, Errno> {
if state.is_stdlib(path) {
return Ok(Stat {
filetype: FILETYPE_REGULAR_FILE,
Expand All @@ -542,7 +545,12 @@ async fn stat_path(state: &GuestState, path: &Path) -> Result<Stat, Errno> {
ino: inode(path),
});
}
match state.fs.stat(path).await {
let meta = if follow {
state.fs.stat(path).await
} else {
state.fs.lstat(path).await
};
match meta {
Ok(m) => Ok(Stat {
filetype: filetype_of(m.file_type),
size: m.size,
Expand Down Expand Up @@ -787,7 +795,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker<GuestState>) -> wasmtime::Result
},
Fd::Dir(d) => {
let path = d.path.clone();
stat_path(c.data(), &path).await?
stat_path(c.data(), &path, true).await?
}
Fd::Stdin | Fd::Stdout | Fd::Stderr => Stat {
filetype: FILETYPE_UNKNOWN,
Expand Down Expand Up @@ -998,12 +1006,13 @@ pub(crate) fn add_to_linker(linker: &mut Linker<GuestState>) -> wasmtime::Result
MODULE,
"path_filestat_get",
|mut c: Caller<'_, GuestState>,
(dirfd, _flags, path, path_len, buf): (i32, i32, i32, i32, i32)| {
(dirfd, flags, path, path_len, buf): (i32, i32, i32, i32, i32)| {
Box::new(async move {
let r = async {
let raw = read_bytes(&mut c, path, path_len)?;
let p = c.data().resolve(dirfd as u32, &raw)?;
let stat = stat_path(c.data(), &p).await?;
let follow = flags & LOOKUPFLAGS_SYMLINK_FOLLOW != 0;
let stat = stat_path(c.data(), &p, follow).await?;
write_bytes(&mut c, buf, &encode_filestat(&stat))
}
.await;
Expand Down Expand Up @@ -1080,7 +1089,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker<GuestState>) -> wasmtime::Result
return Err(EACCES);
}
let fs = c.data().fs.clone();
let meta = fs.stat(&p).await.map_err(|e| map_fs_error(&e))?;
let meta = fs.lstat(&p).await.map_err(|e| map_fs_error(&e))?;
if !meta.file_type.is_dir() {
return Err(ENOTDIR);
}
Expand All @@ -1107,7 +1116,7 @@ pub(crate) fn add_to_linker(linker: &mut Linker<GuestState>) -> wasmtime::Result
return Err(EROFS);
}
let fs = c.data().fs.clone();
let meta = fs.stat(&p).await.map_err(|e| map_fs_error(&e))?;
let meta = fs.lstat(&p).await.map_err(|e| map_fs_error(&e))?;
if meta.file_type.is_dir() {
return Err(EISDIR);
}
Expand Down
7 changes: 3 additions & 4 deletions crates/bashkit/src/builtins/fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ impl Builtin for Rm {
for file in files {
let path = resolve_path(ctx.cwd, file);

// Check if exists
let exists = ctx.fs.exists(&path).await.unwrap_or(false);
if !exists {
// lstat: rm acts on a link itself (dangling links included).
let metadata = ctx.fs.lstat(&path).await;
if metadata.is_err() {
if !force {
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': No such file or directory\n", file),
Expand All @@ -187,7 +187,6 @@ impl Builtin for Rm {
}

// Check if it's a directory
let metadata = ctx.fs.stat(&path).await;
if let Ok(meta) = metadata
&& meta.file_type.is_dir()
&& !recursive
Expand Down
6 changes: 3 additions & 3 deletions crates/bashkit/src/builtins/find/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ async fn canonicalize(fs: &dyn FileSystem, path: &Path) -> std::result::Result<P
continue;
}
let candidate = out.join(&comp);
match fs.stat(&candidate).await {
match fs.lstat(&candidate).await {
Ok(m) if m.file_type.is_symlink() => {
hops += 1;
if hops > MAX_SYMLINK_HOPS {
Expand Down Expand Up @@ -726,7 +726,7 @@ impl FindRun {
async fn start_root(&mut self, index: usize) {
let root = self.cfg.roots[index].clone();
let abs = resolve_path(&self.cwd, &root);
if self.cfg.fs.stat(&abs).await.is_err() {
if self.cfg.fs.lstat(&abs).await.is_err() {
self.error(format!("find: '{root}': No such file or directory\n"));
return;
}
Expand Down Expand Up @@ -767,7 +767,7 @@ impl FindRun {
} => (abs, display, root, depth, ancestors),
};
let fs = self.cfg.fs.clone();
let lmeta = match fs.stat(&abs).await {
let lmeta = match fs.lstat(&abs).await {
Ok(m) => m,
Err(e) => {
self.error(format!("find: '{display}': {e}\n"));
Expand Down
21 changes: 12 additions & 9 deletions crates/bashkit/src/builtins/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,14 @@ impl Builtin for File {
for file in files {
let path = resolve_path(ctx.cwd, file);

if !ctx.fs.exists(&path).await.unwrap_or(false) {
// `file` describes a link itself.
let Ok(metadata) = ctx.fs.lstat(&path).await else {
output.push_str(&format!(
"{}: cannot open '{}' (No such file or directory)\n",
file, file
));
continue;
}

let metadata = ctx.fs.stat(&path).await?;
};
let file_type_str = match metadata.file_type {
FileType::Directory => "directory".to_string(),
FileType::Symlink => {
Expand Down Expand Up @@ -303,7 +302,7 @@ impl Builtin for Stat {
1,
));
}
let _ = matches.get_flag("dereference");
let dereference = matches.get_flag("dereference");
let _ = matches.get_flag("terse");

let format = matches
Expand All @@ -325,14 +324,18 @@ impl Builtin for Stat {
for file in &files {
let path = resolve_path(ctx.cwd, file);

if !ctx.fs.exists(&path).await.unwrap_or(false) {
// GNU stat describes a link itself unless -L.
let metadata = if dereference {
ctx.fs.stat(&path).await
} else {
ctx.fs.lstat(&path).await
};
let Ok(metadata) = metadata else {
return Ok(ExecResult::err(
format!("stat: cannot stat '{}': No such file or directory\n", file),
1,
));
}

let metadata = ctx.fs.stat(&path).await?;
};

if let Some(fmt) = &format {
output.push_str(&format_stat(file, &metadata, fmt));
Expand Down
Loading
Loading