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}