diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index c93e8a029..37f3fced2 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -905,6 +905,50 @@ impl InMemoryFs { ); } + /// Add a directory (and any missing parents) synchronously, for initial + /// setup. + /// + /// Used by [`BashBuilder`](crate::BashBuilder) to provision a home + /// directory for a configured username so writes to `$HOME` / `~` succeed. + /// For runtime use the async [`FileSystem::mkdir`] instead. Existing + /// entries are left untouched; a path that violates VFS limits is ignored. + /// + /// # Example + /// + /// ```rust + /// use bashkit::InMemoryFs; + /// + /// let fs = InMemoryFs::new(); + /// fs.add_dir("/home/eval", 0o755); + /// ``` + pub fn add_dir(&self, path: impl AsRef, mode: u32) { + let path = Self::normalize_path(path.as_ref()); + + if self.limits.validate_path(&path).is_err() { + return; + } + + let mut entries = self.entries.write().unwrap(); + + // Create each path component as a directory if it does not already + // exist, mirroring the parent-creation logic in `add_file`. + let mut current = PathBuf::from("/"); + for component in path.components().skip(1) { + current.push(component); + entries + .entry(current.clone()) + .or_insert_with(|| FsEntry::Directory { + metadata: Metadata { + file_type: FileType::Directory, + size: 0, + mode, + modified: SystemTime::now(), + created: SystemTime::now(), + }, + }); + } + } + /// Add a lazy file whose content is loaded on first read. /// /// The `loader` closure is called at most once when the file is first read. diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 917bfc177..422534096 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -2653,11 +2653,22 @@ impl BashBuilder { /// # Ok(()) /// # } /// ``` - pub fn build(self) -> Bash { + pub fn build(mut self) -> Bash { let base_fs: Arc = if self.shell_profile.is_logic_only() { Arc::new(fs::DisabledFs) + } else if let Some(fs) = self.fs.take() { + fs } else { - self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new())) + // Default in-memory VFS. The interpreter sets `HOME` (and `~`) to + // `/home/` (see `Interpreter::with_config`), but the VFS + // only pre-creates `/home/user`. Provision `/home/` for a + // configured username so writes to `$HOME` / `~` succeed instead of + // failing with "parent directory not found" (issue #2128). + let mem = InMemoryFs::new(); + if let Some(ref username) = self.username { + mem.add_dir(format!("/home/{username}"), 0o755); + } + Arc::new(mem) }; // Layer 1: Apply real filesystem mounts (if any) @@ -4690,6 +4701,43 @@ fn assert_eq!(result.stdout, "charlie\n"); } + #[tokio::test] + async fn test_username_provisions_home_dir() { + // issue #2128: a configured username must get an existing, writable + // home directory so writes to $HOME / ~ don't fail. + let mut bash = Bash::builder().username("eval").build(); + let result = bash + .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "hi\n"); + } + + #[tokio::test] + async fn test_username_home_tilde_write() { + // ~ and $HOME expand to /home/; writes there must succeed. + let mut bash = Bash::builder().username("agent").build(); + let result = bash + .exec("echo data > ~/script.sh && cat $HOME/script.sh") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "data\n"); + } + + #[tokio::test] + async fn test_default_username_home_still_present() { + // The default user's home (/home/user) must remain provisioned. + let mut bash = Bash::new(); + let result = bash + .exec("echo ok > /home/user/f && cat /home/user/f") + .await + .unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "ok\n"); + } + #[tokio::test] async fn test_default_ppid_is_sandboxed() { let mut bash = Bash::new(); diff --git a/specs/vfs.md b/specs/vfs.md index ea4a38d87..b41234b46 100644 --- a/specs/vfs.md +++ b/specs/vfs.md @@ -39,6 +39,10 @@ Do you need a custom filesystem? #### InMemoryFs - `HashMap`, thread-safe via `RwLock`; no persistence - Initial directories: `/`, `/tmp`, `/home`, `/home/user`, `/dev` +- When `BashBuilder::username(name)` is set, the default VFS also provisions + `/home/` (matching the interpreter's `HOME`/`~` default) so writes to + `$HOME` / `~` succeed; custom filesystems are responsible for their own home + dirs. See `InMemoryFs::add_dir`. - Special handling for `/dev/null`, `/dev/urandom`, `/dev/random` - Mount files at build time via `BashBuilder::mount_text()` / `mount_readonly_text()`