Skip to main content

bash_interop/
shell.rs

1//! What a shell is: which bash, how it was given its code, and what it had
2//! switched on when it joined.
3//!
4//! A shell's own account of itself, read back from the words it wrote. Nothing
5//! is inferred from the shape of what it went on to say — bash reports one word
6//! for several different things, `main` standing for a script's top level in
7//! `FUNCNAME`, an interactive prompt in `BASH_SOURCE`, and any function a
8//! subject cares to name that way.
9//!
10//! [`Bash`] and what it holds are description alone. [`Shell`] pairs that with
11//! where the shell sits in the run.
12
13use std::fmt;
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use crate::failure::Failure;
19use crate::rig::wire::{Account, Pid, Stamp, field};
20use bash_strings::parse_array;
21
22/// `$BASH_VERSINFO`, all six elements. What bash behaves like is a function of
23/// this, so a reading that has to bend for an older shell has what it needs.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Version {
26    pub major: u32,
27    pub minor: u32,
28    pub patch: u32,
29    pub build: u32,
30
31    /// `release`, `beta`, `rc1` — what bash calls its release status.
32    pub status: String,
33
34    /// `$MACHTYPE`, which is element five and not a separate fact.
35    pub machine: String,
36}
37
38impl Version {
39    /// The comparison that matters: `(major, minor, patch)` against a
40    /// behaviour's first release.
41    pub fn at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
42        (self.major, self.minor, self.patch) >= (major, minor, patch)
43    }
44
45    fn of(literal: &str) -> Result<Self, Failure> {
46        let parts = parse_array(literal).map_err(|cause| {
47            broken(format!(
48                "the version {literal:?}: {cause}"
49            ))
50        })?;
51
52        let [major, minor, patch, build, status, machine] = parts.as_slice() else {
53            return Err(broken(format!(
54                "a version of {} parts",
55                parts.len()
56            )));
57        };
58        let count = |what: &str, text: &str| text.parse().map_err(|_| broken(format!("{what} {text:?}")));
59
60        Ok(Self {
61            major: count("a major version", major)?,
62            minor: count("a minor version", minor)?,
63            patch: count("a patch level", patch)?,
64            build: count("a build number", build)?,
65            status: status.clone(),
66            machine: machine.clone(),
67        })
68    }
69}
70
71impl fmt::Display for Version {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(
74            f,
75            "{}.{}.{}({})-{}",
76            self.major, self.minor, self.patch, self.build, self.status
77        )
78    }
79}
80
81/// How bash was given the code it runs. `set` refuses `-i`, `-c` and `-s`, so
82/// all three are settled when the shell starts and cannot have changed by the
83/// time anything reads them.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct Invocation {
86    /// `-c`, with the text bash was given. Absent for any other invocation.
87    pub command: Option<String>,
88
89    /// `-s`: the code arrives on standard input.
90    pub standard_input: bool,
91
92    /// `-i`: bash reads from a terminal, keeps history, and writes `main` into
93    /// `BASH_SOURCE` for whatever is defined at the prompt.
94    pub interactive: bool,
95}
96
97impl Invocation {
98    /// Whether bash was handed a file to read, which is what makes
99    /// [`Bash::zero`] a path rather than a word standing in for code bash was
100    /// given directly.
101    pub fn from_a_file(&self) -> bool {
102        self.command.is_none() && !self.standard_input
103    }
104}
105
106/// `$-`, as bash wrote it.
107///
108/// The string, not the reading: how bash was started is [`Invocation`], asked
109/// once, and what is left here are the options a subject turns on and off while
110/// it runs.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Flags(String);
113
114impl Flags {
115    pub fn has(&self, flag: char) -> bool {
116        self.0.contains(flag)
117    }
118}
119
120impl fmt::Display for Flags {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(&self.0)
123    }
124}
125
126/// What a shell had switched on at the moment it said so. A snapshot: a subject
127/// may `set -e` at any point.
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Options {
130    pub flags: Flags,
131
132    /// `$SHELLOPTS`, split — the `set -o` options that were on.
133    pub shellopts: Vec<String>,
134
135    /// `$BASHOPTS`, split — the `shopt` options that were on.
136    pub bashopts: Vec<String>,
137}
138
139/// Which bash a shell is, and how it was started. Constant for as long as the
140/// shell lives — a fork inherits it, and anything that could change it makes a
141/// new shell instead.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Bash {
144    pub version: Version,
145
146    /// `$BASH`: the binary this shell is running.
147    pub binary: PathBuf,
148
149    /// `$0`. A path where bash was handed a file, and otherwise the word bash
150    /// also writes into `BASH_SOURCE` for the code it was given —
151    /// [`Invocation::from_a_file`] is which.
152    pub zero: String,
153
154    pub invocation: Invocation,
155}
156
157/// A shell in a run: which bash it is, where it sits, and what it had switched
158/// on when it joined.
159///
160/// Made once, from the account a shell gives before it says anything else. A
161/// reaction is handed one at construction, so nothing about a shell is ever a
162/// parameter afterwards.
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct Shell {
165    /// The order it joined in, counting from zero.
166    pub nth: usize,
167
168    pub pid: Pid,
169
170    pub shlvl: u32,
171
172    /// `$BASH_SUBSHELL`. A subshell has a `$BASHPID` of its own and so joins as
173    /// a shell of its own, which is why this is fixed for the shell's life.
174    pub subshell: u32,
175
176    /// When it joined, on both clocks.
177    pub joined: Stamp,
178
179    pub bash: Bash,
180    pub options: Options,
181
182    /// The words its join carried — `BC_JOIN <label> <dir> word…` — verbatim,
183    /// and empty where the join brought none. An arglist like a message's:
184    /// `key value` pairs are a convention a client reads with
185    /// [`field`].
186    pub brought: Vec<String>,
187}
188
189impl Shell {
190    /// Read off the words a shell wrote about itself.
191    pub(crate) fn of(nth: usize, account: Account) -> Result<Self, Failure> {
192        let Account {
193            stamp: joined,
194            words,
195        } = account;
196        let word = |key: &str| {
197            field(&words, key)
198                .ok_or_else(|| broken(format!("no {key:?}")))
199                .map(str::to_string)
200        };
201        let count = |key: &str| -> Result<u32, Failure> {
202            let text = word(key)?;
203            text.parse().map_err(|_| broken(format!("{key} {text:?}")))
204        };
205        let split = |key: &str| -> Result<Vec<String>, Failure> {
206            Ok(word(key)?
207                .split(':')
208                .filter(|opt| !opt.is_empty())
209                .map(String::from)
210                .collect())
211        };
212
213        let flags = word("flags")?;
214        let command = word("command")?;
215        let brought = parse_array(&word("brought")?).map_err(|cause| broken(format!("the brought words: {cause}")))?;
216
217        Ok(Self {
218            nth,
219            pid: Pid(count("pid")?),
220            shlvl: count("shlvl")?,
221            subshell: count("subshell")?,
222            joined,
223            bash: Bash {
224                version: Version::of(&word("versinfo")?)?,
225                binary: PathBuf::from(word("bash")?),
226                zero: word("zero")?,
227                invocation: Invocation {
228                    command: flags.contains('c').then_some(command),
229                    standard_input: flags.contains('s'),
230                    interactive: flags.contains('i'),
231                },
232            },
233            options: Options {
234                shellopts: split("shellopts")?,
235                bashopts: split("bashopts")?,
236                flags: Flags(flags),
237            },
238            brought,
239        })
240    }
241}
242
243fn broken(what: String) -> Failure {
244    Failure::new(
245        "reading what a shell said of itself",
246        what,
247    )
248}