Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
42440de
test: pin what -w must answer on every backend, before the fix
tobert Aug 24, 2026
48d978d
types: PathAccess — the one place the two facts combine
tobert Aug 24, 2026
265b279
test: reframe for filling the modes in at the source
tobert Aug 24, 2026
4aa4789
vfs: MemoryFs reports real modes instead of None
tobert Aug 24, 2026
cb55304
vfs: DevFs reports real modes; /dev is 0555, not 0755
tobert Aug 24, 2026
7fdca77
vfs: LocalFs reports a mode on non-Unix too
tobert Aug 24, 2026
e10ec59
types: close the -w absent-mode default now the source is filled in
tobert Aug 24, 2026
80c1559
kernel: per-path access query, and both file-test sites read it
tobert Aug 24, 2026
b3d97ae
vfs: leave OverlayFs on the default, and write down why
tobert Aug 24, 2026
5b4fc94
docs: changelog for the file-test writability fix
tobert Aug 24, 2026
031b9d4
docs: name the two facts -w reads, and -x on a directory
tobert Aug 24, 2026
33967a4
kernel: path_access must fall back the way stat does
tobert Aug 24, 2026
826394a
docs: correct BuiltinFs's lying comment, instruct the next backend
tobert Aug 24, 2026
e00269b
vfs: LocalFs answers file tests from the OS, not from mode bits
tobert Aug 24, 2026
a1236da
test: pin effective access, and correct a WASI claim I did not check
tobert Aug 24, 2026
4dd34f5
Merge remote-tracking branch 'origin/main' into feat/vfs-path-read-only
tobert Aug 24, 2026
7bed658
Cut the path_access comments to the house length
tobert Aug 27, 2026
21d7504
Merge remote-tracking branch 'origin/main' into feat/vfs-path-read-only
tobert Aug 27, 2026
3e3b5a5
Link a private item by name, not by intra-doc link
tobert Aug 27, 2026
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,20 @@ breaking entries are marked **BREAKING**.
document, success or error, so a consumer windowing measurements by build
no longer shells out to `kaish --version` per call.

- **`KernelBackend::path_access` and `Filesystem::path_access`** — the per-path
read/write/execute query behind the file tests. Both are defaulted, so
existing implementations keep compiling; not breaking.

### Fixed
- `help <tool>` renders a tool's subcommands and their flags, and names each
parameter's aliases. `help kj` and every wrapped command showed "No
parameters." before.

- **File tests on virtual and real paths** — `-w` claimed read-only mounts and
root-owned files were writable, and `-x` denied that a memory-backed
directory is searchable. `-w`/`-r`/`-x` now answer from the owning mount plus
the OS's effective access.

## [0.16.0] - 2026-08-23

### Added
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions crates/kaish-kernel/src/backend/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use super::{
};
use crate::tools::{ToolArgs, ToolCtx, ToolRegistry};
use crate::vfs::{DirEntry, Filesystem, MountInfo, VfsRouter};
use kaish_types::PathAccess;

/// Local backend implementation using VfsRouter and ToolRegistry.
///
Expand Down Expand Up @@ -400,6 +401,14 @@ impl KernelBackend for LocalBackend {
self.vfs.read_only()
}

/// Asks the router, which asks the mount that owns the path. The trait
/// default would use `read_only()` above — the whole-router answer —
/// which is false whenever any one mount is writable and would call
/// `/v/bin/echo` writable on the strength of `/tmp`.
async fn path_access(&self, path: &Path) -> BackendResult<PathAccess> {
Ok(self.vfs.path_access(path).await?)
}

fn backend_type(&self) -> &str {
"local"
}
Expand Down
16 changes: 16 additions & 0 deletions crates/kaish-kernel/src/backend/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ use super::{
};
use crate::tools::{ToolArgs, ToolCtx};
use crate::vfs::{DirEntry, Filesystem, MountInfo, VfsRouter};
use kaish_types::PathAccess;

/// The final path component, used to name a synthesized directory entry for a
/// shared ancestor (`/v` → `v`). Falls back to `/` for a component-less path.
Expand Down Expand Up @@ -418,6 +419,21 @@ impl KernelBackend for VirtualOverlayBackend {
self.inner.read_only() && self.vfs.read_only()
}

/// Routes the same way `stat` does, so the answer comes from the layer
/// that owns the path. The trait default would use `read_only()` above,
/// which is the AND of both layers and belongs to neither path.
async fn path_access(&self, path: &Path) -> BackendResult<PathAccess> {
if self.is_virtual_path(path) {
Ok(self.vfs.path_access(path).await?)
} else if self.is_shared_ancestor(path) {
// A synthesized ancestor directory exists only to be traversed:
// readable and searchable, and kaish creates nothing in it.
Ok(PathAccess::resolve(Some(0o555), true))
} else {
self.inner.path_access(path).await
}
}

