1use hasp_core::{Backend, BackendFailureKind, Entry, Error, SecretString};
36use std::process::{Command, Stdio};
37use std::thread;
38use std::time::{Duration, Instant};
39use url::Url;
40
41#[derive(Debug)]
46pub struct OpUrl {
47 pub vault: String,
48 pub item: String,
49 pub field: String,
50}
51
52#[derive(Debug)]
59pub struct OpListUrl {
60 pub vault: String,
61}
62
63impl TryFrom<&Url> for OpListUrl {
64 type Error = Error;
65
66 fn try_from(url: &Url) -> Result<Self, Self::Error> {
67 if url.scheme() != "op" {
68 return Err(Error::InvalidUrl("expected op:// scheme".into()));
69 }
70 if url.query().is_some() {
71 return Err(Error::InvalidUrl(
72 "op:// does not accept query parameters".into(),
73 ));
74 }
75 let vault = url
76 .host_str()
77 .ok_or_else(|| Error::InvalidUrl("op:// requires a vault (host)".into()))?;
78 if vault.is_empty() {
79 return Err(Error::InvalidUrl("op:// vault must not be empty".into()));
80 }
81
82 let extras: Vec<&str> = url
85 .path_segments()
86 .into_iter()
87 .flatten()
88 .filter(|s| !s.is_empty())
89 .collect();
90 if !extras.is_empty() {
91 return Err(Error::InvalidUrl(
92 "op:// list requires only a vault (no item or field)".into(),
93 ));
94 }
95
96 Ok(OpListUrl {
97 vault: vault.to_owned(),
98 })
99 }
100}
101
102impl TryFrom<&Url> for OpUrl {
103 type Error = Error;
104
105 fn try_from(url: &Url) -> Result<Self, Self::Error> {
106 if url.scheme() != "op" {
107 return Err(Error::InvalidUrl("expected op:// scheme".into()));
108 }
109
110 if url.query().is_some() {
111 return Err(Error::InvalidUrl(
112 "op:// does not accept query parameters".into(),
113 ));
114 }
115
116 let vault = url
117 .host_str()
118 .ok_or_else(|| Error::InvalidUrl("op:// requires a vault (host)".into()))?;
119 if vault.is_empty() {
120 return Err(Error::InvalidUrl("op:// vault must not be empty".into()));
121 }
122
123 let mut segments = url.path_segments().into_iter().flatten();
124
125 let item = segments
126 .next()
127 .ok_or_else(|| Error::InvalidUrl("op:// requires an item (path segment)".into()))?;
128 if item.is_empty() {
129 return Err(Error::InvalidUrl("op:// item must not be empty".into()));
130 }
131
132 let field = segments
133 .next()
134 .ok_or_else(|| Error::InvalidUrl("op:// requires a field (path segment)".into()))?;
135 if field.is_empty() {
136 return Err(Error::InvalidUrl("op:// field must not be empty".into()));
137 }
138
139 if segments.next().is_some() {
140 return Err(Error::InvalidUrl(
141 "op:// requires exactly 2 path segments (item/field) after the vault".into(),
142 ));
143 }
144
145 Ok(OpUrl {
146 vault: vault.to_owned(),
147 item: item.to_owned(),
148 field: field.to_owned(),
149 })
150 }
151}
152
153#[derive(Debug)]
164pub struct OpBackend {
165 init: Result<(), Error>,
166}
167
168const GET_TIMEOUT: Duration = Duration::from_secs(15);
174
175const EXISTS_TIMEOUT: Duration = Duration::from_secs(10);
181
182const VERSION_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
184
185impl OpBackend {
186 pub fn new() -> Self {
193 Self {
194 init: Self::check_version(),
195 }
196 }
197
198 fn ensure_init(&self) -> Result<(), Error> {
199 self.init.clone()
200 }
201
202 fn check_version() -> Result<(), Error> {
203 let output = run_op_with_timeout(&["--version"], VERSION_CHECK_TIMEOUT)?;
204
205 if !output.status.success() {
206 let stderr = String::from_utf8_lossy(&output.stderr);
207 let exit_code = output.status.code().unwrap_or(-1);
208 return Err(map_op_error(&stderr, exit_code, "op --version"));
209 }
210
211 if !output.stderr.is_empty() {
213 let stderr = String::from_utf8_lossy(&output.stderr);
214 return Err(Error::Backend {
215 scheme: "op",
216 kind: BackendFailureKind::Permanent,
217 message: format!(
218 "op exited 0 but emitted stderr: {}",
219 redact_reference(&stderr, "op --version")
220 ),
221 });
222 }
223
224 let stdout = String::from_utf8_lossy(&output.stdout);
225 let version = parse_op_version(&stdout).ok_or_else(|| Error::Backend {
226 scheme: "op",
227 kind: BackendFailureKind::Permanent,
228 message: format!("could not parse op version: {}", stdout.trim()),
229 })?;
230
231 if version.0 < 2 || (version.0 == 2 && version.1 < 30) {
232 return Err(Error::Backend {
233 scheme: "op",
234 kind: BackendFailureKind::Permanent,
235 message: format!(
236 "op CLI version {}.{}.{} is unsupported; hasp requires op >= 2.30.0",
237 version.0, version.1, version.2
238 ),
239 });
240 }
241
242 Ok(())
243 }
244}
245
246impl Default for OpBackend {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252impl Backend for OpBackend {
253 fn scheme(&self) -> &'static str {
254 "op"
255 }
256
257 fn validate(&self, url: &Url) -> Result<(), Error> {
258 OpUrl::try_from(url).map(|_| ())
259 }
260
261 fn get(&self, url: &Url) -> Result<SecretString, Error> {
262 self.ensure_init()?;
263 check_ambient_credentials()?;
264
265 let op_url = OpUrl::try_from(url)?;
266 let reference = format!("op://{}/{}/{}", op_url.vault, op_url.item, op_url.field);
267 let args: [&str; 3] = ["read", "--no-color", &reference];
268
269 let output = run_op_with_timeout(&args, GET_TIMEOUT)?;
270
271 if !output.status.success() {
272 let stderr = String::from_utf8_lossy(&output.stderr);
273 let exit_code = output.status.code().unwrap_or(-1);
274 return Err(map_op_error(&stderr, exit_code, &reference));
275 }
276
277 if !output.stderr.is_empty() {
280 let stderr = String::from_utf8_lossy(&output.stderr);
281 let redacted = redact_reference(&stderr, &reference);
282 return Err(Error::Backend {
283 scheme: "op",
284 kind: BackendFailureKind::Permanent,
285 message: format!("op exited 0 but emitted stderr: {redacted}"),
286 });
287 }
288
289 let mut stdout = output.stdout;
293 if stdout.ends_with(b"\n") {
294 stdout.pop();
295 }
296
297 let secret = String::from_utf8(stdout).map_err(|e| Error::Backend {
298 scheme: "op",
299 kind: BackendFailureKind::Permanent,
300 message: format!("op read produced invalid UTF-8: {e}"),
301 })?;
302
303 Ok(SecretString::new(secret.into()))
304 }
305
306 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
307 use hasp_core::ExposeSecret;
308
309 self.ensure_init()?;
310 check_ambient_credentials()?;
311
312 let op_url = OpUrl::try_from(url)?;
313 let reference = format!("op://{}/{}/{}", op_url.vault, op_url.item, op_url.field);
314
315 let assignment = format!("{}={}", op_url.field, value.expose_secret());
325
326 let edit_args: [&str; 6] = [
327 "item",
328 "edit",
329 &op_url.item,
330 "--vault",
331 &op_url.vault,
332 &assignment,
333 ];
334 let edit_output = run_op_with_timeout(&edit_args, GET_TIMEOUT)?;
335
336 if edit_output.status.success() {
337 return Ok(());
338 }
339
340 let edit_err = map_op_error(
341 &String::from_utf8_lossy(&edit_output.stderr),
342 edit_output.status.code().unwrap_or(-1),
343 &reference,
344 );
345
346 if !matches!(edit_err, Error::NotFound(_)) {
349 return Err(edit_err);
350 }
351
352 let create_args: [&str; 8] = [
353 "item",
354 "create",
355 "--vault",
356 &op_url.vault,
357 "--title",
358 &op_url.item,
359 "--category",
360 "password",
361 ];
362 let mut create_args = create_args.to_vec();
368 create_args.push(&assignment);
369 let create_output = run_op_with_timeout(&create_args, GET_TIMEOUT)?;
370
371 if !create_output.status.success() {
372 let stderr = String::from_utf8_lossy(&create_output.stderr);
373 let exit_code = create_output.status.code().unwrap_or(-1);
374 return Err(map_op_error(&stderr, exit_code, &reference));
375 }
376
377 Ok(())
378 }
379
380 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
381 self.ensure_init()?;
382 check_ambient_credentials()?;
383
384 let list_url = OpListUrl::try_from(url)?;
385 let args = ["item", "list", "--vault", &list_url.vault, "--format=json"];
391
392 let output = run_op_with_timeout(&args, EXISTS_TIMEOUT)?;
393 if !output.status.success() {
394 let stderr = String::from_utf8_lossy(&output.stderr);
395 let exit_code = output.status.code().unwrap_or(-1);
396 let reference = format!("op://{}", list_url.vault);
397 return Err(map_op_error(&stderr, exit_code, &reference));
398 }
399
400 let stdout = String::from_utf8(output.stdout).map_err(|e| Error::Backend {
401 scheme: "op",
402 kind: BackendFailureKind::Permanent,
403 message: format!("op item list produced invalid UTF-8: {e}"),
404 })?;
405
406 let items: Vec<serde_json::Value> =
407 serde_json::from_str(&stdout).map_err(|e| Error::Backend {
408 scheme: "op",
409 kind: BackendFailureKind::Permanent,
410 message: format!("op item list returned unparseable JSON: {e}"),
411 })?;
412
413 let mut entries = Vec::with_capacity(items.len());
414 for item in items {
415 let id = item
420 .get("id")
421 .and_then(|v| v.as_str())
422 .or_else(|| item.get("title").and_then(|v| v.as_str()));
423 let title = item
424 .get("title")
425 .and_then(|v| v.as_str())
426 .unwrap_or_else(|| id.unwrap_or("?"));
427 let Some(id) = id else { continue };
428
429 let category = item.get("category").and_then(|v| v.as_str());
439 let addressable = match category {
440 None => true, Some(c) => matches!(c, "LOGIN" | "PASSWORD"),
442 };
443 if !addressable {
444 continue;
445 }
446
447 let entry_url = format!("op://{}/{}/password", list_url.vault, id);
448 let parsed = Url::parse(&entry_url).map_err(|e| Error::Backend {
449 scheme: "op",
450 kind: BackendFailureKind::Permanent,
451 message: format!("op item list yielded malformed URL: {e}"),
452 })?;
453 entries.push(Entry {
454 name: title.to_owned(),
455 url: parsed,
456 });
457 }
458
459 Ok(entries)
460 }
461
462 fn delete(&self, url: &Url) -> Result<(), Error> {
463 self.ensure_init()?;
464 check_ambient_credentials()?;
465
466 let op_url = OpUrl::try_from(url)?;
467 let reference = format!("op://{}/{}/{}", op_url.vault, op_url.item, op_url.field);
468
469 let args = ["item", "delete", &op_url.item, "--vault", &op_url.vault];
473 let output = run_op_with_timeout(&args, EXISTS_TIMEOUT)?;
474
475 if output.status.success() {
476 return Ok(());
477 }
478
479 let stderr = String::from_utf8_lossy(&output.stderr);
480 let exit_code = output.status.code().unwrap_or(-1);
481 Err(map_op_error(&stderr, exit_code, &reference))
482 }
483
484 fn exists(&self, url: &Url) -> Result<bool, Error> {
485 self.ensure_init()?;
486 check_ambient_credentials()?;
487
488 let op_url = OpUrl::try_from(url)?;
489 let reference = format!("op://{}/{}/{}", op_url.vault, op_url.item, op_url.field);
490 let args: [&str; 3] = ["read", "--no-color", &reference];
491
492 let output = run_op_with_timeout(&args, EXISTS_TIMEOUT)?;
493
494 if output.status.success() {
495 return Ok(true);
496 }
497
498 let stderr = String::from_utf8_lossy(&output.stderr);
499 let exit_code = output.status.code().unwrap_or(-1);
500 let err = map_op_error(&stderr, exit_code, &reference);
501
502 match err {
503 Error::NotFound(_) => Ok(false),
504 _ => Err(err),
505 }
506 }
507}
508
509fn run_op_with_timeout(args: &[&str], timeout: Duration) -> Result<std::process::Output, Error> {
519 let mut child = Command::new("op")
520 .args(args)
521 .stdout(Stdio::piped())
522 .stderr(Stdio::piped())
523 .spawn()
524 .map_err(map_spawn_error)?;
525
526 let mut stdout_pipe = child.stdout.take().expect("piped stdout");
527 let mut stderr_pipe = child.stderr.take().expect("piped stderr");
528
529 let stdout_thread = thread::spawn(move || {
530 let mut buf = Vec::new();
531 std::io::Read::read_to_end(&mut stdout_pipe, &mut buf).ok();
532 buf
533 });
534
535 let stderr_thread = thread::spawn(move || {
536 let mut buf = Vec::new();
537 std::io::Read::read_to_end(&mut stderr_pipe, &mut buf).ok();
538 buf
539 });
540
541 let deadline = Instant::now() + timeout;
542 loop {
543 match child.try_wait() {
544 Ok(Some(status)) => {
545 let stdout = stdout_thread.join().unwrap_or_default();
546 let stderr = stderr_thread.join().unwrap_or_default();
547 return Ok(std::process::Output {
548 status,
549 stdout,
550 stderr,
551 });
552 }
553 Ok(None) => {
554 if Instant::now() >= deadline {
555 let _ = child.kill();
556 let _ = child.wait();
557 return Err(Error::Backend {
558 scheme: "op",
559 kind: BackendFailureKind::Transient,
560 message: "op invocation timed out".into(),
561 });
562 }
563 thread::sleep(Duration::from_millis(50));
564 }
565 Err(e) => {
566 return Err(Error::Backend {
567 scheme: "op",
568 kind: BackendFailureKind::Transient,
569 message: format!("failed to wait for op process: {e}"),
570 });
571 }
572 }
573 }
574}
575
576fn map_spawn_error(err: std::io::Error) -> Error {
577 use std::io::ErrorKind;
578 if err.kind() == ErrorKind::NotFound {
579 Error::Backend {
580 scheme: "op",
581 kind: BackendFailureKind::Permanent,
582 message: "op binary not found in PATH".into(),
583 }
584 } else {
585 Error::Backend {
586 scheme: "op",
587 kind: BackendFailureKind::Transient,
588 message: format!("failed to spawn op: {err}"),
589 }
590 }
591}
592
593fn map_op_error(stderr: &str, exit_code: i32, reference: &str) -> Error {
603 let lower = stderr.to_lowercase();
604
605 if lower.contains("could not find item")
606 || lower.contains("isn't a vault")
607 || lower.contains("isn't an item")
608 || lower.contains("more than one item matches")
609 {
610 return Error::NotFound(reference.to_string());
611 }
612
613 if lower.contains("not currently signed in")
614 || lower.contains("authorization timeout")
615 || lower.contains("connecting to desktop app")
616 || lower.contains("connection reset")
617 || lower.contains("signin credentials are not compatible")
618 {
619 return Error::AuthenticationFailed(redact_reference(stderr, reference));
620 }
621
622 if lower.contains("connection reset")
623 || lower.contains("dial")
624 || lower.contains("getaddrinfo")
625 || lower.contains("i/o timeout")
626 || lower.contains("eof")
627 || lower.contains("no such host")
628 {
629 return Error::Backend {
630 scheme: "op",
631 kind: BackendFailureKind::Transient,
632 message: redact_reference(stderr, reference),
633 };
634 }
635
636 let redacted = redact_reference(stderr, reference);
637 Error::Backend {
638 scheme: "op",
639 kind: BackendFailureKind::Permanent,
640 message: format!("op exited with code {exit_code}: {redacted}"),
641 }
642}
643
644fn redact_reference(stderr: &str, reference: &str) -> String {
650 stderr.replace(reference, "op://<redacted>")
651}
652
653fn parse_op_version(output: &str) -> Option<(u32, u32, u32)> {
659 let trimmed = output.trim();
660 let version_part = trimmed.split_whitespace().next()?;
661 let mut parts = version_part.split(|c: char| !c.is_ascii_digit());
662 let major = parts.next()?.parse::<u32>().ok()?;
663 let minor = parts.next()?.parse::<u32>().ok()?;
664 let patch = parts.next()?.parse::<u32>().ok()?;
665 Some((major, minor, patch))
666}
667
668fn check_ambient_credentials() -> Result<(), Error> {
674 let has_service = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
675 let has_session = std::env::vars().any(|(k, _)| k.starts_with("OP_SESSION_"));
676 let has_connect =
677 std::env::var("OP_CONNECT_TOKEN").is_ok() && std::env::var("OP_CONNECT_HOST").is_ok();
678
679 if !has_service && !has_session && !has_connect {
680 return Err(Error::AuthenticationFailed(
681 "no ambient 1Password credentials detected; set OP_SERVICE_ACCOUNT_TOKEN, run `op signin`, or configure Connect".into(),
682 ));
683 }
684
685 Ok(())
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use hasp_core::test_utils::{EnvGuard, ENV_LOCK};
692
693 #[test]
694 fn parse_valid_url() {
695 let url = Url::parse("op://vault/item/field").unwrap();
696 let op = OpUrl::try_from(&url).unwrap();
697 assert_eq!(op.vault, "vault");
698 assert_eq!(op.item, "item");
699 assert_eq!(op.field, "field");
700 }
701
702 #[test]
703 fn parse_empty_segment_fails() {
704 let url = Url::parse("op://vault//field").unwrap();
705 assert!(OpUrl::try_from(&url).is_err());
706 }
707
708 #[test]
709 fn parse_too_few_segments_fails() {
710 let url = Url::parse("op://vault/item").unwrap();
711 assert!(OpUrl::try_from(&url).is_err());
712 }
713
714 #[test]
715 fn parse_too_many_segments_fails() {
716 let url = Url::parse("op://vault/item/field/extra").unwrap();
717 assert!(OpUrl::try_from(&url).is_err());
718 }
719
720 #[test]
721 fn parse_query_param_fails() {
722 let url = Url::parse("op://vault/item/field?raw=true").unwrap();
723 assert!(OpUrl::try_from(&url).is_err());
724 }
725
726 #[test]
727 fn error_map_not_found_item() {
728 let err = map_op_error(
729 "[ERROR] 2024/12/29 23:17:25 could not find item MyItem in vault MyVault",
730 1,
731 "op://MyVault/MyItem/field",
732 );
733 assert!(matches!(err, Error::NotFound(ref s) if s == "op://MyVault/MyItem/field"));
734 }
735
736 #[test]
737 fn error_map_not_found_vault() {
738 let err = map_op_error(
739 r#"[ERROR] 2025/08/08 15:24:36 "Private" isn't a vault in this account."#,
740 1,
741 "op://Private/Item/field",
742 );
743 assert!(matches!(err, Error::NotFound(_)));
744 }
745
746 #[test]
747 fn error_map_not_found_item_isnt() {
748 let err = map_op_error(
749 r#"[ERROR] 2022/07/06 23:28:40 "-" isn't an item."#,
750 1,
751 "op://vault/-/field",
752 );
753 assert!(matches!(err, Error::NotFound(_)));
754 }
755
756 #[test]
757 fn error_map_not_found_multi_match() {
758 let err = map_op_error("more than one item matches", 1, "op://vault/item/field");
759 assert!(matches!(err, Error::NotFound(_)));
760 }
761
762 #[test]
763 fn error_map_auth_not_signed_in() {
764 let err = map_op_error(
765 "(ERROR) You are not currently signed in.",
766 1,
767 "op://vault/item/field",
768 );
769 assert!(matches!(err, Error::AuthenticationFailed(_)));
770 }
771
772 #[test]
773 fn error_map_auth_timeout() {
774 let err = map_op_error(
775 "[ERROR] 2025/07/11 10:16:41 authorization timeout",
776 1,
777 "op://vault/item/field",
778 );
779 assert!(matches!(err, Error::AuthenticationFailed(_)));
780 }
781
782 #[test]
783 fn error_map_auth_desktop_app() {
784 let err = map_op_error(
785 "connecting to desktop app: read: connection reset",
786 1,
787 "op://vault/item/field",
788 );
789 assert!(matches!(err, Error::AuthenticationFailed(_)));
790 }
791
792 #[test]
793 fn error_map_auth_incompatible() {
794 let err = map_op_error(
795 "Signin credentials are not compatible with the provided user auth from server",
796 1,
797 "op://vault/item/field",
798 );
799 assert!(matches!(err, Error::AuthenticationFailed(_)));
800 }
801
802 #[test]
803 fn error_map_transient_network() {
804 for anchor in [
805 "dial tcp",
806 "getaddrinfo",
807 "i/o timeout",
808 "EOF",
809 "no such host",
810 ] {
811 let err = map_op_error(anchor, 1, "op://vault/item/field");
812 assert!(
813 matches!(
814 err,
815 Error::Backend {
816 kind: BackendFailureKind::Transient,
817 ..
818 }
819 ),
820 "expected Transient for anchor: {}",
821 anchor
822 );
823 }
824 }
825
826 #[test]
827 fn error_map_unmatched_is_permanent() {
828 let err = map_op_error("some unexpected error from op", 1, "op://vault/item/field");
829 assert!(matches!(
830 err,
831 Error::Backend {
832 kind: BackendFailureKind::Permanent,
833 ..
834 }
835 ));
836 }
837
838 #[test]
839 fn error_map_first_anchor_wins() {
840 let err = map_op_error(
843 "not currently signed in and connection reset",
844 1,
845 "op://vault/item/field",
846 );
847 assert!(matches!(err, Error::AuthenticationFailed(_)));
848 }
849
850 #[test]
851 fn version_parse_valid() {
852 assert_eq!(parse_op_version("2.30.0"), Some((2, 30, 0)));
853 assert_eq!(parse_op_version("2.30.0-beta.1"), Some((2, 30, 0)));
854 assert_eq!(parse_op_version("2.30.0\n"), Some((2, 30, 0)));
855 }
856
857 #[test]
858 fn version_parse_malformed() {
859 assert_eq!(parse_op_version("not.a.version"), None);
860 assert_eq!(parse_op_version(""), None);
861 }
862
863 #[test]
864 fn version_reject_too_old() {
865 let version = parse_op_version("2.29.0").unwrap();
868 assert!(version.0 < 2 || (version.0 == 2 && version.1 < 30));
869 }
870
871 #[test]
872 fn version_accept_exact_floor() {
873 let version = parse_op_version("2.30.0").unwrap();
874 assert!(!(version.0 < 2 || (version.0 == 2 && version.1 < 30)));
875 }
876
877 #[test]
878 fn preflight_auth_no_creds_fails_fast() {
879 let _lock = ENV_LOCK.lock().unwrap();
880
881 let old_service = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok();
882 let old_connect_token = std::env::var("OP_CONNECT_TOKEN").ok();
883 let old_connect_host = std::env::var("OP_CONNECT_HOST").ok();
884
885 std::env::remove_var("OP_SERVICE_ACCOUNT_TOKEN");
886 std::env::remove_var("OP_CONNECT_TOKEN");
887 std::env::remove_var("OP_CONNECT_HOST");
888
889 for (k, _) in std::env::vars().filter(|(k, _)| k.starts_with("OP_SESSION_")) {
891 std::env::remove_var(&k);
892 }
893
894 let result = check_ambient_credentials();
895
896 if let Some(v) = old_service {
897 std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", v);
898 }
899 if let Some(v) = old_connect_token {
900 std::env::set_var("OP_CONNECT_TOKEN", v);
901 }
902 if let Some(v) = old_connect_host {
903 std::env::set_var("OP_CONNECT_HOST", v);
904 }
905
906 assert!(
907 matches!(result, Err(Error::AuthenticationFailed(_))),
908 "expected AuthenticationFailed when no ambient credentials are present"
909 );
910 }
911
912 #[test]
913 fn preflight_auth_service_account_ok() {
914 let _lock = ENV_LOCK.lock().unwrap();
915 let _guard = EnvGuard::set("OP_SERVICE_ACCOUNT_TOKEN", "test-token");
916 assert!(check_ambient_credentials().is_ok());
917 }
918
919 #[test]
920 fn redact_reference_replaces_url() {
921 let msg = "could not read secret op://MyVault/MyItem/field: not found";
922 let redacted = redact_reference(msg, "op://MyVault/MyItem/field");
923 assert_eq!(redacted, "could not read secret op://<redacted>: not found");
924 }
925}