Skip to main content

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