1mod alias;
26mod archive;
27pub(crate) mod arg_parser;
28mod assert;
29mod awk;
30mod base64;
31mod bc;
32mod caller;
33mod cat;
34mod checksum;
35mod clap_env;
36mod clear;
37mod column;
38mod comm;
39mod compgen;
40mod csv;
41mod curl;
42mod cuttr;
43mod date;
44mod diff;
45mod dirstack;
46mod disk;
47mod dotenv;
48mod echo;
49mod environ;
50mod envsubst;
51mod expand;
52mod export;
53mod expr;
54mod fc;
55mod fileops;
56mod flow;
57mod fold;
58mod generated;
59mod glob_cmd;
60mod grep;
61mod headtail;
62mod help;
63mod hextools;
64mod http;
65mod iconv;
66mod inspect;
67mod introspect;
68mod join;
69#[cfg(feature = "jq")]
70mod jq;
71mod json;
72mod log;
73mod ls;
74mod mapfile;
75mod mkfifo;
76mod navigation;
77mod nl;
78mod numfmt;
79mod parallel;
80mod paste;
81mod patch;
82mod path;
83mod pipeline;
84mod printf;
85mod read;
86mod retry;
87mod rg;
88pub(crate) mod search_common;
89mod sed;
90mod semver;
91mod seq;
92mod shuf;
93mod sleep;
94mod sortuniq;
95mod source;
96mod split;
97mod strings;
98mod system;
99mod template;
100mod test;
101mod textrev;
102pub(crate) mod timeout;
103mod tomlq;
104mod trap;
105mod tree;
106mod truncate;
107mod vars;
108mod verify;
109mod wait;
110mod wc;
111mod yaml;
112mod yes;
113mod zip_cmd;
114
115mod helpers;
116pub(crate) use helpers::BuiltinHelper;
117
118pub(crate) mod limits;
119pub(crate) use limits::MAX_FORMAT_WIDTH;
120
121pub(crate) mod git;
122
123pub(crate) mod ssh;
124
125#[cfg(feature = "python")]
126mod python;
127
128#[cfg(feature = "typescript")]
129mod typescript;
130
131#[cfg(feature = "sqlite")]
132mod sqlite;
133
134pub use alias::{Alias, Unalias};
135pub use archive::{Gunzip, Gzip, Tar};
136pub use assert::Assert;
137pub use awk::Awk;
138pub use base64::Base64;
139pub use bc::Bc;
140pub use caller::Caller;
141pub use cat::Cat;
142pub use checksum::{Md5sum, Sha1sum, Sha256sum};
143pub use clear::Clear;
144pub use column::Column;
145pub use comm::Comm;
146pub use compgen::Compgen;
147pub use csv::Csv;
148pub use curl::{Curl, Wget};
149pub use cuttr::{Cut, Tr};
150pub use date::Date;
151pub use diff::Diff;
152pub use dirstack::{Dirs, Popd, Pushd};
153pub use disk::{Df, Du};
154pub use dotenv::Dotenv;
155pub use echo::Echo;
156pub use environ::{Env, History, Printenv};
157pub use envsubst::Envsubst;
158pub use expand::{Expand, Unexpand};
159pub use export::Export;
160pub use expr::Expr;
161pub use fc::Fc;
162pub use fileops::{Chmod, Chown, Cp, Kill, Ln, Mkdir, Mktemp, Mv, Rm, Touch};
163pub use flow::{Break, Colon, Continue, Exit, False, Return, True};
164pub use fold::Fold;
165pub use glob_cmd::GlobCmd;
166pub use grep::Grep;
167pub use headtail::{Head, Tail};
168pub use help::Help;
169pub use hextools::{Hexdump, Od, Xxd};
170pub use http::Http;
171pub use iconv::Iconv;
172pub use inspect::{File, Less, Stat};
173pub use introspect::{Hash, Type, Which};
174pub use join::Join;
175#[cfg(feature = "jq")]
176pub use jq::Jq;
177pub use json::Json;
178pub use log::Log;
179pub(crate) use ls::glob_match;
180pub use ls::{Find, Ls, Rmdir};
181pub use mapfile::Mapfile;
182pub use mkfifo::Mkfifo;
183pub use navigation::{Cd, Pwd};
184pub use nl::Nl;
185pub use numfmt::Numfmt;
186pub use parallel::Parallel;
187pub use paste::Paste;
188pub use patch::Patch;
189pub use path::{Basename, Dirname, Readlink, Realpath};
190pub use pipeline::{Tee, Watch, Xargs};
191pub use printf::Printf;
192pub use read::Read;
193pub use retry::Retry;
194pub use rg::Rg;
195pub use sed::Sed;
196pub use semver::Semver;
197pub use seq::Seq;
198pub use shuf::Shuf;
199
200pub use sleep::Sleep;
201pub use sortuniq::{Sort, Uniq};
202pub use source::Source;
203pub use split::Split;
204pub use strings::Strings;
205pub use system::{DEFAULT_HOSTNAME, DEFAULT_USERNAME, Hostname, Id, Uname, Whoami};
206pub use template::Template;
207pub use test::{Bracket, Test};
208pub use textrev::{Rev, Tac};
209pub use timeout::Timeout;
210pub use tomlq::Tomlq;
211pub use trap::Trap;
212pub use tree::Tree;
213pub use truncate::Truncate;
214pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
215pub use verify::Verify;
216pub use wait::Wait;
217pub use wc::Wc;
218pub use yaml::Yaml;
219pub use yes::Yes;
220pub use zip_cmd::{Unzip, Zip};
221
222#[cfg(feature = "git")]
223pub use git::Git;
224
225#[cfg(feature = "ssh")]
226pub use ssh::{Scp, Sftp, Ssh};
227
228#[cfg(feature = "python")]
229pub(crate) use python::PythonInprocessOptIn;
230#[cfg(feature = "python")]
231pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits};
232
233#[cfg(feature = "typescript")]
234pub use typescript::{
235 TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
236 TypeScriptLimits,
237};
238
239#[cfg(feature = "sqlite")]
240pub(crate) use sqlite::SqliteInprocessOptIn;
241#[cfg(feature = "sqlite")]
242pub use sqlite::{Sqlite, SqliteBackend, SqliteLimits};
243
244use async_trait::async_trait;
245use clap::{CommandFactory, FromArgMatches};
246use std::any::{Any, TypeId};
247use std::collections::HashMap;
248use std::path::{Path, PathBuf};
249use std::sync::Arc;
250
251use crate::error::Result;
252use crate::fs::FileSystem;
253use crate::interpreter::ExecResult;
254
255pub(crate) async fn read_text_file(
256 fs: &dyn FileSystem,
257 path: &Path,
258 cmd_name: &str,
259) -> std::result::Result<String, ExecResult> {
260 let content = fs
261 .read_file(path)
262 .await
263 .map_err(|e| ExecResult::err(format!("{cmd_name}: {}: {e}\n", path.display()), 1))?;
264
265 if path == Path::new("/dev/urandom") || path == Path::new("/dev/random") {
269 return Ok(content.iter().map(|&b| b as char).collect());
270 }
271
272 Ok(String::from_utf8_lossy(&content).into_owned())
273}
274
275pub(crate) fn check_help_version(
286 args: &[String],
287 help_text: &str,
288 version: Option<&str>,
289) -> Option<ExecResult> {
290 for arg in args {
291 match arg.as_str() {
292 "--help" => return Some(ExecResult::ok(help_text.to_string())),
293 "--version" => {
294 if let Some(ver) = version {
295 return Some(ExecResult::ok(format!("{ver}\n")));
296 }
297 }
298 s if !s.starts_with('-') => break,
300 _ => {}
301 }
302 }
303 None
304}
305
306pub(crate) use crate::interpreter::ShellRef;
308
309pub use crate::interpreter::BuiltinSideEffect;
311
312pub trait Extension: Send + Sync {
318 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)>;
323}
324
325#[derive(Clone, Default)]
340pub struct BuiltinRegistry {
341 inner: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Builtin>>>>,
342}
343
344impl BuiltinRegistry {
345 pub fn new() -> Self {
347 Self::default()
348 }
349
350 pub fn insert(&self, name: impl Into<String>, builtin: Arc<dyn Builtin>) {
352 if let Ok(mut guard) = self.inner.write() {
353 guard.insert(name.into(), builtin);
354 }
355 }
356
357 pub fn remove(&self, name: &str) -> Option<Arc<dyn Builtin>> {
360 self.inner.write().ok().and_then(|mut g| g.remove(name))
361 }
362
363 pub fn lookup(&self, name: &str) -> Option<Arc<dyn Builtin>> {
365 self.inner.read().ok().and_then(|g| g.get(name).cloned())
366 }
367
368 pub fn names(&self) -> Vec<String> {
370 self.inner
371 .read()
372 .map(|g| g.keys().cloned().collect())
373 .unwrap_or_default()
374 }
375
376 pub fn is_empty(&self) -> bool {
378 self.inner.read().map(|g| g.is_empty()).unwrap_or(true)
379 }
380}
381
382#[derive(Default)]
388pub struct ExecutionExtensions {
389 values: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
390}
391
392impl ExecutionExtensions {
393 pub fn new() -> Self {
395 Self::default()
396 }
397
398 pub fn insert<T>(&mut self, value: T) -> Option<T>
400 where
401 T: Send + Sync + 'static,
402 {
403 self.values
404 .insert(TypeId::of::<T>(), Box::new(value))
405 .and_then(|prev| prev.downcast::<T>().ok().map(|prev| *prev))
406 }
407
408 pub fn with<T>(mut self, value: T) -> Self
410 where
411 T: Send + Sync + 'static,
412 {
413 let _ = self.insert(value);
414 self
415 }
416
417 pub fn get<T>(&self) -> Option<&T>
419 where
420 T: Send + Sync + 'static,
421 {
422 self.values
423 .get(&TypeId::of::<T>())
424 .and_then(|value| value.downcast_ref::<T>())
425 }
426
427 pub fn is_empty(&self) -> bool {
429 self.values.is_empty()
430 }
431}
432
433#[derive(Debug, Clone)]
439pub struct SubCommand {
440 pub name: String,
442 pub args: Vec<String>,
444 pub stdin: Option<String>,
446}
447
448#[derive(Debug)]
453pub enum ExecutionPlan {
454 Timeout {
456 duration: std::time::Duration,
458 preserve_status: bool,
460 command: SubCommand,
462 },
463 Batch {
465 commands: Vec<SubCommand>,
467 },
468 BatchWithStatus {
470 commands: Vec<SubCommand>,
472 stderr_prefix: String,
474 force_error_exit: bool,
476 },
477}
478
479pub fn resolve_path(cwd: &Path, path_str: &str) -> PathBuf {
498 let path = Path::new(path_str);
499 let joined = if path.is_absolute() {
500 path.to_path_buf()
501 } else {
502 cwd.join(path)
503 };
504 normalize_path(&joined)
506}
507
508use crate::fs::normalize_path;
510
511pub struct Context<'a> {
542 pub args: &'a [String],
546
547 pub env: &'a HashMap<String, String>,
552
553 #[allow(dead_code)] pub variables: &'a mut HashMap<String, String>,
558
559 pub cwd: &'a mut PathBuf,
563
564 pub fs: Arc<dyn FileSystem>,
568
569 pub stdin: Option<&'a str>,
574
575 #[cfg(feature = "http_client")]
581 pub http_client: Option<&'a crate::network::HttpClient>,
582
583 #[cfg(feature = "git")]
589 pub git_client: Option<&'a crate::builtins::git::GitClient>,
590
591 #[cfg(feature = "ssh")]
597 pub ssh_client: Option<&'a crate::builtins::ssh::SshClient>,
598
599 pub(crate) shell: Option<ShellRef<'a>>,
612}
613
614impl<'a> Context<'a> {
615 pub fn execution_extension<T>(&self) -> Option<&T>
617 where
618 T: Send + Sync + 'static,
619 {
620 self.shell
621 .as_ref()
622 .and_then(|shell| shell.execution_extensions.get::<T>())
623 }
624
625 #[cfg(test)]
629 pub fn new_for_test(
630 args: &'a [String],
631 env: &'a std::collections::HashMap<String, String>,
632 variables: &'a mut std::collections::HashMap<String, String>,
633 cwd: &'a mut std::path::PathBuf,
634 fs: std::sync::Arc<dyn crate::fs::FileSystem>,
635 stdin: Option<&'a str>,
636 ) -> Self {
637 Self {
638 args,
639 env,
640 variables,
641 cwd,
642 fs,
643 stdin,
644 #[cfg(feature = "http_client")]
645 http_client: None,
646 #[cfg(feature = "git")]
647 git_client: None,
648 #[cfg(feature = "ssh")]
649 ssh_client: None,
650 shell: None,
651 }
652 }
653}
654
655#[async_trait]
696pub trait Builtin: Send + Sync {
697 async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult>;
708
709 async fn execution_plan(&self, _ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
720 Ok(None)
721 }
722
723 fn llm_hint(&self) -> Option<&'static str> {
737 None
738 }
739
740 fn reset_session_state(&self) {}
746}
747
748#[async_trait]
793pub trait ClapBuiltin: Send + Sync {
794 type Args: clap::Parser + Send + 'static;
796
797 async fn execute_clap(&self, args: Self::Args, ctx: &mut BashkitContext<'_>) -> Result<()>;
799
800 fn llm_hint(&self) -> Option<&'static str> {
802 None
803 }
804
805 fn reset_session_state(&self) {}
811}
812
813pub struct BashkitContext<'a> {
819 inner: Context<'a>,
820
821 pub stdout: String,
823
824 pub stderr: String,
826
827 pub exit_code: i32,
829}
830
831impl<'a> BashkitContext<'a> {
832 fn new(inner: Context<'a>) -> Self {
833 Self {
834 inner,
835 stdout: String::new(),
836 stderr: String::new(),
837 exit_code: 0,
838 }
839 }
840
841 fn into_exec_result(self) -> ExecResult {
842 ExecResult {
843 stdout: self.stdout,
844 stderr: self.stderr,
845 exit_code: self.exit_code,
846 ..Default::default()
847 }
848 }
849
850 pub fn raw_args(&self) -> &[String] {
852 self.inner.args
853 }
854
855 pub fn env(&self) -> &HashMap<String, String> {
857 self.inner.env
858 }
859
860 pub fn variables(&mut self) -> &mut HashMap<String, String> {
862 self.inner.variables
863 }
864
865 pub fn cwd(&self) -> &Path {
867 self.inner.cwd
868 }
869
870 pub fn cwd_mut(&mut self) -> &mut PathBuf {
872 self.inner.cwd
873 }
874
875 pub fn fs(&self) -> Arc<dyn FileSystem> {
877 Arc::clone(&self.inner.fs)
878 }
879
880 pub fn stdin(&self) -> Option<&str> {
882 self.inner.stdin
883 }
884
885 pub fn execution_extension<T>(&self) -> Option<&T>
887 where
888 T: Send + Sync + 'static,
889 {
890 self.inner.execution_extension::<T>()
891 }
892
893 pub fn write_stdout(&mut self, output: impl AsRef<str>) {
895 self.stdout.push_str(output.as_ref());
896 }
897
898 pub fn write_stderr(&mut self, output: impl AsRef<str>) {
900 self.stderr.push_str(output.as_ref());
901 }
902
903 pub fn set_exit_code(&mut self, exit_code: i32) {
905 self.exit_code = exit_code;
906 }
907
908 pub fn fail(&mut self, stderr: impl AsRef<str>, exit_code: i32) {
910 self.write_stderr(stderr);
911 self.set_exit_code(exit_code);
912 }
913}
914
915#[async_trait]
916impl<T> Builtin for T
917where
918 T: ClapBuiltin,
919{
920 async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
921 let mut command = <T::Args as CommandFactory>::command().color(clap::ColorChoice::Never);
922 let command_name = command.get_name().to_string();
923 let argv = std::iter::once(command_name).chain(ctx.args.iter().cloned());
924
925 let mut matches = match command.try_get_matches_from_mut(argv) {
926 Ok(matches) => matches,
927 Err(error) => return Ok(clap_error_to_exec_result(error)),
928 };
929 let args = match <T::Args as FromArgMatches>::from_arg_matches_mut(&mut matches) {
930 Ok(args) => args,
931 Err(error) => return Ok(clap_error_to_exec_result(error)),
932 };
933
934 let mut ctx = BashkitContext::new(ctx);
935 self.execute_clap(args, &mut ctx).await?;
936 Ok(ctx.into_exec_result())
937 }
938
939 fn llm_hint(&self) -> Option<&'static str> {
940 ClapBuiltin::llm_hint(self)
941 }
942
943 fn reset_session_state(&self) {
944 ClapBuiltin::reset_session_state(self);
945 }
946}
947
948fn clap_error_to_exec_result(error: clap::Error) -> ExecResult {
949 let text = error.to_string();
950 if matches!(
951 error.kind(),
952 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
953 ) {
954 return ExecResult::ok(text);
955 }
956
957 ExecResult::err(cap_diagnostic(text, 1024), error.exit_code())
958}
959
960fn cap_diagnostic(mut text: String, max_bytes: usize) -> String {
961 if text.len() <= max_bytes {
962 return text;
963 }
964
965 let cut = text
966 .char_indices()
967 .map(|(idx, _)| idx)
968 .take_while(|idx| *idx <= max_bytes)
969 .last()
970 .unwrap_or(0);
971 text.truncate(cut);
972 text
973}
974
975#[async_trait]
976impl Builtin for std::sync::Arc<dyn Builtin> {
977 async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
978 (**self).execute(ctx).await
979 }
980
981 async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
982 (**self).execution_plan(ctx).await
983 }
984
985 fn llm_hint(&self) -> Option<&'static str> {
986 (**self).llm_hint()
987 }
988}
989
990#[cfg(test)]
995pub(crate) use crate::testing as debug_leak_check;
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000 use crate::fs::{FileSystem, InMemoryFs};
1001
1002 #[test]
1003 fn test_resolve_path_absolute() {
1004 let cwd = PathBuf::from("/home/user");
1005 let result = resolve_path(&cwd, "/tmp/file.txt");
1006 assert_eq!(result, PathBuf::from("/tmp/file.txt"));
1007 }
1008
1009 #[test]
1010 fn test_resolve_path_relative() {
1011 let cwd = PathBuf::from("/home/user");
1012 let result = resolve_path(&cwd, "downloads/file.txt");
1013 assert_eq!(result, PathBuf::from("/home/user/downloads/file.txt"));
1014 }
1015
1016 #[test]
1017 fn test_resolve_path_dot_from_root() {
1018 let cwd = PathBuf::from("/");
1020 let result = resolve_path(&cwd, ".");
1021 assert_eq!(result, PathBuf::from("/"));
1022 }
1023
1024 #[test]
1025 fn test_resolve_path_dot_from_normal_dir() {
1026 let cwd = PathBuf::from("/home/user");
1028 let result = resolve_path(&cwd, ".");
1029 assert_eq!(result, PathBuf::from("/home/user"));
1030 }
1031
1032 #[test]
1033 fn test_resolve_path_dotdot() {
1034 let cwd = PathBuf::from("/home/user");
1036 let result = resolve_path(&cwd, "..");
1037 assert_eq!(result, PathBuf::from("/home"));
1038 }
1039
1040 #[test]
1041 fn test_resolve_path_dotdot_from_root() {
1042 let cwd = PathBuf::from("/");
1044 let result = resolve_path(&cwd, "..");
1045 assert_eq!(result, PathBuf::from("/"));
1046 }
1047
1048 #[test]
1049 fn test_resolve_path_complex() {
1050 let cwd = PathBuf::from("/home/user");
1052 let result = resolve_path(&cwd, "./downloads/../documents/./file.txt");
1053 assert_eq!(result, PathBuf::from("/home/user/documents/file.txt"));
1054 }
1055
1056 #[tokio::test]
1057 async fn read_text_file_returns_lossy_utf8() {
1058 let fs = InMemoryFs::new();
1059 fs.write_file(Path::new("/tmp/data.bin"), b"hi\xffthere")
1060 .await
1061 .unwrap();
1062
1063 let text = read_text_file(&fs, Path::new("/tmp/data.bin"), "cat")
1064 .await
1065 .unwrap();
1066
1067 assert_eq!(text, "hi\u{fffd}there");
1068 }
1069
1070 #[tokio::test]
1071 async fn read_text_file_formats_missing_file_errors() {
1072 let fs = InMemoryFs::new();
1073 let err = read_text_file(&fs, Path::new("/tmp/missing.txt"), "cat")
1074 .await
1075 .unwrap_err();
1076
1077 assert_eq!(err.exit_code, 1);
1078 assert!(err.stderr.contains("cat: /tmp/missing.txt:"));
1079 }
1080
1081 #[test]
1082 fn check_help_version_returns_help() {
1083 let args = vec!["--help".to_string()];
1084 let r = check_help_version(&args, "usage text\n", Some("v1.0"));
1085 assert!(r.is_some());
1086 assert_eq!(r.unwrap().stdout, "usage text\n");
1087 }
1088
1089 #[test]
1090 fn check_help_version_returns_version() {
1091 let args = vec!["--version".to_string()];
1092 let r = check_help_version(&args, "usage\n", Some("tool 1.0"));
1093 assert!(r.is_some());
1094 assert_eq!(r.unwrap().stdout, "tool 1.0\n");
1095 }
1096
1097 #[test]
1098 fn check_help_version_no_version_configured() {
1099 let args = vec!["--version".to_string()];
1100 let r = check_help_version(&args, "usage\n", None);
1101 assert!(r.is_none());
1102 }
1103
1104 #[test]
1105 fn check_help_version_stops_at_non_flag() {
1106 let args = vec!["file.txt".to_string(), "--help".to_string()];
1107 let r = check_help_version(&args, "usage\n", None);
1108 assert!(r.is_none());
1109 }
1110
1111 #[test]
1112 fn check_help_version_no_match() {
1113 let args = vec!["-c".to_string(), "filter".to_string()];
1114 let r = check_help_version(&args, "usage\n", Some("v1"));
1115 assert!(r.is_none());
1116 }
1117
1118 #[test]
1130 fn no_debug_fmt_in_builtin_source() {
1131 let pat = regex::Regex::new(r"\{[A-Za-z0-9_]*:#?\?\}").unwrap();
1133 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins");
1134 let mut violations = Vec::new();
1135
1136 fn walk(dir: &std::path::Path, violations: &mut Vec<String>, pat: ®ex::Regex) {
1140 for entry in std::fs::read_dir(dir).expect("read builtins dir") {
1141 let entry = entry.unwrap();
1142 let path = entry.path();
1143 if path.is_dir() {
1144 walk(&path, violations, pat);
1145 continue;
1146 }
1147 if path.extension().is_none_or(|e| e != "rs") {
1148 continue;
1149 }
1150 let src = std::fs::read_to_string(&path).expect("read source");
1151 for (i, line) in src.lines().enumerate() {
1152 if line.contains("// debug-ok:") {
1153 continue;
1154 }
1155 if line.trim_start().starts_with("#[derive(") {
1156 continue;
1157 }
1158 if pat.is_match(line) {
1159 let rel = path
1162 .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1163 .unwrap_or(&path);
1164 violations.push(format!(
1165 "{}:{}: {}",
1166 rel.display(),
1167 i + 1,
1168 line.trim_end()
1169 ));
1170 }
1171 }
1172 }
1173 }
1174 walk(&dir, &mut violations, &pat);
1175
1176 assert!(
1177 violations.is_empty(),
1178 "Rust Debug formatting found in builtin source. This leaks \
1179 internal struct shapes into stderr where LLM agents see them. \
1180 Use Display ({{}}) or a domain-specific formatter. Add \
1181 `// debug-ok: <reason>` to the line for legitimate test \
1182 asserts.\n\nViolations:\n{}",
1183 violations.join("\n")
1184 );
1185 }
1186
1187 #[test]
1201 fn no_clap_env_in_generated_parsers() {
1202 let pat = regex::Regex::new(r"\.env\s*\(").unwrap();
1203 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1204 let mut violations = Vec::new();
1205 for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1206 let entry = entry.unwrap();
1207 let path = entry.path();
1208 if path.extension().and_then(|s| s.to_str()) != Some("rs") {
1209 continue;
1210 }
1211 let src = std::fs::read_to_string(&path).expect("read generated file");
1212 for (i, line) in src.lines().enumerate() {
1213 if line.trim_start().starts_with("//") {
1217 continue;
1218 }
1219 if pat.is_match(line) {
1220 let rel = path
1221 .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1222 .unwrap_or(&path);
1223 violations.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
1224 }
1225 }
1226 }
1227 assert!(
1228 violations.is_empty(),
1229 "clap `Arg::env(...)` found in a generated parser. This pulls \
1230 defaults from the host process environment and breaks bashkit's \
1231 sandbox boundary (TM-INF-024). Re-run `just regen-coreutils-args` \
1232 — the codegen harvests these into `<UTIL>_ENV_DEFAULTS` instead — \
1233 or remove the call by hand.\n\n{}",
1234 violations.join("\n")
1235 );
1236 }
1237
1238 #[test]
1245 fn every_generated_parser_emits_env_defaults_table() {
1246 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1247 let mut missing = Vec::new();
1248 for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1249 let entry = entry.unwrap();
1250 let path = entry.path();
1251 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1252 if !name.ends_with("_args.rs") {
1253 continue;
1254 }
1255 let util = name.trim_end_matches("_args.rs");
1257 let const_name = format!("{}_ENV_DEFAULTS", util.to_uppercase());
1258 let needle = format!("pub static {const_name}");
1259 let src = std::fs::read_to_string(&path).expect("read generated file");
1260 if !src.contains(&needle) {
1261 let rel = path
1262 .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1263 .unwrap_or(&path);
1264 missing.push(format!("{}: missing `{needle}: ...`", rel.display()));
1265 }
1266 }
1267 assert!(
1268 missing.is_empty(),
1269 "Generated parser is missing its `<UTIL>_ENV_DEFAULTS` sidecar. \
1270 The codegen always emits this (possibly empty) so the bashkit \
1271 builtin can route argv through `apply_env_defaults` without \
1272 per-util conditionals. Re-run `just regen-coreutils-args` to \
1273 regenerate.\n\n{}",
1274 missing.join("\n")
1275 );
1276 }
1277
1278 #[test]
1285 fn ls_env_defaults_surface_matches_uutils() {
1286 use crate::builtins::generated::ls_args::LS_ENV_DEFAULTS;
1287 let mut got: Vec<(&'static str, &'static str)> = LS_ENV_DEFAULTS
1288 .iter()
1289 .map(|d| (d.env_var, d.long))
1290 .collect();
1291 got.sort();
1292 let mut expected = vec![("TABSIZE", "tabsize"), ("TIME_STYLE", "time-style")];
1293 expected.sort();
1294 assert_eq!(
1295 got, expected,
1296 "LS_ENV_DEFAULTS surface drifted from upstream uutils. Either \
1297 the codegen harvest dropped a row, or uutils added/removed an \
1298 `.env(...)` annotation on `ls` — bump this fixture together \
1299 with the regen."
1300 );
1301 }
1302
1303 #[test]
1309 fn generated_args_headers_match_pinned_uutils_revision() {
1310 let pin = generated::UUTILS_REVISION;
1311 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1312
1313 let mut mismatches = Vec::new();
1314 for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1315 let entry = entry.unwrap();
1316 let path = entry.path();
1317 if path.extension().and_then(|s| s.to_str()) != Some("rs") {
1318 continue;
1319 }
1320 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1321 if !name.ends_with("_args.rs") {
1324 continue;
1325 }
1326 let body = std::fs::read_to_string(&path).expect("read generated file");
1327 let header_rev = body
1328 .lines()
1329 .find_map(|l| {
1330 l.strip_prefix("// Source: uutils/coreutils@")
1331 .and_then(|rest| rest.split_whitespace().next())
1332 })
1333 .unwrap_or("");
1334 if header_rev != pin {
1335 mismatches.push(format!(
1336 "{}: header references uutils@{header_rev}, pin is uutils@{pin}",
1337 path.display()
1338 ));
1339 }
1340 }
1341 assert!(
1342 mismatches.is_empty(),
1343 "Generated argument files drift from `generated::UUTILS_REVISION` \
1344 (`{pin}`). Regenerate every util at the pinned rev or bump the \
1345 pin to match. The drift workflow does both atomically; manual \
1346 bumps must too.\n\n{}",
1347 mismatches.join("\n")
1348 );
1349 }
1350
1351 #[tokio::test]
1356 async fn every_builtin_handles_bogus_flag_cleanly() {
1357 const TOOLS: &[&str] = &[
1358 "cat",
1359 "ls",
1360 "wc",
1361 "head",
1362 "tail",
1363 "sort",
1364 "uniq",
1365 "cut",
1366 "tr",
1367 "grep",
1368 "sed",
1369 "awk",
1370 "find",
1371 "tree",
1372 "diff",
1373 "comm",
1374 "paste",
1375 "column",
1376 "join",
1377 "split",
1378 "fold",
1379 "expand",
1380 "unexpand",
1381 "nl",
1382 "tac",
1383 "truncate",
1384 "shuf",
1385 "rev",
1386 "strings",
1387 "od",
1388 "xxd",
1389 "hexdump",
1390 "base64",
1391 "md5sum",
1392 "sha1sum",
1393 "sha256sum",
1394 "tar",
1395 "gzip",
1396 "gunzip",
1397 "zip",
1398 "unzip",
1399 "seq",
1400 "expr",
1401 "bc",
1402 "numfmt",
1403 "test",
1404 "printf",
1405 "echo",
1406 "env",
1407 "printenv",
1408 "stat",
1409 "file",
1410 "basename",
1411 "dirname",
1412 "realpath",
1413 "readlink",
1414 "mktemp",
1415 "tee",
1416 "csv",
1417 "json",
1418 "yaml",
1419 "tomlq",
1420 "jq",
1421 "semver",
1422 "envsubst",
1423 "template",
1424 "patch",
1425 ];
1426 for tool in TOOLS {
1427 let r =
1428 super::debug_leak_check::run(&format!("{tool} --xyzzy-not-a-real-flag </dev/null"))
1429 .await;
1430 super::debug_leak_check::assert_no_leak(&r, &format!("{tool}_bogus_flag"), &[]);
1431 }
1432 }
1433}