Skip to main content

hasp_backend_op/
lib.rs

1//! `op://` backend for hasp.
2//!
3//! Grammar:
4//! - 3-segment form `op://vault/item/field` for `get` / `put` /
5//!   `delete` / `exists`. All three segments non-empty; no query
6//!   parameters.
7//! - Vault-only form `op://vault` for `list`. Host only, no path.
8//!
9//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
10//! `put` issues `op item edit` and falls back to `op item create` on
11//! NotFound. `delete` removes the entire item; the URL's `field`
12//! segment is ignored on delete. `list` emits `Entry` URLs keyed by
13//! the item UUID when `op item list --format=json` carries `id`.
14//!
15//! `PermissionDenied` is unreachable from `op read` because 1Password's
16//! server returns 404 for both missing and no-permission cases (deliberate
17//! authorization-aware design preventing existence oracles). These map to
18//! `NotFound`.
19//!
20//! Authentication is ambient only (no auth-bootstrap flows):
21//! `OP_SERVICE_ACCOUNT_TOKEN`, `OP_SESSION_*`, or `OP_CONNECT_TOKEN` +
22//! `OP_CONNECT_HOST`. If none are present, every operation fails fast with
23//! `AuthenticationFailed` before spawning the `op` binary, preventing
24//! indefinite hangs in headless contexts.
25//!
26//! Every `op` subprocess invocation carries a wall-clock timeout (15s for
27//! `get`, 10s for `exists`) because `op` has no `--no-prompt` flag — the
28//! only mitigation against biometric or desktop-app hangs.
29//!
30//! Connect HTTP backend mode is deferred. It would recover 401/403/404
31//! distinction but requires name-to-UUID resolution and a separate feature
32//! gate. Service-account direct HTTP is not technically feasible without
33//! reverse-engineering 1Password's SRP handshake.
34
35use 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/// URL shape for `op://` addresses.
42///
43/// Vault, item, and field are identifiers, not secret values. They may
44/// appear in error messages (redacted per URL discipline).
45#[derive(Debug)]
46pub struct OpUrl {
47    pub vault: String,
48    pub item: String,
49    pub field: String,
50}
51
52/// URL shape for `op://` listing.
53///
54/// Listing has different cardinality requirements than `get` / `put` /
55/// `delete`: it operates on a vault prefix (or optionally an item to
56/// list that item's fields). For v1, only the vault-level case is
57/// supported: `op://<vault>` (host only, no path).
58#[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        // Tolerate a single empty trailing path segment (the `op://vault/`
83        // form), but reject any non-empty segment for the v1 list grammar.
84        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/// Subprocess backend for 1Password CLI (`op`).
154///
155/// Construction runs `op --version` once and rejects versions below
156/// 2.30.0. The stored init result is replayed on every operation so
157/// errors surface on first use, not at `Store::with_defaults()` time.
158///
159/// `op://` references (vault/item/field identifiers) may appear on argv;
160/// secret values never do. Stdout is always captured via pipe (never a
161/// raw file FD) to avoid the documented binary-content corruption when
162/// `op read` writes to a non-pipe FD.
163#[derive(Debug)]
164pub struct OpBackend {
165    init: Result<(), Error>,
166}
167
168/// Wall-clock timeout for `op read` invocations.
169///
170/// 1Password community benchmarks show `op read` baseline is ~700ms–2s;
171/// 15s absorbs occasional biometric-desktop-app stalls without
172/// blocking CI indefinitely.
173const GET_TIMEOUT: Duration = Duration::from_secs(15);
174
175/// Wall-clock timeout for `op item list` invocations.
176///
177/// Slightly shorter than `get` because listing metadata-only should
178/// never trigger biometric prompts, but network stalls are still
179/// possible.
180const EXISTS_TIMEOUT: Duration = Duration::from_secs(10);
181
182/// Wall-clock timeout for the one-time `op --version` check.
183const VERSION_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
184
185impl OpBackend {
186    /// Create a new `OpBackend`.
187    ///
188    /// Runs `op --version` once and enforces the minimum supported
189    /// version (2.30.0). Versions >= 2.30.0 are accepted without an
190    /// upper bound so hasp does not ossify against `op`'s release
191    /// cadence.
192    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        // Pre-v2.32.1: op could exit 0 on unrecognized server errors.
212        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        // Defensive: op < v2.32.1 could exit 0 with non-empty stderr on
278        // unrecognized server errors (DG-682 in CLI2 release notes).
279        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        // Strip exactly one trailing newline. `op read` appends `\n` by
290        // default; stripping client-side preserves a legitimate trailing
291        // newline if the secret itself ends in one.
292        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        // `op item edit` requires positional `<field>=<value>` and an
316        // existing item. Try edit first; on NotFound fall back to
317        // `op item create`.
318        //
319        // Argv exposure: the secret value lives on `op`'s argv for the
320        // life of the subprocess. On Linux, `/proc/<pid>/cmdline` is
321        // same-uid readable — the documented cost of the
322        // `op item edit|create` API surface (no stdin variant for
323        // field values).
324        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        // Only fall back to create on NotFound. AuthenticationFailed,
347        // PermissionDenied, transport — surface as-is.
348        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        // Append the field assignment as a 9th positional. `op item
363        // create` accepts arbitrary `<field>=<value>` after the named
364        // flags; the password category default-assigns the value to
365        // the `password` field, but explicit `<field>=<value>`
366        // overrides for non-default field names.
367        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        // `--format=json` gives stable parseable output. Field is not
386        // exposed at item-list granularity; each Entry's URL synthesizes
387        // a placeholder `password` field. Real field-level discovery
388        // requires `op item get --format=json` per item (one extra
389        // subprocess per entry); that's a follow-up.
390        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            // Cache-key identity: prefer `id` (UUID, rename-stable) for the URL.
416            // When the JSON omits `id` (older `op` versions or fake-bin output
417            // that emits title-only), fall back to title — rename-fragile and
418            // documented in the per-backend README.
419            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            // Synthetic URL field defaults to `password`. This is correct for
430            // login items (the dominant 1Password category) but wrong for
431            // documents, secure notes, and API credentials whose primary
432            // value lives in a differently-named field. Consumers piping
433            // `hasp list op://vault | xargs hasp get` will hit `not found`
434            // on those entries — documented in the per-backend README.
435            // The `category` JSON field, when present, is consulted: items
436            // whose category is not `LOGIN` or `PASSWORD` are skipped to
437            // keep the list output addressable by the synthesized URL.
438            let category = item.get("category").and_then(|v| v.as_str());
439            let addressable = match category {
440                None => true, // unknown — keep, fail-open for older `op` JSON
441                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        // `op item delete` removes the entire item, not just one field.
470        // The URL's `field` segment is required by op:// grammar but
471        // ignored here. Documented in the per-backend README.
472        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
509/// Spawn `op` with the given args and enforce a wall-clock timeout.
510///
511/// Two reader threads consume stdout and stderr concurrently while the
512/// main thread polls `try_wait`. This prevents pipe-buffer deadlock when
513/// `op item list` emits large JSON or when stderr is verbose.
514///
515/// Stdout is always piped; never given a raw File FD. `op read` applies
516/// UTF-8 validation that corrupts binary bytes when writing to a
517/// non-pipe FD (verified community finding).
518fn 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
593/// Map `op` stderr + exit code into the locked `hasp_core::Error` taxonomy.
594///
595/// Priority is first-anchor-wins, case-insensitive. Unmatched stderr
596/// falls through to `Backend { Permanent }` — never to `NotFound`. A
597/// future `op` rewording must surface as a backend error, not a silently
598/// misclassified missing item.
599///
600/// `op` echoes the `op://` reference URL inside error messages. The
601/// returned error message has the reference redacted.
602fn 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
644/// Replace occurrences of the secret-reference URL with a redacted token.
645///
646/// The reference identifies a secret but is not itself a secret value.
647/// Redaction preserves URL discipline and prevents accidental log leakage
648/// of internal vault/item/field names.
649fn redact_reference(stderr: &str, reference: &str) -> String {
650    stderr.replace(reference, "op://<redacted>")
651}
652
653/// Parse a semver triple from the `op --version` stdout.
654///
655/// Accepts bare triples (`2.30.0`) and release labels
656/// (`2.30.0-beta.1`). Everything after the third numeric component
657/// is ignored.
658fn 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
668/// Fail fast if no ambient 1Password credentials are present.
669///
670/// `op` has no `--no-prompt` flag; spawning without credentials in a
671/// headless context causes an indefinite biometric hang. This check
672/// happens before every spawn so CI runners and containers never block.
673fn 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        // "connection reset" appears in both auth and transient rows.
841        // Auth row comes first, so this maps to AuthenticationFailed.
842        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        // We can't easily run op --version in a headless unit test,
866        // so we validate the parsing + comparison logic indirectly.
867        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        // Remove any OP_SESSION_* vars.
890        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}