fn backend_type(&self) -> &str {
"virtual-overlay"
}
Expand Down
38 changes: 25 additions & 13 deletions crates/kaish-kernel/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4343,20 +4343,32 @@ impl Kernel {
let ctx = self.exec_ctx.read().await;
(ctx.resolve_path(&path_str), ctx.backend.clone())
};
let entry = backend.stat(&resolved).await.ok();
// `-r`/`-w`/`-x` go through `path_access`, never through
// the raw mode bits: the mount's read-only state is half
// the answer and `stat` does not carry it. The `test`
// builtin's `file_test` reads the same query, and
// `file_test_writable_tests` runs every case through both
// spellings — this mirror has drifted before.
Ok(match op {
FileTestOp::Exists => entry.is_some(),
FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
FileTestOp::Readable => entry.as_ref().is_some_and(|e| {
e.permissions.is_none_or(|p| p & 0o444 != 0)
}),
FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
e.permissions.is_none_or(|p| p & 0o222 != 0)
}),
FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
e.permissions.is_some_and(|p| p & 0o111 != 0)
}),
FileTestOp::Exists => backend.stat(&resolved).await.is_ok(),
FileTestOp::IsFile => {
backend.stat(&resolved).await.is_ok_and(|e| e.is_file())
}
FileTestOp::IsDir => {
backend.stat(&resolved).await.is_ok_and(|e| e.is_dir())
}
FileTestOp::Readable => backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.readable),
FileTestOp::Writable => backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.writable),
FileTestOp::Executable => backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.executable),
})
}
TestExpr::StringTest { op, value } => match op {
Expand Down
35 changes: 22 additions & 13 deletions crates/kaish-kernel/src/tools/builtin/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,20 +338,29 @@ async fn file_test(ctx: &ExecContext, op: &str, path: &str) -> bool {
return false;
}
let resolved = ctx.resolve_path(path);
let entry = ctx.backend.stat(&resolved).await.ok();
// `-r`/`-w`/`-x` go through `path_access`, never through the raw mode
// bits: the mount's read-only state is half the answer and `stat` does
// not carry it. `eval_test_async` reads the same query, and
// `file_test_writable_tests` runs every case through both.
match op {
"-e" => entry.is_some(),
"-f" => entry.as_ref().is_some_and(|e| e.is_file()),
"-d" => entry.as_ref().is_some_and(|e| e.is_dir()),
"-r" => entry
.as_ref()
.is_some_and(|e| e.permissions.is_none_or(|p| p & 0o444 != 0)),
"-w" => entry
.as_ref()
.is_some_and(|e| e.permissions.is_none_or(|p| p & 0o222 != 0)),
"-x" => entry
.as_ref()
.is_some_and(|e| e.permissions.is_some_and(|p| p & 0o111 != 0)),
"-e" => ctx.backend.stat(&resolved).await.is_ok(),
"-f" => ctx.backend.stat(&resolved).await.is_ok_and(|e| e.is_file()),
"-d" => ctx.backend.stat(&resolved).await.is_ok_and(|e| e.is_dir()),
"-r" => ctx
.backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.readable),
"-w" => ctx
.backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.writable),
"-x" => ctx
.backend
.path_access(&resolved)
.await
.is_ok_and(|access| access.executable),
_ => unreachable!("file_test called with non-file op {op:?}"),
}
}
Expand Down
18 changes: 16 additions & 2 deletions crates/kaish-kernel/src/vfs/builtin_fs.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
//! BuiltinFs — read-only VFS that presents builtins as executable entries under `/v/bin/`.
//! BuiltinFs — read-only VFS that lists builtins as file entries under `/v/bin/`.
//!
//! The entries are not executable and nothing here ever executes. `stat`
//! reports no mode, so `test -x /v/bin/grep` answers NO, and `real_path`
//! returns `None`, so there is no path for exec(2) to open. `read` returns a
//! line opening with `#!`, which makes an entry look executable.
//!
//! Running a builtin goes by name through the `ToolRegistry` and never routes
//! through this filesystem. `/v/bin` is an inventory to list and read, not a
//! directory of programs. Reporting `0o111` would flip `test -x /v/bin/*` from
//! NO to YES — a behavior change that wants its own decision.
//!
//! `read_only()` is `true`, and the closed `-w` default depends on it staying
//! true — see `Filesystem::path_access`.

use std::io;
use std::path::{Path, PathBuf};
Expand All @@ -9,7 +22,8 @@ use async_trait::async_trait;
use crate::tools::ToolRegistry;
use super::{DirEntry, Filesystem};

/// A read-only filesystem that exposes registered builtins as entries.
/// A read-only filesystem that exposes registered builtins as file
/// entries. Listable and readable; not executable — see the module docs.
pub struct BuiltinFs {
tools: Arc<ToolRegistry>,
}
Expand Down
115 changes: 115 additions & 0 deletions crates/kaish-kernel/src/vfs/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Routes filesystem operations to the appropriate backend based on path.

