Skip to main content

zsh/extensions/
emulation_startup.rs

1//! Startup- and shutdown-file model for every zshrs emulation drop-in.
2//!
3//! **zshrs-only — no zsh C counterpart.** `Src/init.c::run_init_scripts`
4//! knows exactly two startup-file sets: zsh's
5//! (`zshenv`/`zprofile`/`zshrc`/`zlogin`) and a single lumped Bourne one
6//! (`/etc/profile` + `~/.profile` + `$ENV`). That is enough for upstream,
7//! which only ever emulates `sh`/`ksh` loosely. zshrs ships drop-ins for
8//! eight shells and each has its own set, so the whole table lives here.
9//!
10//! Every row below was read off the shell's own manual AND verified by
11//! running the reference binary with a scratch `$HOME` in which each
12//! candidate file echoes its own name:
13//!
14//! | personality | login shell | interactive non-login | non-interactive |
15//! |-------------|-------------|-----------------------|-----------------|
16//! | `--bash`    | `/etc/profile`, first of `~/.bash_profile` / `~/.bash_login` / `~/.profile` | `/etc/bash.bashrc`†, `~/.bashrc` | `$BASH_ENV` |
17//! | `--ksh`     | `/etc/profile`, `~/.profile`, then the interactive file | `/etc/ksh.kshrc`†, `$ENV` (default `~/.kshrc`) | — |
18//! | `--mksh`    | `/etc/profile`, `~/.profile`, then the interactive file | `$ENV`, or `~/.mkshrc` when `$ENV` is unset | — |
19//! | `--pdksh`   | `/etc/profile`, `~/.profile`, then the interactive file | `$ENV` (no default) | — |
20//! | `--sh` / `--posix` / `--dash` / `--ash` | `/etc/profile`, `~/.profile`, then the interactive file | `$ENV` (no default) | — |
21//! | `--csh`     | the non-login files, then `/etc/csh.login`, `~/.login` | `/etc/csh.cshrc`, `~/.tcshrc` or `~/.cshrc` | — |
22//!
23//! † existence-gated: see [`sys_file`].
24//!
25//! Reference runs (`printf 'true\n' | env -i HOME=… <shell> <flags>`):
26//!
27//! * `bash 5.3.15`: `-l -c true` → `.bash_profile`; with it removed →
28//!   `.bash_login`; with both removed → `.profile`; `-i -c true` →
29//!   `.bashrc`; `-i -l -c true` → `.bash_profile` and NOT `.bashrc`;
30//!   `script.sh` → `$BASH_ENV`; `-l script.sh` → `.bash_profile` then
31//!   `$BASH_ENV`; `-i -c true` with `$BASH_ENV` set → `.bashrc` only;
32//!   `--norc -i` / `--noprofile -l` → nothing; `--rcfile F -i` and
33//!   `--init-file F -i` → `F`; `-l -c exit` → `.bash_profile`,
34//!   `.bash_logout`.
35//! * `ksh93` (`/bin/ksh`, macOS): `-i` → `.kshrc`; `-i` with `$ENV` set →
36//!   that file; `-l` → `.profile`; `-i -l` → `.profile` then `.kshrc`.
37//! * `mksh 59c`: `-i` → `.mkshrc`; `-i` with `$ENV` → that file; `-l` →
38//!   `.profile`; `-i -l` → `.profile` then `.mkshrc`.
39//! * `dash`: `-i` → nothing without `$ENV`; `-i` with `$ENV` → that file;
40//!   `-l` → `.profile`; `-i -l` with `$ENV` → `.profile` then `$ENV`.
41//! * `tcsh` (macOS `/bin/csh`): `-i` → `.tcshrc`, or `.cshrc` when
42//!   `.tcshrc` is absent; `-l` → `.tcshrc` then `.login`.
43//!
44//! pdksh itself is not installed here; its `$ENV`-with-no-default rule is
45//! taken from ksh(1) on OpenBSD, which tells the user to set it by hand
46//! ("`export ENV=$HOME/.kshrc`") — the sentence mksh(1) replaces with its
47//! own `~/.mkshrc` fallback.
48//!
49//! Two deliberate divergences, both marked at their call site:
50//!   * The system-wide rc files bash and ksh93 compile in (`SYS_BASHRC`,
51//!     `/etc/ksh.kshrc`) are sourced when they EXIST. zshrs ships one
52//!     binary to every platform and cannot bake in a packager's
53//!     `-DSYS_BASHRC`; Debian/Ubuntu/Arch define it and ship the file,
54//!     macOS defines neither — so "exists" reproduces both. Same
55//!     reasoning as [`crate::extensions::global_rc`].
56//!   * bash's `rshd`/`sshd` network-stdin case (a non-interactive shell
57//!     whose stdin is a socket reads `~/.bashrc`) is not modeled.
58
59use std::path::{Path, PathBuf};
60use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
61use std::sync::Mutex;
62
63/// Which shell zshrs is standing in for. Selected once by the binary's
64/// CLI mode application (`--bash`, `--ksh`, an `argv[0]` symlink, …) and
65/// read wherever behavior forks per drop-in.
66///
67/// This is deliberately NOT derived from the `emulation` bitmap:
68/// `Src/init.c`'s `parseopts_setemulate` re-derives that from `argv[0]`
69/// during `zsh_main`, which for zshrs is always the zshrs binary, so the
70/// bitmap is reset to zsh on the interactive path. The personality is the
71/// authoritative record of what the user asked for.
72#[derive(Clone, Copy, PartialEq, Eq, Debug)]
73#[repr(u8)]
74pub enum Personality {
75    /// Native zshrs, and the `--zsh` drop-in: zsh's own startup files.
76    Zsh = 0,
77    /// `--bash`.
78    Bash = 1,
79    /// `--ksh` — the ksh93 line.
80    Ksh93 = 2,
81    /// `--mksh` — MirBSD ksh.
82    Mksh = 3,
83    /// `--pdksh` — the Public Domain / OpenBSD ksh line.
84    Pdksh = 4,
85    /// `--sh` / `--posix`.
86    Sh = 5,
87    /// `--dash` / `--ash`.
88    Dash = 6,
89    /// `--csh`.
90    Csh = 7,
91}
92
93impl Personality {
94    /// The `emulate` personality name this drop-in installs — the same
95    /// string the binary passes to `crate::ported::options::emulate`.
96    pub fn emulate_name(self) -> &'static str {
97        match self {
98            Personality::Zsh => "zsh",
99            // bash shares the `sh` option base; its deltas are applied
100            // separately by the binary (brace expansion, BASH_REMATCH, …).
101            Personality::Bash | Personality::Sh => "sh",
102            Personality::Ksh93 | Personality::Mksh | Personality::Pdksh => "ksh",
103            Personality::Dash => "dash",
104            Personality::Csh => "csh",
105        }
106    }
107
108    /// The parameter naming this shell's interactive startup file.
109    /// bash reads `$BASH_ENV` (non-interactive only); every other Bourne
110    /// personality reads `$ENV`; csh has no such parameter.
111    fn env_param(self) -> Option<&'static str> {
112        match self {
113            Personality::Bash => Some("BASH_ENV"),
114            Personality::Ksh93 | Personality::Mksh | Personality::Pdksh => Some("ENV"),
115            Personality::Sh | Personality::Dash => Some("ENV"),
116            Personality::Zsh | Personality::Csh => None,
117        }
118    }
119
120    /// The file read when the shell's `$ENV`-equivalent is unset. Only
121    /// the two Korn shells that document one have it.
122    fn default_env_file(self) -> Option<&'static str> {
123        match self {
124            // ksh(1): "The default value is $HOME/.kshrc."
125            Personality::Ksh93 => Some(".kshrc"),
126            // mksh(1): "if unset or empty, the user mkshrc profile is
127            // processed".
128            Personality::Mksh => Some(".mkshrc"),
129            _ => None,
130        }
131    }
132}
133
134/// Personality as a `u8`, so it can live in an atomic.
135static PERSONALITY: AtomicU8 = AtomicU8::new(Personality::Zsh as u8);
136
137/// True when ANY parity / drop-in mode was selected — every `--MODE`
138/// flag and every `argv[0]` inference, `--zsh` included.
139///
140/// Distinct from [`PERSONALITY_SET`]: `--zsh` and native zshrs share
141/// `Personality::Zsh`, so the personality alone cannot tell "the user
142/// asked for a zsh drop-in" from "this is zshrs being itself". The
143/// zshrs-original line-editor engines key off this, because a parity
144/// mode has to look like the shell it stands in for and none of those
145/// shells have ghost text or input highlighting.
146static EMULATING: AtomicBool = AtomicBool::new(false);
147
148/// True once the binary has explicitly selected a personality. Until then
149/// `parseopts_setemulate` keeps its faithful `argv[0]` behavior.
150static PERSONALITY_SET: AtomicBool = AtomicBool::new(false);
151
152/// Record the drop-in the user asked for. Called once from the binary's
153/// CLI mode application, before any user code runs.
154pub fn set_personality(p: Personality) {
155    PERSONALITY.store(p as u8, Ordering::Relaxed);
156    PERSONALITY_SET.store(true, Ordering::Relaxed);
157}
158
159/// Record that a parity / drop-in mode was selected. Called once from
160/// the binary's CLI mode application, for every `--MODE` and every
161/// `argv[0]` inference.
162#[inline]
163pub fn set_emulating(on: bool) {
164    EMULATING.store(on, Ordering::Relaxed);
165}
166
167/// True when this process is standing in for another shell — any
168/// `--MODE` flag or `argv[0]` inference, `--zsh` included.
169#[inline]
170pub fn emulating() -> bool {
171    EMULATING.load(Ordering::Relaxed)
172}
173
174/// The selected drop-in, defaulting to [`Personality::Zsh`].
175pub fn personality() -> Personality {
176    match PERSONALITY.load(Ordering::Relaxed) {
177        1 => Personality::Bash,
178        2 => Personality::Ksh93,
179        3 => Personality::Mksh,
180        4 => Personality::Pdksh,
181        5 => Personality::Sh,
182        6 => Personality::Dash,
183        7 => Personality::Csh,
184        _ => Personality::Zsh,
185    }
186}
187
188/// The `emulate` name to install, or `None` when no drop-in was selected
189/// and the faithful `argv[0]` derivation should stand.
190pub fn selected_emulate_name() -> Option<&'static str> {
191    PERSONALITY_SET
192        .load(Ordering::Relaxed)
193        .then(|| personality().emulate_name())
194}
195
196/// bash `--norc`: inhibit `~/.bashrc` for an interactive non-login shell.
197static NORC: AtomicBool = AtomicBool::new(false);
198
199/// bash `--noprofile`: inhibit `/etc/profile` and the `~/.bash_profile`
200/// chain for a login shell.
201static NOPROFILE: AtomicBool = AtomicBool::new(false);
202
203/// bash `--rcfile FILE` / `--init-file FILE`: read FILE in place of
204/// `~/.bashrc`. `None` means the default.
205static RCFILE: Mutex<Option<String>> = Mutex::new(None);
206
207/// True when login-ness was asked for with an explicit `-l` / `--login`
208/// rather than inferred from a leading `-` on `argv[0]`.
209///
210/// Only bash distinguishes the two. bash(1): the profile chain is read
211/// "as an interactive login shell, or as a non-interactive shell with the
212/// --login option" — so `bash -l -c CMD` reads it and a login shell that
213/// sshd exec'd as `-bash` running `-c CMD` does NOT, even though both set
214/// `shopt login_shell`. Verified against bash 5.3.15:
215///
216/// | invocation            | shopt login_shell | profile read |
217/// |-----------------------|-------------------|--------------|
218/// | `-bash -c CMD`        | on                | no           |
219/// | `-bash -i -c CMD`     | on                | yes          |
220/// | `bash -l -c CMD`      | on                | yes          |
221/// | `bash -c CMD`         | off               | no           |
222///
223/// Every other shell here reads the profile for all three login rows —
224/// measured on ksh93, mksh, dash and zsh 5.9 — so this gates the bash arm
225/// alone.
226static EXPLICIT_LOGIN: AtomicBool = AtomicBool::new(false);
227
228/// bash's compile-time `SYS_BASHRC` on the distros that define it.
229const SYS_BASHRC: &str = "/etc/bash.bashrc";
230
231/// ksh93's system-wide interactive rc file, where the build has one.
232const SYS_KSHRC: &str = "/etc/ksh.kshrc";
233
234/// The system-wide profile every Bourne-family login shell reads first.
235const SYS_PROFILE: &str = "/etc/profile";
236
237/// The profile a PRIVILEGED Bourne-family login shell reads instead of
238/// the user's own — `Src/init.c:1470` and mksh(1) both name this file.
239const SYS_SUID_PROFILE: &str = "/etc/suid_profile";
240
241/// csh's system-wide rc and login files (tcsh(1) "Startup and shutdown").
242const SYS_CSHRC: &str = "/etc/csh.cshrc";
243const SYS_CSH_LOGIN: &str = "/etc/csh.login";
244const SYS_CSH_LOGOUT: &str = "/etc/csh.logout";
245
246/// bash's `~/.bash_profile` → `~/.bash_login` → `~/.profile` chain, in
247/// the order bash tries them. The FIRST readable one wins; the rest are
248/// skipped even if they exist.
249const BASH_PROFILE_CHAIN: [&str; 3] = [".bash_profile", ".bash_login", ".profile"];
250
251/// tcsh(1): "first ~/.tcshrc (+) or, if ~/.tcshrc is not found, ~/.cshrc".
252const CSH_RC_CHAIN: [&str; 2] = [".tcshrc", ".cshrc"];
253
254/// Record that login-ness came from an explicit `-l` / `--login`.
255#[inline]
256pub fn set_explicit_login(on: bool) {
257    EXPLICIT_LOGIN.store(on, Ordering::Relaxed);
258}
259
260/// True when `-l` / `--login` was given explicitly. See [`EXPLICIT_LOGIN`].
261#[inline]
262pub fn explicit_login() -> bool {
263    EXPLICIT_LOGIN.load(Ordering::Relaxed)
264}
265
266/// Set/clear `--norc`.
267#[inline]
268pub fn set_norc(on: bool) {
269    NORC.store(on, Ordering::Relaxed);
270}
271
272/// True when `--norc` was given.
273#[inline]
274pub fn norc() -> bool {
275    NORC.load(Ordering::Relaxed)
276}
277
278/// Set/clear `--noprofile`.
279#[inline]
280pub fn set_noprofile(on: bool) {
281    NOPROFILE.store(on, Ordering::Relaxed);
282}
283
284/// True when `--noprofile` was given.
285#[inline]
286pub fn noprofile() -> bool {
287    NOPROFILE.load(Ordering::Relaxed)
288}
289
290/// Record `--rcfile FILE` / `--init-file FILE`.
291pub fn set_rcfile(path: &str) {
292    if let Ok(mut slot) = RCFILE.lock() {
293        *slot = Some(path.to_string());
294    }
295}
296
297/// The `--rcfile` override, if one was given.
298pub fn rcfile() -> Option<String> {
299    RCFILE.lock().ok().and_then(|slot| slot.clone())
300}
301
302/// `$HOME` as the shell sees it (paramtab, not the OS environment — a
303/// startup file that reassigns `HOME` must be visible to the next one).
304fn home() -> Option<PathBuf> {
305    crate::ported::params::getsparam("HOME").map(PathBuf::from)
306}
307
308/// A system-wide file, included only when it is actually there. The
309/// shells that compile these in (`SYS_BASHRC`, `/etc/ksh.kshrc`) ship
310/// them alongside; a platform whose packager left the define out also
311/// has no file, so existence reproduces both halves.
312fn sys_file(path: &str) -> Option<PathBuf> {
313    let p = PathBuf::from(path);
314    p.exists().then_some(p)
315}
316
317/// The first readable member of `chain` under `home`.
318///
319/// Readable, not merely present: bash skips a startup file it cannot
320/// open (and reports an error only when it exists but is unreadable),
321/// and tcsh's `~/.tcshrc` → `~/.cshrc` fallback works the same way.
322fn first_readable(home: Option<&Path>, chain: &[&str]) -> Option<PathBuf> {
323    let h = home?;
324    chain
325        .iter()
326        .map(|n| h.join(n))
327        .find(|p| std::fs::File::open(p).is_ok())
328}
329
330/// This personality's `$ENV`-equivalent, word-expanded, or its documented
331/// default when the parameter is unset.
332///
333/// ksh(1) and mksh(1) both specify parameter, command, arithmetic and
334/// tilde substitution on the value; bash(1) says the same of `$BASH_ENV`
335/// ("expands its value if it appears there … but does not use the value
336/// of the PATH variable to search for the filename"). The expansion
337/// mirrors zsh's own `$ENV` handling (`parsestr` + `singsub`,
338/// `Src/init.c:1459`).
339fn env_file(p: Personality, home: Option<&Path>) -> Option<PathBuf> {
340    let raw = p
341        .env_param()
342        .and_then(crate::ported::params::getsparam)
343        .filter(|v| !v.is_empty());
344    match raw {
345        Some(raw) => {
346            let expanded = if raw.contains('$') || raw.contains('`') || raw.starts_with('~') {
347                crate::ported::lex::untokenize(&crate::ported::subst::singsub(&raw))
348            } else {
349                raw
350            };
351            (!expanded.is_empty()).then(|| PathBuf::from(expanded))
352        }
353        None => p
354            .default_env_file()
355            .and_then(|name| home.map(|h| h.join(name))),
356    }
357}
358
359/// Everything [`files_for`] needs to know about the shell it is deciding
360/// for. Passing it in keeps the ordering rules testable without a booted
361/// parameter table.
362struct Ctx<'a> {
363    home: Option<&'a Path>,
364    /// The resolved `$ENV` / `$BASH_ENV` file, already defaulted.
365    env_file: Option<PathBuf>,
366    is_login: bool,
367    is_interactive: bool,
368    privileged: bool,
369    /// Login-ness came from `-l` / `--login`, not from `argv[0]`'s dash.
370    explicit_login: bool,
371}
372
373/// The `/etc/profile` + `~/.profile` opening every Bourne-family login
374/// shell shares. A privileged shell takes `/etc/suid_profile` and no
375/// user file — `Src/init.c:1470` and mksh(1) "A privileged shell then
376/// processes the suid profile".
377fn bourne_profile(c: &Ctx, out: &mut Vec<PathBuf>) {
378    if c.privileged {
379        out.extend(sys_file(SYS_SUID_PROFILE));
380        return;
381    }
382    out.push(PathBuf::from(SYS_PROFILE));
383    out.extend(c.home.map(|h| h.join(".profile")));
384}
385
386/// The ordered startup files one personality reads, given how the shell
387/// was invoked. Pure: every filesystem-independent rule is decided from
388/// `c` alone, so the table above is exercised directly by the tests.
389fn files_for(p: Personality, c: &Ctx) -> Vec<PathBuf> {
390    let mut files = Vec::new();
391    match p {
392        // Handled by the faithful `Src/init.c` port, not here.
393        Personality::Zsh => {}
394
395        Personality::Bash => {
396            // bash(1): "If the shell is started with the effective user
397            // (group) id not equal to the real user (group) id, and the
398            // -p option is not supplied, no startup files are read".
399            // bash reads no suid profile — unlike the Korn/Bourne line.
400            if c.privileged {
401                return files;
402            }
403            // bash(1): the profile chain belongs to "an interactive login
404            // shell, or a non-interactive shell with the --login option".
405            // A shell sshd exec'd as `-bash` to run `-c CMD` is a login
406            // shell by `shopt login_shell` and still reads no profile —
407            // see EXPLICIT_LOGIN for the measured table.
408            if c.is_login && (c.is_interactive || c.explicit_login) {
409                if !noprofile() {
410                    files.push(PathBuf::from(SYS_PROFILE));
411                    files.extend(first_readable(c.home, &BASH_PROFILE_CHAIN));
412                }
413            } else if !c.is_login && c.is_interactive && !norc() {
414                files.extend(sys_file(SYS_BASHRC));
415                match rcfile() {
416                    // bash resolves `--rcfile` against `$PWD`, not
417                    // `$HOME`, so a bare name is used verbatim.
418                    Some(f) => files.push(PathBuf::from(f)),
419                    None => files.extend(c.home.map(|h| h.join(".bashrc"))),
420                }
421            }
422            // A NON-interactive shell also reads $BASH_ENV — including a
423            // non-interactive LOGIN shell, which reads it after the
424            // profile chain (`bash -l script.sh` sources `.bash_profile`
425            // then `$BASH_ENV`). An interactive shell never does.
426            if !c.is_interactive {
427                files.extend(c.env_file.clone());
428            }
429        }
430
431        // The Korn and Bourne lines share one shape and differ only in
432        // what `$ENV` defaults to (resolved by the caller) and whether a
433        // system-wide interactive rc file exists. Unlike bash, an
434        // interactive LOGIN shell reads BOTH the profile and the
435        // interactive file — `ksh -i -l` sources `.profile` then
436        // `.kshrc`, and `dash -i -l` sources `.profile` then `$ENV`.
437        Personality::Ksh93
438        | Personality::Mksh
439        | Personality::Pdksh
440        | Personality::Sh
441        | Personality::Dash => {
442            if c.is_login {
443                bourne_profile(c, &mut files);
444            }
445            if c.is_interactive && !c.privileged {
446                if p == Personality::Ksh93 {
447                    files.extend(sys_file(SYS_KSHRC));
448                }
449                files.extend(c.env_file.clone());
450            }
451        }
452
453        // tcsh(1): "A login shell begins by executing commands from the
454        // system files /etc/csh.cshrc and /etc/csh.login. It then
455        // executes commands from files in the user's home directory:
456        // first ~/.tcshrc (+) or, if ~/.tcshrc is not found, ~/.cshrc,
457        // … then ~/.login". "Non-login shells read only /etc/csh.cshrc
458        // and ~/.tcshrc or ~/.cshrc on startup." csh reads its rc file
459        // whether or not the shell is interactive.
460        Personality::Csh => {
461            if c.privileged {
462                return files;
463            }
464            files.extend(sys_file(SYS_CSHRC));
465            files.extend(first_readable(c.home, &CSH_RC_CHAIN));
466            if c.is_login {
467                files.extend(sys_file(SYS_CSH_LOGIN));
468                files.extend(c.home.map(|h| h.join(".login")));
469            }
470        }
471    }
472    files
473}
474
475/// The ordered startup files the selected drop-in reads. Returned rather
476/// than sourced so the library's `run_init_scripts` hook and the
477/// binary's `-c` / script-file dispatch drive the SAME list through their
478/// own sourcing machinery.
479///
480/// Paths come back unfiltered by existence: the caller sources what is
481/// there and ignores what is not, as every one of these shells does.
482pub fn startup_files(is_login: bool, is_interactive: bool, privileged: bool) -> Vec<PathBuf> {
483    let p = personality();
484    let home = home();
485    files_for(
486        p,
487        &Ctx {
488            home: home.as_deref(),
489            env_file: env_file(p, home.as_deref()),
490            is_login,
491            is_interactive,
492            privileged,
493            explicit_login: explicit_login(),
494        },
495    )
496}
497
498/// The ordered files read when a LOGIN shell exits, given whether the
499/// shell is interactive.
500///
501/// bash(1): "~/.bash_logout". tcsh(1) reads `~/.logout` and
502/// `/etc/csh.logout`. The Korn and Bourne shells document no logout
503/// file, and zsh's `.zlogout` stays with the faithful port in
504/// `Src/builtin.c::zexit`.
505pub fn logout_files(is_interactive: bool) -> Vec<PathBuf> {
506    let home = home();
507    match personality() {
508        // bash reads it for a non-interactive login shell too, as long as
509        // the shell left through the `exit` builtin — `bash -l -c exit`
510        // sources `~/.bash_logout`, `bash -l -c true` does not.
511        Personality::Bash => home.map(|h| h.join(".bash_logout")).into_iter().collect(),
512        // tcsh needs the login shell to be INTERACTIVE: `csh -csh -c exit`
513        // reads `~/.cshrc` and stops there, never `~/.logout`.
514        Personality::Csh if is_interactive => home
515            .map(|h| h.join(".logout"))
516            .into_iter()
517            .chain(sys_file(SYS_CSH_LOGOUT))
518            .collect(),
519        _ => Vec::new(),
520    }
521}
522
523/// Re-apply the selected drop-in's option deltas on top of the `emulate`
524/// preset it shares with another shell.
525///
526/// Must be called after EVERY `options::emulate()` that installs the
527/// personality, because that call resets the option table wholesale. The
528/// binary applies these once during CLI mode selection, but
529/// `Src/init.c`'s `parseopts_setemulate` re-derives and re-installs the
530/// emulation inside `zsh_main` — which only the INTERACTIVE path reaches.
531/// So the deltas used to survive on the `-c` and script-file paths and be
532/// silently wiped for an interactive shell: `zshrs --bash -i` ran with
533/// brace expansion off, no `$BASH_REMATCH`, and `PS1` rendered as raw `%`
534/// sequences, which is exactly the configuration a login shell gets.
535///
536/// Safe to call more than once, and always before any user startup file
537/// runs, so a user's own `setopt` / `shopt` still wins.
538pub fn apply_personality_option_deltas() {
539    use crate::ported::options::opt_state_set;
540    let p = personality();
541    if p == Personality::Zsh {
542        return;
543    }
544    // EVERY drop-in: zsh marks a partial last line with an inverse `%`
545    // before printing the prompt (PROMPT_SP). No Bourne-family shell does
546    // — measured on bash, ksh93, mksh, dash and /bin/sh — and the marker
547    // plus its clear-to-end-of-line padding is the most visible thing on
548    // the screen, so it is off for all of them.
549    opt_state_set("promptsp", false);
550    if p != Personality::Bash {
551        return;
552    }
553    // bash is a SUPERSET of POSIX sh: unlike `emulate sh` (which sets
554    // IGNORE_BRACES), bash performs brace expansion — `echo {a,b}` → `a b`.
555    opt_state_set("ignorebraces", false);
556    // bash always populates `$BASH_REMATCH` after `[[ str =~ re ]]`.
557    opt_state_set("bashrematch", true);
558    // zshrs renders a bash prompt by translating its backslash escapes
559    // into `%` sequences (see `crate::extensions::bash_prompt`), so the
560    // `%` pass must be on even though `emulate sh` turns it off. A
561    // literal `%` is doubled by the translator, so nothing is lost.
562    opt_state_set("promptpercent", true);
563    // bash's `promptvars` shopt (on by default) expands parameters and
564    // command substitutions in the prompt at display time — zsh's
565    // PROMPT_SUBST.
566    opt_state_set("promptsubst", true);
567    // bash-only param syntax (`${!var}`, `${v^^}`) and the `shopt`
568    // defaults for the rows backed by a real zsh option.
569    crate::dash_mode::set_bash_mode(true);
570    crate::dash_mode::bash_shopt_apply_defaults();
571}
572
573/// Whether this drop-in writes the bracketed-paste enable/disable pair
574/// (`\e[?2004h` / `\e[?2004l`) around its line editing.
575///
576/// Measured on this machine by driving each shell under a pty and
577/// counting `?2004h`: bash 5.3 → 1, zsh 5.9 → 1, ksh93 → 0, mksh → 0,
578/// dash → 0, `/bin/sh` → 0, tcsh → 0. zshrs's ZLE sends it
579/// unconditionally, which put a pair of sequences on the wire that the
580/// emulated shell never sends.
581// (Tested from tests/startup_file_parity.rs, not here: asserting these
582// means flipping the process-global personality, and the lib test binary
583// runs its tests in parallel threads that share it — doing it inline made
584// 74 `ported::prompt` tests take the bash translation path.)
585pub fn emits_bracketed_paste() -> bool {
586    matches!(personality(), Personality::Zsh | Personality::Bash)
587}
588
589/// Whether this drop-in writes the OSC 133 shell-integration marker
590/// before each prompt. It is a zsh feature — bash does not send one, and
591/// neither does zsh 5.9 with its default `.term.extensions` — so only
592/// native zshrs keeps it.
593pub fn emits_integration_prompt() -> bool {
594    personality() == Personality::Zsh
595}
596
597/// ksh93 defines NO default aliases in the maintained line.
598///
599/// This one is version-split, and the split is real: AT&T ksh93u+ 2012
600/// (what Apple ships as `/bin/ksh`) defines 19 — `r`, `functions`,
601/// `integer`, `type`, `hash`, `history`, `source`, … — while ksh93u+m
602/// 1.0.10 2024 (Homebrew's `ksh93`, the maintained fork) defines none.
603///
604/// zshrs follows the maintained line, for two reasons. It is what a
605/// current install gives you, and the failure modes are asymmetric:
606/// omitting an alias leaves a command resolving to its builtin, while
607/// inventing one SHADOWS a real command — aliasing `type`, `hash`,
608/// `source` or `r` when the user's ksh would not is the more surprising
609/// error of the two.
610const KSH93_ALIASES: &[(&str, &str)] = &[];
611
612/// mksh's built-in aliases, captured from `mksh -c alias`. The values
613/// really do carry TWO backslashes: mksh does not double a backslash when
614/// listing (`alias foo='a\b'` lists as `foo='a\b'`), so `\\builtin` in
615/// the listing is the stored value, not an escape of it.
616const MKSH_ALIASES: &[(&str, &str)] = &[
617    ("autoload", r"\builtin typeset -fu"),
618    ("functions", r"\builtin typeset -f"),
619    ("hash", r"\builtin alias -t"),
620    ("history", r"\builtin fc -l"),
621    ("integer", r"\builtin typeset -i"),
622    ("local", r"\builtin typeset"),
623    ("login", r"\builtin exec login"),
624    ("nameref", r"\builtin typeset -n"),
625    ("nohup", "nohup "),
626    ("r", r"\builtin fc -e -"),
627    ("type", r"\builtin whence -v"),
628];
629
630/// The aliases this personality starts with, or `None` to leave zsh's own
631/// defaults (`run-help`, `which-command`) in place.
632///
633/// Measured with `<shell> -c alias` in an empty environment: bash 0,
634/// dash 0, `/bin/sh` 0, ksh93 19, mksh 11, zsh 2. zshrs installed zsh's
635/// two in every mode, so `alias` under `--bash` listed `run-help=man`
636/// (bash has none) and under `--ksh` listed neither `r` nor `functions`
637/// (ksh has both).
638pub fn default_aliases() -> Option<&'static [(&'static str, &'static str)]> {
639    match personality() {
640        Personality::Zsh => None,
641        Personality::Ksh93 => Some(KSH93_ALIASES),
642        Personality::Mksh | Personality::Pdksh => Some(MKSH_ALIASES),
643        // bash, dash/ash, sh/posix and csh define no aliases at all.
644        _ => Some(&[]),
645    }
646}
647
648/// Replace the alias table's contents with this personality's defaults.
649/// Called once during CLI mode selection, before any startup file runs,
650/// so a user's own `alias` still wins.
651pub fn install_default_aliases() {
652    let Some(defaults) = default_aliases() else {
653        return;
654    };
655    let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() else {
656        return;
657    };
658    // Drop zsh's `run-help` / `which-command`, then install this shell's
659    // own set. `clear` + `add` is the table's existing API; the drop-in
660    // owns the whole default set, not a delta on zsh's.
661    tab.clear();
662    for (name, text) in defaults {
663        tab.add(crate::ported::hashtable::createaliasnode(name, text, 0));
664    }
665}
666
667/// Single-quote a value the way bash's `alias` / `declare -p` listings
668/// do: wrap in `'…'` and render an embedded quote as `'\''`.
669pub fn single_quote(value: &str) -> String {
670    let mut out = String::with_capacity(value.len() + 2);
671    out.push('\'');
672    for c in value.chars() {
673        if c == '\'' {
674            out.push_str("'\\''");
675        } else {
676            out.push(c);
677        }
678    }
679    out.push('\'');
680    out
681}
682
683/// True when this drop-in owns its own startup files, i.e. the faithful
684/// `run_init_scripts` port must NOT run for it.
685pub fn overrides_zsh_startup() -> bool {
686    personality() != Personality::Zsh
687}
688
689/// Source the selected drop-in's startup files. Library-side entry
690/// point, called from the `run_init_scripts` hook.
691pub fn run_init_scripts() {
692    // `-f` / `unsetopt rcs` suppresses every startup file, the same way
693    // bash's `--norc` + `--noprofile` do.
694    if !crate::ported::zsh_h::isset(crate::ported::zsh_h::RCS) {
695        return;
696    }
697    let files = startup_files(
698        crate::ported::zsh_h::islogin(),
699        crate::ported::zsh_h::interact(),
700        crate::ported::zsh_h::isset(crate::ported::zsh_h::PRIVILEGED),
701    );
702    for f in files {
703        let _ = crate::ported::init::source(&f.to_string_lossy());
704    }
705}
706
707/// Source the selected drop-in's logout files on the way out of a login
708/// shell. Called from `zexit`, where zsh reads `.zlogout`.
709pub fn run_logout_scripts() {
710    if !crate::ported::zsh_h::islogin() {
711        return;
712    }
713    if !crate::ported::zsh_h::isset(crate::ported::zsh_h::RCS) {
714        return;
715    }
716    for f in logout_files(crate::ported::zsh_h::interact()) {
717        let _ = crate::ported::init::source(&f.to_string_lossy());
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    /// Serialize the process-global flag mutations these tests perform.
726    static GUARD: Mutex<()> = Mutex::new(());
727
728    /// Reset every flag so one test's `--norc` cannot leak into the next.
729    fn reset() {
730        set_norc(false);
731        set_noprofile(false);
732        if let Ok(mut slot) = RCFILE.lock() {
733            *slot = None;
734        }
735    }
736
737    /// A `$HOME` holding the named files, so "first readable wins" is
738    /// exercised against a real directory.
739    fn fake_home(files: &[&str]) -> tempfile::TempDir {
740        let dir = tempfile::tempdir().expect("tempdir");
741        for f in files {
742            std::fs::write(dir.path().join(f), "").expect("write");
743        }
744        dir
745    }
746
747    fn ctx<'a>(home: &'a Path, env: Option<&str>, login: bool, interactive: bool) -> Ctx<'a> {
748        Ctx {
749            home: Some(home),
750            env_file: env.map(PathBuf::from),
751            is_login: login,
752            is_interactive: interactive,
753            privileged: false,
754            // The curated rows below all describe an EXPLICIT `-l`, which
755            // is the shape `bash -l` / `ksh -l` documents. The implicit
756            // `argv[0]`-dash form has its own test.
757            explicit_login: true,
758        }
759    }
760
761    fn names(files: Vec<PathBuf>) -> Vec<String> {
762        files
763            .into_iter()
764            .map(|p| p.to_string_lossy().to_string())
765            .collect()
766    }
767
768    /// The non-system files, as bare names — so an assertion reads like
769    /// the shell manual instead of like a tempdir path.
770    ///
771    /// `/etc/…` entries are dropped because the system-wide rows are
772    /// existence-gated and therefore platform-dependent: `/etc/csh.cshrc`
773    /// is present on macOS, `/etc/bash.bashrc` on Debian, `/etc/profile`
774    /// almost everywhere. Their presence is asserted separately, by path,
775    /// where it is part of the rule under test.
776    fn tails(files: Vec<PathBuf>) -> Vec<String> {
777        files
778            .into_iter()
779            .filter(|p| !p.starts_with("/etc"))
780            .map(|p| {
781                p.file_name()
782                    .unwrap_or_default()
783                    .to_string_lossy()
784                    .to_string()
785            })
786            .collect()
787    }
788
789    /// bash: a login shell reads the profile chain and NEVER `~/.bashrc`
790    /// — bash 5.3.15 `-i -l -c true` sources `.bash_profile` alone.
791    #[test]
792    fn bash_login_reads_profile_not_bashrc() {
793        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
794        reset();
795        let home = fake_home(&[".bash_profile", ".bash_login", ".profile", ".bashrc"]);
796        let files = files_for(Personality::Bash, &ctx(home.path(), None, true, true));
797        assert!(
798            names(files.clone()).contains(&SYS_PROFILE.to_string()),
799            "a login bash reads /etc/profile first"
800        );
801        assert_eq!(tails(files), vec![".bash_profile".to_string()]);
802    }
803
804    /// bash: the profile chain falls through in the documented order.
805    #[test]
806    fn bash_profile_chain_falls_through_in_order() {
807        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
808        reset();
809        for (present, expected) in [
810            (vec![".bash_login", ".profile"], ".bash_login"),
811            (vec![".profile"], ".profile"),
812        ] {
813            let home = fake_home(&present);
814            assert_eq!(
815                tails(files_for(
816                    Personality::Bash,
817                    &ctx(home.path(), None, true, true)
818                ))
819                .last()
820                .map(String::as_str),
821                Some(expected),
822                "with {present:?} present, bash reads {expected}"
823            );
824        }
825        let home = fake_home(&[]);
826        assert_eq!(
827            names(files_for(
828                Personality::Bash,
829                &ctx(home.path(), None, true, true)
830            )),
831            vec![SYS_PROFILE.to_string()],
832            "an empty home leaves a login shell with /etc/profile alone"
833        );
834    }
835
836    /// bash: an interactive non-login shell reads `~/.bashrc`, no
837    /// profile, and never a zsh file.
838    #[test]
839    fn bash_interactive_nonlogin_reads_bashrc_only() {
840        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
841        reset();
842        let home = fake_home(&[".bashrc", ".bash_profile", ".zshrc"]);
843        let files = tails(files_for(
844            Personality::Bash,
845            &ctx(home.path(), None, false, true),
846        ));
847        assert!(files.contains(&".bashrc".to_string()), "got {files:?}");
848        assert!(!files.iter().any(|f| f == ".bash_profile"), "got {files:?}");
849        assert!(!files.iter().any(|f| f.contains("zsh")), "got {files:?}");
850    }
851
852    /// bash: `--norc` and `--noprofile` each empty their own phase.
853    #[test]
854    fn bash_norc_and_noprofile_suppress_their_phase() {
855        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
856        reset();
857        let home = fake_home(&[".bashrc", ".bash_profile"]);
858        set_norc(true);
859        assert!(files_for(Personality::Bash, &ctx(home.path(), None, false, true)).is_empty());
860        reset();
861        set_noprofile(true);
862        assert!(files_for(Personality::Bash, &ctx(home.path(), None, true, true)).is_empty());
863        reset();
864    }
865
866    /// bash: `--rcfile FILE` REPLACES `~/.bashrc`.
867    #[test]
868    fn bash_rcfile_overrides_bashrc() {
869        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
870        reset();
871        let home = fake_home(&[".bashrc"]);
872        set_rcfile("/tmp/zshrs-alt-bashrc");
873        let files = names(files_for(
874            Personality::Bash,
875            &ctx(home.path(), None, false, true),
876        ));
877        assert!(
878            files.iter().any(|f| f == "/tmp/zshrs-alt-bashrc"),
879            "got {files:?}"
880        );
881        assert!(
882            !files.iter().any(|f| f.ends_with("/.bashrc")),
883            "got {files:?}"
884        );
885        reset();
886    }
887
888    /// bash: `$BASH_ENV` is non-interactive-only, and a non-interactive
889    /// login shell reads it AFTER the profile chain.
890    #[test]
891    fn bash_env_is_non_interactive_only_and_last() {
892        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
893        reset();
894        let home = fake_home(&[".bashrc", ".bash_profile"]);
895        let probe = "/tmp/zshrs-env-probe";
896        assert_eq!(
897            tails(files_for(
898                Personality::Bash,
899                &ctx(home.path(), Some(probe), true, false)
900            )),
901            vec![".bash_profile".to_string(), "zshrs-env-probe".to_string()],
902            "profile chain first, $BASH_ENV last"
903        );
904        assert_eq!(
905            names(files_for(
906                Personality::Bash,
907                &ctx(home.path(), Some(probe), false, false)
908            )),
909            vec![probe.to_string()],
910        );
911        assert!(
912            !names(files_for(
913                Personality::Bash,
914                &ctx(home.path(), Some(probe), false, true)
915            ))
916            .iter()
917            .any(|f| f == probe),
918            "an interactive bash never reads $BASH_ENV"
919        );
920    }
921
922    /// The Korn/Bourne line: an interactive LOGIN shell reads the
923    /// profile AND the interactive file — unlike bash. Verified with
924    /// `ksh -i -l` (`.profile`, `.kshrc`) and `dash -i -l` with `$ENV`
925    /// set (`.profile`, `$ENV`).
926    #[test]
927    fn korn_and_bourne_interactive_login_read_both() {
928        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
929        reset();
930        let home = fake_home(&[".profile"]);
931        let probe = "/tmp/zshrs-env-probe";
932        for p in [
933            Personality::Ksh93,
934            Personality::Mksh,
935            Personality::Pdksh,
936            Personality::Sh,
937            Personality::Dash,
938        ] {
939            let files = files_for(p, &ctx(home.path(), Some(probe), true, true));
940            assert!(
941                names(files.clone()).contains(&SYS_PROFILE.to_string()),
942                "{p:?} interactive login reads /etc/profile"
943            );
944            assert_eq!(
945                tails(files),
946                vec![".profile".to_string(), "zshrs-env-probe".to_string()],
947                "{p:?} interactive login reads ~/.profile, then $ENV"
948            );
949        }
950    }
951
952    /// The Korn/Bourne line reads NO interactive file when the shell is
953    /// not interactive — `dash script.sh` sources nothing.
954    #[test]
955    fn korn_and_bourne_non_interactive_read_no_rc() {
956        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
957        reset();
958        let home = fake_home(&[".profile", ".kshrc", ".mkshrc"]);
959        let probe = "/tmp/zshrs-env-probe";
960        for p in [Personality::Ksh93, Personality::Mksh, Personality::Dash] {
961            assert!(
962                files_for(p, &ctx(home.path(), Some(probe), false, false)).is_empty(),
963                "{p:?} non-interactive non-login reads nothing"
964            );
965        }
966    }
967
968    /// `$ENV` defaults: ksh93 → `~/.kshrc`, mksh → `~/.mkshrc`, and
969    /// pdksh / sh / dash have none.
970    #[test]
971    fn env_defaults_match_each_korn_line() {
972        let home = fake_home(&[]);
973        assert_eq!(
974            env_file(Personality::Ksh93, Some(home.path())),
975            Some(home.path().join(".kshrc"))
976        );
977        assert_eq!(
978            env_file(Personality::Mksh, Some(home.path())),
979            Some(home.path().join(".mkshrc"))
980        );
981        for p in [Personality::Pdksh, Personality::Sh, Personality::Dash] {
982            assert_eq!(
983                env_file(p, Some(home.path())),
984                None,
985                "{p:?} documents no default for $ENV"
986            );
987        }
988    }
989
990    /// csh reads its rc file whether or not the shell is a login shell,
991    /// prefers `~/.tcshrc` over `~/.cshrc`, and appends `~/.login` for a
992    /// login shell — tcsh(1), and `csh -l` → `.tcshrc`, `.login`.
993    #[test]
994    fn csh_reads_cshrc_always_and_login_after() {
995        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
996        reset();
997        let home = fake_home(&[".tcshrc", ".cshrc", ".login"]);
998        assert_eq!(
999            tails(files_for(
1000                Personality::Csh,
1001                &ctx(home.path(), None, false, true)
1002            )),
1003            vec![".tcshrc".to_string()],
1004        );
1005        assert_eq!(
1006            tails(files_for(
1007                Personality::Csh,
1008                &ctx(home.path(), None, true, true)
1009            )),
1010            vec![".tcshrc".to_string(), ".login".to_string()],
1011        );
1012        let no_tcshrc = fake_home(&[".cshrc"]);
1013        assert_eq!(
1014            tails(files_for(
1015                Personality::Csh,
1016                &ctx(no_tcshrc.path(), None, false, true)
1017            )),
1018            vec![".cshrc".to_string()],
1019            "~/.cshrc is the fallback when ~/.tcshrc is absent"
1020        );
1021    }
1022
1023    /// A privileged shell reads no user file. bash reads nothing at all;
1024    /// the Korn/Bourne line takes `/etc/suid_profile` instead of
1025    /// `/etc/profile` + `~/.profile`.
1026    #[test]
1027    fn privileged_shell_reads_no_user_file() {
1028        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
1029        reset();
1030        let home = fake_home(&[".bashrc", ".bash_profile", ".profile", ".kshrc", ".cshrc"]);
1031        for p in [
1032            Personality::Bash,
1033            Personality::Ksh93,
1034            Personality::Mksh,
1035            Personality::Pdksh,
1036            Personality::Sh,
1037            Personality::Dash,
1038            Personality::Csh,
1039        ] {
1040            for (login, interactive) in [(true, true), (true, false), (false, true), (false, false)]
1041            {
1042                let files = names(files_for(
1043                    p,
1044                    &Ctx {
1045                        home: Some(home.path()),
1046                        env_file: Some(PathBuf::from("/tmp/zshrs-env-probe")),
1047                        is_login: login,
1048                        is_interactive: interactive,
1049                        privileged: true,
1050                        explicit_login: true,
1051                    },
1052                ));
1053                assert!(
1054                    files.iter().all(|f| f.starts_with("/etc/")),
1055                    "{p:?} privileged (login={login}, interactive={interactive}) read {files:?}"
1056                );
1057            }
1058        }
1059    }
1060
1061    /// zsh keeps the faithful `Src/init.c` port; this module contributes
1062    /// nothing for it, and `overrides_zsh_startup` says so.
1063    #[test]
1064    fn zsh_personality_contributes_nothing() {
1065        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
1066        reset();
1067        let home = fake_home(&[".zshrc", ".profile", ".bashrc"]);
1068        assert!(files_for(
1069            Personality::Zsh,
1070            &ctx(home.path(), Some("/tmp/zshrs-env-probe"), true, true)
1071        )
1072        .is_empty());
1073        assert_eq!(Personality::Zsh.emulate_name(), "zsh");
1074    }
1075
1076    /// Each drop-in installs the emulation its binary-side mode selection picks.
1077    #[test]
1078    fn emulate_names_match_the_cli_modes() {
1079        assert_eq!(Personality::Bash.emulate_name(), "sh");
1080        assert_eq!(Personality::Sh.emulate_name(), "sh");
1081        assert_eq!(Personality::Ksh93.emulate_name(), "ksh");
1082        assert_eq!(Personality::Mksh.emulate_name(), "ksh");
1083        assert_eq!(Personality::Pdksh.emulate_name(), "ksh");
1084        assert_eq!(Personality::Dash.emulate_name(), "dash");
1085        assert_eq!(Personality::Csh.emulate_name(), "csh");
1086    }
1087
1088    /// bash alone distinguishes an IMPLICIT login shell (a leading `-` on
1089    /// `argv[0]`, which is how login(1) and sshd start one) from an
1090    /// explicit `-l`. Non-interactive + implicit reads no profile; every
1091    /// other shell reads it in all three login shapes.
1092    #[test]
1093    fn implicit_login_reads_no_bash_profile_but_does_for_the_others() {
1094        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
1095        reset();
1096        let home = fake_home(&[".bash_profile", ".bashrc", ".profile", ".kshrc"]);
1097        let implicit = |p: Personality, interactive: bool| {
1098            tails(files_for(
1099                p,
1100                &Ctx {
1101                    home: Some(home.path()),
1102                    env_file: None,
1103                    is_login: true,
1104                    is_interactive: interactive,
1105                    privileged: false,
1106                    explicit_login: false,
1107                },
1108            ))
1109        };
1110        // bash: non-interactive implicit login reads NOTHING …
1111        assert!(
1112            implicit(Personality::Bash, false).is_empty(),
1113            "`-bash -c CMD` reads no startup file, got {:?}",
1114            implicit(Personality::Bash, false)
1115        );
1116        // … but the interactive form reads the profile chain.
1117        assert_eq!(
1118            implicit(Personality::Bash, true),
1119            vec![".bash_profile".to_string()],
1120        );
1121        // The Korn/Bourne line reads ~/.profile either way.
1122        for p in [Personality::Ksh93, Personality::Sh, Personality::Dash] {
1123            assert!(
1124                implicit(p, false).contains(&".profile".to_string()),
1125                "{p:?} reads ~/.profile for a non-interactive implicit login shell"
1126            );
1127        }
1128    }
1129
1130    /// The atomic round-trips every variant.
1131    #[test]
1132    fn personality_round_trips_through_the_atomic() {
1133        let _g = GUARD.lock().unwrap_or_else(|e| e.into_inner());
1134        let saved = personality();
1135        for p in [
1136            Personality::Zsh,
1137            Personality::Bash,
1138            Personality::Ksh93,
1139            Personality::Mksh,
1140            Personality::Pdksh,
1141            Personality::Sh,
1142            Personality::Dash,
1143            Personality::Csh,
1144        ] {
1145            set_personality(p);
1146            assert_eq!(personality(), p);
1147            assert_eq!(selected_emulate_name(), Some(p.emulate_name()));
1148        }
1149        set_personality(saved);
1150    }
1151}