1#![allow(
5 clippy::cognitive_complexity,
6 clippy::too_many_lines,
7 clippy::significant_drop_tightening
8)]
9
10use super::core::SharedCore;
11use super::wasm;
12use async_trait::async_trait;
13use cuenv_secrets::{SecretError, SecretResolver, SecretSpec, SecureSecret};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use tokio::{process::Command, sync::Mutex};
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub struct OnePasswordConfig {
22 #[serde(rename = "ref")]
24 pub reference: String,
25}
26
27impl OnePasswordConfig {
28 #[must_use]
30 pub fn new(reference: impl Into<String>) -> Self {
31 Self {
32 reference: reference.into(),
33 }
34 }
35}
36
37pub struct OnePasswordResolver {
49 client_id: Option<u64>,
51 cli_auth_state: Mutex<CliAuthState>,
53}
54
55#[derive(Debug, Clone)]
56enum CliAuthState {
57 Unknown,
58 Authenticated,
59 Failed(String),
60}
61
62impl std::fmt::Debug for OnePasswordResolver {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("OnePasswordResolver")
65 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
66 .finish()
67 }
68}
69
70impl OnePasswordResolver {
71 pub fn new() -> Result<Self, SecretError> {
81 let client_id = if Self::http_mode_available() {
82 let id = Self::init_wasm_client().map_err(|e| SecretError::ResolutionFailed {
85 name: "onepassword".to_string(),
86 message: format!(
87 "1Password HTTP mode detected (WASM + token) but initialization failed: {e}\n\
88 \n\
89 This indicates a platform/runtime compatibility issue.\n\
90 To use CLI mode instead, unset OP_SERVICE_ACCOUNT_TOKEN or remove the WASM file."
91 ),
92 })?;
93 tracing::debug!("1Password WASM client initialized successfully");
94 Some(id)
95 } else {
96 tracing::debug!("1Password HTTP mode not available, using CLI");
97 None
98 };
99
100 Ok(Self {
101 client_id,
102 cli_auth_state: Mutex::new(CliAuthState::Unknown),
103 })
104 }
105
106 fn http_mode_available() -> bool {
108 let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
109 let wasm_available = wasm::onepassword_wasm_available();
110 tracing::trace!(
111 token_set,
112 wasm_available,
113 "1Password HTTP mode availability check"
114 );
115 token_set && wasm_available
116 }
117
118 fn init_wasm_client() -> Result<u64, SecretError> {
120 let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
121 SecretError::ResolutionFailed {
122 name: "onepassword".to_string(),
123 message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
124 }
125 })?;
126
127 let core_mutex = SharedCore::get_or_init()?;
128 let mut guard = core_mutex
129 .lock()
130 .map_err(|_| SecretError::ResolutionFailed {
131 name: "onepassword".to_string(),
132 message: "Failed to acquire shared core lock".to_string(),
133 })?;
134
135 let core = guard
136 .as_mut()
137 .ok_or_else(|| SecretError::ResolutionFailed {
138 name: "onepassword".to_string(),
139 message: "SharedCore not initialized".to_string(),
140 })?;
141
142 core.init_client(&token)
143 }
144
145 const fn can_use_http(&self) -> bool {
147 self.client_id.is_some()
148 }
149
150 fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
152 let client_id = self
153 .client_id
154 .ok_or_else(|| SecretError::ResolutionFailed {
155 name: name.to_string(),
156 message: "HTTP client not initialized".to_string(),
157 })?;
158
159 let core_mutex = SharedCore::get_or_init()?;
160 let mut guard = core_mutex
161 .lock()
162 .map_err(|_| SecretError::ResolutionFailed {
163 name: name.to_string(),
164 message: "Failed to acquire shared core lock".to_string(),
165 })?;
166
167 let core = guard
168 .as_mut()
169 .ok_or_else(|| SecretError::ResolutionFailed {
170 name: name.to_string(),
171 message: "SharedCore not initialized".to_string(),
172 })?;
173
174 let mut params = serde_json::Map::new();
176 params.insert(
177 "secret_reference".to_string(),
178 serde_json::Value::String(config.reference.clone()),
179 );
180
181 let result = core.invoke(client_id, "SecretsResolve", ¶ms, &config.reference)?;
182
183 let secret: String =
186 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
187 name: name.to_string(),
188 message: format!("Failed to parse resolve response: {e}"),
189 })?;
190
191 Ok(secret)
192 }
193
194 async fn resolve_cli(
196 &self,
197 name: &str,
198 config: &OnePasswordConfig,
199 ) -> Result<String, SecretError> {
200 tracing::debug!(
201 name = name,
202 reference = config.reference,
203 "1Password resolve_cli"
204 );
205 let output = Command::new("op")
206 .args(["read", &config.reference])
207 .output()
208 .await
209 .map_err(|e| SecretError::ResolutionFailed {
210 name: name.to_string(),
211 message: format!("Failed to execute op CLI: {e}"),
212 })?;
213
214 if !output.status.success() {
215 let stderr = String::from_utf8_lossy(&output.stderr);
216 return Err(SecretError::ResolutionFailed {
217 name: name.to_string(),
218 message: format!("op CLI failed: {stderr}"),
219 });
220 }
221
222 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
223 }
224
225 async fn ensure_cli_authenticated(
230 &self,
231 name: &str,
232 config: &OnePasswordConfig,
233 ) -> Result<Option<String>, SecretError> {
234 let mut state = self.cli_auth_state.lock().await;
235 match &*state {
236 CliAuthState::Authenticated => return Ok(None),
237 CliAuthState::Failed(message) => {
238 return Err(SecretError::ResolutionFailed {
239 name: name.to_string(),
240 message: message.clone(),
241 });
242 }
243 CliAuthState::Unknown => {}
244 }
245
246 let preflight_result = Command::new("op").arg("whoami").output().await;
247
248 match preflight_result {
249 Ok(output) if output.status.success() => {
250 *state = CliAuthState::Authenticated;
251 Ok(None)
252 }
253 Ok(output) => {
254 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
255 let details = if stderr.is_empty() {
256 "no error output from 1Password CLI".to_string()
257 } else {
258 stderr
259 };
260
261 if details.to_lowercase().contains("not signed in") {
265 let read_result = Command::new("op")
266 .args(["read", &config.reference])
267 .output()
268 .await;
269
270 match read_result {
271 Ok(read_output) if read_output.status.success() => {
272 *state = CliAuthState::Authenticated;
273 let secret = String::from_utf8_lossy(&read_output.stdout)
274 .trim()
275 .to_string();
276 return Ok(Some(secret));
277 }
278 Ok(read_output) => {
279 let read_stderr = String::from_utf8_lossy(&read_output.stderr)
280 .trim()
281 .to_string();
282 let read_details = if read_stderr.is_empty() {
283 "no error output from 1Password CLI".to_string()
284 } else {
285 read_stderr
286 };
287 let message = format!(
288 "1Password CLI authentication check failed (`op whoami`) and \
289 bootstrap secret read failed. Run `op signin` and retry. \
290 Details: {read_details}"
291 );
292 *state = CliAuthState::Failed(message.clone());
293 return Err(SecretError::ResolutionFailed {
294 name: name.to_string(),
295 message,
296 });
297 }
298 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
299 let message = "1Password CLI not found (`op` command unavailable). \
300 Install the 1Password CLI and retry."
301 .to_string();
302 *state = CliAuthState::Failed(message.clone());
303 return Err(SecretError::ResolutionFailed {
304 name: name.to_string(),
305 message,
306 });
307 }
308 Err(e) => {
309 let message = format!(
310 "Failed to execute 1Password bootstrap secret read (`op read`): {e}. \
311 Run `op signin` and retry."
312 );
313 *state = CliAuthState::Failed(message.clone());
314 return Err(SecretError::ResolutionFailed {
315 name: name.to_string(),
316 message,
317 });
318 }
319 }
320 }
321
322 let message = format!(
323 "1Password CLI authentication check failed (`op whoami`). \
324 Run `op signin` and retry. Details: {details}"
325 );
326 *state = CliAuthState::Failed(message.clone());
327 Err(SecretError::ResolutionFailed {
328 name: name.to_string(),
329 message,
330 })
331 }
332 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
333 let message = "1Password CLI not found (`op` command unavailable). \
334 Install the 1Password CLI and retry."
335 .to_string();
336 *state = CliAuthState::Failed(message.clone());
337 Err(SecretError::ResolutionFailed {
338 name: name.to_string(),
339 message,
340 })
341 }
342 Err(e) => {
343 let message = format!(
344 "Failed to execute 1Password CLI authentication check (`op whoami`): {e}. \
345 Run `op signin` and retry."
346 );
347 *state = CliAuthState::Failed(message.clone());
348 Err(SecretError::ResolutionFailed {
349 name: name.to_string(),
350 message,
351 })
352 }
353 }
354 }
355
356 async fn resolve_with_config(
358 &self,
359 name: &str,
360 config: &OnePasswordConfig,
361 ) -> Result<String, SecretError> {
362 if self.client_id.is_some() {
364 return self.resolve_http(name, config);
365 }
366
367 if let Some(secret) = self.ensure_cli_authenticated(name, config).await? {
369 return Ok(secret);
370 }
371 self.resolve_cli(name, config).await
372 }
373
374 fn resolve_batch_http(
376 &self,
377 secrets: &HashMap<String, SecretSpec>,
378 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
379 let client_id = self
380 .client_id
381 .ok_or_else(|| SecretError::ResolutionFailed {
382 name: "batch".to_string(),
383 message: "HTTP client not initialized".to_string(),
384 })?;
385
386 let core_mutex = SharedCore::get_or_init()?;
387 let mut guard = core_mutex
388 .lock()
389 .map_err(|_| SecretError::ResolutionFailed {
390 name: "batch".to_string(),
391 message: "Failed to acquire shared core lock".to_string(),
392 })?;
393
394 let core = guard
395 .as_mut()
396 .ok_or_else(|| SecretError::ResolutionFailed {
397 name: "batch".to_string(),
398 message: "SharedCore not initialized".to_string(),
399 })?;
400
401 let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
403 let mut references: Vec<String> = Vec::new();
404
405 for (name, spec) in secrets {
406 let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
407 .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
408
409 ref_to_names
410 .entry(config.reference.clone())
411 .or_default()
412 .push(name.clone());
413
414 if !references.contains(&config.reference) {
415 references.push(config.reference);
416 }
417 }
418
419 let mut params = serde_json::Map::new();
421 params.insert(
422 "secret_references".to_string(),
423 serde_json::Value::Array(
424 references
425 .iter()
426 .map(|r| serde_json::Value::String(r.clone()))
427 .collect(),
428 ),
429 );
430
431 let context = references.first().map_or("batch", String::as_str);
433 let result = core.invoke(client_id, "SecretsResolveAll", ¶ms, context)?;
434
435 let response: serde_json::Value =
437 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
438 name: "batch".to_string(),
439 message: format!("Failed to parse ResolveAll response: {e}"),
440 })?;
441
442 let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
444 SecretError::ResolutionFailed {
445 name: "batch".to_string(),
446 message: "No individualResponses in response".to_string(),
447 }
448 })?;
449
450 let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
452
453 for (i, resp) in individual_responses.iter().enumerate() {
454 let reference = references
455 .get(i)
456 .ok_or_else(|| SecretError::ResolutionFailed {
457 name: "batch".to_string(),
458 message: "Response index out of bounds".to_string(),
459 })?;
460
461 if let Some(error) = resp.get("error")
463 && !error.is_null()
464 {
465 let error_type = error["type"].as_str().unwrap_or("Unknown");
466 let error_msg = error["message"].as_str().unwrap_or("Unknown error");
467 return Err(SecretError::ResolutionFailed {
468 name: reference.clone(),
469 message: format!("1Password error ({error_type}): {error_msg}"),
470 });
471 }
472
473 let secret = resp["content"]["secret"]
475 .as_str()
476 .or_else(|| resp["result"].as_str())
477 .ok_or_else(|| SecretError::ResolutionFailed {
478 name: reference.clone(),
479 message: "No secret value in response".to_string(),
480 })?;
481
482 if let Some(names) = ref_to_names.get(reference) {
484 for name in names {
485 resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
486 }
487 }
488 }
489
490 Ok(resolved)
491 }
492
493 async fn resolve_batch_cli(
495 &self,
496 secrets: &HashMap<String, SecretSpec>,
497 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
498 use futures::future::try_join_all;
499
500 let futures: Vec<_> = secrets
501 .iter()
502 .map(|(name, spec)| {
503 let name = name.clone();
504 let spec = spec.clone();
505 async move {
506 let value = self.resolve(&name, &spec).await?;
507 Ok::<_, SecretError>((name, SecureSecret::new(value)))
508 }
509 })
510 .collect();
511
512 try_join_all(futures).await.map(|v| v.into_iter().collect())
513 }
514}
515
516impl Drop for OnePasswordResolver {
517 fn drop(&mut self) {
518 if let Some(client_id) = self.client_id
519 && let Ok(core_mutex) = SharedCore::get_or_init()
520 && let Ok(mut guard) = core_mutex.lock()
521 && let Some(core) = guard.as_mut()
522 {
523 core.release_client(client_id);
524 }
525 }
526}
527
528#[async_trait]
529impl SecretResolver for OnePasswordResolver {
530 fn provider_name(&self) -> &'static str {
531 "onepassword"
532 }
533
534 fn supports_native_batch(&self) -> bool {
535 true
537 }
538
539 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
540 if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
542 return self.resolve_with_config(name, &config).await;
543 }
544
545 let config = OnePasswordConfig::new(spec.source.clone());
547 self.resolve_with_config(name, &config).await
548 }
549
550 async fn resolve_batch(
551 &self,
552 secrets: &HashMap<String, SecretSpec>,
553 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
554 if secrets.is_empty() {
555 return Ok(HashMap::new());
556 }
557
558 if self.client_id.is_some() {
560 return self.resolve_batch_http(secrets);
561 }
562
563 self.resolve_batch_cli(secrets).await
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 #[cfg(unix)]
572 use std::os::unix::fs::PermissionsExt;
573 #[cfg(unix)]
574 use std::{fs, path::Path};
575
576 #[cfg(unix)]
577 fn write_fake_op_shim(dir: &Path) -> std::path::PathBuf {
578 let op_path = dir.join("op");
579 let script = r#"#!/bin/sh
580cmd="$1"
581shift
582
583case "$cmd" in
584 whoami)
585 printf "whoami\n" >> "$OP_TEST_LOG"
586 if [ "x$OP_TEST_FAIL_WHOAMI" = "x1" ]; then
587 printf "not signed in\n" >&2
588 exit 1
589 fi
590 printf "test-user@example.com\n"
591 exit 0
592 ;;
593 read)
594 printf "read:%s\n" "$1" >> "$OP_TEST_LOG"
595 if [ "x$OP_TEST_FAIL_READ" = "x1" ]; then
596 printf "read failed\n" >&2
597 exit 1
598 fi
599 printf "secret-for-%s\n" "$1"
600 exit 0
601 ;;
602 *)
603 printf "unsupported op command: %s\n" "$cmd" >&2
604 exit 2
605 ;;
606esac
607"#;
608
609 fs::write(&op_path, script).unwrap();
610 let mut perms = fs::metadata(&op_path).unwrap().permissions();
611 perms.set_mode(0o755);
612 fs::set_permissions(&op_path, perms).unwrap();
613 op_path
614 }
615
616 #[cfg(unix)]
617 fn prepend_path(dir: &Path) -> String {
618 let mut parts = vec![dir.to_path_buf()];
619 if let Some(current) = std::env::var_os("PATH") {
620 parts.extend(std::env::split_paths(¤t));
621 }
622 std::env::join_paths(parts)
623 .unwrap()
624 .to_string_lossy()
625 .into_owned()
626 }
627
628 #[cfg(unix)]
629 fn read_log_lines(path: &Path) -> Vec<String> {
630 let content = fs::read_to_string(path).unwrap_or_default();
631 content
632 .lines()
633 .map(str::trim)
634 .filter(|line| !line.is_empty())
635 .map(str::to_string)
636 .collect()
637 }
638
639 #[test]
640 fn test_onepassword_config_serialization() {
641 let config = OnePasswordConfig {
642 reference: "op://vault/item/password".to_string(),
643 };
644
645 let json = serde_json::to_string(&config).unwrap();
646 assert!(json.contains("\"ref\""));
647
648 let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
649 assert_eq!(config, parsed);
650 }
651
652 #[test]
653 fn test_simple_config() {
654 let config = OnePasswordConfig::new("op://Personal/GitHub/token");
655 assert_eq!(config.reference, "op://Personal/GitHub/token");
656 }
657
658 #[test]
659 fn test_config_new_from_string() {
660 let config = OnePasswordConfig::new(String::from("op://vault/item/field"));
661 assert_eq!(config.reference, "op://vault/item/field");
662 }
663
664 #[test]
665 fn test_config_new_from_str_slice() {
666 let ref_str = "op://vault/item/field";
667 let config = OnePasswordConfig::new(ref_str);
668 assert_eq!(config.reference, ref_str);
669 }
670
671 #[test]
672 fn test_config_equality() {
673 let config1 = OnePasswordConfig::new("op://vault/item/field");
674 let config2 = OnePasswordConfig::new("op://vault/item/field");
675 let config3 = OnePasswordConfig::new("op://other/item/field");
676
677 assert_eq!(config1, config2);
678 assert_ne!(config1, config3);
679 }
680
681 #[test]
682 fn test_config_clone() {
683 let config = OnePasswordConfig::new("op://vault/item/field");
684 let cloned = config.clone();
685 assert_eq!(config, cloned);
686 }
687
688 #[test]
689 fn test_config_debug() {
690 let config = OnePasswordConfig::new("op://vault/item/field");
691 let debug = format!("{config:?}");
692 assert!(debug.contains("OnePasswordConfig"));
693 assert!(debug.contains("op://vault/item/field"));
694 }
695
696 #[test]
697 fn test_config_deserialization_with_ref_key() {
698 let json = r#"{"ref": "op://vault/item/field"}"#;
699 let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
700 assert_eq!(config.reference, "op://vault/item/field");
701 }
702
703 #[test]
704 fn test_config_deserialization_camel_case() {
705 let json = r#"{"ref": "op://example/test/password"}"#;
707 let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
708 assert_eq!(config.reference, "op://example/test/password");
709 }
710
711 #[test]
712 fn test_config_deserialization_missing_ref() {
713 let json = r"{}";
714 let result = serde_json::from_str::<OnePasswordConfig>(json);
715 assert!(result.is_err());
716 }
717
718 #[test]
719 fn test_config_with_special_characters() {
720 let config = OnePasswordConfig::new("op://My Vault/My Item 2024/api-key_v1");
721 assert!(config.reference.contains("My Vault"));
722 assert!(config.reference.contains("api-key_v1"));
723 }
724
725 #[test]
726 fn test_http_mode_available_without_env() {
727 let result = OnePasswordResolver::http_mode_available();
730 let _ = result;
732 }
733
734 #[test]
735 fn test_resolver_provider_name() {
736 if (!wasm::onepassword_wasm_available()
739 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
740 && let Ok(resolver) = OnePasswordResolver::new()
741 {
742 assert_eq!(resolver.provider_name(), "onepassword");
743 }
744 }
745
746 #[test]
747 fn test_resolver_supports_native_batch() {
748 if (!wasm::onepassword_wasm_available()
749 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
750 && let Ok(resolver) = OnePasswordResolver::new()
751 {
752 assert!(resolver.supports_native_batch());
753 }
754 }
755
756 #[test]
757 fn test_resolver_can_use_http_false_without_client() {
758 if (!wasm::onepassword_wasm_available()
760 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
761 && let Ok(resolver) = OnePasswordResolver::new()
762 {
763 assert!(!resolver.can_use_http());
764 }
765 }
766
767 #[test]
768 fn test_resolver_debug_output() {
769 if (!wasm::onepassword_wasm_available()
770 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
771 && let Ok(resolver) = OnePasswordResolver::new()
772 {
773 let debug = format!("{resolver:?}");
774 assert!(debug.contains("OnePasswordResolver"));
775 assert!(debug.contains("cli") || debug.contains("http"));
777 }
778 }
779
780 #[tokio::test]
781 async fn test_resolve_batch_empty() {
782 if (!wasm::onepassword_wasm_available()
783 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
784 && let Ok(resolver) = OnePasswordResolver::new()
785 {
786 let empty: HashMap<String, SecretSpec> = HashMap::new();
787 let result = resolver.resolve_batch(&empty).await;
788 assert!(result.is_ok());
789 assert!(result.unwrap().is_empty());
790 }
791 }
792
793 #[test]
794 fn test_config_roundtrip_serialization() {
795 let original = OnePasswordConfig::new("op://vault/item/field");
796 let json = serde_json::to_string(&original).unwrap();
797 let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
798 assert_eq!(original, parsed);
799 }
800
801 #[test]
802 fn test_config_empty_reference() {
803 let config = OnePasswordConfig::new("");
805 assert_eq!(config.reference, "");
806 }
807
808 #[test]
809 fn test_config_unicode_reference() {
810 let config = OnePasswordConfig::new("op://vault/项目/密码");
811 assert_eq!(config.reference, "op://vault/项目/密码");
812 }
813
814 #[cfg(unix)]
815 #[tokio::test]
816 async fn test_cli_preflight_runs_once_for_parallel_batch_reads() {
817 let temp = tempfile::tempdir().unwrap();
818 write_fake_op_shim(temp.path());
819 let log_path = temp.path().join("op.log");
820 let path = prepend_path(temp.path());
821 let log_path_str = log_path.to_string_lossy().into_owned();
822
823 temp_env::async_with_vars(
824 [
825 ("PATH", Some(path.as_str())),
826 ("OP_TEST_LOG", Some(log_path_str.as_str())),
827 ("OP_SERVICE_ACCOUNT_TOKEN", None),
828 ],
829 async {
830 let resolver = OnePasswordResolver::new().unwrap();
831 let secrets = HashMap::from([
832 (
833 "API_KEY".to_string(),
834 SecretSpec::new("op://vault/service/api_key"),
835 ),
836 (
837 "DB_PASSWORD".to_string(),
838 SecretSpec::new("op://vault/service/db_password"),
839 ),
840 (
841 "JWT_SECRET".to_string(),
842 SecretSpec::new("op://vault/service/jwt_secret"),
843 ),
844 ]);
845
846 let secret_values = resolver.resolve_batch(&secrets).await.unwrap();
847 assert_eq!(secret_values.len(), 3);
848 },
849 )
850 .await;
851
852 let lines = read_log_lines(&log_path);
853 assert_eq!(
854 lines.iter().filter(|line| *line == "whoami").count(),
855 1,
856 "expected exactly one auth preflight, got log lines: {lines:?}"
857 );
858 assert_eq!(
859 lines
860 .iter()
861 .filter(|line| line.starts_with("read:"))
862 .count(),
863 3,
864 "expected one read per secret, got log lines: {lines:?}"
865 );
866 }
867
868 #[cfg(unix)]
869 #[tokio::test]
870 async fn test_cli_preflight_signed_out_bootstraps_then_runs_parallel_reads() {
871 let temp = tempfile::tempdir().unwrap();
872 write_fake_op_shim(temp.path());
873 let log_path = temp.path().join("op.log");
874 let path = prepend_path(temp.path());
875 let log_path_str = log_path.to_string_lossy().into_owned();
876
877 temp_env::async_with_vars(
878 [
879 ("PATH", Some(path.as_str())),
880 ("OP_TEST_LOG", Some(log_path_str.as_str())),
881 ("OP_TEST_FAIL_WHOAMI", Some("1")),
882 ("OP_SERVICE_ACCOUNT_TOKEN", None),
883 ],
884 async {
885 let resolver = OnePasswordResolver::new().unwrap();
886 let secrets = HashMap::from([
887 ("A".to_string(), SecretSpec::new("op://vault/item/a")),
888 ("B".to_string(), SecretSpec::new("op://vault/item/b")),
889 ]);
890
891 let result = resolver.resolve_batch(&secrets).await;
892 assert!(result.is_ok(), "expected bootstrap read to recover auth");
893 assert_eq!(result.unwrap().len(), 2);
894 },
895 )
896 .await;
897
898 let lines = read_log_lines(&log_path);
899 assert_eq!(
900 lines.iter().filter(|line| *line == "whoami").count(),
901 1,
902 "expected single preflight attempt, got log lines: {lines:?}"
903 );
904 assert_eq!(
905 lines
906 .iter()
907 .filter(|line| line.starts_with("read:"))
908 .count(),
909 2,
910 "expected one read per secret after bootstrap auth, got log lines: {lines:?}"
911 );
912 }
913
914 #[cfg(unix)]
915 #[tokio::test]
916 async fn test_cli_preflight_bootstrap_read_failure_fails_fast() {
917 let temp = tempfile::tempdir().unwrap();
918 write_fake_op_shim(temp.path());
919 let log_path = temp.path().join("op.log");
920 let path = prepend_path(temp.path());
921 let log_path_str = log_path.to_string_lossy().into_owned();
922
923 temp_env::async_with_vars(
924 [
925 ("PATH", Some(path.as_str())),
926 ("OP_TEST_LOG", Some(log_path_str.as_str())),
927 ("OP_TEST_FAIL_WHOAMI", Some("1")),
928 ("OP_TEST_FAIL_READ", Some("1")),
929 ("OP_SERVICE_ACCOUNT_TOKEN", None),
930 ],
931 async {
932 let resolver = OnePasswordResolver::new().unwrap();
933 let secrets = HashMap::from([
934 ("A".to_string(), SecretSpec::new("op://vault/item/a")),
935 ("B".to_string(), SecretSpec::new("op://vault/item/b")),
936 ]);
937
938 let result = resolver.resolve_batch(&secrets).await;
939 assert!(result.is_err());
940 let err = result.unwrap_err().to_string();
941 assert!(
942 err.contains("bootstrap secret read"),
943 "unexpected error: {err}"
944 );
945 assert!(err.contains("op signin"), "unexpected error: {err}");
946 },
947 )
948 .await;
949
950 let lines = read_log_lines(&log_path);
951 assert_eq!(
952 lines.iter().filter(|line| *line == "whoami").count(),
953 1,
954 "expected single preflight attempt, got log lines: {lines:?}"
955 );
956 assert_eq!(
957 lines
958 .iter()
959 .filter(|line| line.starts_with("read:"))
960 .count(),
961 1,
962 "expected a single bootstrap read attempt before fail-fast, got log lines: {lines:?}"
963 );
964 }
965
966 #[cfg(unix)]
967 #[tokio::test]
968 async fn test_cli_preflight_missing_op_reports_clear_error() {
969 let empty_dir = tempfile::tempdir().unwrap();
970 let path = empty_dir.path().to_string_lossy().into_owned();
971
972 temp_env::async_with_vars(
973 [
974 ("PATH", Some(path.as_str())),
975 ("OP_SERVICE_ACCOUNT_TOKEN", None),
976 ],
977 async {
978 let resolver = OnePasswordResolver::new().unwrap();
979 let spec = SecretSpec::new("op://vault/item/password");
980
981 let result = resolver.resolve("missing-op", &spec).await;
982 assert!(result.is_err());
983 let err = result.unwrap_err().to_string();
984 assert!(err.contains("1Password CLI"), "unexpected error: {err}");
985 assert!(err.contains("not found"), "unexpected error: {err}");
986 },
987 )
988 .await;
989 }
990}