1use 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#[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#[derive(Debug)]
93pub struct BwBackend {
94 init: Result<(), Error>,
95}
96
97const GET_TIMEOUT: Duration = Duration::from_secs(15);
102
103const EXISTS_TIMEOUT: Duration = Duration::from_secs(10);
108
109const VERSION_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
111
112impl BwBackend {
113 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 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
251fn 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
288fn 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
368fn 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
411fn 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
423fn redact_reference(message: &str, reference: &str) -> String {
425 message.replace(reference, "bw://<redacted>")
426}
427
428fn 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
442fn 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
455fn 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}