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