tear_types/capability.rs
1//! Daemon capabilities — what the peer on the other end of the socket
2//! can actually *do*, probed rather than assumed.
3//!
4//! ## Why a capability set and not a version integer
5//!
6//! The obvious fix for "a newer client silently degrades against an
7//! older daemon" is a protocol version number. It was measured and
8//! rejected, for a reason worth keeping written down:
9//!
10//! 1. **It would not have caught the instance that motivated it.**
11//! `args` was threaded through three [`crate::MultiplexerControl`]
12//! methods and the CBOR wire in commit `5974375`. The daemon
13//! running on the operator's machine at that moment was
14//! `tear-0.1.8`; `HEAD` was *also* `0.1.8`. Ten behaviour-changing
15//! commits had landed since the version last moved. A version
16//! compare would have said "same version, all good" and the pane
17//! would still have spawned without its arguments.
18//!
19//! 2. **A version is one scalar for N independent facts.** What a
20//! caller actually needs to know is not "how old are you" but "do
21//! you read the `args` field". Those are different questions, and
22//! only the second one has an answer that stays true as the
23//! codebase moves. A capability names a **field or a behaviour**,
24//! so the refusal lands at the call site that needs it and
25//! *nowhere else* — a caller passing no args is unaffected by a
26//! daemon that cannot read them.
27//!
28//! The version string is still carried in [`DaemonHello`], because it
29//! is genuinely useful in a log line and in `tear status`. It is
30//! **not** what any decision is made on.
31//!
32//! ## Adding a capability
33//!
34//! Add the variant to [`Capability`], give it a `wire_name`, and
35//! classify it in `advertised()`. The classification is an exhaustive
36//! `match`, so a new variant that nobody decided about is a **compile
37//! error**, not a silently-unadvertised capability.
38
39use std::collections::BTreeSet;
40
41use serde::{Deserialize, Serialize};
42
43use crate::control::{ControlError, ControlResult};
44
45/// One named thing a daemon build can do. Each variant names a
46/// **field or a behaviour a caller can gate on** — never a release,
47/// never a date.
48///
49/// The wire form is a string, deliberately: a client that meets a
50/// daemon advertising a capability from a *newer* vocabulary must
51/// ignore the name it doesn't know, not fail to decode the frame.
52/// Strings make that free; a serialized enum would make it a
53/// hard error.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub enum Capability {
56 /// The daemon reads the `args` field on `Request::NewSession`,
57 /// `Request::NewWindow` and `Request::SplitPane`, and passes it
58 /// to the child as argv[1..].
59 ///
60 /// A daemon without this reads the request fine — `args` is
61 /// `#[serde(default)]` and no type sets `deny_unknown_fields`,
62 /// so the key is simply dropped — and spawns the bare program.
63 /// That silent drop is the failure this whole module exists to
64 /// convert into a legible refusal.
65 SpawnArgs,
66 /// The daemon stamps `TearPane::yurai` at spawn from the connection's
67 /// shutai, so a pane records WHAT KIND of actor created it.
68 ///
69 /// Gates provenance READS (`tear list` showing which panes an agent
70 /// drives) and is useful with or without a brake. A daemon without it
71 /// reports every pane as `Yurai::Unknown` — which is honest, and is
72 /// also exactly why `freio` cannot brake `Unknown`.
73 PaneYurai,
74 /// The daemon reads `Request::SetFreio` / `Request::GetFreio`.
75 ///
76 /// Independent of `PaneYurai` in one direction only: freio WITHOUT
77 /// yurai is meaningless (nothing to filter on), yurai without freio is
78 /// useful on its own. They ship in that order and a client can tell.
79 ///
80 /// This capability is the reason a panic button cannot fail silently:
81 /// an old daemon would drop `SetFreio` as an unknown variant and
82 /// answer a legible `Rejected`, but the CLI must `require()` it FIRST
83 /// so the operator sees a typed refusal rather than a wire error on
84 /// the one command where "nothing happened" is unacceptable.
85 Freio,
86}
87
88impl Capability {
89 /// Every capability name this build's *vocabulary* knows. Not the
90 /// same thing as what a given daemon advertises — see
91 /// [`Capability::advertised`].
92 pub const ALL: &'static [Capability] = &[
93 Capability::SpawnArgs,
94 Capability::PaneYurai,
95 Capability::Freio,
96 ];
97
98 /// The on-wire name. Kebab-case, names the field or behaviour.
99 #[must_use]
100 pub fn wire_name(self) -> &'static str {
101 match self {
102 Capability::SpawnArgs => "spawn-args",
103 Capability::PaneYurai => "pane-yurai",
104 Capability::Freio => "freio",
105 }
106 }
107
108 /// Parse a wire name back to a typed capability. `None` for a
109 /// name from a vocabulary this build doesn't have — which is the
110 /// expected outcome when an older client meets a newer daemon,
111 /// and must stay a quiet miss rather than an error.
112 #[must_use]
113 pub fn from_wire(s: &str) -> Option<Self> {
114 Capability::ALL
115 .iter()
116 .copied()
117 .find(|c| c.wire_name() == s)
118 }
119
120 /// Does **this build of the daemon** implement the capability?
121 ///
122 /// The exhaustive `match` is the seal: adding a [`Capability`]
123 /// variant without deciding this is `error[E0004]: non-exhaustive
124 /// patterns`, so no capability can land unclassified.
125 #[must_use]
126 pub fn advertised(self) -> bool {
127 match self {
128 Capability::SpawnArgs | Capability::PaneYurai | Capability::Freio => true,
129 }
130 }
131}
132
133/// What a daemon advertises about itself, in reply to
134/// `Request::Hello`.
135///
136/// `daemon_version` is the daemon process's own
137/// `CARGO_PKG_VERSION` — the first time this has ever been on the
138/// wire. `tear status` previously printed the *CLI's* version under
139/// a `version` key, which is a different binary and can differ
140/// arbitrarily from the daemon's.
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
142pub struct DaemonHello {
143 /// The daemon binary's own package version.
144 pub daemon_version: String,
145 /// Wire names of every capability this daemon implements.
146 pub capabilities: Vec<String>,
147}
148
149impl DaemonHello {
150 /// The hello this build of the daemon answers with.
151 #[must_use]
152 pub fn for_this_build(daemon_version: &str) -> Self {
153 Self {
154 daemon_version: daemon_version.to_owned(),
155 capabilities: Capability::ALL
156 .iter()
157 .copied()
158 .filter(|c| c.advertised())
159 .map(|c| c.wire_name().to_owned())
160 .collect(),
161 }
162 }
163}
164
165/// A client's typed view of the daemon it is connected to.
166///
167/// The **pre-capability** value — [`DaemonIdentity::pre_capability`]
168/// — is not an error state and is not a fallback bolted on the side.
169/// It is what every client gets today, because the daemon running on
170/// this machine predates the probe. Treat it as the normal case.
171#[derive(Clone, Debug, Default, PartialEq, Eq)]
172pub struct DaemonIdentity {
173 /// `None` when the daemon predates `Request::Hello` and could
174 /// not tell us. Never guessed from the client's own version.
175 version: Option<String>,
176 /// Raw wire names, including any this build's vocabulary does
177 /// not know (a newer daemon's). Kept verbatim so `tear status`
178 /// can show an operator a capability their CLI is too old to
179 /// name.
180 capabilities: BTreeSet<String>,
181}
182
183impl DaemonIdentity {
184 /// A daemon that could not answer `Request::Hello`: it predates
185 /// the probe (or refused it). Protocol 0 — **no capabilities**.
186 #[must_use]
187 pub fn pre_capability() -> Self {
188 Self {
189 version: None,
190 capabilities: BTreeSet::new(),
191 }
192 }
193
194 /// Build from a daemon's hello reply.
195 #[must_use]
196 pub fn from_hello(hello: DaemonHello) -> Self {
197 Self {
198 version: Some(hello.daemon_version),
199 capabilities: hello.capabilities.into_iter().collect(),
200 }
201 }
202
203 /// The identity an in-process backend has: it *is* this build,
204 /// so it implements exactly what this build advertises.
205 #[must_use]
206 pub fn local(version: &str) -> Self {
207 Self::from_hello(DaemonHello::for_this_build(version))
208 }
209
210 /// The daemon's own version, or `None` when it predates the
211 /// probe. Never fall back to the client's version here — that
212 /// substitution is precisely the lie `tear status` used to tell.
213 #[must_use]
214 pub fn version(&self) -> Option<&str> {
215 self.version.as_deref()
216 }
217
218 /// True when the daemon could not answer the probe at all.
219 #[must_use]
220 pub fn is_pre_capability(&self) -> bool {
221 self.version.is_none() && self.capabilities.is_empty()
222 }
223
224 /// Wire names, sorted. Includes names this build cannot type.
225 #[must_use]
226 pub fn capability_names(&self) -> Vec<&str> {
227 self.capabilities.iter().map(String::as_str).collect()
228 }
229
230 /// Does the daemon implement `cap`?
231 #[must_use]
232 pub fn has(&self, cap: Capability) -> bool {
233 self.capabilities.contains(cap.wire_name())
234 }
235
236 /// Typed refusal for a call that *needs* `cap`.
237 ///
238 /// Call this only on the branch that actually requires the
239 /// capability — a caller who passes no `args` must not be
240 /// refused by a daemon that cannot read `args`. That
241 /// call-site-scoped shape is the whole point: the refusal is
242 /// about one field, not a global "you are old" banner.
243 ///
244 /// # Errors
245 /// [`ControlError::Unsupported`] naming the capability, the
246 /// daemon's version (or that it predates the probe), and what
247 /// to do about it.
248 pub fn require(&self, cap: Capability, detail: &str) -> ControlResult<()> {
249 if self.has(cap) {
250 return Ok(());
251 }
252 let who = match &self.version {
253 Some(v) => format!("daemon {v} does not advertise it"),
254 None => "the daemon predates capability negotiation and advertises nothing".to_owned(),
255 };
256 Err(ControlError::Unsupported {
257 capability: cap.wire_name(),
258 detail: format!("{detail} ({who}); restart the tear daemon on a build that has it"),
259 })
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 /// Wire names must be unique and round-trip. A duplicate would
268 /// make `from_wire` resolve two capabilities to one.
269 #[test]
270 fn every_capability_wire_name_is_unique_and_round_trips() {
271 let mut seen = BTreeSet::new();
272 for cap in Capability::ALL.iter().copied() {
273 assert!(
274 seen.insert(cap.wire_name()),
275 "duplicate wire name {}",
276 cap.wire_name()
277 );
278 assert_eq!(Capability::from_wire(cap.wire_name()), Some(cap));
279 }
280 }
281
282 /// A name from a vocabulary we don't have is a quiet miss, never
283 /// a panic or an error — that is what lets an older client talk
284 /// to a newer daemon.
285 #[test]
286 fn an_unknown_wire_name_is_a_quiet_miss() {
287 assert_eq!(Capability::from_wire("spawn-cwd"), None);
288 assert_eq!(Capability::from_wire(""), None);
289 }
290
291 /// `Capability::ALL` must actually list every variant. The
292 /// exhaustive match makes a missing variant a compile error here
293 /// rather than a capability that exists but is never advertised.
294 #[test]
295 fn all_lists_every_variant() {
296 for cap in Capability::ALL.iter().copied() {
297 // Exhaustive — adding a variant without adding it to ALL
298 // fails this assertion; adding a variant at all without
299 // touching `advertised()` is E0004 at compile time.
300 match cap {
301 Capability::SpawnArgs => {
302 assert!(Capability::ALL.contains(&Capability::SpawnArgs));
303 }
304 Capability::PaneYurai => {
305 assert!(Capability::ALL.contains(&Capability::PaneYurai));
306 }
307 Capability::Freio => {
308 assert!(Capability::ALL.contains(&Capability::Freio));
309 }
310 }
311 }
312 assert_eq!(Capability::ALL.len(), 3, "update this count with the vocabulary");
313 }
314
315 #[test]
316 fn this_builds_hello_advertises_every_implemented_capability() {
317 let hello = DaemonHello::for_this_build("9.9.9");
318 assert_eq!(hello.daemon_version, "9.9.9");
319 // The exact vec, in ALL order. Deliberately not a `contains` —
320 // this assert IS the forcing function that makes adding a
321 // capability a conscious act rather than a silent one.
322 assert_eq!(
323 hello.capabilities,
324 vec![
325 "spawn-args".to_owned(),
326 "pane-yurai".to_owned(),
327 "freio".to_owned(),
328 ]
329 );
330 }
331
332 #[test]
333 fn pre_capability_has_nothing_and_no_version() {
334 let id = DaemonIdentity::pre_capability();
335 assert!(id.is_pre_capability());
336 assert_eq!(id.version(), None);
337 assert!(!id.has(Capability::SpawnArgs));
338 assert!(id.capability_names().is_empty());
339 }
340
341 /// The refusal names the capability, not the version — and it
342 /// says what to do.
343 #[test]
344 fn require_on_a_pre_capability_daemon_is_a_typed_unsupported() {
345 let id = DaemonIdentity::pre_capability();
346 let err = id
347 .require(Capability::SpawnArgs, "new_window was given 2 argument(s)")
348 .unwrap_err();
349 match err {
350 ControlError::Unsupported { capability, detail } => {
351 assert_eq!(capability, "spawn-args");
352 assert!(detail.contains("new_window was given 2 argument(s)"));
353 assert!(detail.contains("predates capability negotiation"));
354 assert!(detail.contains("restart the tear daemon"));
355 }
356 other => panic!("wrong error: {other:?}"),
357 }
358 }
359
360 #[test]
361 fn require_on_a_capable_daemon_is_ok() {
362 let id = DaemonIdentity::local("0.1.8");
363 assert_eq!(id.version(), Some("0.1.8"));
364 assert!(id.has(Capability::SpawnArgs));
365 assert!(id.require(Capability::SpawnArgs, "whatever").is_ok());
366 }
367
368 /// A daemon from a newer vocabulary: unknown names survive into
369 /// `capability_names` (so an operator can see them) but never
370 /// satisfy a typed `has`.
371 #[test]
372 fn a_newer_daemons_unknown_capability_is_kept_but_never_matches() {
373 let id = DaemonIdentity::from_hello(DaemonHello {
374 daemon_version: "3.0.0".into(),
375 capabilities: vec!["spawn-args".into(), "spawn-cwd".into()],
376 });
377 assert_eq!(id.capability_names(), vec!["spawn-args", "spawn-cwd"]);
378 assert!(id.has(Capability::SpawnArgs));
379 assert!(!id.is_pre_capability());
380 }
381
382 /// A daemon that answers the probe but advertises *nothing* is
383 /// distinguishable from one that never answered: it has a
384 /// version. Both refuse `spawn-args`, and that is the point.
385 #[test]
386 fn an_answering_daemon_with_no_capabilities_still_reports_its_version() {
387 let id = DaemonIdentity::from_hello(DaemonHello {
388 daemon_version: "0.1.9".into(),
389 capabilities: vec![],
390 });
391 assert!(!id.is_pre_capability());
392 assert_eq!(id.version(), Some("0.1.9"));
393 let err = id.require(Capability::SpawnArgs, "x").unwrap_err();
394 assert!(format!("{err}").contains("daemon 0.1.9 does not advertise it"));
395 }
396}