Skip to main content

bw/
client_state.rs

1//! Compile-time client-state machine for CLI commands.
2//!
3//! Each [`Commands`](crate::command::Commands) arm dispatches to a [`BwCommand`] implementation
4//! whose [`BwCommand::Client`] declares the auth/lock state required to run. The dispatcher in
5//! [`main`](crate::main) constructs a [`ClientContext`] once per invocation and routes the command
6//! through the corresponding [`TryFrom`] extractor; commands therefore never check auth/lock state
7//! manually.
8#![allow(
9    dead_code,
10    reason = "While unused now, these states will be consumed as commands move to the CLI."
11)]
12
13use bitwarden_core::GlobalClient;
14use bitwarden_pm::PasswordManagerClient;
15use color_eyre::eyre::{Result, eyre};
16
17use crate::render::CommandResult;
18
19/// Trait implemented by commands that participate in the typestate dispatch.
20///
21/// Commands that don't depend on auth/lock state (e.g. shell-completion generation, base64
22/// encoding) use [`AnyState`] as their [`Client`](Self::Client).
23///
24/// Call sites invoke commands via [`BwCommandExt::dispatch`] rather than calling [`run`](Self::run)
25/// directly; the extension trait performs the typestate extraction in one place.
26///
27/// # Example
28///
29/// ```ignore
30/// use crate::client_state::{BwCommand, LoggedIn};
31/// use crate::render::CommandResult;
32///
33/// pub struct SyncArgs;
34///
35/// impl BwCommand for SyncArgs {
36///     type Client = LoggedIn;
37///
38///     async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
39///         user.sync().sync(Default::default()).await?;
40///         Ok("Synced.".into())
41///     }
42/// }
43/// ```
44pub trait BwCommand {
45    type Client: ClientState;
46    async fn run(self, client: Self::Client) -> CommandResult;
47}
48
49/// Extension trait that wires [`BwCommand`] up to the [`ClientContext`] dispatcher.
50///
51/// Implemented for every [`BwCommand`] via a blanket impl, so commands only need to define
52/// [`run`](BwCommand::run); `dispatch` is provided automatically and cannot be overridden.
53///
54/// # Example
55///
56/// ```ignore
57/// // In `process_commands`:
58/// Commands::Sync(args) => args.dispatch(ctx).await,
59/// ```
60pub trait BwCommandExt: BwCommand {
61    async fn dispatch(self, ctx: ClientContext) -> CommandResult;
62}
63
64impl<C: BwCommand> BwCommandExt for C {
65    async fn dispatch(self, ctx: ClientContext) -> CommandResult {
66        self.run(C::Client::try_from(ctx)?).await
67    }
68}
69
70mod sealed {
71    pub trait Sealed {}
72}
73
74/// Marker trait implemented by the five client-state types in this module.
75///
76/// Sealed: the auth/lock matrix is a closed enumeration; new states should be added here, not
77/// in downstream code.
78pub trait ClientState:
79    sealed::Sealed + TryFrom<ClientContext, Error = color_eyre::eyre::Error>
80{
81}
82
83/// Per-invocation context built once in `process_commands` and consumed by a single extractor.
84pub struct ClientContext {
85    pub global: GlobalClient,
86    pub user: Option<PasswordManagerClient>,
87}
88
89/// Unauthenticated state. The extractor rejects when a user is already logged in.
90pub struct LoggedOut {
91    pub global: GlobalClient,
92}
93
94impl sealed::Sealed for LoggedOut {}
95impl ClientState for LoggedOut {}
96
97impl TryFrom<ClientContext> for LoggedOut {
98    type Error = color_eyre::eyre::Error;
99
100    fn try_from(ctx: ClientContext) -> Result<Self> {
101        if ctx.user.is_some() {
102            return Err(eyre!(
103                "You are already logged in. Log out first with `bw logout`."
104            ));
105        }
106        Ok(LoggedOut { global: ctx.global })
107    }
108}
109
110/// Authenticated state, lock-status-agnostic. The extractor requires a user.
111pub struct LoggedIn {
112    pub global: GlobalClient,
113    pub user: PasswordManagerClient,
114}
115
116impl sealed::Sealed for LoggedIn {}
117impl ClientState for LoggedIn {}
118
119impl TryFrom<ClientContext> for LoggedIn {
120    type Error = color_eyre::eyre::Error;
121
122    fn try_from(ctx: ClientContext) -> Result<Self> {
123        let user = ctx
124            .user
125            .ok_or_else(|| eyre!("You are not logged in. Run `bw login` first."))?;
126        Ok(LoggedIn {
127            global: ctx.global,
128            user,
129        })
130    }
131}
132
133/// Authenticated and locked. The extractor rejects an unlocked vault.
134pub struct Locked {
135    pub global: GlobalClient,
136    pub user: PasswordManagerClient,
137}
138
139impl sealed::Sealed for Locked {}
140impl ClientState for Locked {}
141
142impl TryFrom<ClientContext> for Locked {
143    type Error = color_eyre::eyre::Error;
144
145    fn try_from(ctx: ClientContext) -> Result<Self> {
146        let user = ctx
147            .user
148            .ok_or_else(|| eyre!("You are not logged in. Run `bw login` first."))?;
149        if user.is_unlocked() {
150            return Err(eyre!(
151                "Your vault is already unlocked. Lock it with `bw lock` first."
152            ));
153        }
154        Ok(Locked {
155            global: ctx.global,
156            user,
157        })
158    }
159}
160
161/// Authenticated and unlocked. The extractor requires the user's symmetric key to be loaded.
162pub struct Unlocked {
163    pub global: GlobalClient,
164    pub user: PasswordManagerClient,
165}
166
167impl sealed::Sealed for Unlocked {}
168impl ClientState for Unlocked {}
169
170impl TryFrom<ClientContext> for Unlocked {
171    type Error = color_eyre::eyre::Error;
172
173    fn try_from(ctx: ClientContext) -> Result<Self> {
174        let user = ctx
175            .user
176            .ok_or_else(|| eyre!("You are not logged in. Run `bw login` first."))?;
177        if !user.is_unlocked() {
178            return Err(eyre!(
179                "Your vault is locked. Unlock it by setting the session key with `--session` or `BW_SESSION`."
180            ));
181        }
182        Ok(Unlocked {
183            global: ctx.global,
184            user,
185        })
186    }
187}
188
189/// Catch-all. The extractor is infallible.
190pub struct AnyState {
191    pub global: GlobalClient,
192    pub user: Option<PasswordManagerClient>,
193}
194
195impl sealed::Sealed for AnyState {}
196impl ClientState for AnyState {}
197
198impl TryFrom<ClientContext> for AnyState {
199    type Error = color_eyre::eyre::Error;
200
201    fn try_from(ctx: ClientContext) -> Result<Self> {
202        Ok(AnyState {
203            global: ctx.global,
204            user: ctx.user,
205        })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use std::sync::Once;
212
213    use bitwarden_core::{DeviceType, HostPlatformInfo, init_host_platform_info};
214
215    use super::*;
216
217    // Multiple test binaries in this crate may concurrently call `init_host_platform_info`;
218    // a single guarded init keeps cross-test ordering deterministic.
219    static INIT: Once = Once::new();
220
221    fn ensure_platform_info() {
222        INIT.call_once(|| {
223            init_host_platform_info(HostPlatformInfo {
224                user_agent: "bw-tests".to_string(),
225                device_type: DeviceType::SDK,
226                device_identifier: None,
227                bitwarden_client_version: None,
228                bitwarden_package_type: None,
229            });
230        });
231    }
232
233    fn ctx(user: Option<PasswordManagerClient>) -> ClientContext {
234        ensure_platform_info();
235        ClientContext {
236            global: GlobalClient::new(),
237            user,
238        }
239    }
240
241    #[test]
242    fn logged_out_accepts_when_no_user() {
243        assert!(LoggedOut::try_from(ctx(None)).is_ok());
244    }
245
246    #[test]
247    fn logged_out_rejects_when_user_present() {
248        let user = PasswordManagerClient::new(None);
249        assert!(LoggedOut::try_from(ctx(Some(user))).is_err());
250    }
251
252    #[test]
253    fn logged_in_accepts_when_user_present() {
254        let user = PasswordManagerClient::new(None);
255        assert!(LoggedIn::try_from(ctx(Some(user))).is_ok());
256    }
257
258    #[test]
259    fn logged_in_rejects_when_no_user() {
260        assert!(LoggedIn::try_from(ctx(None)).is_err());
261    }
262
263    #[test]
264    fn locked_accepts_when_user_locked() {
265        // A freshly-built `PasswordManagerClient` has no user key loaded.
266        let user = PasswordManagerClient::new(None);
267        assert!(Locked::try_from(ctx(Some(user))).is_ok());
268    }
269
270    #[test]
271    fn locked_rejects_when_no_user() {
272        assert!(Locked::try_from(ctx(None)).is_err());
273    }
274
275    #[test]
276    fn unlocked_rejects_when_user_locked() {
277        let user = PasswordManagerClient::new(None);
278        assert!(Unlocked::try_from(ctx(Some(user))).is_err());
279    }
280
281    #[test]
282    fn unlocked_rejects_when_no_user() {
283        assert!(Unlocked::try_from(ctx(None)).is_err());
284    }
285
286    #[test]
287    fn any_state_infallible() {
288        assert!(AnyState::try_from(ctx(None)).is_ok());
289        let user = PasswordManagerClient::new(None);
290        assert!(AnyState::try_from(ctx(Some(user))).is_ok());
291    }
292}