use super::{DirEntry, Filesystem};
use kaish_vfs::PathAccess;
use async_trait::async_trait;
use std::collections::BTreeMap;
use std::io;
Expand All @@ -14,6 +15,12 @@ use std::sync::Arc;
// paths keep working.
pub use kaish_types::backend::MountInfo;

/// Mode reported for a directory the router synthesizes rather than reads
/// from a mount: the root, and any ancestor of a mount that has no mount of
/// its own. Readable and searchable, never writable — these directories are
/// derived from the mount table and the router creates nothing in them.
const SYNTHESIZED_DIRECTORY_MODE: u32 = 0o555;

/// Routes filesystem operations to mounted backends.
///
/// Mount points are matched by longest prefix. For example, if `/mnt` and
Expand Down Expand Up @@ -388,6 +395,33 @@ impl Filesystem for VfsRouter {
from_fs.rename(&from_relative, &to_relative).await
}

/// Delegates to the mount that owns the path, so the answer is that
/// mount's — not the whole router's. `read_only()` below is the
/// whole-router question and cannot answer for one path: a router with a
/// writable `/` and a read-only `/v/bin` is read-only for neither.
///
/// Falls back the way `stat` does. `stat` synthesizes a directory for the
/// root and for any ancestor of a mount (`/v` above `/v/jobs`), so those
/// paths exist, and an answer here that errored on them would contradict
/// it: `[[ -e /v ]]` true and `[[ -r /v ]]` false about the same path.
/// A synthesized directory is readable and searchable, and never
/// writable — the router creates nothing in one.
async fn path_access(&self, path: &Path) -> io::Result<PathAccess> {
match self.find_mount(path) {
Ok((fs, relative)) => fs.path_access(&relative).await,
Err(e) => {
let path_str = path.to_string_lossy();
let is_synthesized =
path_str.is_empty() || path_str == "/" || self.has_mount_under(path);
if is_synthesized {
Ok(PathAccess::resolve(Some(SYNTHESIZED_DIRECTORY_MODE), true))
} else {
Err(e)
}
}
}
}

fn read_only(&self) -> bool {
// Router is read-only iff every mount is. Empty router returns
// false — a router with no mounts isn't meaningfully read-only,
Expand Down Expand Up @@ -622,6 +656,87 @@ mod tests {
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported);
}

// `stat` synthesizes a directory for the root and for any ancestor of a
// mount, so those paths exist. `path_access` has to agree with `stat`
// about the same path — going straight to `find_mount` errors where
// `stat` succeeds, and `[[ -e /v ]]` would be true while `[[ -r /v ]]`
// was false about the identical path.
#[tokio::test]
async fn path_access_agrees_with_stat_on_synthesized_directories() {
let mut router = VfsRouter::new();
router.mount("/v/docs", MemoryFs::new());

for path in ["/", "/v"] {
let path = Path::new(path);
assert!(
router.stat(path).await.is_ok(),
"{} is synthesized by stat",
path.display()
);
let access = router
.path_access(path)
.await
.unwrap_or_else(|e| panic!("path_access must not error where stat succeeds: {e}"));
assert!(access.readable, "{} must be readable", path.display());
assert!(access.executable, "{} must be searchable", path.display());
assert!(
!access.writable,
"the router creates nothing in {}",
path.display()
);
}
}

// The synthesis must not swallow a genuinely absent path.
#[tokio::test]
async fn path_access_errors_on_a_path_with_no_mount() {
let mut router = VfsRouter::new();
router.mount("/v/docs", MemoryFs::new());
assert!(router.path_access(Path::new("/nope")).await.is_err());
assert!(router.path_access(Path::new("/v/docs/absent")).await.is_err());
}

// A real mount answers for itself, not with the synthesized defaults.
#[tokio::test]
async fn path_access_at_a_mount_point_asks_the_mount() {
let mut router = VfsRouter::new();
router.mount("/rw", MemoryFs::new());
router.mount("/ro", BuiltinFsStub);

assert!(router.path_access(Path::new("/rw")).await.unwrap().writable);
assert!(!router.path_access(Path::new("/ro")).await.unwrap().writable);
assert!(router.path_access(Path::new("/ro")).await.unwrap().readable);
}

/// A minimal read-only mount that reports no mode, standing in for
/// `BuiltinFs`/`JobFs` without dragging a ToolRegistry into this module.
struct BuiltinFsStub;

#[async_trait]
impl Filesystem for BuiltinFsStub {
async fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
Ok(Vec::new())
}
async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
}
async fn list(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
Ok(Vec::new())
}
async fn stat(&self, _path: &Path) -> io::Result<DirEntry> {
Ok(DirEntry::directory("."))
}
async fn mkdir(&self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
}
async fn remove(&self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
}
fn read_only(&self) -> bool {
true
}
}

#[tokio::test]
async fn read_only_empty_router_returns_false() {
let router = VfsRouter::new();
Expand Down
Loading