1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
//! The kill-by-name floor that keeps the Architect's panes alive (#8942).
//!
//! Why: every daemon path that ends a session reaches the pane by tmux NAME.
//! A stale record that happens to carry the Architect's name (#8935) was
//! enough to kill the live Architect. The two primitives that act on a name —
//! `TmuxDriver::kill_session` and `ManagedTmuxDriver::signal_terminate` — ask
//! this floor first, so no caller can reach a protected pane.
//! What: [`SupervisorFloor`] answers a [`KillVerdict`] for a tmux name from two
//! sources: the Architect launch sidecars (`recorded_session_names`) and the
//! session store's protected-kind records. Anything it cannot read refuses.
//! Test: `supervisor_floor_tests.rs`.
use std::path::{Path, PathBuf};
use crate::core::architect_session::{POLL_SUFFIX, recorded_session_names};
use super::session_kind::SessionKind;
use super::store_integrity;
/// The suffixes of the Architect's helper sessions (`<name>-poll`, …).
const AUX_SUFFIXES: [&str; 2] = [POLL_SUFFIX, super::supervisor_register::COLLECTOR_ROLE];
/// The role string [`SidecarRole`] gives the Architect's own session.
pub const SUPERVISOR_ROLE: &str = "supervisor";
/// What an Architect launch sidecar makes of one tmux name (#8942).
///
/// What: the record kind, and the role [`super::ManagedSessionId::for_supervisor`]
/// keys the stable id on — [`SUPERVISOR_ROLE`] for the Architect, the helper
/// suffix (`-poll`, `-collector`) for a helper.
/// Test: `a_sidecar_names_the_architect_and_its_helpers`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SidecarRole {
/// `Supervisor` or `SupervisorAux`.
pub kind: SessionKind,
/// The id role; see the type doc.
pub role: &'static str,
}
/// Whether a kill-by-name may proceed (#8942).
///
/// Why: "protected" and "cannot tell" are both refusals, but the log must say
/// which one happened.
/// What: `Permit`, `Protected(why)`, or `Undeterminable(why)`.
/// Test: `supervisor_floor_tests.rs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KillVerdict {
/// No source names this session as the Architect's.
Permit,
/// A sidecar or a protected-kind record names this session.
Protected(String),
/// A source could not be read, so the name cannot be cleared.
Undeterminable(String),
}
/// Where the floor reads its protected names from.
#[derive(Debug, Clone)]
enum FloorSource {
/// Sidecars under `root/architect-launch/`, records in `store`.
Root { root: PathBuf, store: PathBuf },
/// No absolute home directory: nothing can be cleared.
NoHome,
/// Unit-test builds only: [`SupervisorFloor::host`] must not read the
/// operator's real `~/.trusty-mpm` from a test. Tests of the floor itself
/// build one with [`SupervisorFloor::at_root`].
#[cfg(test)]
Unguarded,
}
/// The protected-name check for one framework root (#8942).
///
/// Why: see the module doc. It is a value, not a global, so a test can point
/// it at a scratch root and a production driver at the host root.
/// What: holds where to read the sidecars and the store; [`Self::verdict`]
/// reads both on every call, so a sidecar written after the daemon started
/// still counts.
/// Test: `supervisor_floor_tests.rs`.
#[derive(Debug, Clone)]
pub struct SupervisorFloor {
source: FloorSource,
}
impl SupervisorFloor {
/// The floor over the operator's `~/.trusty-mpm`.
///
/// Why: `tm fleet init` writes the sidecars under the home root, and the
/// daemon's store lives there too; the pane guard reads the same root.
/// What: [`Self::from_home`] of `dirs::home_dir()`. In the lib's own
/// `cfg(test)` build it is unguarded instead, so no unit test reads the
/// operator's real state. The `tm` bin tests and `tests/*.rs` link the lib
/// without `cfg(test)`, so there it reads `$HOME`: a test there that
/// reaches a kill must point `$HOME` at a scratch directory.
/// Test: `the_host_floor_reads_the_sidecars_under_home` (`tests/`);
/// `a_missing_or_relative_home_is_undeterminable` covers `from_home`.
pub fn host() -> Self {
#[cfg(test)]
{
Self {
source: FloorSource::Unguarded,
}
}
#[cfg(not(test))]
{
Self::from_home(dirs::home_dir())
}
}
/// The floor for a home directory, or [`FloorSource::NoHome`] when there
/// is none or it is relative.
///
/// Why: `FrameworkPaths` falls back to `.` without a home; a floor rooted
/// at the working directory would clear every name, so no home refuses.
/// What: `home/.trusty-mpm` when `home` is absolute.
/// Test: `a_missing_or_relative_home_is_undeterminable`.
pub fn from_home(home: Option<PathBuf>) -> Self {
match home.filter(|h| h.is_absolute()) {
Some(home) => Self::at_root(&home.join(".trusty-mpm")),
None => Self {
source: FloorSource::NoHome,
},
}
}
/// The floor over framework root `root`.
///
/// What: sidecars under `root/architect-launch/`, the store at
/// `root/session-manager/sessions.json`.
/// Test: every test in `supervisor_floor_tests.rs`.
pub fn at_root(root: &Path) -> Self {
Self {
source: FloorSource::Root {
root: root.to_path_buf(),
store: root.join("session-manager").join("sessions.json"),
},
}
}
/// The floor a session manager with store directory `data_dir` kills under.
///
/// Why: the manager's teardown gate must read the same sidecars and store
/// as the host floor in production, and a test manager's scratch
/// directory, never the operator's home, in a test.
/// What: the store is `data_dir/sessions.json`. The sidecar root is
/// `data_dir`'s parent when `data_dir` is named `session-manager` (the
/// daemon's `<framework root>/session-manager`), else `data_dir` itself.
/// Test: `for_data_dir_reads_the_framework_root_above_the_store`.
pub fn for_data_dir(data_dir: &Path) -> Self {
let root = match data_dir.parent() {
Some(parent) if data_dir.file_name() == Some("session-manager".as_ref()) => parent,
_ => data_dir,
};
Self {
source: FloorSource::Root {
root: root.to_path_buf(),
store: data_dir.join("sessions.json"),
},
}
}
/// Every session name an Architect launch sidecar records.
///
/// Why: boot reconcile reads the list once per pass, then asks
/// [`Self::sidecar_role`] per pane.
/// What: `recorded_session_names` of the root; `Err` when it fails or
/// there is no home. An unguarded test floor records nothing.
/// Test: `a_sidecar_names_the_architect_and_its_helpers`.
pub fn sidecar_names(&self) -> Result<Vec<String>, String> {
match &self.source {
FloorSource::Root { root, .. } => {
recorded_session_names(root).map_err(|why| format!("architect sidecars: {why}"))
}
FloorSource::NoHome => Err("no absolute home directory".into()),
#[cfg(test)]
FloorSource::Unguarded => Ok(Vec::new()),
}
}
/// The role `sidecars` give tmux session `name`, if any.
///
/// What: `Supervisor` when a sidecar records `name`; `SupervisorAux` when
/// `name` is a recorded name plus `-poll` or `-collector`; else `None`.
/// Test: `a_sidecar_names_the_architect_and_its_helpers`.
pub fn sidecar_role(sidecars: &[String], name: &str) -> Option<SidecarRole> {
for recorded in sidecars {
if name == recorded {
return Some(SidecarRole {
kind: SessionKind::Supervisor,
role: SUPERVISOR_ROLE,
});
}
let suffix = name.strip_prefix(recorded.as_str());
if let Some(aux) = AUX_SUFFIXES.iter().find(|s| suffix == Some(**s)) {
return Some(SidecarRole {
kind: SessionKind::SupervisorAux,
role: aux,
});
}
}
None
}
/// Whether tmux session `name` may be killed or signalled.
///
/// Why: #8942, fail closed — "cannot tell" never becomes "not the
/// Architect".
/// What: `Protected` when a sidecar records `name`, or `name` is a
/// sidecar name plus `-poll`/`-collector`, or a non-terminal store record
/// with a protected kind (`Unknown` included) carries `name`.
/// `Undeterminable` when the sidecar directory, a sidecar or the store does
/// not read or parse, or there is no home. A missing directory or store
/// file is empty, not an error. Otherwise `Permit`.
/// Test: `a_sidecar_name_is_protected`, `the_architects_helper_sessions_are_protected`,
/// `a_protected_kind_record_is_protected`,
/// `a_record_with_an_unknown_kind_is_never_torn_down`,
/// `kill_by_name_fails_closed_when_architect_sidecars_cannot_be_read`,
/// `an_unreadable_store_is_undeterminable`, `an_unrelated_name_is_permitted`.
pub fn verdict(&self, name: &str) -> KillVerdict {
let store = match &self.source {
FloorSource::Root { store, .. } => store,
FloorSource::NoHome => {
return KillVerdict::Undeterminable("no absolute home directory".into());
}
#[cfg(test)]
FloorSource::Unguarded => return KillVerdict::Permit,
};
let sidecars = match self.sidecar_names() {
Ok(names) => names,
Err(why) => return KillVerdict::Undeterminable(why),
};
if let Some(role) = Self::sidecar_role(&sidecars, name) {
return KillVerdict::Protected(format!("an architect sidecar makes `{name}` {role:?}"));
}
match protected_record(store, name) {
Ok(Some(why)) => KillVerdict::Protected(why),
Ok(None) => KillVerdict::Permit,
Err(why) => KillVerdict::Undeterminable(format!("session store: {why}")),
}
}
/// The refusal for killing `name`, logged; `None` when the kill may run.
///
/// Why: both primitives need the same verdict-plus-warning in one line.
/// What: [`Self::verdict`]; on anything but `Permit`, a `warn!` naming
/// `caller`, the session and the reason, and `Some(reason)`.
/// Test: `kill_by_name_fails_closed_when_architect_sidecars_cannot_be_read`.
pub fn refuse(&self, name: &str, caller: &str) -> Option<String> {
let why = match self.verdict(name) {
KillVerdict::Permit => return None,
KillVerdict::Protected(why) => format!("protected Architect session: {why}"),
KillVerdict::Undeterminable(why) => format!("cannot rule out the Architect: {why}"),
};
tracing::warn!(
caller,
session = name,
"#8942: refused to kill or signal — {why}"
);
Some(format!("#8942: {caller} refused `{name}`: {why}"))
}
}
/// The reason a live protected-kind record in `store` carries `name`, if one
/// does. A missing store is `Ok(None)`; an unreadable or unparseable one is
/// `Err`.
fn protected_record(store: &Path, name: &str) -> Result<Option<String>, String> {
let raw = match std::fs::read_to_string(store) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", store.display())),
Ok(raw) => raw,
};
let data = store_integrity::validate(store, &raw).map_err(|e| e.diagnostic())?;
Ok(data
.sessions
.values()
.find(|r| r.tmux_name == name && r.kind.is_protected() && !r.state.is_terminal())
.map(|r| format!("record {} has kind {:?}", r.id, r.kind)))
}
#[cfg(test)]
#[path = "supervisor_floor_tests.rs"]
mod tests;