use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use crate::ast::Value;
use crate::backend::{KernelBackend, LocalBackend};
use crate::dispatch::PipelinePosition;
use crate::ignore_config::IgnoreConfig;
use crate::interpreter::{ExecResult, Scope};
use crate::output_limit::OutputLimitConfig;
use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
use crate::tools::ToolRegistry;
use crate::trash::TrashBackend;
use crate::vfs::VfsRouter;
use kaish_vfs::ByteBudget;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use crate::interpreter::OutputFormat;
use super::traits::ToolSchema;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputContext {
#[default]
Interactive,
Piped,
Model,
Script,
}
pub struct ExecContext {
pub backend: Arc<dyn KernelBackend>,
pub scope: Scope,
pub cwd: PathBuf,
pub prev_cwd: Option<PathBuf>,
pub stdin: Option<Vec<u8>>,
pub stdin_data: Option<Value>,
pub stdin_data_rx: Option<oneshot::Receiver<Option<Value>>>,
pub pipe_stdin: Option<PipeReader>,
pub pipe_stdout: Option<PipeWriter>,
pub tool_schemas: Arc<[ToolSchema]>,
pub tools: Option<Arc<ToolRegistry>>,
pub job_manager: Option<Arc<JobManager>>,
pub stderr: Option<StderrStream>,
pub pipeline_position: PipelinePosition,
pub interactive: bool,
pub kill_children_on_parent_death: bool,
pub aliases: HashMap<String, String>,
pub ignore_config: IgnoreConfig,
pub output_limit: OutputLimitConfig,
pub allow_external_commands: bool,
pub trash_backend: Option<Arc<dyn TrashBackend>>,
#[cfg(all(unix, feature = "subprocess"))]
pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
pub cancel: CancellationToken,
pub output_format: Option<OutputFormat>,
pub vfs_budget: Option<Arc<ByteBudget>>,
pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
#[cfg(all(feature = "localfs", feature = "overlay"))]
pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MutationAction {
Proceed,
TrashFirst,
}
#[derive(Debug, Clone)]
pub enum OverwriteExpectation {
Bytes(Vec<u8>),
}
pub type GateExpectations = std::collections::HashMap<PathBuf, OverwriteExpectation>;
pub(crate) fn is_trash_excluded(real_path: Option<&Path>) -> bool {
matches!(real_path, Some(rp) if rp.starts_with("/tmp"))
}
pub(crate) fn decide_mutation_action(
trash_enabled: bool,
real_path: Option<&Path>,
target_exists: bool,
is_append: bool,
file_size: u64,
trash_max_size: u64,
) -> MutationAction {
if !target_exists || is_append {
return MutationAction::Proceed;
}
if is_trash_excluded(real_path) {
return MutationAction::Proceed;
}
if trash_enabled && file_size <= trash_max_size {
return MutationAction::TrashFirst;
}
MutationAction::Proceed
}
pub(crate) async fn cas_overwrite(
backend: &dyn KernelBackend,
resolved: &Path,
content: &[u8],
expected: Option<&OverwriteExpectation>,
) -> Result<(), crate::backend::BackendError> {
match expected {
Some(OverwriteExpectation::Bytes(exp)) => {
let current = backend.read(resolved, None).await?;
if current != *exp {
return Err(concurrent_change_error(resolved));
}
}
None => {}
}
backend
.write(resolved, content, crate::backend::WriteMode::Overwrite)
.await
}
fn concurrent_change_error(resolved: &Path) -> crate::backend::BackendError {
crate::backend::BackendError::InvalidOperation(format!(
"{}: changed since the write-model gate checked it (concurrent write); \
aborting overwrite",
resolved.display()
))
}
impl ExecContext {
pub fn new(vfs: Arc<VfsRouter>) -> Self {
Self {
backend: Arc::new(LocalBackend::new(vfs)),
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
Self {
backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: Some(tools),
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
Self {
backend,
scope: Scope::new(),
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
Self {
backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: Some(tools),
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
Self {
backend: Arc::new(LocalBackend::new(vfs)),
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
Self {
backend,
scope,
cwd: PathBuf::from("/"),
prev_cwd: None,
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: None,
tool_schemas: Vec::new().into(),
tools: None,
job_manager: None,
pipeline_position: PipelinePosition::Only,
interactive: false,
kill_children_on_parent_death: false,
aliases: HashMap::new(),
ignore_config: IgnoreConfig::none(),
output_limit: OutputLimitConfig::none(),
allow_external_commands: true,
trash_backend: None,
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: None,
dispatcher: None,
cancel: CancellationToken::new(),
output_format: None,
vfs_budget: None,
watchdog: None,
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: None,
}
}
pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
self.tool_schemas = schemas.into();
}
pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
self.tools = Some(tools);
}
pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
self.job_manager = Some(manager);
}
pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
self.trash_backend = Some(backend);
}
pub fn set_stdin(&mut self, stdin: impl Into<Vec<u8>>) {
self.stdin = Some(stdin.into());
self.pipe_stdin = None;
}
pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
self.stdin.take()
}
pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
self.stdin = Some(text.into_bytes());
self.stdin_data = data;
}
pub fn take_stdin_data(&mut self) -> Option<Value> {
self.stdin_data.take()
}
pub async fn resolve_stdin(&mut self) -> Result<(Option<Value>, String), String> {
if let Some(data) = self.stdin_data.take() {
return Ok((Some(data), String::new()));
}
let text = self.read_stdin_to_text().await?.unwrap_or_default();
if let Some(rx) = self.stdin_data_rx.take()
&& let Ok(Some(data)) = rx.await
{
return Ok((Some(data), text));
}
Ok((None, text))
}
pub fn resolve_path(&self, path: &str) -> PathBuf {
let raw = if path.starts_with('/') {
PathBuf::from(path)
} else {
self.cwd.join(path)
};
normalize_path(&raw)
}
pub fn set_cwd(&mut self, path: PathBuf) {
self.prev_cwd = Some(self.cwd.clone());
self.cwd = path;
}
pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
self.prev_cwd.as_ref()
}
pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
match self.read_stdin_to_bytes().await {
None => Ok(None),
Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
"input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
or use a binary-aware tool (cat, dd, cmp, wc -c)"
.to_string()
}),
}
}
pub async fn read_stdin_to_bytes(&mut self) -> Option<Vec<u8>> {
let leftover = self.stdin.take();
match self.pipe_stdin.take() {
Some(mut reader) => {
use tokio::io::AsyncReadExt;
let mut buf = leftover.unwrap_or_default();
reader.read_to_end(&mut buf).await.ok()?;
Some(buf)
}
None => leftover,
}
}
pub async fn read_stdin_line(&mut self) -> Result<Option<String>, String> {
loop {
if let Some(buf) = self.stdin.as_mut()
&& let Some(nl) = buf.iter().position(|b| *b == b'\n')
{
let rest = buf.split_off(nl + 1);
let mut line = std::mem::replace(buf, rest);
line.pop(); if line.last() == Some(&b'\r') {
line.pop();
}
if self.pipe_stdin.is_none()
&& self.stdin.as_ref().is_some_and(|b| b.is_empty())
{
self.stdin = None;
}
return decode_stdin_line(line).map(Some);
}
if let Some(reader) = self.pipe_stdin.as_mut() {
use tokio::io::AsyncReadExt;
let mut chunk = [0u8; 8192];
match reader.read(&mut chunk).await {
Ok(0) => {
self.pipe_stdin = None; }
Ok(n) => {
self.stdin
.get_or_insert_with(Vec::new)
.extend_from_slice(&chunk[..n]);
}
Err(e) => return Err(format!("reading stdin: {e}")),
}
continue;
}
return match self.stdin.take() {
Some(buf) if !buf.is_empty() => decode_stdin_line(buf).map(Some),
_ => Ok(None),
};
}
}
pub fn child_for_pipeline(&self) -> Self {
Self {
backend: self.backend.clone(),
scope: self.scope.clone(),
cwd: self.cwd.clone(),
prev_cwd: self.prev_cwd.clone(),
stdin: None,
stdin_data: None,
stdin_data_rx: None,
pipe_stdin: None,
pipe_stdout: None,
stderr: self.stderr.clone(),
tool_schemas: self.tool_schemas.clone(),
tools: self.tools.clone(),
job_manager: self.job_manager.clone(),
pipeline_position: PipelinePosition::Only,
interactive: self.interactive,
kill_children_on_parent_death: self.kill_children_on_parent_death,
aliases: self.aliases.clone(),
ignore_config: self.ignore_config.clone(),
output_limit: self.output_limit.clone(),
allow_external_commands: self.allow_external_commands,
trash_backend: self.trash_backend.clone(),
#[cfg(all(unix, feature = "subprocess"))]
terminal_state: self.terminal_state.clone(),
dispatcher: self.dispatcher.clone(),
cancel: self.cancel.clone(),
output_format: None,
vfs_budget: self.vfs_budget.clone(),
watchdog: self.watchdog.clone(),
#[cfg(all(feature = "localfs", feature = "overlay"))]
overlay_handle: self.overlay_handle.clone(),
}
}
pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
use crate::backend_walker_fs::BackendWalkerFs;
let fs = BackendWalkerFs(self.backend.as_ref());
self.ignore_config.build_filter(root, &fs).await
}
pub async fn snapshot_overwrites(
&mut self,
command: &str,
targets: &[(String, bool)],
) -> Result<GateExpectations, ExecResult> {
let mut expectations = GateExpectations::new();
let trash_enabled = self.scope.trash_enabled();
if !trash_enabled {
return Ok(expectations);
}
let trash_max_size = self.scope.trash_max_size();
struct Decided {
display: String,
resolved: PathBuf,
action: MutationAction,
}
let mut seen = std::collections::HashSet::new();
let mut decided = Vec::with_capacity(targets.len());
for (display, is_append) in targets {
let resolved = self.resolve_path(display);
if !seen.insert(resolved.clone()) {
continue;
}
let real = self.backend.resolve_real_path(Path::new(&resolved));
let exists = self.backend.exists(Path::new(&resolved)).await;
let size = if exists {
self.backend
.stat(Path::new(&resolved))
.await
.map(|e| e.size)
.unwrap_or(0)
} else {
0
};
let action = decide_mutation_action(
trash_enabled,
real.as_deref(),
exists,
*is_append,
size,
trash_max_size,
);
decided.push(Decided {
display: display.clone(),
resolved,
action,
});
}
for d in &decided {
if matches!(d.action, MutationAction::TrashFirst) {
match self.snapshot_for_overwrite(&d.display, &d.resolved).await {
Ok(bytes) => {
expectations.insert(d.resolved.clone(), OverwriteExpectation::Bytes(bytes));
}
Err(e) => return Err(ExecResult::failure(1, format!("{command}: {e}"))),
}
}
}
Ok(expectations)
}
async fn snapshot_for_overwrite(
&self,
display: &str,
resolved: &Path,
) -> Result<Vec<u8>, String> {
let trash = self
.trash_backend
.as_ref()
.ok_or_else(|| "trash backend not available".to_string())?;
let bytes = self
.backend
.read(resolved, None)
.await
.map_err(|e| format!("{display}: {e}"))?;
trash
.trash_bytes(Path::new(display), &bytes)
.await
.map_err(|e| format!("{display}: trash failed: {e}"))?;
Ok(bytes)
}
pub(crate) async fn overwrite_checked(
&self,
resolved: &Path,
content: &[u8],
expected: Option<&OverwriteExpectation>,
) -> Result<(), String> {
cas_overwrite(&*self.backend, resolved, content, expected)
.await
.map_err(|e| e.to_string())
}
pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
use crate::backend_walker_fs::BackendWalkerFs;
use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
let root = if glob.is_anchored() {
self.resolve_path("/")
} else {
self.resolve_path(".")
};
let options = WalkOptions {
entry_types: EntryTypes::all(),
respect_gitignore: self.ignore_config.auto_gitignore(),
..WalkOptions::default()
};
let fs = BackendWalkerFs(self.backend.as_ref());
let mut walker = FileWalker::new(&fs, &root)
.with_pattern(glob)
.with_options(options);
if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
walker = walker.with_ignore(filter);
}
walker.collect().await.map_err(|e| e.to_string())
}
pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
for arg in positional {
let s = match arg {
Value::String(s) => s.clone(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bytes(_) => {
crate::interpreter::value_to_text_sink_named(arg, "a path").map_err(|e| e.to_string())?
}
Value::Json(_) => {
return Err(crate::interpreter::structured_boundary_error("a path", arg)
.unwrap_or_else(|| "cannot use this value as a path".to_string()));
}
Value::Bool(b) => return Err(format!("cannot use a bool ({b}) as a path")),
Value::Null => return Err("cannot use null as a path".to_string()),
};
if crate::glob::contains_glob(&s) {
let expanded = self.expand_glob(&s).await?;
let root = self.resolve_path(".");
for p in expanded {
let rel = p.strip_prefix(&root).unwrap_or(&p);
paths.push(rel.to_string_lossy().to_string());
}
} else {
paths.push(s);
}
}
Ok(paths)
}
pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
pub async fn read_file_chunked<F>(
&self,
path: &std::path::Path,
chunk_size: u64,
mut f: F,
) -> kaish_types::backend::BackendResult<()>
where
F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
{
use kaish_types::ReadRange;
let mut offset = 0u64;
loop {
let chunk = self
.backend
.read(path, Some(ReadRange::bytes(offset, chunk_size)))
.await?;
if chunk.is_empty() {
break;
}
offset += chunk.len() as u64;
if f(&chunk).is_break() {
break;
}
}
Ok(())
}
}
#[async_trait]
impl kaish_tool_api::ToolCtx for ExecContext {
fn backend(&self) -> &Arc<dyn KernelBackend> {
&self.backend
}
fn cwd(&self) -> &std::path::Path {
self.cwd.as_path()
}
fn resolve_path(&self, path: &str) -> PathBuf {
ExecContext::resolve_path(self, path)
}
fn var(&self, name: &str) -> Option<Value> {
self.scope.get(name).cloned()
}
fn set_var(&mut self, name: &str, value: Value) {
self.scope.set(name, value);
}
fn set_output_format(&mut self, format: OutputFormat) {
self.output_format = Some(format);
}
fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
match &self.watchdog {
Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
None => kaish_tool_api::PatientGuard::inert(),
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
fn decode_stdin_line(bytes: Vec<u8>) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| {
"input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
or use a binary-aware tool (cat, dd, cmp, wc -c)"
.to_string()
})
}
fn normalize_path(path: &std::path::Path) -> PathBuf {
let mut parts: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {} Component::ParentDir => {
if let Some(Component::Normal(_)) = parts.last() {
parts.pop();
} else {
parts.push(component);
}
}
_ => parts.push(component),
}
}
if parts.is_empty() {
PathBuf::from("/")
} else {
parts.iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::{decide_mutation_action, MutationAction};
use std::path::Path;
fn decide(
trash: bool,
real: Option<&str>,
exists: bool,
append: bool,
) -> MutationAction {
decide_mutation_action(trash, real.map(Path::new), exists, append, 1, 10_000_000)
}
#[test]
fn new_file_and_append_always_proceed() {
assert_eq!(decide(true, Some("/work/new"), false, false), MutationAction::Proceed);
assert_eq!(decide(true, Some("/work/log"), true, true), MutationAction::Proceed);
}
#[test]
fn an_existing_file_is_snapshotted_before_it_is_overwritten() {
assert_eq!(decide(true, Some("/work/f"), true, false), MutationAction::TrashFirst);
}
#[test]
fn trash_off_proceeds() {
assert_eq!(decide(false, Some("/work/f"), true, false), MutationAction::Proceed);
}
#[test]
fn tmp_is_excluded_but_a_real_v_path_is_still_trashed() {
assert_eq!(decide(true, Some("/tmp/scratch"), true, false), MutationAction::Proceed);
assert_eq!(decide(true, Some("/v/cas/blob.bin"), true, false), MutationAction::TrashFirst);
}
#[test]
fn file_too_big_to_trash_is_written_directly_like_rm() {
let big = 100u64;
let cap = 10u64;
assert_eq!(
decide_mutation_action(true, Some(Path::new("/work/f")), true, false, big, cap),
MutationAction::Proceed
);
assert_eq!(
decide_mutation_action(true, Some(Path::new("/work/f")), true, false, cap, cap),
MutationAction::TrashFirst
);
}
}