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 HTTP header on the WS upgrade response carrying the server's persistent instance ID,
48    /// so a bridge can recognize the same server reached under two URLs (PRD-0012 T-003). Rides
49    /// the upgrade — out-of-band of the wire protocol — so old peers are unaffected.
50    pub const SERVER_ID_HEADER: &'static str = "x-conclave-server-id";
51    /// The separator between the components of a [`SessionPath`] (`user/machine/session`).
52    pub const SESSION_PATH_SEPARATOR: char = '/';
53}
54
55/// How much an inbound message may drive the *recipient's* agent (DESIGN.md §9).
56///
57/// This is a **local** autonomy policy, never sent to the server. Variants are
58/// ordered by ascending autonomy, so a resolved level can be compared against a
59/// threshold (e.g. "below [`PermissionLevel::Converse`] rejects outbound emit").
60#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum PermissionLevel {
63    /// Delivery is suppressed entirely; the message is dropped on your side (lurk).
64    Mute,
65    /// Injected read-only: surface to the human, do not reply or act. The default.
66    #[default]
67    Notify,
68    /// May reply / whisper in conversation, but not take side-effecting actions.
69    Converse,
70    /// May reply *and* act on the message.
71    Act,
72}
73
74impl PermissionLevel {
75    /// Whether the bridge will emit (`send` / `whisper`) on this channel's behalf.
76    ///
77    /// True at [`PermissionLevel::Converse`] and above; the call-time rejection of
78    /// emits below `converse` (DESIGN.md §9) is expressed in terms of this.
79    #[must_use]
80    pub const fn may_emit(self) -> bool {
81        matches!(self, Self::Converse | Self::Act)
82    }
83}
84
85/// The error returned when a permission-level string is not a known level.
86#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
87#[error("unknown permission level `{0}` (expected mute, notify, converse, or act)")]
88pub struct ParsePermissionError(pub String);
89
90impl FromStr for PermissionLevel {
91    type Err = ParsePermissionError;
92
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        match s {
95            "mute" => Ok(Self::Mute),
96            "notify" => Ok(Self::Notify),
97            "converse" => Ok(Self::Converse),
98            "act" => Ok(Self::Act),
99            other => Err(ParsePermissionError(other.to_owned())),
100        }
101    }
102}
103
104/// A channel's discovery / join visibility tier, stored on the channel record (DESIGN.md §6).
105#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum Visibility {
108    /// Appears in discovery; anyone on the server may join.
109    Public,
110    /// Not listed, but joinable by anyone who knows the exact name ("secret-link").
111    Unlisted,
112    /// Not listed; join is authorized via ACL or invite token.
113    Private,
114}
115
116impl Visibility {
117    /// The lowercase wire / storage token for this tier (`public` / `unlisted` / `private`).
118    #[must_use]
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::Public => "public",
122            Self::Unlisted => "unlisted",
123            Self::Private => "private",
124        }
125    }
126}
127
128/// The error returned when a visibility string is not a known tier.
129#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
130#[error("unknown visibility tier `{0}` (expected public, unlisted, or private)")]
131pub struct ParseVisibilityError(pub String);
132
133impl FromStr for Visibility {
134    type Err = ParseVisibilityError;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        match s {
138            "public" => Ok(Self::Public),
139            "unlisted" => Ok(Self::Unlisted),
140            "private" => Ok(Self::Private),
141            other => Err(ParseVisibilityError(other.to_owned())),
142        }
143    }
144}
145
146/// A fully-qualified live participant path, `{user}/{machine}/{session}` (DESIGN.md §5).
147///
148/// Every message's sender is a full path, so a reply or whisper target is always
149/// unambiguous. The `server` component that disambiguates a multi-homed session
150/// (DESIGN.md §8) is carried alongside a path, not embedded in it.
151#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
152pub struct SessionPath {
153    /// The account name (unique per server).
154    pub user: String,
155    /// The enrolled machine (its own keypair) the session runs on.
156    pub machine: String,
157    /// The live-session handle (`--as`, defaulting to the repo/dir name).
158    pub session: String,
159}
160
161impl SessionPath {
162    /// Builds a path from its three components.
163    #[must_use]
164    pub fn new(user: impl Into<String>, machine: impl Into<String>, session: impl Into<String>) -> Self {
165        Self {
166            user: user.into(),
167            machine: machine.into(),
168            session: session.into(),
169        }
170    }
171
172    /// Validates one path component (a username, machine name, or session handle): it must be
173    /// non-empty and free of the path separator, so the assembled `{user}/{machine}/{session}`
174    /// stays unambiguous and `from`-attribution cannot be spoofed (DESIGN.md §5).
175    ///
176    /// # Errors
177    ///
178    /// Returns [`ParsePathError::Malformed`] if the component is empty or contains the separator.
179    pub fn validate_component(component: &str) -> Result<(), ParsePathError> {
180        if component.is_empty() || component.contains(Constant::SESSION_PATH_SEPARATOR) {
181            return Err(ParsePathError::Malformed(component.to_owned()));
182        }
183        Ok(())
184    }
185}
186
187impl fmt::Display for SessionPath {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        let sep = Constant::SESSION_PATH_SEPARATOR;
190        write!(f, "{}{sep}{}{sep}{}", self.user, self.machine, self.session)
191    }
192}
193
194/// The error returned when a [`SessionPath`] string is malformed.
195#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
196pub enum ParsePathError {
197    /// The string was not exactly three `/`-separated, non-empty components.
198    #[error("session path must be `user/machine/session`, got `{0}`")]
199    Malformed(String),
200}
201
202impl FromStr for SessionPath {
203    type Err = ParsePathError;
204
205    fn from_str(s: &str) -> Result<Self, Self::Err> {
206        let mut parts = s.split(Constant::SESSION_PATH_SEPARATOR);
207        let (Some(user), Some(machine), Some(session), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
208            return Err(ParsePathError::Malformed(s.to_owned()));
209        };
210
211        if user.is_empty() || machine.is_empty() || session.is_empty() {
212            return Err(ParsePathError::Malformed(s.to_owned()));
213        }
214
215        Ok(Self::new(user, machine, session))
216    }
217}
218
219/// Parses a human duration (`30s`, `10m`, `24h`, `7d`, or bare seconds) into seconds — shared by
220/// the CLI (`invite create --expires-in`, `tail --since`) and the bridge's `catch_up` tool.
221///
222/// # Errors
223///
224/// Returns an error if the numeric part does not parse.
225pub fn parse_duration_secs(value: &str) -> Res<u64> {
226    use anyhow::Context as _;
227    let value = value.trim();
228    let (digits, mult) = match value.chars().last() {
229        Some('s') => (&value[..value.len() - 1], 1),
230        Some('m') => (&value[..value.len() - 1], 60),
231        Some('h') => (&value[..value.len() - 1], 3600),
232        Some('d') => (&value[..value.len() - 1], 86_400),
233        _ => (value, 1),
234    };
235    let count: u64 = digits.trim().parse().with_context(|| format!("invalid duration `{value}`"))?;
236    Ok(count * mult)
237}
238
239#[cfg(test)]
240mod tests {
241    // Tests relax `unwrap_used` (house convention; DESIGN.md §22).
242    #![allow(clippy::unwrap_used)]
243
244    use super::*;
245    use pretty_assertions::assert_eq;
246
247    #[test]
248    fn permission_levels_order_by_ascending_autonomy() {
249        assert!(PermissionLevel::Mute < PermissionLevel::Notify);
250        assert!(PermissionLevel::Notify < PermissionLevel::Converse);
251        assert!(PermissionLevel::Converse < PermissionLevel::Act);
252    }
253
254    #[test]
255    fn default_permission_level_is_notify() {
256        assert_eq!(PermissionLevel::default(), PermissionLevel::Notify);
257    }
258
259    #[test]
260    fn only_converse_and_above_may_emit() {
261        assert!(!PermissionLevel::Mute.may_emit());
262        assert!(!PermissionLevel::Notify.may_emit());
263        assert!(PermissionLevel::Converse.may_emit());
264        assert!(PermissionLevel::Act.may_emit());
265    }
266
267    #[test]
268    fn permission_level_parses_from_its_lowercase_token() {
269        for (token, level) in [
270            ("mute", PermissionLevel::Mute),
271            ("notify", PermissionLevel::Notify),
272            ("converse", PermissionLevel::Converse),
273            ("act", PermissionLevel::Act),
274        ] {
275            assert_eq!(token.parse::<PermissionLevel>().unwrap(), level);
276        }
277        assert!("bogus".parse::<PermissionLevel>().is_err());
278    }
279
280    #[test]
281    fn session_path_displays_as_slash_separated_triple() {
282        let path = SessionPath::new("aaron", "workstation", "razel");
283        assert_eq!(path.to_string(), "aaron/workstation/razel");
284    }
285
286    #[test]
287    fn session_path_parses_a_slash_separated_triple() {
288        let path: SessionPath = "aaron/workstation/razel".parse().unwrap();
289        assert_eq!(path, SessionPath::new("aaron", "workstation", "razel"));
290    }
291
292    #[test]
293    fn session_path_round_trips_through_display_and_parse() {
294        let path = SessionPath::new("aaron", "sno-box", "dotagent");
295        assert_eq!(path.to_string().parse::<SessionPath>().unwrap(), path);
296    }
297
298    #[test]
299    fn session_path_rejects_malformed_strings() {
300        for bad in ["", "a", "a/b", "a/b/c/d", "a//c", "/b/c", "a/b/"] {
301            assert!(bad.parse::<SessionPath>().is_err(), "expected `{bad}` to be rejected");
302        }
303    }
304
305    #[test]
306    fn session_path_component_validation_rejects_empty_and_separators() {
307        for good in ["aaron", "sno-box", "repo.name", "a_b"] {
308            assert!(SessionPath::validate_component(good).is_ok(), "expected `{good}` to be accepted");
309        }
310        for bad in ["", "a/b", "/", "a/", "/b"] {
311            assert!(SessionPath::validate_component(bad).is_err(), "expected `{bad}` to be rejected");
312        }
313    }
314
315    #[test]
316    fn visibility_round_trips_through_its_wire_token() {
317        for tier in [Visibility::Public, Visibility::Unlisted, Visibility::Private] {
318            assert_eq!(tier.as_str().parse::<Visibility>().unwrap(), tier);
319        }
320        assert!("bogus".parse::<Visibility>().is_err());
321    }
322}