shrub_core/options.rs
1//! Defines runtime options for the shell.
2
3use std::ffi::CString;
4use std::sync::Arc;
5
6use itertools::Itertools;
7
8use crate::{CreateOptions, extensions, namedoptions};
9
10/// The default for [`CreateOptions::max_kept_descriptors`].
11///
12/// It is enough for the descriptors a script keeps with `exec` and `coproc`,
13/// while a script that opens them in a loop fails long before the process
14/// runs out of them.
15pub const DEFAULT_MAX_KEPT_DESCRIPTORS: usize = 64;
16
17/// What the shell does with each external process it spawns, for an embedder
18/// that confines or tracks the processes its commands start. The default does
19/// nothing.
20///
21/// The options are part of the shell's [`RuntimeOptions`], so they reach every
22/// external command the shell runs, pipelines, background jobs and subshells
23/// included, since those copy the shell they run in.
24///
25/// The `exec` builtin applies them in the shell's own process, with no fork,
26/// so an `exec` that fails leaves in place the steps that ran before the
27/// failure. The order is the cgroup join, the process counts, the shell's
28/// other descriptors, then the open-file limit; with process counts set, the
29/// builtin refuses before any step runs.
30#[derive(Clone, Default)]
31pub struct SpawnOptions {
32 /// A file each child writes `0` to after it is forked and before it runs
33 /// its program. For a cgroup v2's `cgroup.procs` file, the write moves
34 /// the child into that cgroup, so it cannot fork a process outside it;
35 /// only Linux has cgroups. When the child cannot open the file or write
36 /// to it, the command fails to execute and its program never runs.
37 pub cgroup_procs: Option<Arc<CString>>,
38 /// The soft limit on open files (`RLIMIT_NOFILE`) each child gets, set
39 /// after it is forked and before it runs its program, for an embedder
40 /// that raised its own soft limit and gives its children the one it
41 /// started with. A value above the hard limit sets the hard limit, and
42 /// on macOS a value it refuses is lowered to `OPEN_MAX` (10240). `None`
43 /// leaves the limit the child inherits; on a platform without resource
44 /// limits, setting it fails every external command.
45 pub open_file_limit: Option<u64>,
46 /// The limit on the processes of the user id (`RLIMIT_NPROC`) each
47 /// child gets, set after it is forked and before it runs its program:
48 /// both its soft and its hard limit become this value, or its hard limit
49 /// when that is lower, so neither the program nor its descendants can
50 /// raise it. The kernel then refuses a fork by the child or a descendant
51 /// while the user id has that many processes, except for root and a
52 /// process with `CAP_SYS_RESOURCE` or `CAP_SYS_ADMIN`, which it does not
53 /// bind. The `exec` builtin refuses to replace the shell while it is
54 /// set, because the shell's own process would set it, and would keep a
55 /// hard limit it cannot raise again if the exec failed; a subshell's
56 /// `exec` forks and is limited as usual. `None` leaves the limit the
57 /// child inherits. Only Linux sets it; on another platform, setting it
58 /// fails every external command.
59 pub process_limit: Option<u64>,
60 /// Process counts each child checks after it joins the cgroup in
61 /// `cgroup_procs` and before it runs its program. The kernel does not
62 /// apply a cgroup's `pids.max` to a process that moves into it, so the
63 /// child checks the count itself and fails its spawn, without running its
64 /// program, when a count is above its limit or cannot be read: the
65 /// command then fails to execute, with status 126, and `on_spawn` is not
66 /// called. The `exec` builtin refuses to replace the shell while the
67 /// list is non-empty, because in the shell's own process a count would
68 /// include every thread of the shell; a subshell's `exec` forks and is
69 /// checked as usual. Empty, the default, checks nothing; on a platform
70 /// without this check, a non-empty list fails every external command.
71 pub pids_limits: Arc<[PidsLimit]>,
72 /// Called before each child is spawned. The shell holds the value it
73 /// returns until `on_spawn` has returned for that child, or until the
74 /// spawn fails, then drops it. An embedder that reaps processes it did
75 /// not spawn, such as a child subreaper, holds a lock in it so that no
76 /// reap runs between the fork and `on_spawn`: a child that exits in that
77 /// window is a zombie the embedder does not yet know, and reaping it
78 /// would take the exit status the shell waits for. It runs on the
79 /// shell's task, so it should return quickly.
80 pub before_spawn: Option<Arc<dyn Fn() -> SpawnGuard + Send + Sync>>,
81 /// Called with each child once it has started, before the shell waits
82 /// for it. It runs on the shell's task, so it should return quickly.
83 pub on_spawn: Option<Arc<dyn Fn(SpawnedProcess) + Send + Sync>>,
84}
85
86/// What [`SpawnOptions::before_spawn`] returns, held across one spawn and
87/// dropped after it.
88pub type SpawnGuard = Box<dyn std::any::Any + Send>;
89
90impl std::fmt::Debug for SpawnOptions {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("SpawnOptions")
93 .field("cgroup_procs", &self.cgroup_procs)
94 .field("open_file_limit", &self.open_file_limit)
95 .field("process_limit", &self.process_limit)
96 .field("pids_limits", &self.pids_limits)
97 .field("before_spawn", &self.before_spawn.as_ref().map(|_| "Fn"))
98 .field("on_spawn", &self.on_spawn.as_ref().map(|_| "Fn"))
99 .finish()
100 }
101}
102
103/// A process count that each child checks before its program runs; see
104/// [`SpawnOptions::pids_limits`].
105#[derive(Clone, Debug)]
106pub struct PidsLimit {
107 /// A cgroup's `pids.current` file, opened before the shell spawns, so the
108 /// child reads it without opening anything.
109 pub current: Arc<std::fs::File>,
110 /// The highest count, the child included, at which the child still runs
111 /// its program.
112 pub max: u64,
113}
114
115/// An external process the shell has started.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct SpawnedProcess {
118 /// The child's process ID.
119 pub pid: i32,
120 /// The process group the child was placed in, if the shell knows it.
121 pub process_group: Option<i32>,
122}
123
124/// Runtime changeable options for a shell instance.
125#[derive(Clone, Default)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127#[expect(clippy::module_name_repetitions)]
128pub struct RuntimeOptions {
129 //
130 // Single-character options.
131 /// -a
132 pub export_variables_on_modification: bool,
133 /// -b
134 pub notify_job_termination_immediately: bool,
135 /// -e
136 pub exit_on_nonzero_command_exit: bool,
137 /// -f
138 pub disable_filename_globbing: bool,
139 /// -h
140 pub remember_command_locations: bool,
141 /// -k
142 pub place_all_assignment_args_in_command_env: bool,
143 /// -m
144 pub enable_job_control: bool,
145 /// -n
146 pub do_not_execute_commands: bool,
147 /// -p
148 pub real_effective_uid_mismatch: bool,
149 /// -t
150 pub exit_after_one_command: bool,
151 /// -u
152 pub treat_unset_variables_as_error: bool,
153 /// -v
154 pub print_shell_input_lines: bool,
155 /// -x
156 pub print_commands_and_arguments: bool,
157 /// -B
158 pub perform_brace_expansion: bool,
159 /// -C
160 pub disallow_overwriting_regular_files_via_output_redirection: bool,
161 /// -E
162 pub shell_functions_inherit_err_trap: bool,
163 /// -H
164 pub enable_bang_style_history_substitution: bool,
165 /// -P
166 pub do_not_resolve_symlinks_when_changing_dir: bool,
167 /// -T
168 pub shell_functions_inherit_debug_and_return_traps: bool,
169
170 //
171 // Options set through -o.
172 /// 'emacs'
173 pub emacs_mode: bool,
174 /// 'history'
175 pub enable_command_history: bool,
176 /// 'ignoreeof'
177 pub ignore_eof: bool,
178 /// 'pipefail'
179 pub return_last_failure_from_pipeline: bool,
180 /// 'posix'
181 pub posix_mode: bool,
182 /// 'vi'
183 pub vi_mode: bool,
184
185 //
186 // Options set through shopt.
187 /// `array_expand_once`
188 pub array_expand_once: bool,
189 /// `assoc_expand_once`
190 pub assoc_expand_once: bool,
191 /// 'autocd'
192 pub auto_cd: bool,
193 /// `bash_source_full_path`
194 pub bash_source_full_path: bool,
195 /// `cdable_vars`
196 pub cdable_vars: bool,
197 /// 'cdspell'
198 pub cd_autocorrect_spelling: bool,
199 /// 'checkhash'
200 pub check_hashtable_before_command_exec: bool,
201 /// 'checkjobs'
202 pub check_jobs_before_exit: bool,
203 /// 'checkwinsize'
204 pub check_window_size_after_external_commands: bool,
205 /// 'cmdhist'
206 pub save_multiline_cmds_in_history: bool,
207 /// 'compat31'
208 pub compat31: bool,
209 /// 'compat32'
210 pub compat32: bool,
211 /// 'compat40'
212 pub compat40: bool,
213 /// 'compat41'
214 pub compat41: bool,
215 /// 'compat42'
216 pub compat42: bool,
217 /// 'compat43'
218 pub compat43: bool,
219 /// 'compat44'
220 pub compat44: bool,
221 /// `complete_fullquote`
222 pub quote_all_metachars_in_completion: bool,
223 /// 'direxpand'
224 pub expand_dir_names_on_completion: bool,
225 /// 'dirspell'
226 pub autocorrect_dir_spelling_on_completion: bool,
227 /// 'dotglob'
228 pub glob_matches_dotfiles: bool,
229 /// 'execfail'
230 pub exit_on_exec_fail: bool,
231 /// `expand_aliases`
232 pub expand_aliases: bool,
233 /// 'extdebug'
234 pub enable_debugger: bool,
235 /// 'extglob'
236 pub extended_globbing: bool,
237 /// 'extquote'
238 pub extquote: bool,
239 /// 'failglob'
240 pub fail_expansion_on_globs_without_match: bool,
241 /// `force_fignore`
242 pub force_fignore: bool,
243 /// 'globasciiranges'
244 pub glob_ranges_use_c_locale: bool,
245 /// 'globskipdots'
246 pub glob_skip_dots: bool,
247 /// 'globstar'
248 pub enable_star_star_glob: bool,
249 /// `gnu_errfmt`
250 pub errors_in_gnu_format: bool,
251 /// 'histappend'
252 pub append_to_history_file: bool,
253 /// 'histreedit'
254 pub allow_reedit_failed_history_subst: bool,
255 /// 'histverify'
256 pub allow_modifying_history_substitution: bool,
257 /// 'hostcomplete'
258 pub enable_hostname_completion: bool,
259 /// 'huponexit'
260 pub send_sighup_to_all_jobs_on_exit: bool,
261 /// `inherit_errexit`
262 pub command_subst_inherits_errexit: bool,
263 /// `interactive_comments`
264 pub interactive_comments: bool,
265 /// 'lastpipe'
266 pub run_last_pipeline_cmd_in_current_shell: bool,
267 /// 'lithist'
268 pub embed_newlines_in_multiline_cmds_in_history: bool,
269 /// `localvar_inherit`
270 pub local_vars_inherit_value_and_attrs: bool,
271 /// `localvar_unset`
272 pub localvar_unset: bool,
273 /// `login_shell`
274 pub login_shell: bool,
275 /// 'mailwarn'
276 pub mail_warn: bool,
277 /// `no_empty_cmd_completion`
278 pub no_empty_cmd_completion: bool,
279 /// 'nocaseglob'
280 pub case_insensitive_pathname_expansion: bool,
281 /// 'nocasematch'
282 pub case_insensitive_conditionals: bool,
283 /// `noexpand_translation`
284 pub no_expand_translation: bool,
285 /// 'nullglob'
286 pub expand_non_matching_patterns_to_null: bool,
287 /// `patsub_replacement`
288 pub patsub_replacement: bool,
289 /// 'progcomp'
290 pub programmable_completion: bool,
291 /// `progcomp_alias`
292 pub programmable_completion_alias: bool,
293 /// 'promptvars'
294 pub expand_prompt_strings: bool,
295 /// `restricted_shell`
296 pub restricted_shell: bool,
297 /// `shift_verbose`
298 pub shift_verbose: bool,
299 /// `sourcepath`
300 pub source_builtin_searches_path: bool,
301 /// `varredir_close`
302 pub var_redir_close: bool,
303 /// `xpg_echo`
304 pub echo_builtin_expands_escape_sequences: bool,
305
306 //
307 // Options set by the shell.
308 /// Whether or not the shell is interactive.
309 pub interactive: bool,
310 /// Whether commands are being read from stdin.
311 pub read_commands_from_stdin: bool,
312 /// Whether the shell is in command string mode (-c).
313 pub command_string_mode: bool,
314 /// Whether or not the shell is in maximal `sh` compatibility mode.
315 pub sh_mode: bool,
316 /// Whether to treat external commands as session leaders.
317 pub external_cmd_leads_session: bool,
318 /// Whether externally spawned commands are killed when their spawning shell
319 /// is dropped.
320 pub kill_external_commands_on_drop: bool,
321 /// What the shell does with each external process it spawns. Defaults to
322 /// nothing; an embedder sets it before each command it runs.
323 #[cfg_attr(feature = "serde", serde(skip))]
324 pub spawn: SpawnOptions,
325 /// Maximum function call depth.
326 pub max_function_call_depth: Option<usize>,
327 /// The most file descriptors above standard error that the shell keeps
328 /// across commands, such as those `exec 3>file` or `coproc` open; `None`
329 /// keeps any number. A command that would keep more fails, and the shell
330 /// keeps the descriptors it had.
331 pub max_kept_descriptors: Option<usize>,
332}
333
334impl RuntimeOptions {
335 /// Creates a default set of runtime options based on the given creation
336 /// options.
337 ///
338 /// # Arguments
339 ///
340 /// * `create_options` - The options used to create the shell.
341 pub fn defaults_from<SE: extensions::ShellExtensions>(
342 create_options: &CreateOptions<SE>,
343 ) -> Self {
344 // There's a set of options enabled by default for all shells.
345 let mut options = Self {
346 interactive: create_options.interactive,
347 disallow_overwriting_regular_files_via_output_redirection: create_options
348 .disallow_overwriting_regular_files_via_output_redirection,
349 do_not_execute_commands: create_options.do_not_execute_commands,
350 enable_command_history: create_options.interactive,
351 enable_job_control: create_options.interactive,
352 exit_after_one_command: create_options.exit_after_one_command,
353 read_commands_from_stdin: create_options.read_commands_from_stdin,
354 command_string_mode: create_options.command_string_mode,
355 sh_mode: create_options.sh_mode,
356 posix_mode: create_options.posix,
357 print_commands_and_arguments: create_options.print_commands_and_arguments,
358 print_shell_input_lines: create_options.verbose,
359 treat_unset_variables_as_error: create_options.treat_unset_variables_as_error,
360 exit_on_nonzero_command_exit: create_options.exit_on_nonzero_command_exit,
361 external_cmd_leads_session: create_options.external_cmd_leads_session,
362 kill_external_commands_on_drop: create_options.kill_external_commands_on_drop,
363 login_shell: create_options.login,
364 disable_filename_globbing: create_options.disable_pathname_expansion,
365 remember_command_locations: true,
366 check_window_size_after_external_commands: true,
367 save_multiline_cmds_in_history: true,
368 extquote: true,
369 force_fignore: true,
370 case_insensitive_pathname_expansion:
371 crate::sys::fs::default_case_insensitive_path_expansion(),
372 enable_hostname_completion: true,
373 interactive_comments: true,
374 expand_prompt_strings: true,
375 source_builtin_searches_path: true,
376 perform_brace_expansion: true,
377 quote_all_metachars_in_completion: true,
378 programmable_completion: true,
379 glob_ranges_use_c_locale: true,
380 glob_skip_dots: true,
381 patsub_replacement: true,
382 max_function_call_depth: create_options.max_function_call_depth,
383 max_kept_descriptors: Some(
384 create_options
385 .max_kept_descriptors
386 .unwrap_or(DEFAULT_MAX_KEPT_DESCRIPTORS),
387 ),
388 ..Self::default()
389 };
390
391 // Additional options are enabled by default for interactive shells.
392 if create_options.interactive {
393 options.enable_bang_style_history_substitution = true;
394 options.emacs_mode = !create_options.no_editing;
395 options.expand_aliases = true;
396 }
397
398 // Update any options.
399 for enabled_option in &create_options.enabled_options {
400 if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO)
401 .get(enabled_option.as_str())
402 {
403 option.set(&mut options, true);
404 }
405 }
406 for disabled_option in &create_options.disabled_options {
407 if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO)
408 .get(disabled_option.as_str())
409 {
410 option.set(&mut options, false);
411 }
412 }
413
414 // Update any shopt options.
415 for enabled_option in &create_options.enabled_shopt_options {
416 if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt)
417 .get(enabled_option.as_str())
418 {
419 shopt_option.set(&mut options, true);
420 }
421 }
422 for disabled_option in &create_options.disabled_shopt_options {
423 if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt)
424 .get(disabled_option.as_str())
425 {
426 shopt_option.set(&mut options, false);
427 }
428 }
429
430 options
431 }
432
433 /// Returns a string representing the current `set`-style option flags set
434 /// in the shell.
435 pub fn option_flags(&self) -> String {
436 let mut cs = vec![];
437
438 for o in namedoptions::options(namedoptions::ShellOptionKind::Set).iter() {
439 if o.definition.get(self)
440 && let Some(c) = o.name.chars().next()
441 {
442 cs.push(c);
443 }
444 }
445
446 // Sort the flags in a way that matches what bash does.
447 cs.sort_by_key(|flag| option_flag_sort_key(*flag));
448
449 cs.into_iter().collect()
450 }
451
452 /// Returns a colon-separated list of sorted 'set -o' options enabled.
453 pub fn seto_optstr(&self) -> String {
454 let mut cs = vec![];
455
456 for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() {
457 if option.definition.get(self) {
458 cs.push(option.name);
459 }
460 }
461
462 cs.sort_unstable();
463 cs.into_iter().join(":")
464 }
465
466 /// Returns a colon-separated list of sorted 'shopt' options enabled.
467 pub fn shopt_optstr(&self) -> String {
468 let mut cs = vec![];
469
470 for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() {
471 if option.definition.get(self) {
472 cs.push(option.name);
473 }
474 }
475
476 cs.sort_unstable();
477 cs.into_iter().join(":")
478 }
479}
480
481/// Sort option flag character in a way that mirrors bash behavior.
482///
483/// # Arguments
484///
485/// * `ch` - The option flag character.
486const fn option_flag_sort_key(ch: char) -> (u8, char) {
487 // NOTE: bash appears to sort in 3 groups. We mimic them:
488 // 1) Lowercase letters excluding 'c' and 's' (sorted)
489 // 2) Uppercase letters (sorted)
490 // 3) All other characters (sorted)
491 let group = if ch.is_ascii_lowercase() && !matches!(ch, 'c' | 's') {
492 0
493 } else if ch.is_ascii_uppercase() {
494 1
495 } else {
496 2
497 };
498
499 (group, ch)
500}
501
502#[cfg(test)]
503mod tests {
504 use super::option_flag_sort_key;
505
506 #[test]
507 fn lowercase_excluding_c_and_s_sort_first() {
508 let mut flags = vec!['b', 'A', 'Z', 's', 'c', 'a'];
509 flags.sort_by_key(|flag| option_flag_sort_key(*flag));
510
511 assert_eq!(flags, vec!['a', 'b', 'A', 'Z', 'c', 's']);
512 }
513
514 #[test]
515 fn uppercase_sorted_before_miscellaneous() {
516 let mut flags = vec!['P', 'B', '1', 'T'];
517 flags.sort_by_key(|flag| option_flag_sort_key(*flag));
518
519 assert_eq!(flags, vec!['B', 'P', 'T', '1']);
520 }
521
522 #[test]
523 fn miscellaneous_characters_respect_ascii_order() {
524 let mut flags = vec!['s', 'c', '%', ':'];
525 flags.sort_by_key(|flag| option_flag_sort_key(*flag));
526
527 assert_eq!(flags, vec!['%', ':', 'c', 's']);
528 }
529}