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    /// 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}