Skip to main content

conclavelib/
base.rs

1//! Foundational constants, error aliases, and domain types shared across `conclavelib`.
2//!
3//! This module is the single source of truth for the small vocabulary every other
4//! module speaks: the `anyhow` error aliases used for application glue, the
5//! [`Constant`] home for magic values, and the core domain enums / paths from
6//! DESIGN.md §5, §6, and §9. Wire-crossing boundary errors (`ProtocolError`,
7//! `AuthError`, `AclError`) live in their respective modules, not here.
8
9use std::{fmt, str::FromStr};
10
11use serde::{Deserialize, Serialize};
12
13/// A helper type for errors.
14pub type Err = anyhow::Error;
15/// A helper type for results.
16pub type Res<T> = anyhow::Result<T, Err>;
17/// A helper type for void results.
18pub type Void = Res<()>;
19
20/// Installs the process-default `rustls` crypto provider (aws-lc-rs) once, so the client's
21/// `tokio_tungstenite::connect_async` can build a TLS config for a `wss://` server. rustls 0.23
22/// cannot auto-select a provider when several are compiled in (aws-lc-rs via the store, ring via the
23/// identity keys), so the client installs one explicitly before dialing. Idempotent (PRD-0009 T-001).
24pub(crate) fn ensure_tls_provider() {
25    use std::sync::Once;
26    static INSTALL: Once = Once::new();
27    INSTALL.call_once(|| {
28        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
29    });
30}
31
32/// A home for the project's magic values, so they are named once and reused.
33pub struct Constant;
34
35impl Constant {
36    /// Size in bytes of a server-issued authentication challenge nonce (DESIGN.md §5).
37    pub const CHALLENGE_SIZE: usize = 32;
38    /// Name of the per-user configuration / keystore directory under the OS config
39    /// dir (i.e. `~/.config/conclave`), where identity and permission state live.
40    pub const CONFIG_DIR_NAME: &'static str = "conclave";
41    /// Upper bound on a single decoded wire frame (16 MiB), rejecting a bogus length
42    /// prefix before it can drive a large allocation.
43    pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
44    /// The wire protocol version negotiated at connect time. Peers advertising an
45    /// incompatible version are rejected or upgraded (DESIGN.md §13).
46    pub const PROTOCOL_VERSION: u32 = 1;
47    /// The separator between the components of a [`SessionPath`] (`user/machine/session`).
48    pub const SESSION_PATH_SEPARATOR: char = '/';
49}
50
51/// How much an inbound message may drive the *recipient's* agent (DESIGN.md §9).
52///
53/// This is a **local** autonomy policy, never sent to the server. Variants are
54/// ordered by ascending autonomy, so a resolved level can be compared against a
55/// threshold (e.g. "below [`PermissionLevel::Converse`] rejects outbound emit").
56#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum PermissionLevel {
59    /// Delivery is suppressed entirely; the message is dropped on your side (lurk).
60    Mute,
61    /// Injected read-only: surface to the human, do not reply or act. The default.
62    #[default]
63    Notify,
64    /// May reply / whisper in conversation, but not take side-effecting actions.
65    Converse,
66    /// May reply *and* act on the message.
67    Act,
68}
69
70impl PermissionLevel {
71    /// Whether the bridge will emit (`send` / `whisper`) on this channel's behalf.
72    ///
73    /// True at [`PermissionLevel::Converse`] and above; the call-time rejection of
74    /// emits below `converse` (DESIGN.md §9) is expressed in terms of this.
75    #[must_use]
76    pub const fn may_emit(self) -> bool {
77        matches!(self, Self::Converse | Self::Act)
78    }
79}
80
81/// The error returned when a permission-level string is not a known level.
82#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
83#[error("unknown permission level `{0}` (expected mute, notify, converse, or act)")]
84pub struct ParsePermissionError(pub String);
85
86impl FromStr for PermissionLevel {
87    type Err = ParsePermissionError;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        match s {
91            "mute" => Ok(Self::Mute),
92            "notify" => Ok(Self::Notify),
93            "converse" => Ok(Self::Converse),
94            "act" => Ok(Self::Act),
95            other => Err(ParsePermissionError(other.to_owned())),
96        }
97    }
98}
99
100/// A channel's discovery / join visibility tier, stored on the channel record (DESIGN.md §6).
101#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Visibility {
104    /// Appears in discovery; anyone on the server may join.
105    Public,
106    /// Not listed, but joinable by anyone who knows the exact name ("secret-link").
107    Unlisted,
108    /// Not listed; join is authorized via ACL or invite token.
109    Private,
110}
111
112impl Visibility {
113    /// The lowercase wire / storage token for this tier (`public` / `unlisted` / `private`).
114    #[must_use]
115    pub const fn as_str(self) -> &'static str {
116        match self {
117            Self::Public => "public",
118            Self::Unlisted => "unlisted",
119            Self::Private => "private",
120        }
121    }
122}
123
124/// The error returned when a visibility string is not a known tier.
125#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
126#[error("unknown visibility tier `{0}` (expected public, unlisted, or private)")]
127pub struct ParseVisibilityError(pub String);
128
129impl FromStr for Visibility {
130    type Err = ParseVisibilityError;
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        match s {
134            "public" => Ok(Self::Public),
135            "unlisted" => Ok(Self::Unlisted),
136            "private" => Ok(Self::Private),
137            other => Err(ParseVisibilityError(other.to_owned())),
138        }
139    }
140}
141
142/// A fully-qualified live participant path, `{user}/{machine}/{session}` (DESIGN.md §5).
143///
144/// Every message's sender is a full path, so a reply or whisper target is always
145/// unambiguous. The `server` component that disambiguates a multi-homed session
146/// (DESIGN.md §8) is carried alongside a path, not embedded in it.
147#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
148pub struct SessionPath {
149    /// The account name (unique per server).
150    pub user: String,
151    /// The enrolled machine (its own keypair) the session runs on.
152    pub machine: String,
153    /// The live-session handle (`--as`, defaulting to the repo/dir name).
154    pub session: String,
155}
156
157impl SessionPath {
158    /// Builds a path from its three components.
159    #[must_use]
160    pub fn new(user: impl Into<String>, machine: impl Into<String>, session: impl Into<String>) -> Self {
161        Self {
162            user: user.into(),
163            machine: machine.into(),
164            session: session.into(),
165        }
166    }
167
168    /// Validates one path component (a username, machine name, or session handle): it must be
169    /// non-empty and free of the path separator, so the assembled `{user}/{machine}/{session}`
170    /// stays unambiguous and `from`-attribution cannot be spoofed (DESIGN.md §5).
171    ///
172    /// # Errors
173    ///
174    /// Returns [`ParsePathError::Malformed`] if the component is empty or contains the separator.
175    pub fn validate_component(component: &str) -> Result<(), ParsePathError> {
176        if component.is_empty() || component.contains(Constant::SESSION_PATH_SEPARATOR) {
177            return Err(ParsePathError::Malformed(component.to_owned()));
178        }
179        Ok(())
180    }
181}
182
183impl fmt::Display for SessionPath {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        let sep = Constant::SESSION_PATH_SEPARATOR;
186        write!(f, "{}{sep}{}{sep}{}", self.user, self.machine, self.session)
187    }
188}
189
190/// The error returned when a [`SessionPath`] string is malformed.
191#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
192pub enum ParsePathError {
193    /// The string was not exactly three `/`-separated, non-empty components.
194    #[error("session path must be `user/machine/session`, got `{0}`")]
195    Malformed(String),
196}
197
198impl FromStr for SessionPath {
199    type Err = ParsePathError;
200
201    fn from_str(s: &str) -> Result<Self, Self::Err> {
202        let mut parts = s.split(Constant::SESSION_PATH_SEPARATOR);
203        let (Some(user), Some(machine), Some(session), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
204            return Err(ParsePathError::Malformed(s.to_owned()));
205        };
206
207        if user.is_empty() || machine.is_empty() || session.is_empty() {
208            return Err(ParsePathError::Malformed(s.to_owned()));
209        }
210
211        Ok(Self::new(user, machine, session))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    // Tests relax `unwrap_used` (house convention; DESIGN.md §22).
218    #![allow(clippy::unwrap_used)]
219
220    use super::*;
221    use pretty_assertions::assert_eq;
222
223    #[test]
224    fn permission_levels_order_by_ascending_autonomy() {
225        assert!(PermissionLevel::Mute < PermissionLevel::Notify);
226        assert!(PermissionLevel::Notify < PermissionLevel::Converse);
227        assert!(PermissionLevel::Converse < PermissionLevel::Act);
228    }
229
230    #[test]
231    fn default_permission_level_is_notify() {
232        assert_eq!(PermissionLevel::default(), PermissionLevel::Notify);
233    }
234
235    #[test]
236    fn only_converse_and_above_may_emit() {
237        assert!(!PermissionLevel::Mute.may_emit());
238        assert!(!PermissionLevel::Notify.may_emit());
239        assert!(PermissionLevel::Converse.may_emit());
240        assert!(PermissionLevel::Act.may_emit());
241    }
242
243    #[test]
244    fn permission_level_parses_from_its_lowercase_token() {
245        for (token, level) in [
246            ("mute", PermissionLevel::Mute),
247            ("notify", PermissionLevel::Notify),
248            ("converse", PermissionLevel::Converse),
249            ("act", PermissionLevel::Act),
250        ] {
251            assert_eq!(token.parse::<PermissionLevel>().unwrap(), level);
252        }
253        assert!("bogus".parse::<PermissionLevel>().is_err());
254    }
255
256    #[test]
257    fn session_path_displays_as_slash_separated_triple() {
258        let path = SessionPath::new("aaron", "workstation", "razel");
259        assert_eq!(path.to_string(), "aaron/workstation/razel");
260    }
261
262    #[test]
263    fn session_path_parses_a_slash_separated_triple() {
264        let path: SessionPath = "aaron/workstation/razel".parse().unwrap();
265        assert_eq!(path, SessionPath::new("aaron", "workstation", "razel"));
266    }
267
268    #[test]
269    fn session_path_round_trips_through_display_and_parse() {
270        let path = SessionPath::new("aaron", "sno-box", "dotagent");
271        assert_eq!(path.to_string().parse::<SessionPath>().unwrap(), path);
272    }
273
274    #[test]
275    fn session_path_rejects_malformed_strings() {
276        for bad in ["", "a", "a/b", "a/b/c/d", "a//c", "/b/c", "a/b/"] {
277            assert!(bad.parse::<SessionPath>().is_err(), "expected `{bad}` to be rejected");
278        }
279    }
280
281    #[test]
282    fn session_path_component_validation_rejects_empty_and_separators() {
283        for good in ["aaron", "sno-box", "repo.name", "a_b"] {
284            assert!(SessionPath::validate_component(good).is_ok(), "expected `{good}` to be accepted");
285        }
286        for bad in ["", "a/b", "/", "a/", "/b"] {
287            assert!(SessionPath::validate_component(bad).is_err(), "expected `{bad}` to be rejected");
288        }
289    }
290
291    #[test]
292    fn visibility_round_trips_through_its_wire_token() {
293        for tier in [Visibility::Public, Visibility::Unlisted, Visibility::Private] {
294            assert_eq!(tier.as_str().parse::<Visibility>().unwrap(), tier);
295        }
296        assert!("bogus".parse::<Visibility>().is_err());
297    }
298}