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