Skip to main content

secretx_bitwarden/
lib.rs

1//! Bitwarden Secrets Manager backend for secretx.
2//!
3//! # Integration test status
4//!
5//! Unit tests (URI parsing, error mapping) pass without credentials.
6//! Live integration tests require a Bitwarden Secrets Manager account
7//! (available on Teams/Enterprise plans) and a machine account access token.
8//! Set `SECRETX_BWS_TEST=1` and `BWS_ACCESS_TOKEN` to enable them.
9//! **Not yet integration-tested.**
10//!
11//! URI: `secretx:bitwarden:<project-name>/<secret-name>`
12//!
13//! Authentication is via the `BWS_ACCESS_TOKEN` environment variable, which
14//! must hold a Bitwarden Secrets Manager machine account access token.
15//!
16//! ```rust,no_run
17//! # async fn example() -> Result<(), secretx_core::SecretError> {
18//! use secretx_bitwarden::BitwardenBackend;
19//! use secretx_core::SecretStore;
20//!
21//! // BWS_ACCESS_TOKEN must be set in the environment.
22//! let store = BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret")?;
23//! let value = store.get().await?;
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! # Zeroization
29//!
30//! `BWS_ACCESS_TOKEN` is stored as `Zeroizing<String>` and zeroed when this backend is dropped.
31//! However, the Bitwarden SDK's `SecretResponse::value` field is a plain `String`; the secret
32//! value returned by the SDK is not zeroed when the SDK response object is dropped. This is an
33//! SDK limitation. The `SecretValue` returned to the caller is zeroed on drop as usual.
34
35use bitwarden::{
36    auth::login::AccessTokenLoginRequest,
37    secrets_manager::{
38        projects::ProjectsListRequest,
39        secrets::{SecretGetRequest, SecretIdentifiersByProjectRequest},
40        ClientProjectsExt, ClientSecretsExt,
41    },
42    Client,
43};
44use std::sync::Arc;
45
46use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue};
47use zeroize::Zeroizing;
48
49const BACKEND: &str = "bitwarden";
50
51/// Construct a [`SecretError::Backend`] (permanent) for this backend.
52fn backend_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
53    SecretError::Backend {
54        backend: BACKEND,
55        source: source.into(),
56    }
57}
58
59/// Construct a [`SecretError::Unavailable`] (transient) for this backend.
60fn unavailable_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
61    SecretError::Unavailable {
62        backend: BACKEND,
63        source: source.into(),
64    }
65}
66
67/// Convert a `SecretResponse` value into a [`SecretValue`], rejecting empty
68/// responses.  An empty `value` field typically indicates a server-side issue
69/// or a secret that was created without a value; returning it silently could
70/// cause hard-to-diagnose failures downstream.
71fn secret_value_from_response(value: String) -> Result<SecretValue, SecretError> {
72    if value.is_empty() {
73        return Err(backend_error(
74            "Bitwarden returned an empty secret value",
75        ));
76    }
77    Ok(SecretValue::new(value.into_bytes()))
78}
79
80/// Per-SDK-call timeout.  Each individual Bitwarden SDK network call
81/// (login, list-projects, list-secrets, get-secret) is wrapped with this
82/// timeout so that a hung connection does not block the caller indefinitely.
83const SDK_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
84
85/// Cached session state produced by the first successful `get()`.
86///
87/// Stores the authenticated client together with the three UUIDs resolved
88/// during the initial handshake so that subsequent `get()` calls can skip
89/// the list-projects and list-secrets round-trips.
90struct BitwardenSession {
91    client: Client,
92    /// Stored for diagnostics and potential future per-org operations;
93    /// not read on the hot path today.
94    #[allow(dead_code)]
95    org_id: uuid::Uuid,
96    /// Stored so a future `list_secrets` refresh does not need to
97    /// re-resolve the project; not read on the hot path today.
98    #[allow(dead_code)]
99    project_id: uuid::Uuid,
100    secret_id: uuid::Uuid,
101}
102
103/// Backend that reads secrets from Bitwarden Secrets Manager.
104///
105/// Construct with [`from_uri`](BitwardenBackend::from_uri). Authenticates
106/// lazily on the first [`get`](SecretStore::get) call using the
107/// `BWS_ACCESS_TOKEN` environment variable.
108///
109/// After the first successful `get`, the authenticated client, organization
110/// UUID, project UUID, and secret UUID are all cached.  Subsequent calls make
111/// exactly one API call (`secrets().get(secret_id)`) instead of three
112/// (list_projects + list_secrets + get).
113///
114/// # Session lifetime
115///
116/// The session state is cached for the **lifetime of this object**.
117/// If the access token expires after the first successful `get`,
118/// call [`refresh`](SecretStore::refresh) to re-authenticate and update
119/// the cached session.  Subsequent `get` calls will then use the new client.
120pub struct BitwardenBackend {
121    access_token: Zeroizing<String>,
122    project_name: String,
123    secret_name: String,
124    /// Lazily-initialized session state.  Populated on the first successful
125    /// `get()`.  Caches project_id and secret_id to avoid N+2 API calls on
126    /// every subsequent `get()` (list_projects + list_secrets + get → just get).
127    ///
128    /// Uses `RwLock<Option<…>>` instead of `OnceCell` so that `refresh()` can
129    /// replace the session with a re-authenticated client.
130    session: tokio::sync::RwLock<Option<BitwardenSession>>,
131}
132
133impl std::fmt::Debug for BitwardenBackend {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("BitwardenBackend")
136            .field("project_name", &self.project_name)
137            .field("secret_name", &self.secret_name)
138            .finish_non_exhaustive()
139    }
140}
141
142impl BitwardenBackend {
143    /// Construct from a `secretx:bitwarden:<project-name>/<secret-name>` URI.
144    ///
145    /// Reads the `BWS_ACCESS_TOKEN` environment variable at construction time.
146    /// No network call is made until [`get`](SecretStore::get) is called.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`SecretError::InvalidUri`] if the URI does not match the
151    /// expected format or does not name the `bitwarden` backend.
152    ///
153    /// Returns [`SecretError::Unavailable`] if `BWS_ACCESS_TOKEN` is not set.
154    pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
155        Self::from_parsed_uri(&SecretUri::parse(uri)?)
156    }
157
158    /// Construct from a pre-parsed [`SecretUri`].
159    pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
160        if parsed.backend() != BACKEND {
161            return Err(SecretError::InvalidUri(format!(
162                "expected backend `{BACKEND}`, got `{}`",
163                parsed.backend()
164            )));
165        }
166
167        // path is "<project-name>/<secret-name>"
168        let (project_name, secret_name) = parsed.path().split_once('/').ok_or_else(|| {
169            SecretError::InvalidUri(format!(
170                "bitwarden URI requires `<project-name>/<secret-name>`, got path: `{}`",
171                parsed.path()
172            ))
173        })?;
174
175        if project_name.is_empty() || secret_name.is_empty() {
176            return Err(SecretError::InvalidUri(
177                "bitwarden URI: project-name and secret-name must not be empty".into(),
178            ));
179        }
180        // Bitwarden does not allow '/' in project or secret names.  After
181        // percent-decoding, a slash in either component would mean the URI
182        // encoded a literal '/' (%2F) which we cannot reliably distinguish
183        // from the project/secret separator.  Reject to avoid silent mismatch.
184        if secret_name.contains('/') {
185            return Err(SecretError::InvalidUri(
186                "bitwarden URI: secret-name must not contain '/' \
187                 (only one '/' separator between project-name and secret-name is allowed)"
188                    .into(),
189            ));
190        }
191
192        // Bitwarden Secrets Manager returns the secret value as a plain string;
193        // ?field= JSON extraction is not supported and would silently return the
194        // full raw value, which is confusing.  Reject early.
195        if parsed.param("field").is_some() {
196            return Err(SecretError::InvalidUri(
197                "bitwarden does not support ?field= (Bitwarden secret values are plain strings, \
198                 not JSON objects); remove ?field= or use a backend that supports JSON field \
199                 extraction (e.g. aws-sm)"
200                    .into(),
201            ));
202        }
203
204        let access_token = std::env::var("BWS_ACCESS_TOKEN").map_err(|e| match e {
205            std::env::VarError::NotPresent => {
206                unavailable_error("BWS_ACCESS_TOKEN environment variable is not set")
207            }
208            std::env::VarError::NotUnicode(_) => {
209                unavailable_error("BWS_ACCESS_TOKEN environment variable contains non-UTF-8 bytes")
210            }
211        })?;
212        if access_token.is_empty() {
213            return Err(unavailable_error(
214                "BWS_ACCESS_TOKEN environment variable is set but empty",
215            ));
216        }
217
218        Ok(Self {
219            access_token: Zeroizing::new(access_token),
220            project_name: project_name.to_owned(),
221            secret_name: secret_name.to_owned(),
222            session: tokio::sync::RwLock::new(None),
223        })
224    }
225}
226
227/// Wrap a future with [`SDK_CALL_TIMEOUT`], converting `Elapsed` into
228/// [`SecretError::Unavailable`].
229async fn with_timeout<F, T>(fut: F) -> Result<T, SecretError>
230where
231    F: std::future::Future<Output = Result<T, SecretError>>,
232{
233    tokio::time::timeout(SDK_CALL_TIMEOUT, fut)
234        .await
235        .map_err(|_elapsed| {
236            unavailable_error(format!(
237                "SDK call timed out after {}s",
238                SDK_CALL_TIMEOUT.as_secs()
239            ))
240        })?
241}
242
243/// Create an authenticated Bitwarden client using the given access token.
244///
245/// Returns the client and the organization UUID extracted from the JWT token
246/// embedded in the access token.
247async fn build_authed_client(access_token: &str) -> Result<(Client, uuid::Uuid), SecretError> {
248    let client = Client::new(None);
249
250    let auth_resp = with_timeout(async {
251        client
252            .auth()
253            // NOTE: access_token is copied into a plain String here because the
254            // Bitwarden SDK's AccessTokenLoginRequest requires an owned String —
255            // there is no Zeroizing-aware alternative.  This is an SDK limitation
256            // documented in the module-level zeroization note.
257            .login_access_token(&AccessTokenLoginRequest {
258                access_token: access_token.to_string(),
259                state_file: None,
260            })
261            .await
262            .map_err(unavailable_error)
263    })
264    .await?;
265
266    if !auth_resp.authenticated {
267        return Err(unavailable_error(
268            "access token login did not authenticate",
269        ));
270    }
271
272    // `client.internal` is a `#[doc(hidden)]` pub field of `bitwarden::Client`
273    // (bitwarden-core 2.0.0, bitwarden-core/src/client/client.rs).  There is no
274    // other public API in bitwarden 2.0.0 to retrieve the organization UUID after
275    // `login_access_token` — the `AccessTokenLoginResponse` struct does not expose it
276    // and it is not returned via any other pub method on `Client`.
277    //
278    // NOTE: The org UUID is NOT embedded in the access token JWT string.  The token
279    // format is `0.<access_token_id_uuid>.<client_secret>:<encryption_key_base64>`;
280    // the org UUID is stored in the SDK's internal `login_method` state after auth.
281    // There is therefore no JWT-parsing fallback if `client.internal` is removed.
282    //
283    // Verified against bitwarden = "2.0.0". When upgrading bitwarden, check:
284    //   bitwarden-core/src/client/client.rs — `pub internal` field still present
285    //   bitwarden-core/src/client/internal.rs — `get_access_token_organization` still present
286    // If the internal field is removed, file an issue with bitwarden-core to expose
287    // the org ID via a stable public API.
288    let org_id: uuid::Uuid = client
289        .internal
290        .get_access_token_organization()
291        .ok_or_else(|| {
292            unavailable_error("could not determine organization ID from access token")
293        })?
294        .into();
295
296    Ok((client, org_id))
297}
298
299/// Classify a `SecretsManagerError` into `Backend` (permanent) or `Unavailable` (transient).
300///
301/// `SecretsManagerError` is a private type in `bitwarden-sm` so we cannot pattern-match on it
302/// from outside the crate.  Both `SecretsManagerError` and its inner `ApiError` use
303/// `#[error(transparent)]`, which makes `Display` delegate all the way down and `source()`
304/// skip intermediate types.  As a result:
305///
306/// - `ApiError::ResponseContent { status, .. }` formats as
307///   `"Received error message from server: [NNN] ..."` — we detect 5xx this way.
308/// - `ApiError::Reqwest(e)` formats as the reqwest error message (e.g., "error sending request
309///   for url (...): connection refused") — we detect network errors by known substrings.
310/// - `ApiError::Io(e)` formats similarly.
311///
312/// Everything else (Validation, Crypto, Serde, 4xx) is a permanent caller/config error → Backend.
313///
314/// If a future version of bitwarden-core changes the `ResponseContent` Display format, update
315/// the `"[5"` / `"[429]"` checks here.
316fn classify_bitwarden_sdk_error(e: impl std::error::Error + Send + Sync + 'static) -> SecretError {
317    let msg = e.to_string();
318    // 5xx responses: "Received error message from server: [5XX] ..."
319    // 429 (rate limit): transient, the caller should back off and retry.
320    let transient_response =
321        msg.starts_with("Received error message from server: [5") || msg.contains("[429]");
322    // reqwest / IO network failures (these bubble up through the transparent chain).
323    let transient_network = msg.contains("error sending request")
324        || msg.contains("connection refused")
325        || msg.contains("connection reset")
326        || msg.contains("timed out")
327        || msg.contains("dns error")
328        || msg.contains("No such host")
329        || msg.contains("Name or service not known");
330    if transient_response || transient_network {
331        unavailable_error(e)
332    } else {
333        backend_error(e)
334    }
335}
336
337/// Resolve `project_name` to its UUID by listing all projects in the org.
338async fn resolve_project_id(
339    client: &Client,
340    org_id: uuid::Uuid,
341    project_name: &str,
342) -> Result<uuid::Uuid, SecretError> {
343    let resp = with_timeout(async {
344        client
345            .projects()
346            .list(&ProjectsListRequest {
347                organization_id: org_id,
348            })
349            .await
350            .map_err(classify_bitwarden_sdk_error)
351    })
352    .await?;
353
354    resp.data
355        .into_iter()
356        .find(|p| p.name == project_name)
357        .map(|p| p.id)
358        .ok_or(SecretError::NotFound)
359}
360
361/// Find the secret UUID within a project by secret name.
362async fn resolve_secret_id(
363    client: &Client,
364    project_id: uuid::Uuid,
365    secret_name: &str,
366) -> Result<uuid::Uuid, SecretError> {
367    let resp = with_timeout(async {
368        client
369            .secrets()
370            .list_by_project(&SecretIdentifiersByProjectRequest { project_id })
371            .await
372            .map_err(classify_bitwarden_sdk_error)
373    })
374    .await?;
375
376    resp.data
377        .into_iter()
378        .find(|s| s.key == secret_name)
379        .map(|s| s.id)
380        .ok_or(SecretError::NotFound)
381}
382
383impl BitwardenBackend {
384    /// Fetch the secret value using cached session state.
385    ///
386    /// On the first call: authenticates, resolves project_name → project_id and
387    /// secret_name → secret_id, then fetches the secret.  All intermediate IDs
388    /// are stored in `self.session` so subsequent calls skip the two list
389    /// operations and make exactly one API call (`secrets().get`).
390    async fn fetch(&self) -> Result<SecretValue, SecretError> {
391        // Fast path: session already initialized — read lock allows concurrent gets.
392        {
393            let guard = self.session.read().await;
394            if let Some(session) = &*guard {
395                let secret_resp = with_timeout(async {
396                    session
397                        .client
398                        .secrets()
399                        .get(&SecretGetRequest {
400                            id: session.secret_id,
401                        })
402                        .await
403                        .map_err(classify_bitwarden_sdk_error)
404                })
405                .await?;
406                return secret_value_from_response(secret_resp.value);
407            }
408        }
409
410        // Slow path: first call — take write lock and initialize.
411        let mut guard = self.session.write().await;
412        // Double-check: another task may have initialized while we waited.
413        if guard.is_none() {
414            let (client, org_id) = build_authed_client(&self.access_token).await?;
415            let project_id =
416                resolve_project_id(&client, org_id, &self.project_name).await?;
417            let secret_id =
418                resolve_secret_id(&client, project_id, &self.secret_name).await?;
419            *guard = Some(BitwardenSession {
420                client,
421                org_id,
422                project_id,
423                secret_id,
424            });
425        }
426        let session = guard.as_ref().unwrap();
427        let secret_resp = with_timeout(async {
428            session
429                .client
430                .secrets()
431                .get(&SecretGetRequest {
432                    id: session.secret_id,
433                })
434                .await
435                .map_err(classify_bitwarden_sdk_error)
436        })
437        .await?;
438        secret_value_from_response(secret_resp.value)
439    }
440}
441
442#[async_trait::async_trait]
443impl SecretStore for BitwardenBackend {
444    async fn get(&self) -> Result<SecretValue, SecretError> {
445        self.fetch().await
446    }
447
448    async fn refresh(&self) -> Result<SecretValue, SecretError> {
449        // Re-authenticate and re-resolve IDs outside the lock.
450        let (client, org_id) = build_authed_client(&self.access_token).await?;
451        let project_id = resolve_project_id(&client, org_id, &self.project_name).await?;
452        let secret_id = resolve_secret_id(&client, project_id, &self.secret_name).await?;
453
454        // Fetch the secret before updating the cache so a fetch failure
455        // does not discard a previously-working session.
456        let secret_resp = with_timeout(async {
457            client
458                .secrets()
459                .get(&SecretGetRequest { id: secret_id })
460                .await
461                .map_err(classify_bitwarden_sdk_error)
462        })
463        .await?;
464
465        // Update the cached session so subsequent get() calls use the
466        // refreshed client.
467        *self.session.write().await = Some(BitwardenSession {
468            client,
469            org_id,
470            project_id,
471            secret_id,
472        });
473
474        secret_value_from_response(secret_resp.value)
475    }
476}
477
478inventory::submit!(secretx_core::BackendRegistration::new(
479    "bitwarden",
480    |uri: &secretx_core::SecretUri| {
481        let b = BitwardenBackend::from_parsed_uri(uri)?;
482        Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
483    },
484));
485
486// ── Tests ─────────────────────────────────────────────────────────────────────
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use std::sync::Mutex;
492
493    // Serialize all tests that read or write BWS_ACCESS_TOKEN to prevent races
494    // within this test binary.
495    //
496    // Scope limitation: ENV_LOCK only coordinates threads inside this test
497    // binary.  If a future integration-test harness or workspace-level test
498    // binary also mutates BWS_ACCESS_TOKEN in a separate thread, it will not
499    // be covered by this lock.  Keep env-var mutation confined to this crate's
500    // tests.  If cross-crate coordination is ever needed, move to a
501    // per-process lock file or a dedicated test environment variable.
502    static ENV_LOCK: Mutex<()> = Mutex::new(());
503
504    #[test]
505    fn from_uri_wrong_backend() {
506        let _g = ENV_LOCK.lock().unwrap();
507        // SAFETY: ENV_LOCK serializes all env-var mutations within this
508        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
509        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
510        assert!(matches!(
511            BitwardenBackend::from_uri("secretx:env:FOO"),
512            Err(SecretError::InvalidUri(_))
513        ));
514    }
515
516    #[test]
517    fn from_uri_wrong_scheme() {
518        let _g = ENV_LOCK.lock().unwrap();
519        // SAFETY: ENV_LOCK serializes all env-var mutations within this
520        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
521        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
522        assert!(matches!(
523            BitwardenBackend::from_uri("https://bitwarden/proj/sec"),
524            Err(SecretError::InvalidUri(_))
525        ));
526    }
527
528    #[test]
529    fn from_uri_missing_secret_name() {
530        let _g = ENV_LOCK.lock().unwrap();
531        // SAFETY: ENV_LOCK serializes all env-var mutations within this
532        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
533        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
534        assert!(matches!(
535            BitwardenBackend::from_uri("secretx:bitwarden:only-project"),
536            Err(SecretError::InvalidUri(_))
537        ));
538    }
539
540    #[test]
541    fn from_uri_empty_project() {
542        let _g = ENV_LOCK.lock().unwrap();
543        // SAFETY: ENV_LOCK serializes all env-var mutations within this
544        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
545        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
546        assert!(matches!(
547            BitwardenBackend::from_uri("secretx:bitwarden:/secret-name"),
548            Err(SecretError::InvalidUri(_))
549        ));
550    }
551
552    #[test]
553    fn from_uri_missing_token() {
554        let _g = ENV_LOCK.lock().unwrap();
555        // SAFETY: ENV_LOCK serializes all env-var mutations within this
556        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
557        unsafe { std::env::remove_var("BWS_ACCESS_TOKEN") };
558        assert!(matches!(
559            BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
560            Err(SecretError::Unavailable { .. })
561        ));
562    }
563
564    #[test]
565    fn from_uri_empty_token() {
566        let _g = ENV_LOCK.lock().unwrap();
567        // SAFETY: ENV_LOCK serializes all env-var mutations within this
568        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
569        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "") };
570        assert!(matches!(
571            BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
572            Err(SecretError::Unavailable { .. })
573        ));
574    }
575
576    #[test]
577    fn from_uri_valid() {
578        let _g = ENV_LOCK.lock().unwrap();
579        // SAFETY: ENV_LOCK serializes all env-var mutations within this
580        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
581        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
582        let backend = BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret");
583        assert!(backend.is_ok());
584        let b = backend.unwrap();
585        assert_eq!(b.project_name, "my-project");
586        assert_eq!(b.secret_name, "my-secret");
587    }
588
589    #[test]
590    fn from_uri_field_selector_rejected() {
591        // Bitwarden values are plain strings; ?field= is not supported and must
592        // be rejected before BWS_ACCESS_TOKEN is read.  Use a dummy token to
593        // get past path validation so the ?field= guard is exercised.
594        let _g = ENV_LOCK.lock().unwrap();
595        // SAFETY: ENV_LOCK serializes all env-var mutations within this
596        // test binary; no other thread reads BWS_ACCESS_TOKEN concurrently.
597        unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
598        let result =
599            BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret?field=password");
600        match result {
601            Err(SecretError::InvalidUri(msg)) => {
602                assert!(
603                    msg.contains("bitwarden does not support ?field="),
604                    "error must mention the limitation, got: {msg}"
605                );
606            }
607            Err(e) => panic!("expected InvalidUri, got: {e}"),
608            Ok(_) => panic!("expected InvalidUri, got Ok"),
609        }
610    }
611
612    // ── classify_bitwarden_sdk_error tests ──────────────────────────────────
613
614    /// Minimal error type used as an oracle-independent input to
615    /// `classify_bitwarden_sdk_error`.  Each test constructs one with a known
616    /// message string and asserts the returned `SecretError` variant.
617    #[derive(Debug)]
618    struct FakeError(String);
619
620    impl std::fmt::Display for FakeError {
621        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
622            f.write_str(&self.0)
623        }
624    }
625
626    impl std::error::Error for FakeError {}
627
628    /// Helper: returns `true` when the error is the transient `Unavailable` variant.
629    fn is_unavailable(e: &SecretError) -> bool {
630        matches!(e, SecretError::Unavailable { .. })
631    }
632
633    /// Helper: returns `true` when the error is the permanent `Backend` variant.
634    fn is_backend(e: &SecretError) -> bool {
635        matches!(e, SecretError::Backend { .. })
636    }
637
638    #[test]
639    fn classify_5xx_server_error() {
640        let e = classify_bitwarden_sdk_error(FakeError(
641            "Received error message from server: [500] Internal Server Error".into(),
642        ));
643        assert!(is_unavailable(&e), "5xx should be transient: {e:?}");
644    }
645
646    #[test]
647    fn classify_503_server_error() {
648        let e = classify_bitwarden_sdk_error(FakeError(
649            "Received error message from server: [503] Service Unavailable".into(),
650        ));
651        assert!(is_unavailable(&e), "503 should be transient: {e:?}");
652    }
653
654    #[test]
655    fn classify_429_rate_limit() {
656        let e = classify_bitwarden_sdk_error(FakeError(
657            "Received error message from server: [429] Too Many Requests".into(),
658        ));
659        assert!(is_unavailable(&e), "429 should be transient: {e:?}");
660    }
661
662    #[test]
663    fn classify_4xx_permanent() {
664        let e = classify_bitwarden_sdk_error(FakeError(
665            "Received error message from server: [401] Unauthorized".into(),
666        ));
667        assert!(is_backend(&e), "401 should be permanent: {e:?}");
668    }
669
670    #[test]
671    fn classify_error_sending_request() {
672        let e = classify_bitwarden_sdk_error(FakeError(
673            "error sending request for url (https://example.com): connection refused".into(),
674        ));
675        assert!(is_unavailable(&e), "network send error should be transient: {e:?}");
676    }
677
678    #[test]
679    fn classify_connection_refused() {
680        let e = classify_bitwarden_sdk_error(FakeError("connection refused".into()));
681        assert!(is_unavailable(&e), "connection refused should be transient: {e:?}");
682    }
683
684    #[test]
685    fn classify_connection_reset() {
686        let e = classify_bitwarden_sdk_error(FakeError("connection reset by peer".into()));
687        assert!(is_unavailable(&e), "connection reset should be transient: {e:?}");
688    }
689
690    #[test]
691    fn classify_timed_out() {
692        let e = classify_bitwarden_sdk_error(FakeError("operation timed out".into()));
693        assert!(is_unavailable(&e), "timed out should be transient: {e:?}");
694    }
695
696    #[test]
697    fn classify_dns_error() {
698        let e = classify_bitwarden_sdk_error(FakeError(
699            "dns error: failed to lookup address information".into(),
700        ));
701        assert!(is_unavailable(&e), "dns error should be transient: {e:?}");
702    }
703
704    #[test]
705    fn classify_no_such_host() {
706        let e = classify_bitwarden_sdk_error(FakeError("No such host is known".into()));
707        assert!(is_unavailable(&e), "No such host should be transient: {e:?}");
708    }
709
710    #[test]
711    fn classify_name_or_service_not_known() {
712        let e = classify_bitwarden_sdk_error(FakeError(
713            "Name or service not known".into(),
714        ));
715        assert!(is_unavailable(&e), "Name or service not known should be transient: {e:?}");
716    }
717
718    #[test]
719    fn classify_unknown_error_is_permanent() {
720        let e = classify_bitwarden_sdk_error(FakeError(
721            "some unknown SDK error message".into(),
722        ));
723        assert!(is_backend(&e), "unrecognised error should be permanent: {e:?}");
724    }
725
726    #[test]
727    fn classify_validation_error_is_permanent() {
728        let e = classify_bitwarden_sdk_error(FakeError(
729            "Validation failed: field X is required".into(),
730        ));
731        assert!(is_backend(&e), "validation error should be permanent: {e:?}");
732    }
733
734    // Integration tests — skipped unless BWS_ACCESS_TOKEN is set AND
735    // SECRETX_BWS_TEST=1.
736
737    #[tokio::test]
738    async fn integration_get() {
739        if std::env::var("SECRETX_BWS_TEST").as_deref() != Ok("1") {
740            return;
741        }
742        let project = match std::env::var("SECRETX_BWS_TEST_PROJECT") {
743            Ok(p) => p,
744            Err(_) => return,
745        };
746        let secret = match std::env::var("SECRETX_BWS_TEST_SECRET") {
747            Ok(s) => s,
748            Err(_) => return,
749        };
750        let uri = format!("secretx:bitwarden:{project}/{secret}");
751        let store = BitwardenBackend::from_uri(&uri).unwrap();
752        let value = store.get().await.unwrap();
753        assert!(!value.as_bytes().is_empty());
754    }
755}