cuenv_1password/secrets/
resolver.rs

1//! 1Password secret resolver with auto-negotiating dual-mode (HTTP via WASM SDK + CLI)
2
3// Complex WASM+CLI dual-mode resolver with mutex-based shared core management
4#![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/// Configuration for 1Password secret resolution
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub struct OnePasswordConfig {
22    /// Secret reference (e.g., `op://vault/item/field`)
23    #[serde(rename = "ref")]
24    pub reference: String,
25}
26
27impl OnePasswordConfig {
28    /// Create a new 1Password secret config
29    #[must_use]
30    pub fn new(reference: impl Into<String>) -> Self {
31        Self {
32            reference: reference.into(),
33        }
34    }
35}
36
37/// Resolves secrets from 1Password
38///
39/// Mode is auto-negotiated based on environment:
40/// - If `OP_SERVICE_ACCOUNT_TOKEN` is set AND WASM SDK is installed → HTTP mode
41/// - Otherwise → CLI mode (uses `op` CLI)
42///
43/// To enable HTTP mode, run: `cuenv secrets setup onepassword`
44///
45/// The `source` field in [`SecretSpec`] can be:
46/// - A JSON-encoded [`OnePasswordConfig`]
47/// - A simple reference string (e.g., `op://vault/item/field`)
48pub struct OnePasswordResolver {
49    /// Client ID for WASM SDK (when using HTTP mode)
50    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    /// Create a new 1Password resolver with auto-detected mode
63    ///
64    /// If 1Password service account token is available AND the WASM SDK is installed,
65    /// uses HTTP mode. Otherwise, CLI mode will be used.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if HTTP mode is detected (WASM + token) but WASM initialization fails.
70    /// This prevents silent fallback to CLI mode which masks configuration errors.
71    pub fn new() -> Result<Self, SecretError> {
72        let client_id = if Self::http_mode_available() {
73            // HTTP mode is available (WASM + token), WASM MUST initialize successfully
74            // Do NOT silently fall back to CLI - that masks the real error
75            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    /// Check if HTTP mode is available (token set + WASM installed)
95    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    /// Check if HTTP credentials are available in environment
107    #[allow(dead_code)]
108    fn http_credentials_available() -> bool {
109        std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok()
110    }
111
112    /// Initialize the WASM client and return the client ID
113    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    /// Check if this resolver can use HTTP mode
140    const fn can_use_http(&self) -> bool {
141        self.client_id.is_some()
142    }
143
144    /// Resolve using the 1Password WASM SDK (HTTP mode)
145    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        // Invoke the SecretsResolve method (Go SDK uses this name, not "Secrets.Resolve")
169        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", &params, &config.reference)?;
176
177        // Parse the response - the Go SDK returns a JSON-encoded string
178        // The invoke response is the raw string from WASM, which is a JSON-quoted secret value
179        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    /// Resolve using the op CLI
189    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    /// Resolve a secret - tries HTTP first if available, falls back to CLI
215    async fn resolve_with_config(
216        &self,
217        name: &str,
218        config: &OnePasswordConfig,
219    ) -> Result<String, SecretError> {
220        // Try HTTP mode if available
221        if self.client_id.is_some() {
222            return self.resolve_http(name, config);
223        }
224
225        // Fallback to CLI
226        self.resolve_cli(name, config).await
227    }
228
229    /// Resolve multiple secrets using Secrets.ResolveAll (HTTP mode)
230    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        // Build list of references and track mapping back to names
257        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        // Invoke SecretsResolveAll with array of references
275        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        // Use first reference as context for top-level errors
287        let context = references.first().map_or("batch", String::as_str);
288        let result = core.invoke(client_id, "SecretsResolveAll", &params, context)?;
289
290        // Parse the response
291        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        // Extract individual responses
298        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        // Map responses back to original names
306        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            // Check for errors - fail immediately with the specific secret reference
317            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            // Extract secret value
329            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            // Map to all names that use this reference
338            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    /// Resolve multiple secrets using CLI (fallback, concurrent)
349    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        // 1Password SDK supports Secrets.ResolveAll
391        true
392    }
393
394    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
395        // Try to parse source as JSON OnePasswordConfig
396        if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
397            return self.resolve_with_config(name, &config).await;
398        }
399
400        // Fallback: treat source as a simple reference string
401        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        // Use Secrets.ResolveAll if HTTP mode is available
414        if self.client_id.is_some() {
415            return self.resolve_batch_http(secrets);
416        }
417
418        // Fallback to concurrent CLI calls
419        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        // This test just ensures the function exists and doesn't panic
449        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        // Since serde uses camelCase, the field is "ref"
500        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        // Without OP_SERVICE_ACCOUNT_TOKEN, HTTP mode should not be available
522        // (unless already set in environment)
523        let result = OnePasswordResolver::http_mode_available();
524        // Just verify it returns a boolean without panicking
525        let _ = result;
526    }
527
528    #[test]
529    fn test_resolver_provider_name() {
530        // Create a resolver in CLI mode (without WASM)
531        // If WASM is not available and token is not set, this should work
532        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        // A resolver without client_id should return false for can_use_http
553        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            // Should show mode as cli when no WASM client
570            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        // Empty reference should be allowed at config level
598        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}