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;
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}
52
53impl std::fmt::Debug for OnePasswordResolver {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("OnePasswordResolver")
56 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
57 .finish()
58 }
59}
60
61impl OnePasswordResolver {
62 pub fn new() -> Result<Self, SecretError> {
72 let client_id = if Self::http_mode_available() {
73 let id = Self::init_wasm_client().map_err(|e| SecretError::ResolutionFailed {
76 name: "onepassword".to_string(),
77 message: format!(
78 "1Password HTTP mode detected (WASM + token) but initialization failed: {e}\n\
79 \n\
80 This indicates a platform/runtime compatibility issue.\n\
81 To use CLI mode instead, unset OP_SERVICE_ACCOUNT_TOKEN or remove the WASM file."
82 ),
83 })?;
84 tracing::debug!("1Password WASM client initialized successfully");
85 Some(id)
86 } else {
87 tracing::debug!("1Password HTTP mode not available, using CLI");
88 None
89 };
90
91 Ok(Self { client_id })
92 }
93
94 fn http_mode_available() -> bool {
96 let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
97 let wasm_available = wasm::onepassword_wasm_available();
98 tracing::trace!(
99 token_set,
100 wasm_available,
101 "1Password HTTP mode availability check"
102 );
103 token_set && wasm_available
104 }
105
106 #[allow(dead_code)]
108 fn http_credentials_available() -> bool {
109 std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok()
110 }
111
112 fn init_wasm_client() -> Result<u64, SecretError> {
114 let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
115 SecretError::ResolutionFailed {
116 name: "onepassword".to_string(),
117 message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
118 }
119 })?;
120
121 let core_mutex = SharedCore::get_or_init()?;
122 let mut guard = core_mutex
123 .lock()
124 .map_err(|_| SecretError::ResolutionFailed {
125 name: "onepassword".to_string(),
126 message: "Failed to acquire shared core lock".to_string(),
127 })?;
128
129 let core = guard
130 .as_mut()
131 .ok_or_else(|| SecretError::ResolutionFailed {
132 name: "onepassword".to_string(),
133 message: "SharedCore not initialized".to_string(),
134 })?;
135
136 core.init_client(&token)
137 }
138
139 const fn can_use_http(&self) -> bool {
141 self.client_id.is_some()
142 }
143
144 fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
146 let client_id = self
147 .client_id
148 .ok_or_else(|| SecretError::ResolutionFailed {
149 name: name.to_string(),
150 message: "HTTP client not initialized".to_string(),
151 })?;
152
153 let core_mutex = SharedCore::get_or_init()?;
154 let mut guard = core_mutex
155 .lock()
156 .map_err(|_| SecretError::ResolutionFailed {
157 name: name.to_string(),
158 message: "Failed to acquire shared core lock".to_string(),
159 })?;
160
161 let core = guard
162 .as_mut()
163 .ok_or_else(|| SecretError::ResolutionFailed {
164 name: name.to_string(),
165 message: "SharedCore not initialized".to_string(),
166 })?;
167
168 let mut params = serde_json::Map::new();
170 params.insert(
171 "secret_reference".to_string(),
172 serde_json::Value::String(config.reference.clone()),
173 );
174
175 let result = core.invoke(client_id, "SecretsResolve", ¶ms, &config.reference)?;
176
177 let secret: String =
180 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
181 name: name.to_string(),
182 message: format!("Failed to parse resolve response: {e}"),
183 })?;
184
185 Ok(secret)
186 }
187
188 async fn resolve_cli(
190 &self,
191 name: &str,
192 config: &OnePasswordConfig,
193 ) -> Result<String, SecretError> {
194 let output = Command::new("op")
195 .args(["read", &config.reference])
196 .output()
197 .await
198 .map_err(|e| SecretError::ResolutionFailed {
199 name: name.to_string(),
200 message: format!("Failed to execute op CLI: {e}"),
201 })?;
202
203 if !output.status.success() {
204 let stderr = String::from_utf8_lossy(&output.stderr);
205 return Err(SecretError::ResolutionFailed {
206 name: name.to_string(),
207 message: format!("op CLI failed: {stderr}"),
208 });
209 }
210
211 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
212 }
213
214 async fn resolve_with_config(
216 &self,
217 name: &str,
218 config: &OnePasswordConfig,
219 ) -> Result<String, SecretError> {
220 if self.client_id.is_some() {
222 return self.resolve_http(name, config);
223 }
224
225 self.resolve_cli(name, config).await
227 }
228
229 fn resolve_batch_http(
231 &self,
232 secrets: &HashMap<String, SecretSpec>,
233 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
234 let client_id = self
235 .client_id
236 .ok_or_else(|| SecretError::ResolutionFailed {
237 name: "batch".to_string(),
238 message: "HTTP client not initialized".to_string(),
239 })?;
240
241 let core_mutex = SharedCore::get_or_init()?;
242 let mut guard = core_mutex
243 .lock()
244 .map_err(|_| SecretError::ResolutionFailed {
245 name: "batch".to_string(),
246 message: "Failed to acquire shared core lock".to_string(),
247 })?;
248
249 let core = guard
250 .as_mut()
251 .ok_or_else(|| SecretError::ResolutionFailed {
252 name: "batch".to_string(),
253 message: "SharedCore not initialized".to_string(),
254 })?;
255
256 let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
258 let mut references: Vec<String> = Vec::new();
259
260 for (name, spec) in secrets {
261 let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
262 .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
263
264 ref_to_names
265 .entry(config.reference.clone())
266 .or_default()
267 .push(name.clone());
268
269 if !references.contains(&config.reference) {
270 references.push(config.reference);
271 }
272 }
273
274 let mut params = serde_json::Map::new();
276 params.insert(
277 "secret_references".to_string(),
278 serde_json::Value::Array(
279 references
280 .iter()
281 .map(|r| serde_json::Value::String(r.clone()))
282 .collect(),
283 ),
284 );
285
286 let context = references.first().map_or("batch", String::as_str);
288 let result = core.invoke(client_id, "SecretsResolveAll", ¶ms, context)?;
289
290 let response: serde_json::Value =
292 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
293 name: "batch".to_string(),
294 message: format!("Failed to parse ResolveAll response: {e}"),
295 })?;
296
297 let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
299 SecretError::ResolutionFailed {
300 name: "batch".to_string(),
301 message: "No individualResponses in response".to_string(),
302 }
303 })?;
304
305 let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
307
308 for (i, resp) in individual_responses.iter().enumerate() {
309 let reference = references
310 .get(i)
311 .ok_or_else(|| SecretError::ResolutionFailed {
312 name: "batch".to_string(),
313 message: "Response index out of bounds".to_string(),
314 })?;
315
316 if let Some(error) = resp.get("error")
318 && !error.is_null()
319 {
320 let error_type = error["type"].as_str().unwrap_or("Unknown");
321 let error_msg = error["message"].as_str().unwrap_or("Unknown error");
322 return Err(SecretError::ResolutionFailed {
323 name: reference.clone(),
324 message: format!("1Password error ({error_type}): {error_msg}"),
325 });
326 }
327
328 let secret = resp["content"]["secret"]
330 .as_str()
331 .or_else(|| resp["result"].as_str())
332 .ok_or_else(|| SecretError::ResolutionFailed {
333 name: reference.clone(),
334 message: "No secret value in response".to_string(),
335 })?;
336
337 if let Some(names) = ref_to_names.get(reference) {
339 for name in names {
340 resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
341 }
342 }
343 }
344
345 Ok(resolved)
346 }
347
348 async fn resolve_batch_cli(
350 &self,
351 secrets: &HashMap<String, SecretSpec>,
352 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
353 use futures::future::try_join_all;
354
355 let futures: Vec<_> = secrets
356 .iter()
357 .map(|(name, spec)| {
358 let name = name.clone();
359 let spec = spec.clone();
360 async move {
361 let value = self.resolve(&name, &spec).await?;
362 Ok::<_, SecretError>((name, SecureSecret::new(value)))
363 }
364 })
365 .collect();
366
367 try_join_all(futures).await.map(|v| v.into_iter().collect())
368 }
369}
370
371impl Drop for OnePasswordResolver {
372 fn drop(&mut self) {
373 if let Some(client_id) = self.client_id
374 && let Ok(core_mutex) = SharedCore::get_or_init()
375 && let Ok(mut guard) = core_mutex.lock()
376 && let Some(core) = guard.as_mut()
377 {
378 core.release_client(client_id);
379 }
380 }
381}
382
383#[async_trait]
384impl SecretResolver for OnePasswordResolver {
385 fn provider_name(&self) -> &'static str {
386 "onepassword"
387 }
388
389 fn supports_native_batch(&self) -> bool {
390 true
392 }
393
394 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
395 if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
397 return self.resolve_with_config(name, &config).await;
398 }
399
400 let config = OnePasswordConfig::new(spec.source.clone());
402 self.resolve_with_config(name, &config).await
403 }
404
405 async fn resolve_batch(
406 &self,
407 secrets: &HashMap<String, SecretSpec>,
408 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
409 if secrets.is_empty() {
410 return Ok(HashMap::new());
411 }
412
413 if self.client_id.is_some() {
415 return self.resolve_batch_http(secrets);
416 }
417
418 self.resolve_batch_cli(secrets).await
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn test_onepassword_config_serialization() {
429 let config = OnePasswordConfig {
430 reference: "op://vault/item/password".to_string(),
431 };
432
433 let json = serde_json::to_string(&config).unwrap();
434 assert!(json.contains("\"ref\""));
435
436 let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
437 assert_eq!(config, parsed);
438 }
439
440 #[test]
441 fn test_simple_config() {
442 let config = OnePasswordConfig::new("op://Personal/GitHub/token");
443 assert_eq!(config.reference, "op://Personal/GitHub/token");
444 }
445
446 #[test]
447 fn test_http_credentials_check() {
448 let _ = OnePasswordResolver::http_credentials_available();
450 }
451
452 #[test]
453 fn test_config_new_from_string() {
454 let config = OnePasswordConfig::new(String::from("op://vault/item/field"));
455 assert_eq!(config.reference, "op://vault/item/field");
456 }
457
458 #[test]
459 fn test_config_new_from_str_slice() {
460 let ref_str = "op://vault/item/field";
461 let config = OnePasswordConfig::new(ref_str);
462 assert_eq!(config.reference, ref_str);
463 }
464
465 #[test]
466 fn test_config_equality() {
467 let config1 = OnePasswordConfig::new("op://vault/item/field");
468 let config2 = OnePasswordConfig::new("op://vault/item/field");
469 let config3 = OnePasswordConfig::new("op://other/item/field");
470
471 assert_eq!(config1, config2);
472 assert_ne!(config1, config3);
473 }
474
475 #[test]
476 fn test_config_clone() {
477 let config = OnePasswordConfig::new("op://vault/item/field");
478 let cloned = config.clone();
479 assert_eq!(config, cloned);
480 }
481
482 #[test]
483 fn test_config_debug() {
484 let config = OnePasswordConfig::new("op://vault/item/field");
485 let debug = format!("{config:?}");
486 assert!(debug.contains("OnePasswordConfig"));
487 assert!(debug.contains("op://vault/item/field"));
488 }
489
490 #[test]
491 fn test_config_deserialization_with_ref_key() {
492 let json = r#"{"ref": "op://vault/item/field"}"#;
493 let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
494 assert_eq!(config.reference, "op://vault/item/field");
495 }
496
497 #[test]
498 fn test_config_deserialization_camel_case() {
499 let json = r#"{"ref": "op://example/test/password"}"#;
501 let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
502 assert_eq!(config.reference, "op://example/test/password");
503 }
504
505 #[test]
506 fn test_config_deserialization_missing_ref() {
507 let json = r"{}";
508 let result = serde_json::from_str::<OnePasswordConfig>(json);
509 assert!(result.is_err());
510 }
511
512 #[test]
513 fn test_config_with_special_characters() {
514 let config = OnePasswordConfig::new("op://My Vault/My Item 2024/api-key_v1");
515 assert!(config.reference.contains("My Vault"));
516 assert!(config.reference.contains("api-key_v1"));
517 }
518
519 #[test]
520 fn test_http_mode_available_without_env() {
521 let result = OnePasswordResolver::http_mode_available();
524 let _ = result;
526 }
527
528 #[test]
529 fn test_resolver_provider_name() {
530 if (!wasm::onepassword_wasm_available()
533 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
534 && let Ok(resolver) = OnePasswordResolver::new()
535 {
536 assert_eq!(resolver.provider_name(), "onepassword");
537 }
538 }
539
540 #[test]
541 fn test_resolver_supports_native_batch() {
542 if (!wasm::onepassword_wasm_available()
543 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
544 && let Ok(resolver) = OnePasswordResolver::new()
545 {
546 assert!(resolver.supports_native_batch());
547 }
548 }
549
550 #[test]
551 fn test_resolver_can_use_http_false_without_client() {
552 if (!wasm::onepassword_wasm_available()
554 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
555 && let Ok(resolver) = OnePasswordResolver::new()
556 {
557 assert!(!resolver.can_use_http());
558 }
559 }
560
561 #[test]
562 fn test_resolver_debug_output() {
563 if (!wasm::onepassword_wasm_available()
564 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
565 && let Ok(resolver) = OnePasswordResolver::new()
566 {
567 let debug = format!("{resolver:?}");
568 assert!(debug.contains("OnePasswordResolver"));
569 assert!(debug.contains("cli") || debug.contains("http"));
571 }
572 }
573
574 #[tokio::test]
575 async fn test_resolve_batch_empty() {
576 if (!wasm::onepassword_wasm_available()
577 || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
578 && let Ok(resolver) = OnePasswordResolver::new()
579 {
580 let empty: HashMap<String, SecretSpec> = HashMap::new();
581 let result = resolver.resolve_batch(&empty).await;
582 assert!(result.is_ok());
583 assert!(result.unwrap().is_empty());
584 }
585 }
586
587 #[test]
588 fn test_config_roundtrip_serialization() {
589 let original = OnePasswordConfig::new("op://vault/item/field");
590 let json = serde_json::to_string(&original).unwrap();
591 let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
592 assert_eq!(original, parsed);
593 }
594
595 #[test]
596 fn test_config_empty_reference() {
597 let config = OnePasswordConfig::new("");
599 assert_eq!(config.reference, "");
600 }
601
602 #[test]
603 fn test_config_unicode_reference() {
604 let config = OnePasswordConfig::new("op://vault/项目/密码");
605 assert_eq!(config.reference, "op://vault/项目/密码");
606 }
607}