Skip to main content

secretenv_backend_1password/
lib.rs

1// Copyright (C) 2026 Mandeep Patel
2// SPDX-License-Identifier: AGPL-3.0-only
3
4//! 1Password backend for SecretEnv.
5//!
6//! Wraps the `op` CLI — never 1Password's Connect Server SDK. The CLI
7//! handles SSO, biometric unlock, service accounts, and multiple signed-
8//! in accounts without us touching any of it.
9//!
10//! # URI shape
11//!
12//! `<instance>://<vault>/<item>/<field>` — exactly three non-empty path
13//! segments. Example: `1password-personal://Engineering/Prod DB/url`
14//! targets the `url` field of the `Prod DB` item in the `Engineering`
15//! vault.
16//!
17//! Nested fields (items with sections, `op://vault/item/section/field`)
18//! are out of scope for v0.1; the strict 3-segment rule is documented
19//! in the error message when parsing fails.
20//!
21//! # Config fields
22//!
23//! - `op_account` (optional) — 1Password account shorthand or URL
24//!   (e.g. `myteam.1password.com`). Needed only when multiple accounts
25//!   are signed in simultaneously. Passed as `--account <value>` to
26//!   every `op` invocation.
27//!
28//! # Semantics
29//!
30//! - [`get`](OnePasswordBackend) calls `op read op://<v>/<i>/<f>` and
31//!   returns the field value verbatim.
32//! - [`set`](OnePasswordBackend) calls `op item edit <item> <field>=<value> --vault <vault>`.
33//!   Errors if the item does not exist — we never auto-create.
34//! - [`delete`](OnePasswordBackend) calls `op item edit <item> <field>= --vault <vault>`
35//!   (empty value). Full item deletion is out of scope for v0.1.
36//! - [`list`](OnePasswordBackend) fetches the field value and parses it
37//!   as flat TOML `HashMap<String, String>`. This is the registry-
38//!   document shape: a 1Password note whose body is the alias → URI
39//!   map in TOML form.
40//! - [`check`](OnePasswordBackend) runs `op --version` (Level 1) and
41//!   `op whoami --format=json` (Level 2).
42//!
43//! # Safety
44//!
45//! Every argv call goes through `tokio::process::Command::args(&[…])`
46//! — never `sh -c`. URI-derived values never touch a shell interpreter.
47#![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
67/// A live instance of the 1Password backend.
68pub struct OnePasswordBackend {
69    backend_type: &'static str,
70    instance_name: String,
71    op_account: Option<String>,
72    /// Path or name of the `op` binary. Defaults to `"op"` (PATH
73    /// lookup); tests override to a mock script path.
74    op_bin: String,
75    /// Per-instance fetch deadline; from `timeout_secs` config field.
76    timeout: Duration,
77    /// **Opt-in to the v0.3 CV-1 limitation.** Default `false` —
78    /// `set` errors out with a clean message rather than silently
79    /// passing the secret value through child argv. Set
80    /// `op_unsafe_set = true` in `[backends.<name>]` to acknowledge
81    /// the exposure (`/proc/<pid>/cmdline` on multi-user Linux
82    /// hosts) and proceed with the legacy argv-based behavior. The
83    /// `op` CLI offers no portable stdin-fed value form across the
84    /// 1.x and 2.x generations, so the safer default is to refuse.
85    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    /// Parse `uri.path` into `(vault, item, field)`. Exactly 3 non-empty
97    /// `/`-separated segments; a leading `/` is tolerated.
98    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        // v0.3: Level 1 + Level 2 via `tokio::join!`. Halves `doctor`
151        // latency for this backend.
152        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        // --- Level 1 ---
160        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        // --- Level 2 ---
185        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    // `check_extensive` uses the `Backend` trait default (list().len()).
223
224    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        // Defensive post-condition: parse_path only returns 3 segments
228        // each free of `/` — `op://<vault>/<item>/<field>` construction
229        // is safe. Assert to guard against a future parse_path refactor.
230        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        // KNOWN LIMITATION (review finding CV-1): the 1Password CLI
256        // (`op item edit`) reads field assignments as `field=value`
257        // argv tokens. Across the 1.x and 2.x CLI generations users
258        // have installed, `op` offers no portable stdin-fed value
259        // form (the closest, `op item create --template -`, doesn't
260        // apply to in-place field edits). Until that lands upstream,
261        // secretenv refuses the operation by default.
262        //
263        // Exposure: the secret value is visible in the child
264        // process's argv for the lifetime of the `op` subprocess
265        // (normally O(200 ms) — 2 s). On multi-user Linux hosts,
266        // `/proc/<pid>/cmdline` is world-readable. Operators who
267        // accept this exposure (single-user host, audited shared
268        // host) opt in by setting `op_unsafe_set = true` under
269        // `[backends.<instance>]` in config.toml.
270        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    /// v0.15 migrate destination path. `Gated` per the v0.15 audit
307    /// table — refuses unless `op_unsafe_set = true` in
308    /// `[backends.<instance>]`. Returns a typed
309    /// [`BackendError::WriteNotSupported`](secretenv_core::BackendError::WriteNotSupported)
310    /// so the migrate handler can dispatch on the variant rather
311    /// than parse the underlying `set()` error's context string.
312    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    /// v0.15 migrate `--delete-source` cleanup path. `Gated` per the
324    /// v0.15 audit table — refuses unless `op_unsafe_set = true`,
325    /// same gate as `write_secret`. Wraps `delete()` once authorized.
326    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    /// v0.15 migrate success-message cleanup hint. Aligned with this
338    /// backend's `delete()` semantics: 1Password CLI has no field-
339    /// removal — `delete()` clears the field by setting it to the
340    /// empty string. The hint mirrors that exact operation rather
341    /// than `op item delete <item>` (whole-item) so a copy-paste does
342    /// not remove adjacent fields the operator did not intend to
343    /// touch. Phase 7 audit fix — code-rev S2.
344    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        // 1Password's CLI has no "clear one field" — we set it to empty.
364        // Full item deletion is out of scope for v0.1.
365        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
399/// Factory for the 1Password backend. No required config fields;
400/// `op_account` is optional.
401pub struct OnePasswordFactory(&'static str);
402
403impl OnePasswordFactory {
404    /// Construct the factory. Equivalent to `OnePasswordFactory::default()`.
405    #[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        // Default test backend opts INTO unsafe-set so the existing
464        // strict-mock argv lock for `set` keeps working. The
465        // dedicated `set_refuses_when_op_unsafe_set_is_false` test
466        // covers the new default-off behavior.
467        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    // ---- Factory ----
500
501    #[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    // ---- URI parsing ----
587
588    #[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    // ---- get happy path ----
628
629    #[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    // ---- get errors ----
666
667    #[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    // Non-UTF-8 response bodies: the strict harness's `Response.stdout:
698    // String` field is UTF-8-only by construction, so this path stays on
699    // the raw `install_mock` API. If a future backend routinely needs
700    // non-UTF-8 stdout, extend the harness with `stdout_bytes: Vec<u8>`
701    // — YAGNI until then. See v0.2.3 aws-ssm retrofit for precedent.
702    #[tokio::test]
703    async fn get_non_utf8_response_errors_with_context() {
704        let dir = TempDir::new().unwrap();
705        // Octal escapes (POSIX), not \xFF (bash-specific).
706        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    // ---- set, delete, list ----
726
727    #[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        // Under the strict harness, the declared argv `F=` (empty
756        // assignment) is enforced by exact match — no side-channel log
757        // needed. A regression that passed `F=foo` or omitted the
758        // assignment would produce a no-match (exit 97) and fail the
759        // test with a clear diagnostic.
760        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    // ---- check ----
804
805    #[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    // ---- drift-catch regression locks ----
886
887    // Guard against a regression that drops `--account <X>` from the argv
888    // when the backend was configured with an account. The declared argv
889    // intentionally omits the `--account` suffix; the real backend always
890    // appends it when `op_account` is Some. A no-match (exit 97) with a
891    // "strict-mock-no-match" stderr is the desired test outcome —
892    // indicating account pass-through is still active.
893    #[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            // Declared WITHOUT the `--account` tail. Real backend emits it.
898            .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    // ---- v0.4 Phase 3: op_unsafe_set default-off lock ----
911
912    #[tokio::test]
913    async fn set_refuses_when_op_unsafe_set_is_false() {
914        // The default-off behavior is the security improvement: rather
915        // than silently passing the secret through argv (CV-1),
916        // secretenv refuses unless the operator explicitly opts in.
917        // The error message names the field and the exposure so the
918        // operator can make an informed call.
919        let dir = TempDir::new().unwrap();
920        // Mock would succeed if reached, but the safe-default backend
921        // shouldn't even invoke `op` — the bail! short-circuits.
922        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        // The opt-in path uses the existing argv-based behavior,
937        // logged with a warning. The strict mock asserts the same
938        // argv as before — proves the opt-in restores prior shape.
939        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    // Guard against a regression that drops the `--vault <V>` flag from
950    // set()'s argv — without it, `op item edit` would target whatever
951    // vault the user is defaulted to rather than the one the URI names.
952    // Declared argv omits `--vault V`; real backend always appends it.
953    #[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            // Declared WITHOUT the `--vault V` tail. Real backend emits it.
958            .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}