Skip to main content

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}