Skip to main content

hasp_backend_bw/
lib.rs

1//! `bw://` backend for hasp.
2//!
3//! Grammar: `bw://<item>/<field-path>`
4//!   - `<item>`       — Bitwarden item name (host component). Must be non-empty.
5//!   - `<field-path>` — Dot-separated path into the item JSON (first path
6//!     segment). Examples: `login.password`, `notes`, `fields.0.value`.
7//!   - No query parameters.
8//!
9//! Supported operations: `get`, `exists`.
10//! `put`, `list`, `delete`: `UnsupportedOperation`.
11//!
12//! Authentication is ambient only: `BW_SESSION`. If the variable is missing,
13//! every operation fails fast with `AuthenticationFailed` before spawning
14//! the `bw` binary, preventing biometric unlock prompts in headless contexts.
15//!
16//! Every `bw` invocation carries a wall-clock timeout (15 s for `get`,
17//! 10 s for `exists`) because `bw` may prompt for unlock in interactive
18//! mode. The `--nointeraction` flag mitigates but does not eliminate hangs.
19//!
20//! `bw get item` returns full item JSON. The backend extracts only the
21//! requested field and wraps it in `SecretString`. `exists` also fetches
22//! full item JSON because `bw` lacks a metadata-only existence probe.
23
24use hasp_core::{Backend, BackendFailureKind, Entry, Error, SecretString};
25use std::process::{Command, Stdio};
26use std::thread;
27use std::time::{Duration, Instant};
28use url::Url;
29
30/// URL shape for `bw://` addresses.
31///
32/// `item` and `field_path` are identifiers, not secret values. They may
33/// appear in error messages (redacted per URL discipline).
34#[derive(Debug)]
35pub struct BwUrl {
36    pub item: String,
37    pub field_path: String,
38}
39
40impl TryFrom<&Url> for BwUrl {
41    type Error = Error;
42
43    fn try_from(url: &Url) -> Result<Self, Self::Error> {
44        if url.scheme() != "bw" {
45            return Err(Error::InvalidUrl("expected bw:// scheme".into()));
46        }
47
48        if url.query().is_some() {
49            return Err(Error::InvalidUrl(
50                "bw:// does not accept query parameters".into(),
51            ));
52        }
53
54        let item = url
55            .host_str()
56            .ok_or_else(|| Error::InvalidUrl("bw:// requires an item name (host)".into()))?
57            .to_owned();
58        if item.is_empty() {
59            return Err(Error::InvalidUrl(
60                "bw:// item name must not be empty".into(),
61            ));
62        }
63
64        let mut segments = url.path_segments().into_iter().flatten();
65        let field_path = segments.next().ok_or_else(|| {
66            Error::InvalidUrl("bw:// requires a field path (path segment)".into())
67        })?;
68        if field_path.is_empty() {
69            return Err(Error::InvalidUrl(
70                "bw:// field path must not be empty".into(),
71            ));
72        }
73
74        if segments.next().is_some() {
75            return Err(Error::InvalidUrl(
76                "bw:// requires exactly one path segment (field path) after the item".into(),
77            ));
78        }
79
80        Ok(BwUrl {
81            item,
82            field_path: field_path.to_owned(),
83        })
84    }
85}
86
87/// Subprocess backend for Bitwarden CLI (`bw`).
88///
89/// Construction runs `bw --version` once. The stored init result is replayed
90/// on every operation so errors surface on first use, not at
91/// `Store::with_defaults()` time.
92#[derive(Debug)]
93pub struct BwBackend {
94    init: Result<(), Error>,
95}
96
97/// Wall-clock timeout for `bw get item` invocations.
98///
99/// Baseline `bw get item` is ~500 ms–2 s; 15 s absorbs occasional network
100/// stalls without blocking CI indefinitely.
101const GET_TIMEOUT: Duration = Duration::from_secs(15);
102
103/// Wall-clock timeout for existence probes.
104///
105/// Should not trigger biometric prompts (auth is pre-checked), but network
106/// stalls are still possible.
107const EXISTS_TIMEOUT: Duration = Duration::from_secs(10);
108
109/// Wall-clock timeout for the one-time `bw --version` check.
110const VERSION_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
111
112impl BwBackend {
113    /// Create a new `BwBackend`.
114    ///
115    /// Verifies `bw` is present by running `--version`. Versions in
116    /// `YYYY.MM.N` format are accepted with a floor of 2023.1.0.
117    pub fn new() -> Self {
118        Self {
119            init: Self::check_version(),
120        }
121    }
122
123    fn ensure_init(&self) -> Result<(), Error> {
124        self.init.clone()
125    }
126
127    fn check_version() -> Result<(), Error> {
128        let output = run_bw_with_timeout(&["--version"], VERSION_CHECK_TIMEOUT)?;
129
130        if !output.status.success() {
131            let stderr = String::from_utf8_lossy(&output.stderr);
132            let exit_code = output.status.code().unwrap_or(-1);
133            return Err(map_bw_stderr(&stderr, exit_code));
134        }
135
136        let stdout = String::from_utf8_lossy(&output.stdout);
137        let version = parse_bw_version(&stdout).ok_or_else(|| Error::Backend {
138            scheme: "bw",
139            kind: BackendFailureKind::Permanent,
140            message: format!("could not parse bw version: {}", stdout.trim()),
141        })?;
142
143        // Floor: 2023.1.0 — well past the introduction of `--response`.
144        if version.0 < 2023 || (version.0 == 2023 && version.1 < 1) {
145            return Err(Error::Backend {
146                scheme: "bw",
147                kind: BackendFailureKind::Permanent,
148                message: format!(
149                    "bw CLI version {}.{}.{} is unsupported; hasp requires bw >= 2023.1.0",
150                    version.0, version.1, version.2
151                ),
152            });
153        }
154
155        Ok(())
156    }
157}
158
159impl Default for BwBackend {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl Backend for BwBackend {
166    fn scheme(&self) -> &'static str {
167        "bw"
168    }
169
170    fn validate(&self, url: &Url) -> Result<(), Error> {
171        BwUrl::try_from(url).map(|_| ())
172    }
173
174    fn get(&self, url: &Url) -> Result<SecretString, Error> {
175        self.ensure_init()?;
176        check_ambient_credentials()?;
177
178        let bw_url = BwUrl::try_from(url)?;
179        let reference = format!("bw://{}/{}", bw_url.item, bw_url.field_path);
180        let envelope = get_item_envelope(&bw_url.item, GET_TIMEOUT, &reference)?;
181
182        let data = envelope.get("data").ok_or_else(|| Error::Backend {
183            scheme: "bw",
184            kind: BackendFailureKind::Permanent,
185            message: "bw response missing data field".into(),
186        })?;
187
188        let secret = extract_field(data, &bw_url.field_path, &reference)?;
189        Ok(SecretString::new(secret.into()))
190    }
191
192    fn put(&self, _url: &Url, _value: &SecretString) -> Result<(), Error> {
193        Err(Error::UnsupportedOperation {
194            scheme: "bw",
195            operation: "put",
196        })
197    }
198
199    fn list(&self, _url: &Url) -> Result<Vec<Entry>, Error> {
200        Err(Error::UnsupportedOperation {
201            scheme: "bw",
202            operation: "list",
203        })
204    }
205
206    fn delete(&self, _url: &Url) -> Result<(), Error> {
207        Err(Error::UnsupportedOperation {
208            scheme: "bw",
209            operation: "delete",
210        })
211    }
212
213    fn exists(&self, url: &Url) -> Result<bool, Error> {
214        self.ensure_init()?;
215        check_ambient_credentials()?;
216
217        let bw_url = BwUrl::try_from(url)?;
218        let reference = format!("bw://{}/{}", bw_url.item, bw_url.field_path);
219        let envelope = get_item_envelope(&bw_url.item, EXISTS_TIMEOUT, &reference)?;
220
221        let success = envelope
222            .get("success")
223            .and_then(|v| v.as_bool())
224            .unwrap_or(false);
225
226        if !success {
227            let message = envelope
228                .get("message")
229                .and_then(|v| v.as_str())
230                .unwrap_or("unknown bw error");
231            if message.eq_ignore_ascii_case("not found.") {
232                return Ok(false);
233            }
234            return Err(map_bw_response_error(message, &reference));
235        }
236
237        let data = envelope.get("data").ok_or_else(|| Error::Backend {
238            scheme: "bw",
239            kind: BackendFailureKind::Permanent,
240            message: "bw response missing data field".into(),
241        })?;
242
243        match extract_field(data, &bw_url.field_path, &reference) {
244            Ok(_) => Ok(true),
245            Err(Error::NotFound(_)) => Ok(false),
246            Err(e) => Err(e),
247        }
248    }
249}
250
251/// Fetch the JSON response envelope for `bw get item`.
252///
253/// Parses the `--response` output and checks `success`. On failure, maps
254/// the `message` into the hasp error taxonomy.
255fn get_item_envelope(
256    item: &str,
257    timeout: Duration,
258    reference: &str,
259) -> Result<serde_json::Value, Error> {
260    let output = run_bw_with_timeout(
261        &["--response", "--nointeraction", "get", "item", item],
262        timeout,
263    )?;
264
265    let envelope: serde_json::Value =
266        serde_json::from_slice(&output.stdout).map_err(|e| Error::Backend {
267            scheme: "bw",
268            kind: BackendFailureKind::Permanent,
269            message: format!("bw produced invalid JSON: {e}"),
270        })?;
271
272    let success = envelope
273        .get("success")
274        .and_then(|v| v.as_bool())
275        .unwrap_or(false);
276
277    if !success {
278        let message = envelope
279            .get("message")
280            .and_then(|v| v.as_str())
281            .unwrap_or("unknown bw error");
282        return Err(map_bw_response_error(message, reference));
283    }
284
285    Ok(envelope)
286}
287
288/// Spawn `bw` with the given args and enforce a wall-clock timeout.
289///
290/// Two reader threads consume stdout and stderr concurrently while the
291/// main thread polls `try_wait`. This prevents pipe-buffer deadlock when
292/// `bw` emits large JSON or when stderr is verbose.
293fn run_bw_with_timeout(args: &[&str], timeout: Duration) -> Result<std::process::Output, Error> {
294    let mut child = Command::new("bw")
295        .args(args)
296        .stdout(Stdio::piped())
297        .stderr(Stdio::piped())
298        .spawn()
299        .map_err(map_spawn_error)?;
300
301    let mut stdout_pipe = child.stdout.take().expect("piped stdout");
302    let mut stderr_pipe = child.stderr.take().expect("piped stderr");
303
304    let stdout_thread = thread::spawn(move || {
305        let mut buf = Vec::new();
306        std::io::Read::read_to_end(&mut stdout_pipe, &mut buf).ok();
307        buf
308    });
309
310    let stderr_thread = thread::spawn(move || {
311        let mut buf = Vec::new();
312        std::io::Read::read_to_end(&mut stderr_pipe, &mut buf).ok();
313        buf
314    });
315
316    let deadline = Instant::now() + timeout;
317    loop {
318        match child.try_wait() {
319            Ok(Some(status)) => {
320                let stdout = stdout_thread.join().unwrap_or_default();
321                let stderr = stderr_thread.join().unwrap_or_default();
322                return Ok(std::process::Output {
323                    status,
324                    stdout,
325                    stderr,
326                });
327            }
328            Ok(None) => {
329                if Instant::now() >= deadline {
330                    let _ = child.kill();
331                    let _ = child.wait();
332                    return Err(Error::Backend {
333                        scheme: "bw",
334                        kind: BackendFailureKind::Transient,
335                        message: "bw invocation timed out".into(),
336                    });
337                }
338                thread::sleep(Duration::from_millis(50));
339            }
340            Err(e) => {
341                return Err(Error::Backend {
342                    scheme: "bw",
343                    kind: BackendFailureKind::Transient,
344                    message: format!("failed to wait for bw process: {e}"),
345                });
346            }
347        }
348    }
349}
350
351fn map_spawn_error(err: std::io::Error) -> Error {
352    use std::io::ErrorKind;
353    if err.kind() == ErrorKind::NotFound {
354        Error::Backend {
355            scheme: "bw",
356            kind: BackendFailureKind::Permanent,
357            message: "bw binary not found in PATH".into(),
358        }
359    } else {
360        Error::Backend {
361            scheme: "bw",
362            kind: BackendFailureKind::Transient,
363            message: format!("failed to spawn bw: {err}"),
364        }
365    }
366}
367
368/// Map a Bitwarden `--response` error message into the locked
369/// `hasp_core::Error` taxonomy.
370///
371/// Priority is first-anchor-wins, case-insensitive.
372fn map_bw_response_error(message: &str, reference: &str) -> Error {
373    let lower = message.to_lowercase();
374
375    if lower.contains("not found.") || lower.contains("more than one result was found") {
376        return Error::NotFound(reference.to_string());
377    }
378
379    if lower.contains("vault is locked")
380        || lower.contains("you are not logged in")
381        || lower.contains("your authentication request appears to be coming from a bot")
382    {
383        return Error::AuthenticationFailed(redact_reference(message, reference));
384    }
385
386    if lower.contains("fetch failed")
387        || lower.contains("timeout")
388        || lower.contains("connection")
389        || lower.contains("dial")
390        || lower.contains("getaddrinfo")
391        || lower.contains("no such host")
392    {
393        return Error::Backend {
394            scheme: "bw",
395            kind: BackendFailureKind::Transient,
396            message: redact_reference(message, reference),
397        };
398    }
399
400    if lower.contains("access to this item type is restricted by organizational policy") {
401        return Error::PermissionDenied(redact_reference(message, reference));
402    }
403
404    Error::Backend {
405        scheme: "bw",
406        kind: BackendFailureKind::Permanent,
407        message: redact_reference(message, reference),
408    }
409}
410
411/// Map stderr from a non-`--response` `bw` invocation.
412///
413/// Used only for the `bw --version` path, which does not support
414/// `--response` on some older builds.
415fn map_bw_stderr(stderr: &str, exit_code: i32) -> Error {
416    Error::Backend {
417        scheme: "bw",
418        kind: BackendFailureKind::Permanent,
419        message: format!("bw exited with code {exit_code}: {stderr}"),
420    }
421}
422
423/// Replace occurrences of the secret-reference URL with a redacted token.
424fn redact_reference(message: &str, reference: &str) -> String {
425    message.replace(reference, "bw://<redacted>")
426}
427
428/// Parse a `YYYY.MM.N` version string from `bw --version` stdout.
429///
430/// Accepts plain triples (`2026.4.1`) and prefixed releases
431/// (`cli-v2026.4.1`).
432fn parse_bw_version(output: &str) -> Option<(u32, u32, u32)> {
433    let trimmed = output.trim();
434    let version_part = trimmed.strip_prefix("cli-v").unwrap_or(trimmed);
435    let mut parts = version_part.split('.');
436    let year = parts.next()?.parse::<u32>().ok()?;
437    let month = parts.next()?.parse::<u32>().ok()?;
438    let patch = parts.next()?.parse::<u32>().ok()?;
439    Some((year, month, patch))
440}
441
442/// Fail fast if no ambient Bitwarden session is present.
443///
444/// `bw` has no universal `--no-prompt` flag. Checking `BW_SESSION`
445/// before spawn prevents biometric unlock prompts in headless contexts.
446fn check_ambient_credentials() -> Result<(), Error> {
447    if std::env::var("BW_SESSION").is_err() {
448        return Err(Error::AuthenticationFailed(
449            "no ambient Bitwarden session detected; set BW_SESSION".into(),
450        ));
451    }
452    Ok(())
453}
454
455/// Extract a string value from a JSON object using a dot-separated path.
456///
457/// Supports object keys and array indices (e.g. `fields.0.value`).
458/// Returns `NotFound` if any segment is missing.
459fn extract_field(data: &serde_json::Value, path: &str, reference: &str) -> Result<String, Error> {
460    let mut current = data;
461    for segment in path.split('.') {
462        if segment.is_empty() {
463            return Err(Error::InvalidUrl(
464                "bw:// field path contains empty segment".into(),
465            ));
466        }
467        if let Ok(index) = segment.parse::<usize>() {
468            current = current.get(index).ok_or_else(|| {
469                Error::NotFound(format!("field index {index} out of bounds in {reference}"))
470            })?;
471        } else {
472            current = current.get(segment).ok_or_else(|| {
473                Error::NotFound(format!("field '{segment}' not found in {reference}"))
474            })?;
475        }
476    }
477    current
478        .as_str()
479        .map(|s| s.to_owned())
480        .ok_or_else(|| Error::NotFound(format!("field '{path}' in {reference} is not a string")))
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use hasp_core::test_utils::{EnvGuard, ENV_LOCK};
487
488    #[test]
489    fn parse_valid_url() {
490        let url = Url::parse("bw://github.com/login.password").unwrap();
491        let bw = BwUrl::try_from(&url).unwrap();
492        assert_eq!(bw.item, "github.com");
493        assert_eq!(bw.field_path, "login.password");
494    }
495
496    #[test]
497    fn parse_url_with_encoded_space() {
498        let url = Url::parse("bw://My%20Note/notes").unwrap();
499        let bw = BwUrl::try_from(&url).unwrap();
500        assert_eq!(bw.item, "My%20Note");
501        assert_eq!(bw.field_path, "notes");
502    }
503
504    #[test]
505    fn parse_empty_segment_fails() {
506        let url = Url::parse("bw://github.com/").unwrap();
507        assert!(BwUrl::try_from(&url).is_err());
508    }
509
510    #[test]
511    fn parse_too_few_segments_fails() {
512        let url = Url::parse("bw://github.com").unwrap();
513        assert!(BwUrl::try_from(&url).is_err());
514    }
515
516    #[test]
517    fn parse_too_many_segments_fails() {
518        let url = Url::parse("bw://github.com/login/password").unwrap();
519        assert!(BwUrl::try_from(&url).is_err());
520    }
521
522    #[test]
523    fn parse_query_param_fails() {
524        let url = Url::parse("bw://github.com/login.password?raw=true").unwrap();
525        assert!(BwUrl::try_from(&url).is_err());
526    }
527
528    #[test]
529    fn error_map_not_found() {
530        let err = map_bw_response_error("Not found.", "bw://github.com/login.password");
531        assert!(matches!(err, Error::NotFound(ref s) if s == "bw://github.com/login.password"));
532    }
533
534    #[test]
535    fn error_map_multiple_results() {
536        let err = map_bw_response_error(
537            "More than one result was found. Try getting a specific object by `id` instead.",
538            "bw://github.com/login.password",
539        );
540        assert!(matches!(err, Error::NotFound(_)));
541    }
542
543    #[test]
544    fn error_map_vault_locked() {
545        let err = map_bw_response_error("Vault is locked.", "bw://github.com/login.password");
546        assert!(matches!(err, Error::AuthenticationFailed(_)));
547    }
548
549    #[test]
550    fn error_map_not_logged_in() {
551        let err = map_bw_response_error("You are not logged in.", "bw://github.com/login.password");
552        assert!(matches!(err, Error::AuthenticationFailed(_)));
553    }
554
555    #[test]
556    fn error_map_bot_detection() {
557        let err = map_bw_response_error(
558            "Your authentication request appears to be coming from a bot.",
559            "bw://github.com/login.password",
560        );
561        assert!(matches!(err, Error::AuthenticationFailed(_)));
562    }
563
564    #[test]
565    fn error_map_transient_network() {
566        for anchor in [
567            "fetch failed",
568            "timeout",
569            "connection reset",
570            "dial tcp",
571            "getaddrinfo",
572            "no such host",
573        ] {
574            let err = map_bw_response_error(anchor, "bw://github.com/login.password");
575            assert!(
576                matches!(
577                    err,
578                    Error::Backend {
579                        kind: BackendFailureKind::Transient,
580                        ..
581                    }
582                ),
583                "expected Transient for anchor: {}",
584                anchor
585            );
586        }
587    }
588
589    #[test]
590    fn error_map_org_policy() {
591        let err = map_bw_response_error(
592            "Access to this item type is restricted by organizational policy.",
593            "bw://github.com/login.password",
594        );
595        assert!(matches!(err, Error::PermissionDenied(_)));
596    }
597
598    #[test]
599    fn error_map_unmatched_is_permanent() {
600        let err = map_bw_response_error("some unexpected error", "bw://github.com/login.password");
601        assert!(matches!(
602            err,
603            Error::Backend {
604                kind: BackendFailureKind::Permanent,
605                ..
606            }
607        ));
608    }
609
610    #[test]
611    fn version_parse_valid() {
612        assert_eq!(parse_bw_version("2026.4.1"), Some((2026, 4, 1)));
613        assert_eq!(parse_bw_version("2023.1.0"), Some((2023, 1, 0)));
614        assert_eq!(parse_bw_version("cli-v2024.2.3"), Some((2024, 2, 3)));
615        assert_eq!(parse_bw_version("2026.4.1\n"), Some((2026, 4, 1)));
616    }
617
618    #[test]
619    fn version_parse_malformed() {
620        assert_eq!(parse_bw_version("not.a.version"), None);
621        assert_eq!(parse_bw_version(""), None);
622    }
623
624    #[test]
625    fn version_reject_too_old() {
626        let version = parse_bw_version("2022.12.0").unwrap();
627        assert!(version.0 < 2023 || (version.0 == 2023 && version.1 < 1));
628    }
629
630    #[test]
631    fn version_accept_exact_floor() {
632        let version = parse_bw_version("2023.1.0").unwrap();
633        assert!(!(version.0 < 2023 || (version.0 == 2023 && version.1 < 1)));
634    }
635
636    #[test]
637    fn preflight_auth_no_session_fails_fast() {
638        let _lock = ENV_LOCK.lock().unwrap();
639        let old_session = std::env::var("BW_SESSION").ok();
640        std::env::remove_var("BW_SESSION");
641
642        let result = check_ambient_credentials();
643
644        if let Some(v) = old_session {
645            std::env::set_var("BW_SESSION", v);
646        }
647
648        assert!(
649            matches!(result, Err(Error::AuthenticationFailed(_))),
650            "expected AuthenticationFailed when no BW_SESSION is present"
651        );
652    }
653
654    #[test]
655    fn preflight_auth_session_ok() {
656        let _lock = ENV_LOCK.lock().unwrap();
657        let _guard = EnvGuard::set("BW_SESSION", "test-session-key");
658        assert!(check_ambient_credentials().is_ok());
659    }
660
661    #[test]
662    fn redact_reference_replaces_url() {
663        let msg = "could not read secret bw://MyItem/login.password: not found";
664        let redacted = redact_reference(msg, "bw://MyItem/login.password");
665        assert_eq!(redacted, "could not read secret bw://<redacted>: not found");
666    }
667
668    #[test]
669    fn extract_field_nested() {
670        let data = serde_json::json!({
671            "login": {
672                "password": "secret123"
673            }
674        });
675        let secret = extract_field(&data, "login.password", "bw://item/login.password").unwrap();
676        assert_eq!(secret, "secret123");
677    }
678
679    #[test]
680    fn extract_field_top_level() {
681        let data = serde_json::json!({
682            "notes": "my note"
683        });
684        let secret = extract_field(&data, "notes", "bw://item/notes").unwrap();
685        assert_eq!(secret, "my note");
686    }
687
688    #[test]
689    fn extract_field_array_index() {
690        let data = serde_json::json!({
691            "fields": [
692                { "name": "API Key", "value": "sk-xxx" }
693            ]
694        });
695        let secret = extract_field(&data, "fields.0.value", "bw://item/fields.0.value").unwrap();
696        assert_eq!(secret, "sk-xxx");
697    }
698
699    #[test]
700    fn extract_field_missing() {
701        let data = serde_json::json!({
702            "login": {
703                "password": "secret123"
704            }
705        });
706        let err = extract_field(&data, "login.missing", "bw://item/login.missing").unwrap_err();
707        assert!(matches!(err, Error::NotFound(_)));
708    }
709
710    #[test]
711    fn extract_field_not_string() {
712        let data = serde_json::json!({
713            "login": {
714                "password": 12345
715            }
716        });
717        let err = extract_field(&data, "login.password", "bw://item/login.password").unwrap_err();
718        assert!(matches!(err, Error::NotFound(_)));
719    }
720}