Skip to main content

bw/platform/
status.rs

1//! `bw status` — server, last sync, user information, and vault lock state.
2//!
3//! Runs in every auth state, so the command takes [`AnyState`] and branches internally rather than
4//! letting a typestate extractor reject the unauthenticated case.
5//!
6//! `serverUrl` is resolved from `config.json` alone (see [`resolve_server_url`]). The persisted
7//! `BASE_URLS` setting holds only the derived api/identity URLs, from which the user-entered base
8//! URL is not recoverable for split-domain deployments. Consequence: a self-hosted login performed
9//! through
10//! `bw login --server <url>` (which persists no base URL at all today) reports the cloud default
11//! until the server is also set with `bw config server <url>`. Same underlying gap as the TODO on
12//! `web_vault_url` in `crate::tools::send`.
13
14use bitwarden_core::{
15    UserId,
16    client::persisted_state::{USER_EMAIL, USER_ID},
17};
18use chrono::SecondsFormat;
19use clap::Args;
20use serde::Serialize;
21
22use crate::{
23    client_state::{AnyState, BwCommand},
24    platform::config::resolve_server_url,
25    render::{CommandOutput, CommandResult},
26};
27
28#[derive(Args, Clone)]
29#[command(
30    about = "Show server, last sync, user information, and vault status.",
31    after_help = r#"Example return value:
32  {
33    "serverUrl": "https://bitwarden.example.com",
34    "lastSync": "2020-06-16T06:33:51.419Z",
35    "userEmail": "[email protected]",
36    "userId": "00000000-0000-0000-0000-000000000000",
37    "status": "locked"
38  }
39
40Notes:
41  `status` is one of:
42    - `unauthenticated` when you are not logged in
43    - `locked` when you are logged in and the vault is locked
44    - `unlocked` when you are logged in and the vault is unlocked
45"#
46)]
47pub struct StatusArgs;
48
49/// Output of `bw status`. The camelCase field names are consumed by scripts written against the
50/// Node CLI, so they must not change.
51#[derive(Serialize)]
52#[serde(rename_all = "camelCase")]
53struct StatusResponse {
54    server_url: String,
55    /// RFC3339 with millisecond precision to match the old CLI's format; `null` when never synced.
56    last_sync: Option<String>,
57    user_email: Option<String>,
58    user_id: Option<UserId>,
59    status: &'static str,
60}
61
62impl BwCommand for StatusArgs {
63    type Client = AnyState;
64
65    async fn run(self, state: AnyState) -> CommandResult {
66        let response = build_status(resolve_server_url()?, state.user).await?;
67        Ok(CommandOutput::Object(Box::new(response)))
68    }
69}
70
71/// Assemble the response for an already-resolved server URL, so the state-dependent mapping is
72/// testable without touching the caller's appdata directory.
73async fn build_status(
74    server_url: String,
75    user: Option<bitwarden_pm::PasswordManagerClient>,
76) -> color_eyre::eyre::Result<StatusResponse> {
77    let Some(user) = user else {
78        return Ok(StatusResponse {
79            server_url,
80            last_sync: None,
81            user_email: None,
82            user_id: None,
83            status: "unauthenticated",
84        });
85    };
86
87    let status = if user.is_unlocked() {
88        "unlocked"
89    } else {
90        "locked"
91    };
92
93    let last_sync = user
94        .sync()
95        .last_sync()
96        .await
97        .map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true));
98
99    // Reading persisted settings does not require an unlocked vault, so the locked state reports
100    // the user's identity and last sync just like the Node CLI does.
101    let persisted = user.platform().state();
102
103    Ok(StatusResponse {
104        server_url,
105        last_sync,
106        user_email: persisted.setting(USER_EMAIL)?.get().await?,
107        user_id: persisted.setting(USER_ID)?.get().await?,
108        status,
109    })
110}
111
112#[cfg(test)]
113mod tests {
114    use std::sync::Once;
115
116    use bitwarden_core::{DeviceType, HostPlatformInfo, init_host_platform_info};
117    use bitwarden_pm::PasswordManagerClient;
118
119    use super::*;
120
121    // Multiple test binaries in this crate may concurrently call `init_host_platform_info`;
122    // a single guarded init keeps cross-test ordering deterministic.
123    static INIT: Once = Once::new();
124
125    fn ensure_platform_info() {
126        INIT.call_once(|| {
127            init_host_platform_info(HostPlatformInfo {
128                user_agent: "bw-tests".to_string(),
129                device_type: DeviceType::SDK,
130                device_identifier: None,
131                bitwarden_client_version: None,
132                bitwarden_package_type: None,
133            });
134        });
135    }
136
137    async fn status_json(user: Option<PasswordManagerClient>) -> serde_json::Value {
138        ensure_platform_info();
139        let response = build_status("https://example.com".to_string(), user)
140            .await
141            .unwrap();
142        serde_json::to_value(&response).unwrap()
143    }
144
145    #[tokio::test]
146    async fn logged_out_reports_unauthenticated_with_null_user_fields() {
147        let json = status_json(None).await;
148
149        assert_eq!(json["status"], "unauthenticated");
150        assert!(json["lastSync"].is_null());
151        assert!(json["userEmail"].is_null());
152        assert!(json["userId"].is_null());
153    }
154
155    #[tokio::test]
156    async fn field_names_match_the_node_cli() {
157        let json = status_json(None).await;
158
159        let keys: Vec<&str> = json
160            .as_object()
161            .unwrap()
162            .keys()
163            .map(String::as_str)
164            .collect();
165        assert_eq!(
166            keys,
167            ["serverUrl", "lastSync", "userEmail", "userId", "status"],
168        );
169    }
170
171    #[tokio::test]
172    async fn user_without_a_loaded_key_reports_locked() {
173        // A freshly-built `PasswordManagerClient` has no user key loaded.
174        let json = status_json(Some(PasswordManagerClient::new(None))).await;
175
176        assert_eq!(json["status"], "locked");
177    }
178}