kaish_kernel/tools/context.rs
1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8
9use crate::ast::Value;
10use crate::backend::{KernelBackend, LocalBackend};
11use crate::dispatch::PipelinePosition;
12use crate::ignore_config::IgnoreConfig;
13use crate::interpreter::{ExecResult, Scope};
14use crate::output_limit::OutputLimitConfig;
15use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
16use crate::tools::ToolRegistry;
17use crate::trash::TrashBackend;
18use crate::vfs::VfsRouter;
19use kaish_vfs::ByteBudget;
20use tokio::sync::oneshot;
21use tokio_util::sync::CancellationToken;
22
23use crate::interpreter::OutputFormat;
24
25use super::traits::ToolSchema;
26
27/// Output context determines how command output should be formatted.
28///
29/// Different contexts prefer different output formats:
30/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
31/// - **Piped** — Raw output for pipeline processing
32/// - **Model** — Token-efficient compact formats (MCP server / agent context)
33/// - **Script** — Non-interactive script execution
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum OutputContext {
36 /// Interactive TTY/REPL - use human-friendly format with colors.
37 #[default]
38 Interactive,
39 /// Output to another command - use raw output for pipes.
40 Piped,
41 /// MCP server / agent context - use token-efficient model format.
42 Model,
43 /// Non-interactive script - use raw output.
44 Script,
45}
46
47/// Execution context passed to tools.
48///
49/// Provides access to the backend (for file operations and tool dispatch),
50/// scope, and other kernel state.
51pub struct ExecContext {
52 /// Kernel backend for I/O operations.
53 ///
54 /// This is the preferred way to access filesystem operations.
55 /// Use `backend.read()`, `backend.write()`, etc.
56 pub backend: Arc<dyn KernelBackend>,
57 /// Variable scope.
58 pub scope: Scope,
59 /// Current working directory (VFS path).
60 pub cwd: PathBuf,
61 /// Previous working directory (for `cd -`).
62 pub prev_cwd: Option<PathBuf>,
63 /// Standard input for the tool (from a redirect, heredoc, here-string, or
64 /// `ExecuteOptions::stdin`). Bytes-typed (GH #176) so a `< binfile`
65 /// redirect over non-UTF-8 content reaches a byte-aware builtin intact
66 /// instead of erroring at redirect setup; a text-only builtin still
67 /// refuses it loudly when it calls `read_stdin_to_text`.
68 pub stdin: Option<Vec<u8>>,
69 /// Structured data from pipeline (pre-parsed JSON from previous command).
70 /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
71 pub stdin_data: Option<Value>,
72 /// Sideband receiver for the previous stage's structured `.data`, set by the
73 /// concurrent pipeline runner. Resolved lazily via [`Self::resolve_stdin`]
74 /// AFTER the pipe is drained — never pre-read — so a streaming upstream that
75 /// only sends its data after writing the pipe can't deadlock a consumer that
76 /// awaits it. Non-`Clone`, so it's moved on resolve.
77 pub stdin_data_rx: Option<oneshot::Receiver<Option<Value>>>,
78 /// Streaming pipe input (set when this command is in a concurrent pipeline).
79 pub pipe_stdin: Option<PipeReader>,
80 /// Streaming pipe output (set when this command is in a concurrent pipeline).
81 pub pipe_stdout: Option<PipeWriter>,
82 /// Tool schemas for help command.
83 ///
84 /// `Arc<[…]>` rather than `Vec`: the full builtin schema catalog (~70
85 /// entries, each with its own `Vec`s and `String`s) is snapshotted into a
86 /// fresh `ExecContext` at every command dispatch and pipeline/fork child. As
87 /// a `Vec` that was a deep clone of the whole catalog per command; as an
88 /// `Arc<[…]>` it's a refcount bump (GH #48, item 8). Immutable after the
89 /// kernel seeds it, so a shared slice is the right shape.
90 pub tool_schemas: Arc<[ToolSchema]>,
91 /// Tool registry reference (for tools that need to inspect available tools).
92 pub tools: Option<Arc<ToolRegistry>>,
93 /// Job manager for background jobs (optional).
94 pub job_manager: Option<Arc<JobManager>>,
95 /// Kernel stderr stream for real-time error output from pipeline stages.
96 ///
97 /// When set, pipeline stages write stderr here instead of buffering in
98 /// `ExecResult.err`. This allows stderr from all stages to stream to
99 /// the terminal (or other sink) concurrently, matching bash behavior.
100 pub stderr: Option<StderrStream>,
101 /// Position of this command within a pipeline (for stdio decisions).
102 pub pipeline_position: PipelinePosition,
103 /// Whether we're running in interactive (REPL) mode.
104 pub interactive: bool,
105 /// Arm `PR_SET_PDEATHSIG(SIGKILL)` on external commands spawned from this
106 /// context, so a hard-killed kaish process cannot orphan them.
107 ///
108 /// Seeded from `KernelConfig::kill_children_on_parent_death` — read that
109 /// field for the tradeoff and the macOS gap. It lives here, not on the
110 /// `Kernel`, because both external-command spawn sites (`Kernel::
111 /// try_execute_external` and `dispatch.rs`'s `BackendDispatcher`) reach an
112 /// `ExecContext` and only one of them reaches a `Kernel`; one home keeps
113 /// the two `pre_exec` blocks from drifting.
114 ///
115 /// `false` for a stand-alone `ExecContext` built outside a kernel, which
116 /// is the pre-existing behavior.
117 pub kill_children_on_parent_death: bool,
118 /// Command aliases (name → expansion string).
119 pub aliases: HashMap<String, String>,
120 /// Ignore file configuration for file-walking tools.
121 pub ignore_config: IgnoreConfig,
122 /// Output size limit configuration for agent safety.
123 pub output_limit: OutputLimitConfig,
124 /// Whether external command execution is allowed.
125 ///
126 /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
127 /// Only kaish builtins and backend-registered tools (MCP) are available.
128 pub allow_external_commands: bool,
129 /// Trash backend for safe file deletion.
130 ///
131 /// Always present when the kernel creates the context (even if `set -o trash`
132 /// is off — the backend exists so `kaish-trash list/restore/empty` work
133 /// regardless of the trash flag).
134 pub trash_backend: Option<Arc<dyn TrashBackend>>,
135 /// Terminal state for job control (interactive mode, Unix only).
136 #[cfg(all(unix, feature = "subprocess"))]
137 pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
138 /// Command dispatcher for re-dispatching through the full resolution chain.
139 ///
140 /// When set (via `Kernel::into_arc()`), builtins like `timeout` can dispatch
141 /// inner commands through the full chain (user tools → builtins → .kai scripts
142 /// → external commands) instead of being limited to `backend.call_tool()`.
143 ///
144 /// `None` when the Kernel was not wrapped via `into_arc()`.
145 pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
146 /// Cancellation token for this execution path.
147 ///
148 /// Populated by the kernel at execute entry, then propagated through pipeline
149 /// stages, foreground forks (scatter workers, concurrent pipeline stages,
150 /// `$(...)` cmdsubs), and into spawned external children. When the token
151 /// fires, externals receive SIGTERM/SIGKILL via the `wait_or_kill` helper.
152 ///
153 /// Default for stand-alone `ExecContext` constructors is a fresh, never-fired
154 /// token so non-kernel test contexts behave as before.
155 pub cancel: CancellationToken,
156 /// Per-execution output format override set by a builtin's GlobalFlags
157 /// flatten (e.g. `--json`). The dispatcher reads this after `tool.execute()`
158 /// returns and applies the format via `apply_output_format`.
159 ///
160 /// Builtins set this via `GlobalFlags::apply(ctx)`; external commands
161 /// don't touch it.
162 pub output_format: Option<OutputFormat>,
163
164 /// Shared VFS memory budget for this kernel's `MemoryFs` mounts.
165 ///
166 /// `Arc`-cloned from the owning `Kernel` (or its fork parent) so all
167 /// concurrent execution paths draw from the same pool. `None` means
168 /// unbounded. Populated by `Kernel::assemble` and forwarded through
169 /// `child_for_pipeline` / `fork_inner` so background jobs and scatter
170 /// workers see the same cap as foreground execution.
171 pub vfs_budget: Option<Arc<ByteBudget>>,
172
173 /// The per-execute timeout watchdog, when a script timeout is in effect.
174 ///
175 /// Populated by the kernel at execute entry (alongside `cancel`) and
176 /// shared through `child_for_pipeline` so forks and pipeline stages can
177 /// acquire patient holds against the same script clock. `None` when no
178 /// timeout is configured — `ToolCtx::patient` then returns an inert guard.
179 pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
180
181 /// Active overlay handle when the kernel was constructed with `overlay: true`.
182 ///
183 /// `Arc`-cloned so forks and pipeline stages share the same transaction.
184 /// `None` when no overlay is active (most kernels).
185 #[cfg(all(feature = "localfs", feature = "overlay"))]
186 pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
187
188}
189
190/// What the write-model gate chose for a single truncating overwrite.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub(crate) enum MutationAction {
193 /// Write now — new file, append, excluded path, or trash off.
194 Proceed,
195 /// Snapshot the prior content to trash, then write.
196 TrashFirst,
197}
198
199/// What a snapshotted overwrite must still find at the target before it
200/// writes — the compare-and-swap expectation `overwrite_checked` enforces.
201///
202/// The trash path already holds the prior bytes (it had to copy them to the
203/// trash), so the expectation compares bytes.
204#[derive(Debug, Clone)]
205pub enum OverwriteExpectation {
206 /// The exact prior bytes, from the trash snapshot.
207 Bytes(Vec<u8>),
208}
209
210/// What each snapshotted target must still look like when the caller writes
211/// it, keyed by resolved path (see `overwrite_checked`).
212///
213/// Every existing target the trash snapshotted appears. A new file, an
214/// append, and an excluded or unsnapshotted path are absent, because none of
215/// them has prior content to lose.
216pub type GateExpectations = std::collections::HashMap<PathBuf, OverwriteExpectation>;
217
218/// Real paths the trash gate skips: host scratch under `/tmp`, where
219/// snapshotting prior content to trash is pointless. Shared by `rm`'s delete
220/// gate (`decide_rm_action`) and the overwrite gate (`decide_mutation_action`)
221/// so the exclusion can't drift between them. `Path::starts_with` is
222/// component-aware, so `/tmp_file` does not match `/tmp`.
223///
224/// Note: kaish's own in-memory VFS mounts (e.g. `/v/blobs`) have `real_path ==
225/// None`, so they are handled by the no-real-path gating path, not here — there
226/// is deliberately no lexical `/v` exclusion. Mount-coverage routing delegates
227/// unclaimed `/v/*` to the embedder's backend, whose *real* content under `/v`
228/// (a real path like `/v/cas/blob.bin`) must keep the trash safety net; a
229/// `/v` prefix exclusion here would silently strip it.
230pub(crate) fn is_trash_excluded(real_path: Option<&Path>) -> bool {
231 matches!(real_path, Some(rp) if rp.starts_with("/tmp"))
232}
233
234/// Decide whether a truncating overwrite snapshots to trash first, mirroring
235/// `rm`'s trash priority. Pure so the decision table is unit-testable in
236/// isolation.
237///
238/// - A non-existent target or an append has nothing to lose → `Proceed`.
239/// - A real path under `/tmp` (host scratch) is excluded (matches `rm`) → `Proceed`.
240/// - `TrashFirst` when trash is on **and** the prior content fits under
241/// `trash_max_size` (a file too big to snapshot can't be backed up, so it
242/// falls through, exactly like `rm`); else `Proceed`.
243///
244/// An overlay/in-memory target has `real_path == None`, so it is *not*
245/// excluded and still snapshots — the protection is about agent-operation
246/// safety, not just real-FS data (Amy, 2026-06-17).
247pub(crate) fn decide_mutation_action(
248 trash_enabled: bool,
249 real_path: Option<&Path>,
250 target_exists: bool,
251 is_append: bool,
252 file_size: u64,
253 trash_max_size: u64,
254) -> MutationAction {
255 if !target_exists || is_append {
256 return MutationAction::Proceed;
257 }
258 if is_trash_excluded(real_path) {
259 return MutationAction::Proceed;
260 }
261 if trash_enabled && file_size <= trash_max_size {
262 return MutationAction::TrashFirst;
263 }
264 // A target too large for the trash is written directly. kaish does not
265 // hold it back: nothing in the kernel decides whether an overwrite is
266 // allowed — an embedder that wants to refuse one reads the plan first.
267 MutationAction::Proceed
268}
269
270/// Overwrite `resolved` with `content`, compare-and-swapping against
271/// `expected` first when there is one. The target's current state is
272/// re-derived and must match, else a concurrent change is a loud conflict —
273/// never a silent clobber. Binary-safe (raw bytes, unlike the `String`-based
274/// `PatchOp` CAS). Shared by the byte-oriented gated builtins via
275/// `ExecContext::overwrite_checked` (`tee`/`write`/`dd`) and directly by
276/// `cp`'s free copy path.
277///
278/// This catches a change between the snapshot and the write. It does not
279/// make the write OS-atomic — a crash mid-write can still truncate (the
280/// atomic write-temp-then-rename primitive is a tracked write-model
281/// residual).
282pub(crate) async fn cas_overwrite(
283 backend: &dyn KernelBackend,
284 resolved: &Path,
285 content: &[u8],
286 expected: Option<&OverwriteExpectation>,
287) -> Result<(), crate::backend::BackendError> {
288 // A re-read or re-digest failure propagates loudly — never
289 // `unwrap_or_default()` to empty bytes, which would false-match an empty
290 // snapshot (silent overwrite) or report a bogus "file changed" for a real
291 // I/O error. A target that vanished since the gate is a change → abort.
292 match expected {
293 Some(OverwriteExpectation::Bytes(exp)) => {
294 let current = backend.read(resolved, None).await?;
295 if current != *exp {
296 return Err(concurrent_change_error(resolved));
297 }
298 }
299 None => {}
300 }
301 backend
302 .write(resolved, content, crate::backend::WriteMode::Overwrite)
303 .await
304}
305
306/// One wording for "somebody else wrote this while the write-model gate was
307/// deciding".
308fn concurrent_change_error(resolved: &Path) -> crate::backend::BackendError {
309 crate::backend::BackendError::InvalidOperation(format!(
310 "{}: changed since the write-model gate checked it (concurrent write); \
311 aborting overwrite",
312 resolved.display()
313 ))
314}
315
316impl ExecContext {
317 /// Create a new execution context with a VFS (uses LocalBackend without tools).
318 ///
319 /// This constructor is for backward compatibility and tests that don't need tool dispatch.
320 /// For full tool support, use `with_vfs_and_tools`.
321 pub fn new(vfs: Arc<VfsRouter>) -> Self {
322 Self {
323 backend: Arc::new(LocalBackend::new(vfs)),
324 scope: Scope::new(),
325 cwd: PathBuf::from("/"),
326 prev_cwd: None,
327 stdin: None,
328 stdin_data: None,
329 stdin_data_rx: None,
330 pipe_stdin: None,
331 pipe_stdout: None,
332 stderr: None,
333 tool_schemas: Vec::new().into(),
334 tools: None,
335 job_manager: None,
336 pipeline_position: PipelinePosition::Only,
337 interactive: false,
338 kill_children_on_parent_death: false,
339 aliases: HashMap::new(),
340 ignore_config: IgnoreConfig::none(),
341 output_limit: OutputLimitConfig::none(),
342 allow_external_commands: true,
343 trash_backend: None,
344 #[cfg(all(unix, feature = "subprocess"))]
345 terminal_state: None,
346 dispatcher: None,
347 cancel: CancellationToken::new(),
348 output_format: None,
349 vfs_budget: None,
350 watchdog: None,
351 #[cfg(all(feature = "localfs", feature = "overlay"))]
352 overlay_handle: None,
353 }
354 }
355
356 /// Create a new execution context with VFS and tool registry.
357 ///
358 /// This is the preferred constructor for full kaish operation where
359 /// tools need to be dispatched through the backend.
360 pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
361 Self {
362 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
363 scope: Scope::new(),
364 cwd: PathBuf::from("/"),
365 prev_cwd: None,
366 stdin: None,
367 stdin_data: None,
368 stdin_data_rx: None,
369 pipe_stdin: None,
370 pipe_stdout: None,
371 stderr: None,
372 tool_schemas: Vec::new().into(),
373 tools: Some(tools),
374 job_manager: None,
375 pipeline_position: PipelinePosition::Only,
376 interactive: false,
377 kill_children_on_parent_death: false,
378 aliases: HashMap::new(),
379 ignore_config: IgnoreConfig::none(),
380 output_limit: OutputLimitConfig::none(),
381 allow_external_commands: true,
382 trash_backend: None,
383 #[cfg(all(unix, feature = "subprocess"))]
384 terminal_state: None,
385 dispatcher: None,
386 cancel: CancellationToken::new(),
387 output_format: None,
388 vfs_budget: None,
389 watchdog: None,
390 #[cfg(all(feature = "localfs", feature = "overlay"))]
391 overlay_handle: None,
392 }
393 }
394
395 /// Create a new execution context with a custom backend.
396 pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
397 Self {
398 backend,
399 scope: Scope::new(),
400 cwd: PathBuf::from("/"),
401 prev_cwd: None,
402 stdin: None,
403 stdin_data: None,
404 stdin_data_rx: None,
405 pipe_stdin: None,
406 pipe_stdout: None,
407 stderr: None,
408 tool_schemas: Vec::new().into(),
409 tools: None,
410 job_manager: None,
411 pipeline_position: PipelinePosition::Only,
412 interactive: false,
413 kill_children_on_parent_death: false,
414 aliases: HashMap::new(),
415 ignore_config: IgnoreConfig::none(),
416 output_limit: OutputLimitConfig::none(),
417 allow_external_commands: true,
418 trash_backend: None,
419 #[cfg(all(unix, feature = "subprocess"))]
420 terminal_state: None,
421 dispatcher: None,
422 cancel: CancellationToken::new(),
423 output_format: None,
424 vfs_budget: None,
425 watchdog: None,
426 #[cfg(all(feature = "localfs", feature = "overlay"))]
427 overlay_handle: None,
428 }
429 }
430
431 /// Create a context with VFS, tools, and a specific scope.
432 pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
433 Self {
434 backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
435 scope,
436 cwd: PathBuf::from("/"),
437 prev_cwd: None,
438 stdin: None,
439 stdin_data: None,
440 stdin_data_rx: None,
441 pipe_stdin: None,
442 pipe_stdout: None,
443 stderr: None,
444 tool_schemas: Vec::new().into(),
445 tools: Some(tools),
446 job_manager: None,
447 pipeline_position: PipelinePosition::Only,
448 interactive: false,
449 kill_children_on_parent_death: false,
450 aliases: HashMap::new(),
451 ignore_config: IgnoreConfig::none(),
452 output_limit: OutputLimitConfig::none(),
453 allow_external_commands: true,
454 trash_backend: None,
455 #[cfg(all(unix, feature = "subprocess"))]
456 terminal_state: None,
457 dispatcher: None,
458 cancel: CancellationToken::new(),
459 output_format: None,
460 vfs_budget: None,
461 watchdog: None,
462 #[cfg(all(feature = "localfs", feature = "overlay"))]
463 overlay_handle: None,
464 }
465 }
466
467 /// Create a context with a specific scope (uses LocalBackend without tools).
468 ///
469 /// For tests that don't need tool dispatch. For full tool support,
470 /// use `with_vfs_tools_and_scope`.
471 pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
472 Self {
473 backend: Arc::new(LocalBackend::new(vfs)),
474 scope,
475 cwd: PathBuf::from("/"),
476 prev_cwd: None,
477 stdin: None,
478 stdin_data: None,
479 stdin_data_rx: None,
480 pipe_stdin: None,
481 pipe_stdout: None,
482 stderr: None,
483 tool_schemas: Vec::new().into(),
484 tools: None,
485 job_manager: None,
486 pipeline_position: PipelinePosition::Only,
487 interactive: false,
488 kill_children_on_parent_death: false,
489 aliases: HashMap::new(),
490 ignore_config: IgnoreConfig::none(),
491 output_limit: OutputLimitConfig::none(),
492 allow_external_commands: true,
493 trash_backend: None,
494 #[cfg(all(unix, feature = "subprocess"))]
495 terminal_state: None,
496 dispatcher: None,
497 cancel: CancellationToken::new(),
498 output_format: None,
499 vfs_budget: None,
500 watchdog: None,
501 #[cfg(all(feature = "localfs", feature = "overlay"))]
502 overlay_handle: None,
503 }
504 }
505
506 /// Create a context with a custom backend and scope.
507 pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
508 Self {
509 backend,
510 scope,
511 cwd: PathBuf::from("/"),
512 prev_cwd: None,
513 stdin: None,
514 stdin_data: None,
515 stdin_data_rx: None,
516 pipe_stdin: None,
517 pipe_stdout: None,
518 stderr: None,
519 tool_schemas: Vec::new().into(),
520 tools: None,
521 job_manager: None,
522 pipeline_position: PipelinePosition::Only,
523 interactive: false,
524 kill_children_on_parent_death: false,
525 aliases: HashMap::new(),
526 ignore_config: IgnoreConfig::none(),
527 output_limit: OutputLimitConfig::none(),
528 allow_external_commands: true,
529 trash_backend: None,
530 #[cfg(all(unix, feature = "subprocess"))]
531 terminal_state: None,
532 dispatcher: None,
533 cancel: CancellationToken::new(),
534 output_format: None,
535 vfs_budget: None,
536 watchdog: None,
537 #[cfg(all(feature = "localfs", feature = "overlay"))]
538 overlay_handle: None,
539 }
540 }
541
542 /// Set the available tool schemas (for help command).
543 ///
544 /// Takes a `Vec` for caller convenience and converts to the shared
545 /// `Arc<[…]>` the field stores (see the field docs; GH #48).
546 pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
547 self.tool_schemas = schemas.into();
548 }
549
550 /// Set the tool registry reference.
551 pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
552 self.tools = Some(tools);
553 }
554
555 /// Set the job manager for background job tracking.
556 pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
557 self.job_manager = Some(manager);
558 }
559
560 /// Set the trash backend.
561 pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
562 self.trash_backend = Some(backend);
563 }
564
565 /// Set stdin for this execution.
566 ///
567 /// An explicit stdin buffer (`< file`, heredoc, here-string, or a pipeline
568 /// hand-off) supersedes any inherited lazy `pipe_stdin`. Since `read_stdin_*`
569 /// prefers `pipe_stdin`, clear it here so redirect precedence holds — a
570 /// `< file` must beat a frontend-seeded piped stdin. Accepts anything
571 /// `Into<Vec<u8>>` — a `String`/`&str` (heredocs, here-strings, most
572 /// callers) or a raw `Vec<u8>` (a `< binfile` redirect, GH #176) both work.
573 pub fn set_stdin(&mut self, stdin: impl Into<Vec<u8>>) {
574 self.stdin = Some(stdin.into());
575 self.pipe_stdin = None;
576 }
577
578 /// Get stdin, consuming it.
579 pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
580 self.stdin.take()
581 }
582
583 /// Set both text stdin and structured data.
584 ///
585 /// Use this when passing output through a pipeline where the previous
586 /// command produced structured data (e.g., JSON from MCP tools). The text
587 /// side is always a genuine `String` here (structured-data hand-off is a
588 /// JSON-producing pipeline stage, never binary).
589 pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
590 self.stdin = Some(text.into_bytes());
591 self.stdin_data = data;
592 }
593
594 /// Take structured data if available, consuming it.
595 ///
596 /// Tools can use this to avoid re-parsing JSON that was already parsed
597 /// by a previous command in the pipeline.
598 pub fn take_stdin_data(&mut self) -> Option<Value> {
599 self.stdin_data.take()
600 }
601
602 /// Resolve stdin for a builtin that can consume *either* structured `.data`
603 /// or raw text from the previous pipeline stage (jq, scatter, …). Returns
604 /// `(Some(data), _)` when the upstream produced structured data, else
605 /// `(None, text)`.
606 ///
607 /// Ordering matters and is the whole point: the pipe is drained to text
608 /// FIRST, which runs the upstream producer to completion (it can't be parked
609 /// on pipe backpressure), and only THEN is the structured-data sideband
610 /// awaited — by which point the producer has definitely sent it (it sends
611 /// before writing/closing its pipe). A streaming upstream that emits a lot
612 /// of text before sending its (absent) data therefore can't deadlock us, and
613 /// a fast structured producer (`seq`) is no longer lost to a startup race
614 /// that a one-shot `try_recv` used to drop on the floor.
615 pub async fn resolve_stdin(&mut self) -> Result<(Option<Value>, String), String> {
616 // Data set directly on the context (not via the pipeline sideband) wins
617 // and needs no pipe — e.g. a non-pipeline caller seeded `stdin_data`.
618 if let Some(data) = self.stdin_data.take() {
619 return Ok((Some(data), String::new()));
620 }
621 // Drain the pipe (and/or buffered stdin) to text — unblocks the upstream.
622 let text = self.read_stdin_to_text().await?.unwrap_or_default();
623 // Upstream has now finished; its structured data (if any) is waiting.
624 if let Some(rx) = self.stdin_data_rx.take()
625 && let Ok(Some(data)) = rx.await
626 {
627 return Ok((Some(data), text));
628 }
629 Ok((None, text))
630 }
631
632 /// Resolve a path relative to cwd, normalizing `.` and `..` components.
633 pub fn resolve_path(&self, path: &str) -> PathBuf {
634 let raw = if path.starts_with('/') {
635 PathBuf::from(path)
636 } else {
637 self.cwd.join(path)
638 };
639 normalize_path(&raw)
640 }
641
642 /// Change the current working directory.
643 ///
644 /// Saves the old directory for `cd -` support.
645 pub fn set_cwd(&mut self, path: PathBuf) {
646 self.prev_cwd = Some(self.cwd.clone());
647 self.cwd = path;
648 }
649
650 /// Get the previous working directory (for `cd -`).
651 pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
652 self.prev_cwd.as_ref()
653 }
654
655 /// Read stdin as text, erroring on non-UTF-8 instead of silently
656 /// lossy-decoding it (which corrupts binary with `U+FFFD`).
657 ///
658 /// The strict counterpart to [`Self::read_stdin_to_bytes`], for text-only
659 /// builtins (`grep`, `sed`, `awk`, `cut`, `sort`, `jq`, …): a binary stream
660 /// is a loud error, not a mangle. Returns `Ok(None)` when there is no stdin
661 /// at all. The `Err` is a ready-to-use message; callers prefix their name.
662 /// See `docs/binary-data.md`.
663 pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
664 match self.read_stdin_to_bytes().await {
665 None => Ok(None),
666 Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
667 "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
668 or use a binary-aware tool (cat, dd, cmp, wc -c)"
669 .to_string()
670 }),
671 }
672 }
673
674 /// Read all of stdin as raw bytes, preserving binary intact.
675 ///
676 /// The byte-clean counterpart to [`Self::read_stdin_to_text`], for
677 /// binary-aware builtins (`base64`, `xxd`, `checksum`, `wc -c`, `cmp`, …).
678 /// Returns `None` when there is no stdin at all (no pipe and no buffer);
679 /// an empty pipe yields `Some(vec![])`. The buffered source is already
680 /// bytes-typed (GH #176), so this is a plain move, never a re-encode.
681 /// See `docs/binary-data.md`.
682 ///
683 /// Anything an earlier [`Self::read_stdin_line`] left behind comes first,
684 /// then the rest of the pipe — `read x; cat` gives `cat` everything after
685 /// the line `read` took, in order, and nothing twice.
686 pub async fn read_stdin_to_bytes(&mut self) -> Option<Vec<u8>> {
687 let leftover = self.stdin.take();
688 match self.pipe_stdin.take() {
689 Some(mut reader) => {
690 use tokio::io::AsyncReadExt;
691 let mut buf = leftover.unwrap_or_default();
692 reader.read_to_end(&mut buf).await.ok()?;
693 Some(buf)
694 }
695 None => leftover,
696 }
697 }
698
699 /// Read one line from stdin, leaving the rest for the next reader.
700 ///
701 /// This is the stream-shaped counterpart to [`Self::read_stdin_to_bytes`]:
702 /// it takes a single line and keeps everything after it, so `read x; read y`
703 /// binds two lines and `read x; cat` hands `cat` the remainder. Draining to
704 /// EOF for one line would discard the rest of the stream — there is no way
705 /// to put it back once a pipe has been read.
706 ///
707 /// The trailing newline is stripped, and a final line without one is still
708 /// a line. Returns `Ok(None)` at end of input — no line left, which is a
709 /// fact the caller reports, not an empty binding. `Err` on non-UTF-8, with
710 /// the same message shape as [`Self::read_stdin_to_text`].
711 pub async fn read_stdin_line(&mut self) -> Result<Option<String>, String> {
712 loop {
713 // A complete line already buffered? Take it and keep the rest.
714 if let Some(buf) = self.stdin.as_mut()
715 && let Some(nl) = buf.iter().position(|b| *b == b'\n')
716 {
717 let rest = buf.split_off(nl + 1);
718 let mut line = std::mem::replace(buf, rest);
719 line.pop(); // the '\n' itself
720 if line.last() == Some(&b'\r') {
721 line.pop();
722 }
723 // Drop an emptied buffer only when nothing can refill it, so
724 // `read_stdin_to_bytes` can still tell "no stdin" (None) from
725 // "stdin that is now empty" (Some(vec![])).
726 if self.pipe_stdin.is_none()
727 && self.stdin.as_ref().is_some_and(|b| b.is_empty())
728 {
729 self.stdin = None;
730 }
731 return decode_stdin_line(line).map(Some);
732 }
733
734 // No newline buffered — pull another chunk from the pipe. The
735 // reader stays in place: taking it would strand the remainder.
736 if let Some(reader) = self.pipe_stdin.as_mut() {
737 use tokio::io::AsyncReadExt;
738 let mut chunk = [0u8; 8192];
739 match reader.read(&mut chunk).await {
740 Ok(0) => {
741 self.pipe_stdin = None; // EOF; fall through to the tail
742 }
743 Ok(n) => {
744 self.stdin
745 .get_or_insert_with(Vec::new)
746 .extend_from_slice(&chunk[..n]);
747 }
748 Err(e) => return Err(format!("reading stdin: {e}")),
749 }
750 continue;
751 }
752
753 // Nothing left to read: whatever is buffered is the last line.
754 return match self.stdin.take() {
755 Some(buf) if !buf.is_empty() => decode_stdin_line(buf).map(Some),
756 _ => Ok(None),
757 };
758 }
759 }
760
761 /// Create a child context for a pipeline stage.
762 ///
763 /// Shares backend, tools, job_manager, aliases, cwd, and scope
764 /// but has independent stdin/stdout pipes.
765 pub fn child_for_pipeline(&self) -> Self {
766 Self {
767 backend: self.backend.clone(),
768 scope: self.scope.clone(),
769 cwd: self.cwd.clone(),
770 prev_cwd: self.prev_cwd.clone(),
771 stdin: None,
772 stdin_data: None,
773 stdin_data_rx: None,
774 pipe_stdin: None,
775 pipe_stdout: None,
776 stderr: self.stderr.clone(),
777 tool_schemas: self.tool_schemas.clone(),
778 tools: self.tools.clone(),
779 job_manager: self.job_manager.clone(),
780 pipeline_position: PipelinePosition::Only,
781 interactive: self.interactive,
782 kill_children_on_parent_death: self.kill_children_on_parent_death,
783 aliases: self.aliases.clone(),
784 ignore_config: self.ignore_config.clone(),
785 output_limit: self.output_limit.clone(),
786 allow_external_commands: self.allow_external_commands,
787 trash_backend: self.trash_backend.clone(),
788 #[cfg(all(unix, feature = "subprocess"))]
789 terminal_state: self.terminal_state.clone(),
790 dispatcher: self.dispatcher.clone(),
791 cancel: self.cancel.clone(),
792 // Output format is per-execution; child pipeline stages start fresh.
793 output_format: None,
794 // Budget is shared: the child draws from the same pool as the parent.
795 vfs_budget: self.vfs_budget.clone(),
796 // Watchdog is shared: a patient hold in a pipeline stage or fork
797 // suspends the same script clock as foreground execution.
798 watchdog: self.watchdog.clone(),
799 // Overlay handle is shared: pipeline stages share the same transaction.
800 #[cfg(all(feature = "localfs", feature = "overlay"))]
801 overlay_handle: self.overlay_handle.clone(),
802 }
803 }
804
805 /// Build an `IgnoreFilter` from the current ignore configuration.
806 ///
807 /// Returns `None` if no filtering is configured.
808 pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
809 use crate::backend_walker_fs::BackendWalkerFs;
810 let fs = BackendWalkerFs(self.backend.as_ref());
811 self.ignore_config.build_filter(root, &fs).await
812 }
813
814 /// Snapshot a batch of truncating overwrites into the trash, the way `rm`
815 /// snapshots deletes — so `tee`/`patch`/`sed -i` can't clobber a file
816 /// under `set -o trash` without leaving a recoverable prior copy.
817 ///
818 /// Each target is `(display_path, is_append)`. A path that doesn't exist
819 /// yet or is an append has nothing to lose and passes. For an existing
820 /// file under `set -o trash`, the prior content is copied to trash first
821 /// (via `trash_bytes`) so it's recoverable; the file is left in place for
822 /// the caller to overwrite. With trash off, every target passes: the
823 /// kernel does not decide whether an overwrite is allowed.
824 ///
825 /// `Ok(snapshots)` means every snapshot is done and the caller may write
826 /// all targets; `snapshots` maps each trash-snapshotted target's resolved
827 /// path to its prior bytes, so a byte-oriented caller can pass them as the
828 /// `expected` to `overwrite_checked` for a binary-safe compare-and-swap.
829 /// `Err(result)` is what the caller must return verbatim — a trash failure
830 /// is an error, never a fall-through to a destructive overwrite.
831 pub async fn snapshot_overwrites(
832 &mut self,
833 command: &str,
834 targets: &[(String, bool)],
835 ) -> Result<GateExpectations, ExecResult> {
836 let mut expectations = GateExpectations::new();
837 let trash_enabled = self.scope.trash_enabled();
838 // Fast path: nothing is trashed, so this costs one branch and
839 // allocates nothing.
840 if !trash_enabled {
841 return Ok(expectations);
842 }
843 let trash_max_size = self.scope.trash_max_size();
844
845 struct Decided {
846 display: String,
847 resolved: PathBuf,
848 action: MutationAction,
849 }
850 // Dedup by resolved path (keep first): a multi-file patch with an
851 // explicit target lists the same file once per hunk-group, and we must
852 // not snapshot it N times or list it N times in the request.
853 let mut seen = std::collections::HashSet::new();
854 let mut decided = Vec::with_capacity(targets.len());
855 for (display, is_append) in targets {
856 let resolved = self.resolve_path(display);
857 if !seen.insert(resolved.clone()) {
858 continue;
859 }
860 // `real` is used only for the exclusion decision (/tmp, /v); the
861 // snapshot reads bytes through the backend, not the real path.
862 let real = self.backend.resolve_real_path(Path::new(&resolved));
863 let exists = self.backend.exists(Path::new(&resolved)).await;
864 // Prior size decides trash eligibility (a file too big to snapshot
865 // can't be backed up). Only stat an existing target.
866 let size = if exists {
867 self.backend
868 .stat(Path::new(&resolved))
869 .await
870 .map(|e| e.size)
871 .unwrap_or(0)
872 } else {
873 0
874 };
875 let action = decide_mutation_action(
876 trash_enabled,
877 real.as_deref(),
878 exists,
879 *is_append,
880 size,
881 trash_max_size,
882 );
883 decided.push(Decided {
884 display: display.clone(),
885 resolved,
886 action,
887 });
888 }
889
890 // Snapshot prior content for every trash-first target before any write,
891 // keeping the bytes so a byte-oriented caller can CAS against them.
892 for d in &decided {
893 if matches!(d.action, MutationAction::TrashFirst) {
894 match self.snapshot_for_overwrite(&d.display, &d.resolved).await {
895 Ok(bytes) => {
896 expectations.insert(d.resolved.clone(), OverwriteExpectation::Bytes(bytes));
897 }
898 Err(e) => return Err(ExecResult::failure(1, format!("{command}: {e}"))),
899 }
900 }
901 }
902 Ok(expectations)
903 }
904
905 /// Copy the prior content of `resolved` into the trash before it's
906 /// overwritten, returning those bytes for the caller's compare-and-swap.
907 ///
908 /// We **copy** (not move): the builtin overwrites the file in place next,
909 /// and read-modify-write callers (`patch`, `sed -i`) still need to read it —
910 /// the file keeps its identity, only its content changes. (`rm` *moves*
911 /// because removal is the op; an overwrite backs up the prior bytes.) Reads
912 /// through the backend so a real, overlay, or in-memory file is handled the
913 /// same way. A missing trash backend or a trash failure is an error — never
914 /// a silent fall-through to a destructive overwrite.
915 async fn snapshot_for_overwrite(
916 &self,
917 display: &str,
918 resolved: &Path,
919 ) -> Result<Vec<u8>, String> {
920 let trash = self
921 .trash_backend
922 .as_ref()
923 .ok_or_else(|| "trash backend not available".to_string())?;
924 let bytes = self
925 .backend
926 .read(resolved, None)
927 .await
928 .map_err(|e| format!("{display}: {e}"))?;
929 trash
930 .trash_bytes(Path::new(display), &bytes)
931 .await
932 .map_err(|e| format!("{display}: trash failed: {e}"))?;
933 Ok(bytes)
934 }
935
936 /// Overwrite `resolved` with `content`. When `expected` is `Some`, this is a
937 /// binary-safe compare-and-swap: the current bytes are re-read and must
938 /// equal `expected` (the gate's snapshot), else it errors — a concurrent
939 /// change since the gate is a loud conflict, never a silent clobber. Unlike
940 /// the `String`-based `PatchOp::Replace` CAS used by `patch`/`sed -i`, this
941 /// operates on raw bytes, so binary overwrites (`tee`, `write`, `dd`, `cp`,
942 /// `mv`) keep the same protection. It is *not* OS-atomic — a crash mid-write
943 /// can still truncate; the atomic write-temp-then-rename primitive remains a
944 /// tracked write-model residual.
945 pub(crate) async fn overwrite_checked(
946 &self,
947 resolved: &Path,
948 content: &[u8],
949 expected: Option<&OverwriteExpectation>,
950 ) -> Result<(), String> {
951 cas_overwrite(&*self.backend, resolved, content, expected)
952 .await
953 .map_err(|e| e.to_string())
954 }
955
956 /// Expand a glob pattern to matching file paths.
957 ///
958 /// Returns the matched paths (absolute). Used by builtins that accept glob
959 /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
960 pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
961 use crate::backend_walker_fs::BackendWalkerFs;
962 use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
963
964 let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
965
966 let root = if glob.is_anchored() {
967 self.resolve_path("/")
968 } else {
969 self.resolve_path(".")
970 };
971
972 let options = WalkOptions {
973 entry_types: EntryTypes::all(),
974 respect_gitignore: self.ignore_config.auto_gitignore(),
975 ..WalkOptions::default()
976 };
977
978 let fs = BackendWalkerFs(self.backend.as_ref());
979 let mut walker = FileWalker::new(&fs, &root)
980 .with_pattern(glob)
981 .with_options(options);
982
983 // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
984 // the root .gitignore is loaded twice (once here, once by the walker).
985 // This is harmless — merge is additive and rules are idempotent.
986 if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
987 walker = walker.with_ignore(filter);
988 }
989
990 walker.collect().await.map_err(|e| e.to_string())
991 }
992
993 /// Expand positional arguments, resolving glob patterns to relative paths.
994 ///
995 /// Used by file-processing builtins (cat, head, tail, wc) that accept
996 /// glob patterns in their path arguments. Non-string values are converted
997 /// to strings (matching shell conventions).
998 ///
999 /// A `Value::Bytes` operand goes LOUD (GH #93 item 1), and `Value::Json`
1000 /// (list/record), `Value::Bool`, and `Value::Null` operands go LOUD too
1001 /// (GH #121) — none is silently dropped by a catch-all anymore. Every
1002 /// caller here falls back to reading stdin (or a generic "missing path"
1003 /// error) when the path list comes back empty, so a structured, bool, or
1004 /// null path used to vanish into a wrong data source instead of erroring.
1005 /// The match is exhaustive over all 7 `Value` variants on purpose: a
1006 /// future new variant fails to compile here until handled, rather than
1007 /// silently falling through a wildcard arm.
1008 pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
1009 let mut paths = Vec::new();
1010 for arg in positional {
1011 let s = match arg {
1012 Value::String(s) => s.clone(),
1013 Value::Int(n) => n.to_string(),
1014 Value::Float(f) => f.to_string(),
1015 Value::Bytes(_) => {
1016 crate::interpreter::value_to_text_sink_named(arg, "a path").map_err(|e| e.to_string())?
1017 }
1018 Value::Json(_) => {
1019 return Err(crate::interpreter::structured_boundary_error("a path", arg)
1020 .unwrap_or_else(|| "cannot use this value as a path".to_string()));
1021 }
1022 Value::Bool(b) => return Err(format!("cannot use a bool ({b}) as a path")),
1023 Value::Null => return Err("cannot use null as a path".to_string()),
1024 };
1025 if crate::glob::contains_glob(&s) {
1026 let expanded = self.expand_glob(&s).await?;
1027 let root = self.resolve_path(".");
1028 for p in expanded {
1029 let rel = p.strip_prefix(&root).unwrap_or(&p);
1030 paths.push(rel.to_string_lossy().to_string());
1031 }
1032 } else {
1033 paths.push(s);
1034 }
1035 }
1036 Ok(paths)
1037 }
1038
1039 /// Default chunk size for forward file scans. Bounds the memory a
1040 /// scan-oriented builtin holds at once, independent of file size.
1041 pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
1042
1043 /// Stream a file's bytes forward in `chunk_size` slices, handing each
1044 /// non-empty chunk to `f`.
1045 ///
1046 /// Reads are issued as positional `read_range` requests, so backends slice
1047 /// without materialising the whole file (LocalFs seeks; MemoryFs/OverlayFs
1048 /// slice their stored bytes). The loop terminates on the first empty chunk,
1049 /// which every backend returns once the offset reaches EOF. `f` returns a
1050 /// [`ControlFlow`](std::ops::ControlFlow): `Break` stops the loop early
1051 /// (e.g. a consumer that has detected binary content and will discard the
1052 /// rest), so we don't keep reading a file the caller is done with. This is
1053 /// the shared engine for scan-oriented builtins (`wc`, `checksum`, `grep`)
1054 /// that walk a file front-to-back and must not hold it all in memory.
1055 pub async fn read_file_chunked<F>(
1056 &self,
1057 path: &std::path::Path,
1058 chunk_size: u64,
1059 mut f: F,
1060 ) -> kaish_types::backend::BackendResult<()>
1061 where
1062 F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
1063 {
1064 use kaish_types::ReadRange;
1065 let mut offset = 0u64;
1066 loop {
1067 let chunk = self
1068 .backend
1069 .read(path, Some(ReadRange::bytes(offset, chunk_size)))
1070 .await?;
1071 if chunk.is_empty() {
1072 break;
1073 }
1074 offset += chunk.len() as u64;
1075 if f(&chunk).is_break() {
1076 break;
1077 }
1078 }
1079 Ok(())
1080 }
1081}
1082
1083/// The kernel's full execution context satisfies the trimmed portable
1084/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
1085///
1086/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
1087/// pipes, dispatcher) through
1088/// [`ToolCtx::as_any_mut`](kaish_tool_api::ToolCtx::as_any_mut).
1089#[async_trait]
1090impl kaish_tool_api::ToolCtx for ExecContext {
1091 fn backend(&self) -> &Arc<dyn KernelBackend> {
1092 &self.backend
1093 }
1094
1095 fn cwd(&self) -> &std::path::Path {
1096 self.cwd.as_path()
1097 }
1098
1099 fn resolve_path(&self, path: &str) -> PathBuf {
1100 // Inherent methods shadow trait methods in call syntax, so the
1101 // fully-qualified inherent call here is not recursive.
1102 ExecContext::resolve_path(self, path)
1103 }
1104
1105 fn var(&self, name: &str) -> Option<Value> {
1106 self.scope.get(name).cloned()
1107 }
1108
1109 fn set_var(&mut self, name: &str, value: Value) {
1110 self.scope.set(name, value);
1111 }
1112
1113 fn set_output_format(&mut self, format: OutputFormat) {
1114 self.output_format = Some(format);
1115 }
1116
1117 fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
1118 match &self.watchdog {
1119 Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
1120 None => kaish_tool_api::PatientGuard::inert(),
1121 }
1122 }
1123
1124 fn as_any(&self) -> &dyn std::any::Any {
1125 self
1126 }
1127
1128 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1129 self
1130 }
1131}
1132
1133/// Decode one line of stdin as UTF-8, refusing binary rather than mangling it.
1134///
1135/// Same rule and same wording as [`ExecContext::read_stdin_to_text`] — a
1136/// line-at-a-time reader must not be the one place where `U+FFFD` creeps in.
1137fn decode_stdin_line(bytes: Vec<u8>) -> Result<String, String> {
1138 String::from_utf8(bytes).map_err(|_| {
1139 "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
1140 or use a binary-aware tool (cat, dd, cmp, wc -c)"
1141 .to_string()
1142 })
1143}
1144
1145/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
1146fn normalize_path(path: &std::path::Path) -> PathBuf {
1147 let mut parts: Vec<Component> = Vec::new();
1148 for component in path.components() {
1149 match component {
1150 Component::CurDir => {} // skip `.`
1151 Component::ParentDir => {
1152 // Pop the last normal component, but don't pop past root
1153 if let Some(Component::Normal(_)) = parts.last() {
1154 parts.pop();
1155 } else {
1156 parts.push(component);
1157 }
1158 }
1159 _ => parts.push(component),
1160 }
1161 }
1162 if parts.is_empty() {
1163 PathBuf::from("/")
1164 } else {
1165 parts.iter().collect()
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::{decide_mutation_action, MutationAction};
1172 use std::path::Path;
1173
1174 fn decide(
1175 trash: bool,
1176 real: Option<&str>,
1177 exists: bool,
1178 append: bool,
1179 ) -> MutationAction {
1180 // Default to a small file well under the cap; the size-cap behavior
1181 // has its own dedicated test below.
1182 decide_mutation_action(trash, real.map(Path::new), exists, append, 1, 10_000_000)
1183 }
1184
1185 #[test]
1186 fn new_file_and_append_always_proceed() {
1187 // Non-existent target: nothing to lose.
1188 assert_eq!(decide(true, Some("/work/new"), false, false), MutationAction::Proceed);
1189 // Append to an existing file doesn't destroy prior content.
1190 assert_eq!(decide(true, Some("/work/log"), true, true), MutationAction::Proceed);
1191 }
1192
1193 #[test]
1194 fn an_existing_file_is_snapshotted_before_it_is_overwritten() {
1195 assert_eq!(decide(true, Some("/work/f"), true, false), MutationAction::TrashFirst);
1196 }
1197
1198 #[test]
1199 fn trash_off_proceeds() {
1200 assert_eq!(decide(false, Some("/work/f"), true, false), MutationAction::Proceed);
1201 }
1202
1203 #[test]
1204 fn tmp_is_excluded_but_a_real_v_path_is_still_trashed() {
1205 // /tmp scratch proceeds even with trash on (matches rm).
1206 assert_eq!(decide(true, Some("/tmp/scratch"), true, false), MutationAction::Proceed);
1207 // A *real* path under /v is NOT excluded: mount-coverage routing
1208 // delegates unclaimed /v/* to the embedder's backend, so its real
1209 // content under /v keeps the trash safety net.
1210 assert_eq!(decide(true, Some("/v/cas/blob.bin"), true, false), MutationAction::TrashFirst);
1211 }
1212
1213 #[test]
1214 fn file_too_big_to_trash_is_written_directly_like_rm() {
1215 // Prior content larger than the cap can't be snapshotted, so trash is
1216 // skipped and the overwrite proceeds unbacked. Nothing holds it back.
1217 let big = 100u64;
1218 let cap = 10u64;
1219 assert_eq!(
1220 decide_mutation_action(true, Some(Path::new("/work/f")), true, false, big, cap),
1221 MutationAction::Proceed
1222 );
1223 // Exactly at the cap still trashes (inclusive bound, matches rm).
1224 assert_eq!(
1225 decide_mutation_action(true, Some(Path::new("/work/f")), true, false, cap, cap),
1226 MutationAction::TrashFirst
1227 );
1228 }
1229}