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}