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