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