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