mod alias;
mod archive;
pub(crate) mod arg_parser;
mod assert;
mod atomic_write;
mod awk;
mod base64;
mod basenc;
mod bc;
mod caller;
mod cat;
mod checksum;
mod clap_cache;
mod clap_env;
mod clear;
mod cmp;
mod column;
mod comm;
mod compgen;
mod coreutils_extra;
mod csv;
mod curl;
mod cuttr;
mod date;
mod dd;
mod diff;
mod dirstack;
mod disk;
mod dotenv;
mod echo;
mod environ;
mod envsubst;
mod expand;
mod export;
mod expr;
mod factor;
mod fc;
mod fileops;
mod find;
mod flow;
mod fmt;
mod fold;
mod generated;
mod glob_cmd;
mod grep;
mod grep_pattern;
mod headtail;
mod help;
mod hextools;
mod http;
mod iconv;
mod inspect;
mod install;
mod introspect;
mod jobctl;
mod join;
#[cfg(feature = "jq")]
mod jq;
mod json;
mod log;
mod ls;
mod make;
mod man;
mod mapfile;
mod mkfifo;
#[cfg(feature = "terminal")]
mod nano;
mod navigation;
mod nl;
mod numfmt;
#[cfg(feature = "terminal")]
mod pager;
mod parallel;
mod paste;
mod patch;
mod path;
mod pipeline;
mod pr;
mod printf;
pub(crate) mod random;
mod read;
mod retry;
mod rg;
pub(crate) mod search_common;
mod sed;
mod semver;
mod seq;
mod shellenv;
mod shuf;
mod sleep;
mod sortuniq;
mod source;
mod split;
mod strings;
mod sysextra;
mod system;
mod template;
mod test;
mod textrev;
pub(crate) mod timeout;
mod tomlq;
mod trap;
mod tree;
mod truncate;
mod tsort;
mod vars;
mod verify;
#[cfg(feature = "terminal")]
mod vi;
mod wait;
mod wc;
mod yes;
#[cfg(feature = "jq")]
mod yq;
mod zip_cmd;
mod helpers;
pub(crate) use crate::execution_capability::ExecutionExtensions;
pub(crate) use helpers::{BuiltinHelper, invalid_option};
pub(crate) mod limits;
pub(crate) use limits::MAX_FORMAT_WIDTH;
pub(crate) mod git;
pub(crate) mod ssh;
#[cfg(any(feature = "python", feature = "typescript", feature = "cpython"))]
mod runtime_limits;
#[cfg(feature = "cpython")]
mod cpython;
#[cfg(feature = "python")]
mod python;
#[cfg(feature = "typescript")]
mod typescript;
#[cfg(feature = "sqlite")]
mod sqlite;
pub use alias::{Alias, Unalias};
pub use archive::{Bunzip2, Bzcat, Bzip2, Gunzip, Gzip, Tar};
pub use assert::Assert;
pub use awk::Awk;
pub use base64::Base64;
pub use basenc::{Base32, Basenc};
pub use bc::Bc;
pub use caller::Caller;
pub use cat::Cat;
pub use checksum::{B2sum, Cksum, Md5sum, Sha1sum, Sha224sum, Sha256sum, Sha384sum, Sha512sum};
pub use clear::Clear;
pub use cmp::Cmp;
pub use column::Column;
pub use comm::Comm;
pub use compgen::{Compgen, ShellOnly};
pub use csv::Csv;
pub use curl::{Curl, Wget};
pub use cuttr::{Cut, Tr};
pub use date::Date;
pub use dd::Dd;
pub use diff::Diff;
pub use dirstack::{Dirs, Popd, Pushd};
pub use disk::{Df, Du};
pub use dotenv::Dotenv;
pub use echo::Echo;
pub use environ::{Env, History, Printenv};
pub use envsubst::Envsubst;
pub use expand::{Expand, Unexpand};
pub use export::Export;
pub use expr::Expr;
pub use factor::Factor;
pub use fc::Fc;
pub use fileops::{Chmod, Chown, Cp, Ln, Mkdir, Mktemp, Mv, Rm, Touch};
pub use find::Find;
pub use flow::{Break, Colon, Continue, Exit, False, Return, True};
pub use fmt::Fmt;
pub use fold::Fold;
pub use glob_cmd::GlobCmd;
pub use grep::Grep;
pub use headtail::{Head, Tail};
pub use help::Help;
pub use hextools::{Hexdump, Od, Xxd};
pub use http::Http;
pub use iconv::Iconv;
pub use inspect::{File, Less, Stat};
pub use install::Install;
pub(crate) use introspect::CommandHash;
pub use introspect::{Hash, Type, Which};
pub(crate) use jobctl::signal_name;
pub use join::Join;
#[cfg(feature = "jq")]
pub use jq::Jq;
pub use json::Json;
pub use log::Log;
pub use ls::{Ls, Rmdir};
pub(crate) use ls::{fnmatch, glob_match};
pub use make::Make;
pub use man::Man;
pub use mapfile::Mapfile;
pub use mkfifo::Mkfifo;
pub use navigation::{Cd, Pwd};
pub use nl::Nl;
pub use numfmt::Numfmt;
pub use parallel::Parallel;
pub use paste::Paste;
pub use patch::Patch;
pub use path::{Basename, Dirname, Readlink, Realpath};
pub use pipeline::{Tee, Watch, Xargs};
pub use pr::Pr;
pub use printf::Printf;
pub use random::{Openssl, Uuidgen};
pub use read::Read;
pub(crate) use read::consumed_len as read_consumed_len;
pub use retry::Retry;
pub use rg::Rg;
pub use sed::Sed;
pub use semver::Semver;
pub use seq::Seq;
pub use shellenv::{Enable, Locale, Ulimit, Umask};
pub use shuf::Shuf;
pub use tsort::Tsort;
pub use coreutils_extra::{Chgrp, GrepAlias, Link, Shasum, Sum};
pub use jobctl::{Bg, Disown, Fg, Jobs, Kill, Pgrep, Ps};
#[cfg(feature = "terminal")]
pub use nano::Nano;
#[cfg(feature = "terminal")]
pub use pager::More;
pub use sleep::Sleep;
pub use sortuniq::{Sort, Uniq};
pub use source::Source;
pub use split::Split;
pub use strings::Strings;
pub use sysextra::{Arch, Free, Getconf, Hostid, RunAs, SyncCmd, Tty, Uptime, UserInfo};
pub use system::{DEFAULT_HOSTNAME, DEFAULT_USERNAME, Hostname, Id, Nproc, Uname, Whoami};
pub(crate) use system::{VIRTUAL_KERNEL_RELEASE, VIRTUAL_KERNEL_VERSION, VIRTUAL_NPROC};
pub use template::Template;
pub use test::{Bracket, Test};
pub use textrev::{Rev, Tac};
pub use timeout::Timeout;
pub use tomlq::Tomlq;
pub use trap::Trap;
pub use tree::Tree;
pub use truncate::Truncate;
pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
pub(crate) use vars::{
SET_O_OPTIONS, SHOPT_OPTIONS, bashopts_value, dollar_dash, set_o_var_by_letter,
set_o_var_by_name, shellopts_value, shopt_known, shopt_on,
};
pub use verify::Verify;
#[cfg(feature = "terminal")]
pub use vi::Vi;
#[cfg(all(feature = "terminal", feature = "git"))]
pub(crate) use vi::edit_file;
pub use wait::Wait;
pub use wc::Wc;
pub use yes::Yes;
#[cfg(feature = "jq")]
pub use yq::Yq;
pub use zip_cmd::{Unzip, Zip};
#[cfg(feature = "git")]
pub use git::Git;
#[cfg(feature = "ssh")]
pub use ssh::{Scp, Sftp, Ssh};
#[cfg(any(feature = "python", feature = "typescript", feature = "cpython"))]
pub use runtime_limits::RuntimeLimits;
#[cfg(feature = "cpython")]
pub(crate) use cpython::check_http_request_invariants;
#[cfg(feature = "cpython")]
pub use cpython::{CPython, CPythonLimits};
#[cfg(feature = "python")]
pub(crate) use python::PythonInprocessOptIn;
#[cfg(feature = "python")]
pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits};
#[cfg(feature = "typescript")]
pub use typescript::{
TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
TypeScriptLimits,
};
#[cfg(feature = "sqlite")]
pub(crate) use sqlite::SqliteInprocessOptIn;
#[cfg(feature = "sqlite")]
pub use sqlite::{Sqlite, SqliteBackend, SqliteLimits};
use async_trait::async_trait;
use clap::{CommandFactory, FromArgMatches};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::Result;
use crate::fs::FileSystem;
use crate::interpreter::ExecResult;
pub(crate) async fn read_text_file(
fs: &dyn FileSystem,
path: &Path,
cmd_name: &str,
) -> std::result::Result<String, ExecResult> {
let content = read_stream_file(fs, path, cmd_name).await?;
Ok(content.to_string())
}
pub(crate) async fn read_stream_file(
fs: &dyn FileSystem,
path: &Path,
cmd_name: &str,
) -> std::result::Result<crate::StreamData, ExecResult> {
fs.read_file(path)
.await
.map(crate::StreamData::from)
.map_err(|e| {
ExecResult::err(
format!(
"{cmd_name}: {}: {}\n",
path.display(),
crate::error::io_error_reason(&e)
),
1,
)
})
}
pub(crate) fn check_help_version(
args: &[String],
help_text: &str,
version: Option<&str>,
) -> Option<ExecResult> {
for arg in args {
match arg.as_str() {
"--" => break,
"--help" => return Some(ExecResult::ok(help_text.to_string())),
"--version" => {
if let Some(ver) = version {
return Some(ExecResult::ok(format!("{ver}\n")));
}
}
s if !s.starts_with('-') => break,
_ => {}
}
}
None
}
pub(crate) use crate::interpreter::ShellRef;
pub use crate::interpreter::BuiltinSideEffect;
pub trait Extension: Send + Sync {
fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)>;
}
#[derive(Clone, Default)]
pub struct BuiltinRegistry {
inner: Arc<std::sync::RwLock<HashMap<String, RegisteredBuiltin>>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum BuiltinAccess {
Scoped,
TrustedHost,
}
#[derive(Clone)]
pub(crate) struct RegisteredBuiltin {
pub(crate) builtin: Arc<dyn Builtin>,
pub(crate) access: BuiltinAccess,
}
impl BuiltinRegistry {
pub fn insert_command(&self, name: impl Into<String>, command: impl crate::Command + 'static) {
let name = name.into();
self.insert(
name.clone(),
Arc::new(crate::command::CommandAdapter {
name,
command: Arc::new(command),
}),
);
}
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, name: impl Into<String>, builtin: Arc<dyn Builtin>) {
if let Ok(mut guard) = self.inner.write() {
guard.insert(
name.into(),
RegisteredBuiltin {
builtin,
access: BuiltinAccess::Scoped,
},
);
}
}
pub fn insert_trusted(&self, name: impl Into<String>, builtin: Arc<dyn Builtin>) {
if let Ok(mut guard) = self.inner.write() {
guard.insert(
name.into(),
RegisteredBuiltin {
builtin,
access: BuiltinAccess::TrustedHost,
},
);
}
}
pub fn remove(&self, name: &str) -> Option<Arc<dyn Builtin>> {
self.inner
.write()
.ok()
.and_then(|mut g| g.remove(name))
.map(|entry| entry.builtin)
}
pub fn lookup(&self, name: &str) -> Option<Arc<dyn Builtin>> {
self.lookup_entry(name).map(|entry| entry.builtin)
}
pub(crate) fn lookup_entry(&self, name: &str) -> Option<RegisteredBuiltin> {
self.inner.read().ok().and_then(|g| g.get(name).cloned())
}
pub fn names(&self) -> Vec<String> {
self.inner
.read()
.map(|g| g.keys().cloned().collect())
.unwrap_or_default()
}
pub fn is_empty(&self) -> bool {
self.inner.read().map(|g| g.is_empty()).unwrap_or(true)
}
}
pub trait CommandResolver: Send + Sync {
fn resolve(&self, name: &str) -> Option<Arc<dyn Builtin>>;
}
#[derive(Debug, Clone)]
pub(crate) struct ExecutionDeadline {
#[allow(dead_code)]
started_at: crate::time_compat::Instant,
timeout: std::time::Duration,
}
impl ExecutionDeadline {
pub(crate) fn new(timeout: std::time::Duration) -> Self {
Self {
started_at: crate::time_compat::Instant::now(),
timeout,
}
}
#[allow(dead_code)]
pub(crate) fn remaining(&self) -> std::time::Duration {
let remaining = self.timeout.saturating_sub(self.started_at.elapsed());
if remaining.is_zero() {
std::time::Duration::from_millis(1)
} else {
remaining
}
}
#[allow(dead_code)]
pub(crate) fn is_expired(&self) -> bool {
self.started_at.elapsed() >= self.timeout
}
}
#[derive(Debug, Clone)]
pub struct SubCommand {
pub name: String,
pub args: Vec<String>,
pub stdin: Option<crate::StreamData>,
pub assignments: Vec<(String, String)>,
}
pub(crate) const BASH_BUILTIN_NAMES: &[&str] = &[
".",
":",
"[",
"alias",
"bg",
"bind",
"break",
"builtin",
"caller",
"cd",
"command",
"compgen",
"complete",
"compopt",
"continue",
"declare",
"dirs",
"disown",
"echo",
"enable",
"eval",
"exec",
"exit",
"export",
"false",
"fc",
"fg",
"getopts",
"hash",
"help",
"history",
"jobs",
"kill",
"let",
"local",
"logout",
"mapfile",
"popd",
"printf",
"pushd",
"pwd",
"read",
"readarray",
"readonly",
"return",
"set",
"shift",
"shopt",
"source",
"suspend",
"test",
"times",
"trap",
"true",
"type",
"typeset",
"ulimit",
"umask",
"unalias",
"unset",
"wait",
];
pub(crate) async fn search_path(ctx: &Context<'_>, name: &str) -> Option<String> {
search_path_all(ctx, name, true).await.into_iter().next()
}
pub(crate) async fn search_path_all(
ctx: &Context<'_>,
name: &str,
first_only: bool,
) -> Vec<String> {
search_path_with(ctx, name, first_only, false).await
}
pub(crate) async fn search_path_or_file(ctx: &Context<'_>, name: &str) -> Option<String> {
search_path_with(ctx, name, true, true)
.await
.into_iter()
.next()
}
async fn search_path_with(
ctx: &Context<'_>,
name: &str,
first_only: bool,
file_fallback: bool,
) -> Vec<String> {
let mut found = Vec::new();
let mut fallback = None;
if name.is_empty() || name.contains('/') {
return found;
}
let path_var = ctx
.variables
.get("PATH")
.or_else(|| ctx.env.get("PATH"))
.cloned()
.unwrap_or_default();
for dir in path_var.split(':') {
let candidate = path_candidate(dir, name);
let resolved = resolve_path(ctx.cwd, &candidate);
let Ok(meta) = ctx.fs.stat(&resolved).await else {
continue;
};
if !meta.file_type.is_file() || found.contains(&candidate) {
continue;
}
if meta.mode & 0o111 == 0 {
if file_fallback && fallback.is_none() {
fallback = Some(candidate);
}
continue;
}
found.push(candidate);
if first_only {
break;
}
}
if found.is_empty()
&& let Some(file) = fallback
{
found.push(file);
}
found
}
pub(crate) fn path_candidate(dir: &str, name: &str) -> String {
if dir.is_empty() {
format!("./{name}")
} else {
format!("{}/{name}", dir.trim_end_matches('/'))
}
}
pub enum PlanStep {
Run {
command: SubCommand,
cwd: Option<PathBuf>,
},
Capture {
command: SubCommand,
cwd: Option<PathBuf>,
},
Emit {
stdout: crate::StreamData,
stderr: crate::StreamData,
},
Done(ExecResult),
}
#[async_trait]
pub trait PlanDriver: Send {
async fn next(&mut self, last: Option<ExecResult>) -> Result<PlanStep>;
}
impl std::fmt::Debug for dyn PlanDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PlanDriver")
}
}
#[derive(Debug)]
pub enum ExecutionPlan {
Timeout {
duration: std::time::Duration,
preserve_status: bool,
command: SubCommand,
},
Env {
command: SubCommand,
clear: bool,
unset: Vec<String>,
set: Vec<(String, String)>,
chdir: Option<String>,
},
Batch {
commands: Vec<SubCommand>,
},
Driver(Box<dyn PlanDriver>),
BatchWithStatus {
commands: Vec<SubCommand>,
stderr_prefix: String,
force_error_exit: bool,
},
}
pub fn resolve_path(cwd: &Path, path_str: &str) -> PathBuf {
let path = Path::new(path_str);
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
vfs_join(cwd, path)
};
normalize_path(&joined)
}
use crate::fs::normalize_path;
use crate::fs::vfs_join;
pub struct Context<'a> {
pub args: &'a [String],
pub env: &'a HashMap<String, String>,
#[allow(dead_code)] pub variables: &'a mut HashMap<String, String>,
pub cwd: &'a mut PathBuf,
pub fs: Arc<dyn FileSystem>,
pub stdin: Option<&'a crate::StreamData>,
#[cfg(feature = "http_client")]
pub http_client: Option<&'a std::sync::Arc<crate::network::HttpClient>>,
#[cfg(feature = "git")]
pub git_client: Option<&'a crate::builtins::git::GitClient>,
#[cfg(feature = "ssh")]
pub ssh_client: Option<&'a crate::builtins::ssh::SshClient>,
pub(crate) shell: Option<ShellRef<'a>>,
}
pub(crate) struct StdinStream {
pipe: std::sync::Arc<crate::interpreter::pipe::Pipe>,
budget: crate::ExecutionBudget,
}
impl StdinStream {
pub(crate) async fn read(&self) -> Result<Vec<u8>> {
let bytes = self.budget.run(self.pipe.read_some()).await?;
self.budget.consume_input(bytes.len())?;
self.budget
.consume_work(1 + bytes.len().div_ceil(1024) as u64)?;
Ok(bytes)
}
pub(crate) async fn read_to_end(&self) -> Result<crate::limits::BudgetedBytes> {
let mut bytes = crate::limits::BudgetedBytes::new(Some(&self.budget))?;
loop {
let chunk = self.read().await?;
if chunk.is_empty() {
return Ok(bytes);
}
bytes.try_extend_from_slice(&chunk)?;
}
}
}
pub(crate) struct CollectedInput {
data: crate::StreamData,
_storage: crate::stream::StreamStorage,
}
impl std::ops::Deref for CollectedInput {
type Target = crate::StreamData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
pub(crate) const STREAM_CHUNK_BYTES: usize = 4 * 1024;
pub(crate) struct InputChunks<'c> {
buf: crate::limits::BudgetedBytes,
pos: usize,
buffered: Option<std::borrow::Cow<'c, [u8]>>,
buffered_pos: usize,
budget: Option<crate::ExecutionBudget>,
input: Option<StdinStream>,
eof: bool,
}
impl<'c> InputChunks<'c> {
pub(crate) fn stdin(ctx: &Context<'c>) -> Result<Self> {
let budget = ctx.shell.as_ref().map(|s| s.execution_budget.clone());
Ok(Self {
buf: crate::limits::BudgetedBytes::new(budget.as_ref())?,
pos: 0,
buffered: ctx.stdin.map(|s| std::borrow::Cow::Borrowed(s.as_bytes())),
buffered_pos: 0,
budget,
input: ctx.stdin_stream(),
eof: false,
})
}
pub(crate) fn bytes(ctx: &Context<'c>, bytes: Vec<u8>) -> Result<Self> {
let mut input = Self::stdin(ctx)?;
input.buffered = Some(std::borrow::Cow::Owned(bytes));
input.input = None;
Ok(input)
}
pub(crate) async fn next(
&mut self,
cut: impl Fn(&[u8]) -> usize,
) -> Result<Option<crate::limits::BudgetedBytes>> {
loop {
let rest = &self.buf[self.pos..];
let window = rest.len().min(STREAM_CHUNK_BYTES);
let mut n = cut(&rest[..window]);
if n == 0 && rest.len() > window {
n = cut(rest);
}
if n == 0 && self.eof {
n = rest.len();
}
if n > 0 {
let mut piece = crate::limits::BudgetedBytes::new(self.budget.as_ref())?;
piece.try_extend_from_slice(&rest[..n])?;
self.pos += n;
return Ok(Some(piece));
}
if self.eof {
return Ok(None);
}
self.buf.copy_within(self.pos.., 0);
let remaining = self.buf.len() - self.pos;
self.buf.truncate(remaining);
self.pos = 0;
if let Some(buffered) = &self.buffered {
let end = (self.buffered_pos + STREAM_CHUNK_BYTES).min(buffered.len());
self.buf
.try_extend_from_slice(&buffered[self.buffered_pos..end])?;
self.buffered_pos = end;
if end == buffered.len() {
self.buffered = None;
}
continue;
}
let more = match &self.input {
Some(input) => input.read().await?,
None => Vec::new(),
};
if more.is_empty() {
self.eof = true;
} else {
self.buf.try_extend_from_slice(&more)?;
}
}
}
}
pub(crate) fn cut_records(term: u8) -> impl Fn(&[u8]) -> usize {
move |data| data.iter().rposition(|&b| b == term).map_or(0, |p| p + 1)
}
pub(crate) fn cut_utf8(data: &[u8]) -> usize {
match std::str::from_utf8(data) {
Err(e) if e.error_len().is_none() => e.valid_up_to(),
_ => data.len(),
}
}
pub(crate) struct StdoutStream(crate::command::CommandOutput);
impl StdoutStream {
pub(crate) async fn write(&self, data: &[u8]) -> Result<bool> {
match self.0.write_all(data).await {
Ok(()) => Ok(true),
Err(crate::Error::Io(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => {
Ok(false)
}
Err(error) => Err(error),
}
}
}
impl<'a> Context<'a> {
pub fn stdin_bytes(&self) -> Option<&[u8]> {
self.stdin.map(crate::StreamData::as_bytes)
}
pub fn stdin_text_lossy(&self) -> Option<std::borrow::Cow<'_, str>> {
self.stdin.map(crate::StreamData::text_lossy)
}
pub fn execution_budget(
&self,
) -> Option<crate::ExecutionCapability<crate::limits::ExecutionBudget>> {
self.execution_extension::<crate::limits::ExecutionBudget>()
}
pub(crate) fn consume_budget_input(&self, bytes: usize) -> Result<()> {
if let Some(budget) = self.execution_budget() {
budget
.try_with(|budget| budget.consume_input(bytes))
.map_err(|_| crate::Error::Cancelled)??;
}
Ok(())
}
pub(crate) fn consume_budget_work(&self, units: u64) -> Result<()> {
if let Some(budget) = self.execution_budget() {
budget
.try_with(|budget| budget.consume_work(units))
.map_err(|_| crate::Error::Cancelled)??;
}
Ok(())
}
pub(crate) fn lease_budget_bytes(
&self,
bytes: usize,
) -> Result<Option<crate::limits::ExecutionBudgetLease>> {
self.execution_budget()
.map(|budget| {
budget
.try_with(|budget| budget.lease_bytes(bytes))
.map_err(|_| crate::Error::Cancelled)?
.map_err(Into::into)
})
.transpose()
}
pub async fn run_budgeted<F>(&self, future: F) -> Result<F::Output>
where
F: std::future::Future,
{
match self.execution_budget() {
Some(budget) => {
let budget = budget
.try_with(Clone::clone)
.map_err(|_| crate::Error::Cancelled)?;
budget.run(future).await.map_err(Into::into)
}
None => Ok(future.await),
}
}
pub fn execution_extension<T>(&self) -> Option<crate::ExecutionCapability<T>>
where
T: Send + Sync + 'static,
{
self.shell
.as_ref()
.and_then(|shell| shell.execution_extensions.get::<T>())
}
pub(crate) fn stdout_stream(&self) -> Option<StdoutStream> {
self.shell
.as_ref()
.and_then(|shell| shell.stdout_stream.clone())
.map(StdoutStream)
}
pub(crate) fn stdin_stream(&self) -> Option<StdinStream> {
self.shell.as_ref().and_then(|shell| {
shell.stdin_pipe.clone().map(|pipe| StdinStream {
pipe,
budget: shell.execution_budget.clone(),
})
})
}
pub(crate) async fn stdin_to_end(&self) -> Result<CollectedInput> {
let budget = self.shell.as_ref().map(|s| s.execution_budget.clone());
let mut all = crate::limits::BudgetedBytes::new(budget.as_ref())?;
if let Some(stdin) = self.stdin {
all.try_extend_from_slice(stdin.as_bytes())?;
}
if let Some(input) = self.stdin_stream() {
loop {
let chunk = input.read().await?;
if chunk.is_empty() {
break;
}
all.try_extend_from_slice(&chunk)?;
}
}
self.collected_input(all)
}
pub(crate) fn collected_input(
&self,
bytes: crate::limits::BudgetedBytes,
) -> Result<CollectedInput> {
let (data, storage) = if let Some(shell) = &self.shell {
crate::StreamData::from_budgeted_bytes(bytes, shell.execution_budget)?
} else {
(bytes.into_parts().0.into(), [None, None])
};
Ok(CollectedInput {
data,
_storage: storage,
})
}
pub fn remaining_deadline(&self) -> Option<std::time::Duration> {
self.execution_extension::<ExecutionDeadline>()?
.try_with(ExecutionDeadline::remaining)
.ok()
}
#[cfg(any(feature = "python", feature = "typescript"))]
pub(crate) fn execution_extensions(&self) -> Option<std::sync::Arc<ExecutionExtensions>> {
self.shell
.as_ref()
.map(|shell| shell.execution_extensions.clone())
}
pub fn execution_capability<T>(&self, value: T) -> Option<crate::ExecutionCapability<T>>
where
T: Send + Sync + 'static,
{
self.shell
.as_ref()
.and_then(|shell| shell.execution_extensions.capability(value))
}
#[cfg(test)]
pub fn new_for_test(
args: &'a [String],
env: &'a std::collections::HashMap<String, String>,
variables: &'a mut std::collections::HashMap<String, String>,
cwd: &'a mut std::path::PathBuf,
fs: std::sync::Arc<dyn crate::fs::FileSystem>,
stdin: Option<&'a str>,
) -> Self {
let stdin = stdin.map(|text| {
let stream: &'static crate::StreamData =
Box::leak(Box::new(crate::StreamData::from(text)));
stream as &'a crate::StreamData
});
Self {
args,
env,
variables,
cwd,
fs,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
}
}
}
#[cfg(test)]
pub(crate) fn test_stream(text: &str) -> &'static crate::StreamData {
Box::leak(Box::new(crate::StreamData::from(text)))
}
#[cfg(test)]
pub(crate) fn test_stream_opt(text: Option<&str>) -> Option<&'static crate::StreamData> {
text.map(test_stream)
}
#[async_trait]
pub trait Builtin: Send + Sync {
#[doc(hidden)]
fn streams_io(&self) -> bool {
false
}
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult>;
async fn execution_plan(&self, _ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
Ok(None)
}
fn llm_hint(&self) -> Option<&'static str> {
None
}
fn reset_session_state(&self) {}
}
#[async_trait]
pub trait ClapBuiltin: Send + Sync {
type Args: clap::Parser + Send + 'static;
async fn execute_clap(&self, args: Self::Args, ctx: &mut BashkitContext<'_>) -> Result<()>;
fn llm_hint(&self) -> Option<&'static str> {
None
}
fn reset_session_state(&self) {}
}
pub struct BashkitContext<'a> {
inner: Context<'a>,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl<'a> BashkitContext<'a> {
fn new(inner: Context<'a>) -> Self {
Self {
inner,
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
}
}
fn into_exec_result(self) -> ExecResult {
ExecResult {
stdout: self.stdout.into(),
stderr: self.stderr.into(),
exit_code: self.exit_code,
..Default::default()
}
}
pub fn raw_args(&self) -> &[String] {
self.inner.args
}
pub fn env(&self) -> &HashMap<String, String> {
self.inner.env
}
pub fn variables(&mut self) -> &mut HashMap<String, String> {
self.inner.variables
}
pub fn cwd(&self) -> &Path {
self.inner.cwd
}
pub fn cwd_mut(&mut self) -> &mut PathBuf {
self.inner.cwd
}
pub fn fs(&self) -> Arc<dyn FileSystem> {
Arc::clone(&self.inner.fs)
}
pub fn stdin(&self) -> Option<&str> {
self.inner.stdin.and_then(|stdin| stdin.text().ok())
}
pub fn execution_extension<T>(&self) -> Option<crate::ExecutionCapability<T>>
where
T: Send + Sync + 'static,
{
self.inner.execution_extension::<T>()
}
pub fn write_stdout(&mut self, output: impl AsRef<str>) {
self.stdout.push_str(output.as_ref());
}
pub fn write_stderr(&mut self, output: impl AsRef<str>) {
self.stderr.push_str(output.as_ref());
}
pub fn set_exit_code(&mut self, exit_code: i32) {
self.exit_code = exit_code;
}
pub fn fail(&mut self, stderr: impl AsRef<str>, exit_code: i32) {
self.write_stderr(stderr);
self.set_exit_code(exit_code);
}
}
#[async_trait]
impl<T> Builtin for T
where
T: ClapBuiltin,
{
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut command = <T::Args as CommandFactory>::command();
let command_name = command.get_name().to_string();
let argv = std::iter::once(command_name).chain(ctx.args.iter().cloned());
let mut matches = match command.try_get_matches_from_mut(argv) {
Ok(matches) => matches,
Err(error) => return Ok(clap_error_to_exec_result(error)),
};
let args = match <T::Args as FromArgMatches>::from_arg_matches_mut(&mut matches) {
Ok(args) => args,
Err(error) => return Ok(clap_error_to_exec_result(error)),
};
let mut ctx = BashkitContext::new(ctx);
self.execute_clap(args, &mut ctx).await?;
Ok(ctx.into_exec_result())
}
fn llm_hint(&self) -> Option<&'static str> {
ClapBuiltin::llm_hint(self)
}
fn reset_session_state(&self) {
ClapBuiltin::reset_session_state(self);
}
}
fn strip_ansi(text: &str) -> String {
if !text.contains('\u{1b}') {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
match chars.next() {
Some('[') => {
for next in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&next) {
break;
}
}
}
Some(']') => {
while let Some(next) = chars.next() {
if next == '\u{7}' {
break;
}
if next == '\u{1b}' {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
}
}
Some(byte) if ('\u{20}'..='\u{2f}').contains(&byte) => {
while let Some(next) = chars.peek() {
if ('\u{20}'..='\u{2f}').contains(next) {
chars.next();
} else {
break;
}
}
chars.next();
}
Some(_) => {}
None => {}
}
}
out
}
fn clap_error_to_exec_result(error: clap::Error) -> ExecResult {
let text = strip_ansi(&error.to_string());
if matches!(
error.kind(),
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
) {
return ExecResult::ok(text);
}
ExecResult::err(cap_diagnostic(text, 1024), error.exit_code())
}
fn cap_diagnostic(mut text: String, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text;
}
let cut = text
.char_indices()
.map(|(idx, _)| idx)
.take_while(|idx| *idx <= max_bytes)
.last()
.unwrap_or(0);
text.truncate(cut);
text
}
#[async_trait]
impl Builtin for std::sync::Arc<dyn Builtin> {
fn streams_io(&self) -> bool {
(**self).streams_io()
}
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
(**self).execute(ctx).await
}
async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
(**self).execution_plan(ctx).await
}
fn llm_hint(&self) -> Option<&'static str> {
(**self).llm_hint()
}
}
#[cfg(test)]
pub(crate) use crate::testing as debug_leak_check;
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFs};
#[test]
fn test_resolve_path_absolute() {
let cwd = PathBuf::from("/home/user");
let result = resolve_path(&cwd, "/tmp/file.txt");
assert_eq!(result, PathBuf::from("/tmp/file.txt"));
}
#[test]
fn test_resolve_path_relative() {
let cwd = PathBuf::from("/home/user");
let result = resolve_path(&cwd, "downloads/file.txt");
assert_eq!(result, PathBuf::from("/home/user/downloads/file.txt"));
}
#[test]
fn test_resolve_path_dot_from_root() {
let cwd = PathBuf::from("/");
let result = resolve_path(&cwd, ".");
assert_eq!(result, PathBuf::from("/"));
}
#[test]
fn test_resolve_path_dot_from_normal_dir() {
let cwd = PathBuf::from("/home/user");
let result = resolve_path(&cwd, ".");
assert_eq!(result, PathBuf::from("/home/user"));
}
#[test]
fn test_resolve_path_dotdot() {
let cwd = PathBuf::from("/home/user");
let result = resolve_path(&cwd, "..");
assert_eq!(result, PathBuf::from("/home"));
}
#[test]
fn test_resolve_path_dotdot_from_root() {
let cwd = PathBuf::from("/");
let result = resolve_path(&cwd, "..");
assert_eq!(result, PathBuf::from("/"));
}
#[test]
fn test_resolve_path_complex() {
let cwd = PathBuf::from("/home/user");
let result = resolve_path(&cwd, "./downloads/../documents/./file.txt");
assert_eq!(result, PathBuf::from("/home/user/documents/file.txt"));
}
#[tokio::test]
async fn read_text_file_returns_lossy_utf8() {
let fs = InMemoryFs::new();
fs.write_file(Path::new("/tmp/data.bin"), b"hi\xffthere")
.await
.unwrap();
let text = read_text_file(&fs, Path::new("/tmp/data.bin"), "cat")
.await
.unwrap();
assert_eq!(text, "hi\u{fffd}there");
}
#[tokio::test]
async fn read_text_file_formats_missing_file_errors() {
let fs = InMemoryFs::new();
let err = read_text_file(&fs, Path::new("/tmp/missing.txt"), "cat")
.await
.unwrap_err();
assert_eq!(err.exit_code, 1);
assert!(err.stderr.contains("cat: /tmp/missing.txt:"));
}
#[test]
fn check_help_version_returns_help() {
let args = vec!["--help".to_string()];
let r = check_help_version(&args, "usage text\n", Some("v1.0"));
assert!(r.is_some());
assert_eq!(r.unwrap().stdout, "usage text\n");
}
#[test]
fn check_help_version_returns_version() {
let args = vec!["--version".to_string()];
let r = check_help_version(&args, "usage\n", Some("tool 1.0"));
assert!(r.is_some());
assert_eq!(r.unwrap().stdout, "tool 1.0\n");
}
#[test]
fn check_help_version_no_version_configured() {
let args = vec!["--version".to_string()];
let r = check_help_version(&args, "usage\n", None);
assert!(r.is_none());
}
#[test]
fn check_help_version_stops_at_non_flag() {
let args = vec!["file.txt".to_string(), "--help".to_string()];
let r = check_help_version(&args, "usage\n", None);
assert!(r.is_none());
}
#[test]
fn check_help_version_stops_at_option_delimiter() {
let args = vec!["--".to_string(), "--help".to_string()];
let r = check_help_version(&args, "usage\n", Some("v1"));
assert!(r.is_none());
}
#[test]
fn check_help_version_no_match() {
let args = vec!["-c".to_string(), "filter".to_string()];
let r = check_help_version(&args, "usage\n", Some("v1"));
assert!(r.is_none());
}
#[test]
fn no_debug_fmt_in_builtin_source() {
let pat = regex::Regex::new(r"\{[A-Za-z0-9_]*:#?\?\}").unwrap();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins");
let mut violations = Vec::new();
fn walk(dir: &std::path::Path, violations: &mut Vec<String>, pat: ®ex::Regex) {
for entry in std::fs::read_dir(dir).expect("read builtins dir") {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
walk(&path, violations, pat);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("read source");
for (i, line) in src.lines().enumerate() {
if line.contains("// debug-ok:") {
continue;
}
if line.trim_start().starts_with("#[derive(") {
continue;
}
if pat.is_match(line) {
let rel = path
.strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&path);
violations.push(format!(
"{}:{}: {}",
rel.display(),
i + 1,
line.trim_end()
));
}
}
}
}
walk(&dir, &mut violations, &pat);
assert!(
violations.is_empty(),
"Rust Debug formatting found in builtin source. This leaks \
internal struct shapes into stderr where LLM agents see them. \
Use Display ({{}}) or a domain-specific formatter. Add \
`// debug-ok: <reason>` to the line for legitimate test \
asserts.\n\nViolations:\n{}",
violations.join("\n")
);
}
#[test]
fn no_host_separator_joins_on_vfs_paths() {
let shapes = ["cwd.join(", ".join(&entry.name)", ".join(&e.name)"];
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates/ dir");
let mut violations = Vec::new();
fn walk(
dir: &std::path::Path,
root: &std::path::Path,
shapes: &[&str],
violations: &mut Vec<String>,
) {
for entry in std::fs::read_dir(dir).expect("read source dir") {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
walk(&path, root, shapes, violations);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
if path.file_name().is_some_and(|n| n == "tests.rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("read source");
let mut in_test_mod = false;
let mut prev_attr = false;
for (i, line) in src.lines().enumerate() {
if in_test_mod {
in_test_mod = line != "}";
continue;
}
if prev_attr && line.starts_with("mod ") && line.ends_with('{') {
in_test_mod = true;
prev_attr = false;
continue;
}
prev_attr = line == "#[cfg(test)]";
if line.contains("// host-join-ok:") {
continue;
}
if shapes.iter().any(|shape| line.contains(shape)) {
let rel = path.strip_prefix(root).unwrap_or(&path);
violations.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim()));
}
}
}
}
for crate_dir in std::fs::read_dir(workspace).expect("read crates dir") {
let src = crate_dir.expect("crate entry").path().join("src");
if src.is_dir() {
walk(&src, workspace, &shapes, &mut violations);
}
}
assert!(
violations.is_empty(),
"`Path::join` used to build a VFS path. On Windows it inserts the \
host separator, so the path prints as `/d/proj\\src\\main.rs` \
(issue #2425). Use `crate::fs::vfs_join` instead, or add \
`// host-join-ok: <reason>` for a genuine host path.\n\nViolations:\n{}",
violations.join("\n")
);
}
#[test]
fn no_clap_env_in_generated_parsers() {
let pat = regex::Regex::new(r"\.env\s*\(").unwrap();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
let mut violations = Vec::new();
for entry in std::fs::read_dir(&dir).expect("read generated dir") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("read generated file");
for (i, line) in src.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
if pat.is_match(line) {
let rel = path
.strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&path);
violations.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
}
}
}
assert!(
violations.is_empty(),
"clap `Arg::env(...)` found in a generated parser. This pulls \
defaults from the host process environment and breaks bashkit's \
sandbox boundary (TM-INF-024). Re-run `just regen-coreutils-args` \
— the codegen harvests these into `<UTIL>_ENV_DEFAULTS` instead — \
or remove the call by hand.\n\n{}",
violations.join("\n")
);
}
#[test]
fn no_host_env_reads_in_generated_code() {
let pat = regex::Regex::new(r"env\s*::\s*(var|vars|args)(_os)?\s*\(").unwrap();
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
let mut violations = Vec::new();
fn walk(dir: &std::path::Path, out: &mut Vec<String>, pat: ®ex::Regex) {
for entry in std::fs::read_dir(dir).expect("read generated dir") {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out, pat);
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("read generated file");
for (i, line) in src.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
if pat.is_match(line) {
let rel = path
.strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&path);
out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
}
}
}
}
walk(&root, &mut violations, &pat);
assert!(
violations.is_empty(),
"Host-process environment read found in generated code. A vendored \
body has no `ctx`, so this reads the *host* environment and breaks \
bashkit's sandbox boundary (TM-INF-024). Re-run the port — it folds \
names listed in `vendored.toml`'s `host_env` to \"unset\" and \
rejects the rest.\n\nViolations:\n{}",
violations.join("\n")
);
}
#[test]
fn every_generated_parser_emits_env_defaults_table() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
let mut missing = Vec::new();
for entry in std::fs::read_dir(&dir).expect("read generated dir") {
let entry = entry.unwrap();
let path = entry.path();
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !name.ends_with("_args.rs") {
continue;
}
let util = name.trim_end_matches("_args.rs");
let const_name = format!("{}_ENV_DEFAULTS", util.to_uppercase());
let needle = format!("pub static {const_name}");
let src = std::fs::read_to_string(&path).expect("read generated file");
if !src.contains(&needle) {
let rel = path
.strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
.unwrap_or(&path);
missing.push(format!("{}: missing `{needle}: ...`", rel.display()));
}
}
assert!(
missing.is_empty(),
"Generated parser is missing its `<UTIL>_ENV_DEFAULTS` sidecar. \
The codegen always emits this (possibly empty) so the bashkit \
builtin can route argv through `apply_env_defaults` without \
per-util conditionals. Re-run `just regen-coreutils-args` to \
regenerate.\n\n{}",
missing.join("\n")
);
}
#[test]
fn ls_env_defaults_surface_matches_uutils() {
use crate::builtins::generated::ls_args::LS_ENV_DEFAULTS;
let mut got: Vec<(&'static str, &'static str)> = LS_ENV_DEFAULTS
.iter()
.map(|d| (d.env_var, d.long))
.collect();
got.sort();
let mut expected = vec![("TABSIZE", "tabsize"), ("TIME_STYLE", "time-style")];
expected.sort();
assert_eq!(
got, expected,
"LS_ENV_DEFAULTS surface drifted from upstream uutils. Either \
the codegen harvest dropped a row, or uutils added/removed an \
`.env(...)` annotation on `ls` — bump this fixture together \
with the regen."
);
}
#[test]
fn generated_args_headers_match_pinned_uutils_revision() {
let pin = generated::UUTILS_REVISION;
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
let mut mismatches = Vec::new();
for entry in std::fs::read_dir(&dir).expect("read generated dir") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !name.ends_with("_args.rs") {
continue;
}
let body = std::fs::read_to_string(&path).expect("read generated file");
let header_rev = body
.lines()
.find_map(|l| {
l.strip_prefix("// Source: uutils/coreutils@")
.and_then(|rest| rest.split_whitespace().next())
})
.unwrap_or("");
if header_rev != pin {
mismatches.push(format!(
"{}: header references uutils@{header_rev}, pin is uutils@{pin}",
path.display()
));
}
}
assert!(
mismatches.is_empty(),
"Generated argument files drift from `generated::UUTILS_REVISION` \
(`{pin}`). Regenerate every util at the pinned rev or bump the \
pin to match. The drift workflow does both atomically; manual \
bumps must too.\n\n{}",
mismatches.join("\n")
);
}
#[tokio::test]
async fn every_builtin_handles_bogus_flag_cleanly() {
const TOOLS: &[&str] = &[
"cat",
"ls",
"wc",
"head",
"tail",
"sort",
"uniq",
"cut",
"tr",
"grep",
"sed",
"awk",
"find",
"tree",
"diff",
"comm",
"paste",
"column",
"join",
"split",
"fmt",
"fold",
"expand",
"unexpand",
"nl",
"tac",
"truncate",
"shuf",
"rev",
"strings",
"od",
"xxd",
"hexdump",
"base64",
"md5sum",
"sha1sum",
"sha256sum",
"sha224sum",
"sha384sum",
"sha512sum",
"b2sum",
"cksum",
"base32",
"basenc",
"cmp",
"nproc",
"tsort",
"factor",
"install",
"locale",
"tar",
"gzip",
"gunzip",
"bzip2",
"bunzip2",
"bzcat",
"zip",
"unzip",
"seq",
"expr",
"bc",
"numfmt",
"test",
"pr",
"printf",
"echo",
"env",
"printenv",
"stat",
"file",
"basename",
"dirname",
"realpath",
"readlink",
"mktemp",
"tee",
"csv",
"json",
"yq",
"tomlq",
"jq",
"semver",
"envsubst",
"template",
"patch",
];
for tool in TOOLS {
let r =
super::debug_leak_check::run(&format!("{tool} --xyzzy-not-a-real-flag </dev/null"))
.await;
super::debug_leak_check::assert_no_leak(&r, &format!("{tool}_bogus_flag"), &[]);
}
}
}
#[cfg(test)]
mod clap_output_tests {
use super::{ClapBuiltin, strip_ansi};
#[test]
fn strip_ansi_passes_plain_text_through() {
assert_eq!(strip_ansi("error: no such flag\n"), "error: no such flag\n");
assert_eq!(strip_ansi(""), "");
}
#[test]
fn strip_ansi_removes_sgr_sequences() {
let styled = "\u{1b}[1m\u{1b}[31merror:\u{1b}[0m bad flag\n";
assert_eq!(strip_ansi(styled), "error: bad flag\n");
}
#[test]
fn strip_ansi_removes_multi_param_csi_and_keeps_text() {
let styled = "\u{1b}[38;5;9mred\u{1b}[0m and \u{1b}[1;4mbold\u{1b}[0m";
assert_eq!(strip_ansi(styled), "red and bold");
}
#[test]
fn strip_ansi_drops_non_csi_and_dangling_escapes() {
assert_eq!(strip_ansi("a\u{1b}(Bb"), "ab");
assert_eq!(strip_ansi("tail\u{1b}"), "tail");
}
#[test]
fn clap_builtin_output_carries_no_escapes() {
use crate::clap::Parser;
use crate::{Bash, BashkitContext};
use async_trait::async_trait;
#[derive(Parser)]
#[command(name = "demo", about = "Demo")]
struct Args {
#[arg(long)]
name: String,
}
struct Demo;
#[async_trait]
impl ClapBuiltin for Demo {
type Args = Args;
async fn execute_clap(
&self,
args: Self::Args,
ctx: &mut BashkitContext<'_>,
) -> crate::Result<()> {
ctx.write_stdout(format!("{}\n", args.name));
Ok(())
}
}
tokio_test::block_on(async {
let mut bash = Bash::builder().builtin("demo", Box::new(Demo)).build();
for script in ["demo --bogus", "demo", "demo --help"] {
let result = bash.exec(script).await.expect("exec");
assert!(
!result.stdout.contains('\u{1b}') && !result.stderr.contains('\u{1b}'),
"escape sequence in output of `{script}`"
);
}
});
}
}