1use super::core::SharedCore;
4use super::wasm;
5use async_trait::async_trait;
6use cuenv_secrets::{SecretError, SecretResolver, SecretSpec, SecureSecret};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use tokio::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "camelCase")]
14pub struct OnePasswordConfig {
15 #[serde(rename = "ref")]
17 pub reference: String,
18}
19
20impl OnePasswordConfig {
21 #[must_use]
23 pub fn new(reference: impl Into<String>) -> Self {
24 Self {
25 reference: reference.into(),
26 }
27 }
28}
29
30pub struct OnePasswordResolver {
42 client_id: Option<u64>,
44}
45
46impl std::fmt::Debug for OnePasswordResolver {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("OnePasswordResolver")
49 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
50 .finish()
51 }
52}
53
54impl OnePasswordResolver {
55 pub fn new() -> Result<Self, SecretError> {
64 let client_id = if Self::http_mode_available() {
65 match Self::init_wasm_client() {
66 Ok(id) => {
67 tracing::debug!("1Password WASM client initialized successfully");
68 Some(id)
69 }
70 Err(e) => {
71 tracing::warn!(
72 "Failed to initialize 1Password WASM client, falling back to CLI: {e}"
73 );
74 None
75 }
76 }
77 } else {
78 tracing::debug!("1Password HTTP mode not available, using CLI");
79 None
80 };
81
82 Ok(Self { client_id })
83 }
84
85 fn http_mode_available() -> bool {
87 let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
88 let wasm_available = wasm::onepassword_wasm_available();
89 tracing::trace!(
90 token_set,
91 wasm_available,
92 "1Password HTTP mode availability check"
93 );
94 token_set && wasm_available
95 }
96
97 #[allow(dead_code)]
99 fn http_credentials_available() -> bool {
100 std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok()
101 }
102
103 fn init_wasm_client() -> Result<u64, SecretError> {
105 let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
106 SecretError::ResolutionFailed {
107 name: "onepassword".to_string(),
108 message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
109 }
110 })?;
111
112 let core_mutex = SharedCore::get_or_init()?;
113 let mut guard = core_mutex
114 .lock()
115 .map_err(|_| SecretError::ResolutionFailed {
116 name: "onepassword".to_string(),
117 message: "Failed to acquire shared core lock".to_string(),
118 })?;
119
120 let core = guard
121 .as_mut()
122 .ok_or_else(|| SecretError::ResolutionFailed {
123 name: "onepassword".to_string(),
124 message: "SharedCore not initialized".to_string(),
125 })?;
126
127 core.init_client(&token)
128 }
129
130 fn can_use_http(&self) -> bool {
132 self.client_id.is_some()
133 }
134
135 fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
137 let client_id = self
138 .client_id
139 .ok_or_else(|| SecretError::ResolutionFailed {
140 name: name.to_string(),
141 message: "HTTP client not initialized".to_string(),
142 })?;
143
144 let core_mutex = SharedCore::get_or_init()?;
145 let mut guard = core_mutex
146 .lock()
147 .map_err(|_| SecretError::ResolutionFailed {
148 name: name.to_string(),
149 message: "Failed to acquire shared core lock".to_string(),
150 })?;
151
152 let core = guard
153 .as_mut()
154 .ok_or_else(|| SecretError::ResolutionFailed {
155 name: name.to_string(),
156 message: "SharedCore not initialized".to_string(),
157 })?;
158
159 let mut params = serde_json::Map::new();
161 params.insert(
162 "secret_reference".to_string(),
163 serde_json::Value::String(config.reference.clone()),
164 );
165
166 let result = core.invoke(client_id, "SecretsResolve", ¶ms)?;
167
168 let secret: String =
171 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
172 name: name.to_string(),
173 message: format!("Failed to parse resolve response: {e}"),
174 })?;
175
176 Ok(secret)
177 }
178
179 async fn resolve_cli(
181 &self,
182 name: &str,
183 config: &OnePasswordConfig,
184 ) -> Result<String, SecretError> {
185 let output = Command::new("op")
186 .args(["read", &config.reference])
187 .output()
188 .await
189 .map_err(|e| SecretError::ResolutionFailed {
190 name: name.to_string(),
191 message: format!("Failed to execute op CLI: {e}"),
192 })?;
193
194 if !output.status.success() {
195 let stderr = String::from_utf8_lossy(&output.stderr);
196 return Err(SecretError::ResolutionFailed {
197 name: name.to_string(),
198 message: format!("op CLI failed: {stderr}"),
199 });
200 }
201
202 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
203 }
204
205 async fn resolve_with_config(
207 &self,
208 name: &str,
209 config: &OnePasswordConfig,
210 ) -> Result<String, SecretError> {
211 if self.client_id.is_some() {
213 return self.resolve_http(name, config);
214 }
215
216 self.resolve_cli(name, config).await
218 }
219
220 fn resolve_batch_http(
222 &self,
223 secrets: &HashMap<String, SecretSpec>,
224 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
225 let client_id = self
226 .client_id
227 .ok_or_else(|| SecretError::ResolutionFailed {
228 name: "batch".to_string(),
229 message: "HTTP client not initialized".to_string(),
230 })?;
231
232 let core_mutex = SharedCore::get_or_init()?;
233 let mut guard = core_mutex
234 .lock()
235 .map_err(|_| SecretError::ResolutionFailed {
236 name: "batch".to_string(),
237 message: "Failed to acquire shared core lock".to_string(),
238 })?;
239
240 let core = guard
241 .as_mut()
242 .ok_or_else(|| SecretError::ResolutionFailed {
243 name: "batch".to_string(),
244 message: "SharedCore not initialized".to_string(),
245 })?;
246
247 let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
249 let mut references: Vec<String> = Vec::new();
250
251 for (name, spec) in secrets {
252 let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
253 .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
254
255 ref_to_names
256 .entry(config.reference.clone())
257 .or_default()
258 .push(name.clone());
259
260 if !references.contains(&config.reference) {
261 references.push(config.reference);
262 }
263 }
264
265 let mut params = serde_json::Map::new();
267 params.insert(
268 "secret_references".to_string(),
269 serde_json::Value::Array(
270 references
271 .iter()
272 .map(|r| serde_json::Value::String(r.clone()))
273 .collect(),
274 ),
275 );
276
277 let result = core.invoke(client_id, "SecretsResolveAll", ¶ms)?;
278
279 let response: serde_json::Value =
281 serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
282 name: "batch".to_string(),
283 message: format!("Failed to parse ResolveAll response: {e}"),
284 })?;
285
286 let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
288 SecretError::ResolutionFailed {
289 name: "batch".to_string(),
290 message: "No individualResponses in response".to_string(),
291 }
292 })?;
293
294 let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
296
297 for (i, resp) in individual_responses.iter().enumerate() {
298 let reference = references
299 .get(i)
300 .ok_or_else(|| SecretError::ResolutionFailed {
301 name: "batch".to_string(),
302 message: "Response index out of bounds".to_string(),
303 })?;
304
305 if let Some(error) = resp.get("error")
307 && !error.is_null()
308 {
309 let error_type = error["type"].as_str().unwrap_or("Unknown");
310 let error_msg = error["message"].as_str().unwrap_or("Unknown error");
311 tracing::warn!(
312 reference = %reference,
313 error_type = %error_type,
314 message = %error_msg,
315 "Failed to resolve secret in batch"
316 );
317 continue;
318 }
319
320 let secret = resp["content"]["secret"]
322 .as_str()
323 .or_else(|| resp["result"].as_str())
324 .ok_or_else(|| SecretError::ResolutionFailed {
325 name: reference.clone(),
326 message: "No secret value in response".to_string(),
327 })?;
328
329 if let Some(names) = ref_to_names.get(reference) {
331 for name in names {
332 resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
333 }
334 }
335 }
336
337 Ok(resolved)
338 }
339
340 async fn resolve_batch_cli(
342 &self,
343 secrets: &HashMap<String, SecretSpec>,
344 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
345 use futures::future::try_join_all;
346
347 let futures: Vec<_> = secrets
348 .iter()
349 .map(|(name, spec)| {
350 let name = name.clone();
351 let spec = spec.clone();
352 async move {
353 let value = self.resolve(&name, &spec).await?;
354 Ok::<_, SecretError>((name, SecureSecret::new(value)))
355 }
356 })
357 .collect();
358
359 try_join_all(futures).await.map(|v| v.into_iter().collect())
360 }
361}
362
363impl Drop for OnePasswordResolver {
364 fn drop(&mut self) {
365 if let Some(client_id) = self.client_id
366 && let Ok(core_mutex) = SharedCore::get_or_init()
367 && let Ok(mut guard) = core_mutex.lock()
368 && let Some(core) = guard.as_mut()
369 {
370 core.release_client(client_id);
371 }
372 }
373}
374
375#[async_trait]
376impl SecretResolver for OnePasswordResolver {
377 fn provider_name(&self) -> &'static str {
378 "onepassword"
379 }
380
381 fn supports_native_batch(&self) -> bool {
382 true
384 }
385
386 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
387 if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
389 return self.resolve_with_config(name, &config).await;
390 }
391
392 let config = OnePasswordConfig::new(spec.source.clone());
394 self.resolve_with_config(name, &config).await
395 }
396
397 async fn resolve_batch(
398 &self,
399 secrets: &HashMap<String, SecretSpec>,
400 ) -> Result<HashMap<String, SecureSecret>, SecretError> {
401 if secrets.is_empty() {
402 return Ok(HashMap::new());
403 }
404
405 if self.client_id.is_some() {
407 return self.resolve_batch_http(secrets);
408 }
409
410 self.resolve_batch_cli(secrets).await
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_onepassword_config_serialization() {
421 let config = OnePasswordConfig {
422 reference: "op://vault/item/password".to_string(),
423 };
424
425 let json = serde_json::to_string(&config).unwrap();
426 assert!(json.contains("\"ref\""));
427
428 let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
429 assert_eq!(config, parsed);
430 }
431
432 #[test]
433 fn test_simple_config() {
434 let config = OnePasswordConfig::new("op://Personal/GitHub/token");
435 assert_eq!(config.reference, "op://Personal/GitHub/token");
436 }
437
438 #[test]
439 fn test_http_credentials_check() {
440 let _ = OnePasswordResolver::http_credentials_available();
442 }
443}