Skip to main content

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