1#![forbid(unsafe_code)]
48#![allow(clippy::module_name_repetitions)]
49
50use std::collections::HashMap;
51use std::io;
52use std::time::Duration;
53
54use anyhow::{anyhow, bail, Context, Result};
55use async_trait::async_trait;
56use secretenv_core::{
57 optional_bool, optional_duration_secs, Backend, BackendFactory, BackendStatus, BackendUri,
58 Secret, DEFAULT_GET_TIMEOUT,
59};
60use serde::Deserialize;
61use tokio::process::Command;
62
63const CLI_NAME: &str = "op";
64const INSTALL_HINT: &str =
65 "install via https://developer.1password.com/docs/cli/get-started/ (Homebrew: brew install 1password-cli)";
66
67pub struct OnePasswordBackend {
69 backend_type: &'static str,
70 instance_name: String,
71 op_account: Option<String>,
72 op_bin: String,
75 timeout: Duration,
77 op_unsafe_set: bool,
86}
87
88#[derive(Deserialize)]
89struct WhoAmI {
90 url: String,
91 #[serde(default)]
92 email: String,
93}
94
95impl OnePasswordBackend {
96 fn parse_path(uri: &BackendUri) -> Result<(String, String, String)> {
99 let path = uri.path.strip_prefix('/').unwrap_or(&uri.path);
100 let parts: Vec<&str> = path.split('/').collect();
101 if parts.len() != 3 || parts.iter().any(|s| s.is_empty()) {
102 bail!(
103 "1password URI '{}' must have exactly three non-empty path segments \
104 (vault/item/field); got {} — v0.1 does not support nested sections",
105 uri.raw,
106 parts.len()
107 );
108 }
109 Ok((parts[0].to_owned(), parts[1].to_owned(), parts[2].to_owned()))
110 }
111
112 fn append_account(&self, cmd: &mut Command) {
113 if let Some(account) = &self.op_account {
114 cmd.args(["--account", account]);
115 }
116 }
117
118 fn cli_missing() -> BackendStatus {
119 BackendStatus::CliMissing {
120 cli_name: CLI_NAME.to_owned(),
121 install_hint: INSTALL_HINT.to_owned(),
122 }
123 }
124
125 fn operation_failure_message(&self, uri: &BackendUri, op: &str, stderr: &[u8]) -> String {
126 let stderr_str = String::from_utf8_lossy(stderr).trim().to_owned();
127 format!(
128 "1password backend '{}': {op} failed for URI '{}': {stderr_str}",
129 self.instance_name, uri.raw
130 )
131 }
132}
133
134#[async_trait]
135impl Backend for OnePasswordBackend {
136 fn backend_type(&self) -> &str {
137 self.backend_type
138 }
139
140 fn instance_name(&self) -> &str {
141 &self.instance_name
142 }
143
144 fn timeout(&self) -> Duration {
145 self.timeout
146 }
147
148 #[allow(clippy::similar_names)]
149 async fn check(&self) -> BackendStatus {
150 let version_fut = Command::new(&self.op_bin).arg("--version").output();
153 let mut whoami_cmd = Command::new(&self.op_bin);
154 whoami_cmd.args(["whoami", "--format=json"]);
155 self.append_account(&mut whoami_cmd);
156 let whoami_fut = whoami_cmd.output();
157 let (version_res, whoami_res) = tokio::join!(version_fut, whoami_fut);
158
159 let version_out = match version_res {
161 Ok(o) => o,
162 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::cli_missing(),
163 Err(e) => {
164 return BackendStatus::Error {
165 message: format!(
166 "1password backend '{}': failed to invoke '{}': {e}",
167 self.instance_name, self.op_bin
168 ),
169 };
170 }
171 };
172 if !version_out.status.success() {
173 return BackendStatus::Error {
174 message: format!(
175 "1password backend '{}': 'op --version' exited non-zero: {}",
176 self.instance_name,
177 String::from_utf8_lossy(&version_out.stderr).trim()
178 ),
179 };
180 }
181 let version_str = String::from_utf8_lossy(&version_out.stdout).trim().to_owned();
182 let cli_version = format!("op/{version_str}");
183
184 let whoami_out = match whoami_res {
186 Ok(o) => o,
187 Err(e) => {
188 return BackendStatus::Error {
189 message: format!(
190 "1password backend '{}': failed to invoke whoami: {e}",
191 self.instance_name
192 ),
193 };
194 }
195 };
196 if !whoami_out.status.success() {
197 let stderr = String::from_utf8_lossy(&whoami_out.stderr).trim().to_owned();
198 let signin_hint = self
199 .op_account
200 .as_ref()
201 .map_or_else(|| "op signin".to_owned(), |a| format!("op signin --account {a}"));
202 return BackendStatus::NotAuthenticated {
203 hint: format!("run: {signin_hint} (stderr: {stderr})"),
204 };
205 }
206 let who: WhoAmI = match serde_json::from_slice(&whoami_out.stdout) {
207 Ok(w) => w,
208 Err(e) => {
209 return BackendStatus::Error {
210 message: format!(
211 "1password backend '{}': parsing whoami JSON: {e}",
212 self.instance_name
213 ),
214 };
215 }
216 };
217 let email_part =
218 if who.email.is_empty() { String::new() } else { format!(" email={}", who.email) };
219 BackendStatus::Ok { cli_version, identity: format!("account={}{email_part}", who.url) }
220 }
221
222 async fn get(&self, uri: &BackendUri) -> Result<Secret<String>> {
225 uri.reject_any_fragment("1password")?;
226 let (vault, item, field) = Self::parse_path(uri)?;
227 debug_assert!(!vault.contains('/') && !item.contains('/') && !field.contains('/'));
231 let op_uri = format!("op://{vault}/{item}/{field}");
232 let mut cmd = Command::new(&self.op_bin);
233 cmd.args(["read", &op_uri]);
234 self.append_account(&mut cmd);
235 let output = cmd.output().await.with_context(|| {
236 format!(
237 "1password backend '{}': failed to invoke 'op read' for URI '{}'",
238 self.instance_name, uri.raw
239 )
240 })?;
241 if !output.status.success() {
242 bail!(self.operation_failure_message(uri, "get", &output.stderr));
243 }
244 let stdout = String::from_utf8(output.stdout).with_context(|| {
245 format!(
246 "1password backend '{}': non-UTF-8 response for URI '{}'",
247 self.instance_name, uri.raw
248 )
249 })?;
250 Ok(Secret::new(stdout.strip_suffix("\n").unwrap_or(&stdout).to_owned()))
251 }
252
253 async fn set(&self, uri: &BackendUri, value: &str) -> Result<()> {
254 uri.reject_any_fragment("1password")?;
255 if !self.op_unsafe_set {
271 bail!(
272 "1password backend '{instance}': refusing `set` for URI '{uri}' — the `op` CLI \
273 has no portable stdin-fed value form across 1.x/2.x, so the secret would pass \
274 through subprocess argv (visible via /proc/<pid>/cmdline on multi-user Linux \
275 hosts). Either edit the field manually with `op item edit`, or opt in by adding \
276 `op_unsafe_set = true` to [backends.{instance}] (acknowledging the exposure on \
277 single-user / audited hosts).",
278 instance = self.instance_name,
279 uri = uri.raw,
280 );
281 }
282 let (vault, item, field) = Self::parse_path(uri)?;
283 let assignment = format!("{field}={value}");
284 tracing::warn!(
285 instance = self.instance_name.as_str(),
286 uri = uri.raw.as_str(),
287 "`op item edit` passes the field value through subprocess argv \
288 (op_unsafe_set = true was set; CV-1 exposure acknowledged) — \
289 do not run on multi-user hosts unless audited"
290 );
291 let mut cmd = Command::new(&self.op_bin);
292 cmd.args(["item", "edit", &item, &assignment, "--vault", &vault]);
293 self.append_account(&mut cmd);
294 let output = cmd.output().await.with_context(|| {
295 format!(
296 "1password backend '{}': failed to invoke 'op item edit' for URI '{}'",
297 self.instance_name, uri.raw
298 )
299 })?;
300 if !output.status.success() {
301 bail!(self.operation_failure_message(uri, "set", &output.stderr));
302 }
303 Ok(())
304 }
305
306 async fn write_secret(&self, uri: &BackendUri, value: &Secret<String>) -> Result<()> {
313 if !self.op_unsafe_set {
314 return Err(secretenv_core::BackendError::WriteNotSupported {
315 backend_type: self.backend_type().to_owned(),
316 reason: "op_unsafe_set is false — set the flag in [backends.<instance>] of config.toml to opt in",
317 }
318 .into());
319 }
320 self.set(uri, value.expose_secret()).await
321 }
322
323 async fn delete_secret(&self, uri: &BackendUri) -> Result<()> {
327 if !self.op_unsafe_set {
328 return Err(secretenv_core::BackendError::DeleteNotSupported {
329 backend_type: self.backend_type().to_owned(),
330 reason: "op_unsafe_set is false — set the flag in [backends.<instance>] of config.toml to opt in",
331 }
332 .into());
333 }
334 self.delete(uri).await
335 }
336
337 fn delete_hint(&self, uri: &BackendUri) -> String {
345 let account_flag =
346 self.op_account.as_deref().map_or_else(String::new, |a| format!(" --account {a}"));
347 if let Ok((vault, item, field)) = Self::parse_path(uri) {
348 format!(
349 "# 1Password has no field-removal; this clears the field to empty:\n\
350 op item edit \"{item}\" \"{field}=\" --vault \"{vault}\"{account_flag}"
351 )
352 } else {
353 format!(
354 "# 1Password has no field-removal; clear the field by setting it empty:\n\
355 op item edit \"<item>\" \"<field>=\" --vault \"<vault>\"{account_flag}"
356 )
357 }
358 }
359
360 async fn delete(&self, uri: &BackendUri) -> Result<()> {
361 uri.reject_any_fragment("1password")?;
362 let (vault, item, field) = Self::parse_path(uri)?;
363 let assignment = format!("{field}=");
366 let mut cmd = Command::new(&self.op_bin);
367 cmd.args(["item", "edit", &item, &assignment, "--vault", &vault]);
368 self.append_account(&mut cmd);
369 let output = cmd.output().await.with_context(|| {
370 format!(
371 "1password backend '{}': failed to invoke 'op item edit' for URI '{}'",
372 self.instance_name, uri.raw
373 )
374 })?;
375 if !output.status.success() {
376 bail!(self.operation_failure_message(uri, "delete", &output.stderr));
377 }
378 Ok(())
379 }
380
381 async fn list(&self, uri: &BackendUri) -> Result<Vec<(String, String)>> {
382 let body = self.get(uri).await?;
383 let parsed: HashMap<String, String> =
384 toml::from_str(body.expose_secret()).with_context(|| {
385 format!(
386 "1password backend '{}': field at '{}' is not a flat TOML key→string map \
387 (v0.1 only supports registry-document shape)",
388 self.instance_name, uri.raw
389 )
390 })?;
391 Ok(parsed.into_iter().collect())
392 }
393
394 fn registry_format(&self) -> secretenv_core::RegistryFormat {
395 secretenv_core::RegistryFormat::Toml
396 }
397}
398
399pub struct OnePasswordFactory(&'static str);
402
403impl OnePasswordFactory {
404 #[must_use]
406 pub const fn new() -> Self {
407 Self("1password")
408 }
409}
410
411impl Default for OnePasswordFactory {
412 fn default() -> Self {
413 Self::new()
414 }
415}
416
417impl BackendFactory for OnePasswordFactory {
418 fn backend_type(&self) -> &str {
419 self.0
420 }
421
422 fn create(
423 &self,
424 instance_name: &str,
425 config: &HashMap<String, toml::Value>,
426 ) -> Result<Box<dyn Backend>> {
427 let op_account = match config.get("op_account") {
428 None => None,
429 Some(v) => Some(v.as_str().map(str::to_owned).ok_or_else(|| {
430 anyhow!(
431 "1password instance '{instance_name}': field 'op_account' must be a \
432 string, got {}",
433 v.type_str()
434 )
435 })?),
436 };
437 let timeout = optional_duration_secs(config, "timeout_secs", "1password", instance_name)?
438 .unwrap_or(DEFAULT_GET_TIMEOUT);
439 let op_unsafe_set =
440 optional_bool(config, "op_unsafe_set", "1password", instance_name)?.unwrap_or(false);
441 Ok(Box::new(OnePasswordBackend {
442 backend_type: "1password",
443 instance_name: instance_name.to_owned(),
444 op_account,
445 op_bin: CLI_NAME.to_owned(),
446 timeout,
447 op_unsafe_set,
448 }))
449 }
450}
451
452#[cfg(test)]
453#[allow(clippy::unwrap_used, clippy::expect_used)]
454mod tests {
455 use std::path::Path;
456
457 use secretenv_testing::{Response, StrictMock};
458 use tempfile::TempDir;
459
460 use super::*;
461
462 fn backend(mock_path: &Path, account: Option<&str>) -> OnePasswordBackend {
463 OnePasswordBackend {
468 backend_type: "1password",
469 instance_name: "1password-personal".to_owned(),
470 op_account: account.map(ToOwned::to_owned),
471 op_bin: mock_path.to_str().unwrap().to_owned(),
472 timeout: DEFAULT_GET_TIMEOUT,
473 op_unsafe_set: true,
474 }
475 }
476
477 fn backend_safe_default(mock_path: &Path) -> OnePasswordBackend {
478 OnePasswordBackend {
479 backend_type: "1password",
480 instance_name: "1password-personal".to_owned(),
481 op_account: None,
482 op_bin: mock_path.to_str().unwrap().to_owned(),
483 timeout: DEFAULT_GET_TIMEOUT,
484 op_unsafe_set: false,
485 }
486 }
487
488 fn backend_with_nonexistent_op() -> OnePasswordBackend {
489 OnePasswordBackend {
490 backend_type: "1password",
491 instance_name: "1password-personal".to_owned(),
492 op_account: None,
493 op_bin: "/definitely/not/a/real/path/to/op-binary-98765".to_owned(),
494 timeout: DEFAULT_GET_TIMEOUT,
495 op_unsafe_set: false,
496 }
497 }
498
499 #[test]
502 fn factory_builds_backend_with_no_required_fields() {
503 let factory = OnePasswordFactory::new();
504 let cfg: HashMap<String, toml::Value> = HashMap::new();
505 let b = factory.create("1password-personal", &cfg).unwrap();
506 assert_eq!(b.backend_type(), "1password");
507 assert_eq!(b.instance_name(), "1password-personal");
508 }
509
510 #[test]
511 fn factory_accepts_op_account() {
512 let factory = OnePasswordFactory::new();
513 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
514 cfg.insert("op_account".to_owned(), toml::Value::String("myteam.1password.com".to_owned()));
515 assert!(factory.create("1password-team", &cfg).is_ok());
516 }
517
518 #[test]
519 fn factory_rejects_non_string_op_account() {
520 let factory = OnePasswordFactory::new();
521 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
522 cfg.insert("op_account".to_owned(), toml::Value::Boolean(true));
523 let Err(err) = factory.create("1password-team", &cfg) else {
524 panic!("expected error for non-string op_account");
525 };
526 let msg = format!("{err:#}");
527 assert!(msg.contains("op_account"), "names the field: {msg}");
528 }
529
530 #[test]
531 fn factory_backend_type_is_1password() {
532 assert_eq!(OnePasswordFactory::new().backend_type(), "1password");
533 }
534
535 #[test]
536 fn factory_op_unsafe_set_accepts_true() {
537 let factory = OnePasswordFactory::new();
538 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
539 cfg.insert("op_unsafe_set".to_owned(), toml::Value::Boolean(true));
540 assert!(factory.create("1password-personal", &cfg).is_ok());
541 }
542
543 #[test]
544 fn factory_rejects_non_bool_op_unsafe_set() {
545 let factory = OnePasswordFactory::new();
546 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
547 cfg.insert("op_unsafe_set".to_owned(), toml::Value::String("yes".to_owned()));
548 let Err(err) = factory.create("1password-personal", &cfg) else {
549 panic!("expected error for non-bool op_unsafe_set");
550 };
551 let msg = format!("{err:#}");
552 assert!(msg.contains("op_unsafe_set"), "names the field: {msg}");
553 assert!(msg.contains("must be a boolean"), "describes type mismatch: {msg}");
554 }
555
556 #[test]
557 fn factory_honors_timeout_secs() {
558 let factory = OnePasswordFactory::new();
559 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
560 cfg.insert("timeout_secs".to_owned(), toml::Value::Integer(17));
561 let b = factory.create("1password-personal", &cfg).unwrap();
562 assert_eq!(b.timeout(), Duration::from_secs(17));
563 }
564
565 #[test]
566 fn factory_uses_default_timeout_when_omitted() {
567 let factory = OnePasswordFactory::new();
568 let cfg: HashMap<String, toml::Value> = HashMap::new();
569 let b = factory.create("1password-personal", &cfg).unwrap();
570 assert_eq!(b.timeout(), DEFAULT_GET_TIMEOUT);
571 }
572
573 #[test]
574 fn factory_rejects_non_integer_timeout_secs() {
575 let factory = OnePasswordFactory::new();
576 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
577 cfg.insert("timeout_secs".to_owned(), toml::Value::String("30".to_owned()));
578 let Err(err) = factory.create("1password-personal", &cfg) else {
579 panic!("expected error for non-integer timeout_secs");
580 };
581 let msg = format!("{err:#}");
582 assert!(msg.contains("timeout_secs"), "names the field: {msg}");
583 assert!(msg.contains("must be an integer"), "describes type mismatch: {msg}");
584 }
585
586 #[test]
589 fn parse_path_three_segments_happy() {
590 let uri = BackendUri::parse("1password-personal://Engineering/Prod DB/url").unwrap();
591 let (v, i, f) = OnePasswordBackend::parse_path(&uri).unwrap();
592 assert_eq!(v, "Engineering");
593 assert_eq!(i, "Prod DB");
594 assert_eq!(f, "url");
595 }
596
597 #[test]
598 fn parse_path_tolerates_leading_slash() {
599 let uri = BackendUri::parse("1password-personal:///Engineering/DB/url").unwrap();
600 let (v, i, f) = OnePasswordBackend::parse_path(&uri).unwrap();
601 assert_eq!(v, "Engineering");
602 assert_eq!(i, "DB");
603 assert_eq!(f, "url");
604 }
605
606 #[test]
607 fn parse_path_rejects_two_segments() {
608 let uri = BackendUri::parse("1password-personal://vault/item").unwrap();
609 let err = OnePasswordBackend::parse_path(&uri).unwrap_err();
610 assert!(format!("{err:#}").contains("vault/item/field"));
611 }
612
613 #[test]
614 fn parse_path_rejects_four_segments() {
615 let uri = BackendUri::parse("1password-personal://vault/item/section/field").unwrap();
616 let err = OnePasswordBackend::parse_path(&uri).unwrap_err();
617 assert!(format!("{err:#}").contains("three"));
618 }
619
620 #[test]
621 fn parse_path_rejects_empty_segment() {
622 let uri = BackendUri::parse("1password-personal://vault//field").unwrap();
623 let err = OnePasswordBackend::parse_path(&uri).unwrap_err();
624 assert!(format!("{err:#}").contains("non-empty"));
625 }
626
627 #[tokio::test]
630 async fn get_returns_field_value_no_account() {
631 let dir = TempDir::new().unwrap();
632 let mock = StrictMock::new("op")
633 .on(&["read", "op://Eng/API/key"], Response::success("super-secret-value\n"))
634 .install(dir.path());
635 let b = backend(&mock, None);
636 let uri = BackendUri::parse("1password-personal://Eng/API/key").unwrap();
637 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "super-secret-value");
638 }
639
640 #[tokio::test]
641 async fn get_returns_field_value_with_account() {
642 let dir = TempDir::new().unwrap();
643 let mock = StrictMock::new("op")
644 .on(
645 &["read", "op://Eng/API/key", "--account", "myteam.1password.com"],
646 Response::success("value-from-team\n"),
647 )
648 .install(dir.path());
649 let b = backend(&mock, Some("myteam.1password.com"));
650 let uri = BackendUri::parse("1password-team://Eng/API/key").unwrap();
651 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "value-from-team");
652 }
653
654 #[tokio::test]
655 async fn get_strips_single_trailing_newline() {
656 let dir = TempDir::new().unwrap();
657 let mock = StrictMock::new("op")
658 .on(&["read", "op://V/I/F"], Response::success("raw-secret\n"))
659 .install(dir.path());
660 let b = backend(&mock, None);
661 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
662 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "raw-secret");
663 }
664
665 #[tokio::test]
668 async fn get_item_not_found_error_includes_stderr_and_uri() {
669 let dir = TempDir::new().unwrap();
670 let mock = StrictMock::new("op")
671 .on(
672 &["read", "op://Engineering/ProdDB/url"],
673 Response::failure(
674 1,
675 "[ERROR] 2026/04/17 14:00:00 item \"ProdDB\" not found in vault \"Engineering\"\n",
676 ),
677 )
678 .install(dir.path());
679 let b = backend(&mock, None);
680 let uri = BackendUri::parse("1password-personal://Engineering/ProdDB/url").unwrap();
681 let err = b.get(&uri).await.unwrap_err();
682 let msg = format!("{err:#}");
683 assert!(msg.contains("not found"), "stderr propagates: {msg}");
684 assert!(msg.contains("ProdDB"), "uri in context: {msg}");
685 assert!(msg.contains("1password-personal"), "instance in context: {msg}");
686 }
687
688 #[tokio::test]
689 async fn get_fails_fast_when_binary_missing() {
690 let b = backend_with_nonexistent_op();
691 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
692 let err = b.get(&uri).await.unwrap_err();
693 let msg = format!("{err:#}");
694 assert!(msg.contains("1password-personal"), "instance in context: {msg}");
695 }
696
697 #[tokio::test]
703 async fn get_non_utf8_response_errors_with_context() {
704 let dir = TempDir::new().unwrap();
705 let mock = secretenv_testing::install_mock(
707 dir.path(),
708 "op",
709 r#"
710if [ "$1" = "read" ]; then
711 printf '\377\376\375\374'
712 exit 0
713fi
714exit 1
715"#,
716 );
717 let b = backend(&mock, None);
718 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
719 let err = b.get(&uri).await.unwrap_err();
720 let msg = format!("{err:#}");
721 assert!(msg.contains("non-UTF-8"), "specific error: {msg}");
722 assert!(msg.contains("1password-personal"), "instance in context: {msg}");
723 }
724
725 #[tokio::test]
728 async fn set_succeeds_on_zero_exit() {
729 let dir = TempDir::new().unwrap();
730 let mock = StrictMock::new("op")
731 .on(&["item", "edit", "I", "F=new-secret", "--vault", "V"], Response::success(""))
732 .install(dir.path());
733 let b = backend(&mock, None);
734 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
735 b.set(&uri, "new-secret").await.unwrap();
736 }
737
738 #[tokio::test]
739 async fn set_propagates_item_not_found_error() {
740 let dir = TempDir::new().unwrap();
741 let mock = StrictMock::new("op")
742 .on(
743 &["item", "edit", "I", "F=v", "--vault", "V"],
744 Response::failure(1, "[ERROR] item \"I\" not found in vault \"V\"\n"),
745 )
746 .install(dir.path());
747 let b = backend(&mock, None);
748 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
749 let err = b.set(&uri, "v").await.unwrap_err();
750 assert!(format!("{err:#}").contains("not found"));
751 }
752
753 #[tokio::test]
754 async fn delete_runs_edit_with_empty_value() {
755 let dir = TempDir::new().unwrap();
761 let mock = StrictMock::new("op")
762 .on(&["item", "edit", "I", "F=", "--vault", "V"], Response::success(""))
763 .install(dir.path());
764 let b = backend(&mock, None);
765 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
766 b.delete(&uri).await.unwrap();
767 }
768
769 #[tokio::test]
770 async fn list_parses_toml_registry_document() {
771 let dir = TempDir::new().unwrap();
772 let body = "stripe-key = \"aws-ssm-prod:///prod/stripe\"\n\
773 db-url = \"1password-personal://Engineering/ProdDB/url\"\n";
774 let mock = StrictMock::new("op")
775 .on(&["read", "op://Shared/Registry/content"], Response::success(body))
776 .install(dir.path());
777 let b = backend(&mock, None);
778 let uri = BackendUri::parse("1password-personal://Shared/Registry/content").unwrap();
779 let mut entries = b.list(&uri).await.unwrap();
780 entries.sort_by(|a, b| a.0.cmp(&b.0));
781 assert_eq!(
782 entries,
783 vec![
784 ("db-url".to_owned(), "1password-personal://Engineering/ProdDB/url".to_owned(),),
785 ("stripe-key".to_owned(), "aws-ssm-prod:///prod/stripe".to_owned()),
786 ]
787 );
788 }
789
790 #[tokio::test]
791 async fn list_errors_when_body_is_not_flat_toml() {
792 let dir = TempDir::new().unwrap();
793 let mock = StrictMock::new("op")
794 .on(&["read", "op://V/I/F"], Response::success("[sub]\nkey = \"value\"\n"))
795 .install(dir.path());
796 let b = backend(&mock, None);
797 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
798 let err = b.list(&uri).await.unwrap_err();
799 let msg = format!("{err:#}");
800 assert!(msg.contains("flat TOML") || msg.contains("string"), "specific error: {msg}");
801 }
802
803 #[tokio::test]
806 async fn check_returns_cli_missing_when_binary_not_found() {
807 let b = backend_with_nonexistent_op();
808 match b.check().await {
809 BackendStatus::CliMissing { cli_name, install_hint } => {
810 assert_eq!(cli_name, "op");
811 assert!(
812 install_hint.contains("1password-cli") || install_hint.contains("developer")
813 );
814 }
815 other => panic!("expected CliMissing, got {other:?}"),
816 }
817 }
818
819 #[tokio::test]
820 async fn check_returns_ok_when_version_and_whoami_succeed() {
821 let dir = TempDir::new().unwrap();
822 let mock = StrictMock::new("op")
823 .on(&["--version"], Response::success("2.30.0\n"))
824 .on(
825 &["whoami", "--format=json", "--account", "my.1password.com"],
826 Response::success("{\"url\":\"my.1password.com\",\"email\":\"me@example.com\"}\n"),
827 )
828 .install(dir.path());
829 let b = backend(&mock, Some("my.1password.com"));
830 match b.check().await {
831 BackendStatus::Ok { cli_version, identity } => {
832 assert!(cli_version.contains("2.30.0"));
833 assert!(identity.contains("account=my.1password.com"));
834 assert!(identity.contains("email=me@example.com"));
835 }
836 other => panic!("expected Ok, got {other:?}"),
837 }
838 }
839
840 #[tokio::test]
841 async fn check_returns_not_authenticated_on_sign_in_error() {
842 let dir = TempDir::new().unwrap();
843 let mock = StrictMock::new("op")
844 .on(&["--version"], Response::success("2.30.0\n"))
845 .on(
846 &["whoami", "--format=json"],
847 Response::failure(
848 1,
849 "[ERROR] You are not signed in. Run \"op signin\" to authenticate and try again.\n",
850 ),
851 )
852 .install(dir.path());
853 let b = backend(&mock, None);
854 match b.check().await {
855 BackendStatus::NotAuthenticated { hint } => {
856 assert!(hint.contains("op signin"), "hint names signin: {hint}");
857 assert!(hint.contains("not signed in"), "stderr in hint: {hint}");
858 }
859 other => panic!("expected NotAuthenticated, got {other:?}"),
860 }
861 }
862
863 #[tokio::test]
864 async fn check_signin_hint_includes_account_when_configured() {
865 let dir = TempDir::new().unwrap();
866 let mock = StrictMock::new("op")
867 .on(&["--version"], Response::success("2.30.0\n"))
868 .on(
869 &["whoami", "--format=json", "--account", "myteam.1password.com"],
870 Response::failure(1, "[ERROR] You are not signed in.\n"),
871 )
872 .install(dir.path());
873 let b = backend(&mock, Some("myteam.1password.com"));
874 match b.check().await {
875 BackendStatus::NotAuthenticated { hint } => {
876 assert!(
877 hint.contains("--account myteam.1password.com"),
878 "hint tailored to configured account: {hint}"
879 );
880 }
881 other => panic!("expected NotAuthenticated, got {other:?}"),
882 }
883 }
884
885 #[tokio::test]
894 async fn get_drift_catch_rejects_missing_account_flag() {
895 let dir = TempDir::new().unwrap();
896 let mock = StrictMock::new("op")
897 .on(&["read", "op://V/I/F"], Response::success("never-matches\n"))
899 .install(dir.path());
900 let b = backend(&mock, Some("myteam.1password.com"));
901 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
902 let err = b.get(&uri).await.unwrap_err();
903 let msg = format!("{err:#}");
904 assert!(
905 msg.contains("strict-mock-no-match") || msg.contains("not found"),
906 "no-match diagnostic or stderr propagated: {msg}"
907 );
908 }
909
910 #[tokio::test]
913 async fn set_refuses_when_op_unsafe_set_is_false() {
914 let dir = TempDir::new().unwrap();
920 let mock = StrictMock::new("op")
923 .on(&["item", "edit", "I", "F=v", "--vault", "V"], Response::success(""))
924 .install(dir.path());
925 let b = backend_safe_default(&mock);
926 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
927 let err = b.set(&uri, "v").await.unwrap_err();
928 let msg = format!("{err:#}");
929 assert!(msg.contains("op_unsafe_set"), "names the opt-in field: {msg}");
930 assert!(msg.contains("1password-personal"), "names instance: {msg}");
931 assert!(msg.contains("argv"), "explains exposure: {msg}");
932 }
933
934 #[tokio::test]
935 async fn set_proceeds_when_op_unsafe_set_is_true() {
936 let dir = TempDir::new().unwrap();
940 let mock = StrictMock::new("op")
941 .on(&["item", "edit", "I", "F=new-secret", "--vault", "V"], Response::success(""))
942 .install(dir.path());
943 let mut b = backend_safe_default(&mock);
944 b.op_unsafe_set = true;
945 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
946 b.set(&uri, "new-secret").await.unwrap();
947 }
948
949 #[tokio::test]
954 async fn set_drift_catch_rejects_missing_vault_flag() {
955 let dir = TempDir::new().unwrap();
956 let mock = StrictMock::new("op")
957 .on(&["item", "edit", "I", "F=new-secret"], Response::success(""))
959 .install(dir.path());
960 let b = backend(&mock, None);
961 let uri = BackendUri::parse("1password-personal://V/I/F").unwrap();
962 let err = b.set(&uri, "new-secret").await.unwrap_err();
963 let msg = format!("{err:#}");
964 assert!(msg.contains("strict-mock-no-match"), "no-match diagnostic: {msg}");
965 }
966}