1use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::PathBuf;
16use std::process::{Command, Stdio};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use serde_json::Value;
21
22use super::{Driver, SecretMeta};
23use crate::error::{Error, Result};
24use crate::org::OrgId;
25
26pub const TOKEN_SECRET: &str = "onepassword-token";
28
29const OP_TIMEOUT: Duration = Duration::from_secs(30);
31
32pub type TokenSource = Arc<dyn Fn(&OrgId) -> Result<Option<String>> + Send + Sync>;
34
35pub struct OnePasswordDriver {
36 op: PathBuf,
37 token: TokenSource,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Reference {
43 pub vault: String,
44 pub item: String,
45 pub field: String,
46}
47
48impl Reference {
49 pub fn parse(r: &str) -> Result<Reference> {
50 let r = r.strip_prefix("op://").unwrap_or(r);
51 let parts: Vec<&str> = r.split('/').collect();
52 if parts.len() < 3 || parts.len() > 4 || parts.iter().any(|p| p.trim().is_empty()) {
53 return Err(Error::invalid(format!(
54 "1Password reference {r:?}: expected vault/item/field (or vault/item/section/field)"
55 )));
56 }
57 Ok(Reference {
58 vault: parts[0].into(),
59 item: parts[1].into(),
60 field: parts[2..].join("/"),
61 })
62 }
63
64 fn uri(&self) -> String {
65 format!("op://{}/{}/{}", self.vault, self.item, self.field)
66 }
67}
68
69impl OnePasswordDriver {
70 pub fn new(token: TokenSource) -> OnePasswordDriver {
72 let op = std::env::var_os("ISB_OP_BIN")
73 .map(PathBuf::from)
74 .unwrap_or_else(|| PathBuf::from("op"));
75 OnePasswordDriver { op, token }
76 }
77
78 pub fn with_binary(mut self, op: impl Into<PathBuf>) -> Self {
79 self.op = op.into();
80 self
81 }
82
83 fn op(&self, org: &OrgId, args: &[&str]) -> Result<Vec<u8>> {
86 let token = (self.token)(org)?.ok_or_else(|| {
87 Error::invalid(format!(
88 "org {org} has no 1Password token: store its service-account token as the secret {TOKEN_SECRET:?} (isb secret create {TOKEN_SECRET} --org {org} -)"
89 ))
90 })?;
91 let mut cmd = Command::new(&self.op);
92 cmd.args(args)
93 .env_clear()
94 .env("OP_SERVICE_ACCOUNT_TOKEN", token)
95 .stdin(Stdio::null())
96 .stdout(Stdio::piped())
97 .stderr(Stdio::piped());
98 for k in ["PATH", "HOME", "XDG_CONFIG_HOME", "TMPDIR"] {
99 if let Some(v) = std::env::var_os(k) {
100 cmd.env(k, v);
101 }
102 }
103 let mut child = cmd.spawn().map_err(|e| {
104 Error::invalid(format!(
105 "cannot run {} for the onepassword driver: {e} (install the 1Password CLI, or set ISB_OP_BIN)",
106 self.op.display()
107 ))
108 })?;
109 let (mut out, mut err) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
110 let reader = std::thread::spawn(move || {
111 let mut b = Vec::new();
112 let _ = out.read_to_end(&mut b);
113 b
114 });
115 let err_reader = std::thread::spawn(move || {
116 let mut b = Vec::new();
117 let _ = err.read_to_end(&mut b);
118 b
119 });
120 let started = Instant::now();
121 let status = loop {
122 if let Some(s) = child.try_wait()? {
123 break s;
124 }
125 if started.elapsed() > OP_TIMEOUT {
126 let _ = child.kill();
127 let _ = child.wait();
128 return Err(Error::invalid(format!(
129 "op {} took longer than {OP_TIMEOUT:?}",
130 args.first().unwrap_or(&"")
131 )));
132 }
133 std::thread::sleep(Duration::from_millis(20));
134 };
135 let stdout = reader.join().unwrap_or_default();
136 let stderr = err_reader.join().unwrap_or_default();
137 if !status.success() {
138 let msg = String::from_utf8_lossy(&stderr);
139 let msg = msg.trim();
140 if msg.contains("isn't an item") || msg.contains("not found") || msg.contains("no item")
141 {
142 return Err(Error::NotFound(format!(
143 "1Password {}",
144 args.last().unwrap_or(&"")
145 )));
146 }
147 return Err(Error::invalid(format!(
148 "op {}: {msg}",
149 args.first().unwrap_or(&"")
150 )));
151 }
152 Ok(stdout)
153 }
154
155 fn item(&self, org: &OrgId, r: &Reference) -> Result<Value> {
156 let out = self.op(
157 org,
158 &[
159 "item", "get", &r.item, "--vault", &r.vault, "--format", "json",
160 ],
161 )?;
162 serde_json::from_slice(&out).map_err(|e| Error::Protocol(format!("op item get: {e}")))
163 }
164
165 fn meta(&self, org: &OrgId, name: &str, item: &Value) -> SecretMeta {
166 let version = item["version"].as_u64().unwrap_or(0);
167 let ts = |k: &str| item[k].as_str().and_then(rfc3339_to_unix).unwrap_or(0);
168 SecretMeta {
169 org: org.clone(),
170 name: name.to_string(),
171 driver: "onepassword".into(),
172 version,
173 created_at: ts("created_at"),
174 updated_at: ts("updated_at"),
175 labels: BTreeMap::new(),
176 }
177 }
178}
179
180fn reference(name: &str) -> Result<Reference> {
183 if !name.contains('/') {
184 return Err(Error::NotFound(format!("1Password reference {name}")));
185 }
186 Reference::parse(name)
187}
188
189impl Driver for OnePasswordDriver {
190 fn name(&self) -> &str {
191 "onepassword"
192 }
193
194 fn get(&self, org: &OrgId, name: &str) -> Result<(Vec<u8>, u64)> {
195 let r = reference(name)?;
196 let value = self.op(org, &["read", "--no-newline", &r.uri()])?;
197 let version = self.item(org, &r)?["version"].as_u64().unwrap_or(0);
198 Ok((value, version))
199 }
200
201 fn version(&self, org: &OrgId, name: &str) -> Result<u64> {
202 let r = reference(name)?;
203 Ok(self.item(org, &r)?["version"].as_u64().unwrap_or(0))
204 }
205
206 fn inspect(&self, org: &OrgId, name: &str) -> Result<SecretMeta> {
207 let r = reference(name)?;
208 let item = self.item(org, &r)?;
209 Ok(self.meta(org, name, &item))
210 }
211
212 fn list(&self, _org: &OrgId) -> Result<Vec<SecretMeta>> {
215 Ok(Vec::new())
216 }
217}
218
219fn rfc3339_to_unix(s: &str) -> Option<u64> {
221 let (date, rest) = s.split_once('T')?;
222 let mut d = date.split('-').map(|x| x.parse::<i64>().ok());
223 let (y, m, day) = (d.next()??, d.next()??, d.next()??);
224 let time: String = rest.chars().take(8).collect();
225 let mut t = time.split(':').map(|x| x.parse::<i64>().ok());
226 let (hh, mm, ss) = (t.next()??, t.next()??, t.next()??);
227 let tail = &rest[8.min(rest.len())..];
228 let tail = tail.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
229 let offset = match tail {
230 "" | "Z" | "z" => 0,
231 o => {
232 let sign = if o.starts_with('-') { -1 } else { 1 };
233 let o = o.trim_start_matches(['+', '-']);
234 let (oh, om) = o.split_once(':').unwrap_or((o, "0"));
235 sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60)
236 }
237 };
238 let y2 = if m <= 2 { y - 1 } else { y };
240 let era = y2.div_euclid(400);
241 let yoe = y2 - era * 400;
242 let mp = (m + 9) % 12;
243 let doy = (153 * mp + 2) / 5 + day - 1;
244 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
245 let days = era * 146_097 + doe - 719_468;
246 let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset;
247 u64::try_from(secs).ok()
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn references() {
256 let r = Reference::parse("k8s-ocai/smtp/password").unwrap();
257 assert_eq!(
258 (r.vault.as_str(), r.item.as_str(), r.field.as_str()),
259 ("k8s-ocai", "smtp", "password")
260 );
261 assert_eq!(r.uri(), "op://k8s-ocai/smtp/password");
262 assert_eq!(Reference::parse("op://v/i/s/f").unwrap().field, "s/f");
263 assert!(Reference::parse("v/i").is_err());
264 assert!(Reference::parse("v//f").is_err());
265 }
266
267 #[test]
268 fn timestamps() {
269 assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0));
270 assert_eq!(rfc3339_to_unix("2026-10-03T04:00:00Z"), Some(1_791_000_000));
271 assert_eq!(
272 rfc3339_to_unix("2026-10-03T06:00:00.123+02:00"),
273 Some(1_791_000_000)
274 );
275 }
276
277 #[test]
280 fn talks_to_op_with_the_orgs_token() {
281 let dir = tempfile::tempdir().unwrap();
282 let fake = dir.path().join("op");
283 std::fs::write(
284 &fake,
285 "#!/bin/sh\n\
286 [ \"$OP_SERVICE_ACCOUNT_TOKEN\" = tok-alpha ] || { echo 'bad token' >&2; exit 1; }\n\
287 case \"$1\" in\n\
288 read) printf 's3cret' ;;\n\
289 item) printf '{\"version\": 7, \"updated_at\": \"2026-10-03T04:00:00Z\"}' ;;\n\
290 *) exit 2 ;;\n\
291 esac\n",
292 )
293 .unwrap();
294 std::fs::set_permissions(&fake, std::os::unix::fs::PermissionsExt::from_mode(0o755))
295 .unwrap();
296 let alpha = OrgId::new("alpha").unwrap();
297 let token: TokenSource =
298 Arc::new(|o: &OrgId| Ok((o.as_str() == "alpha").then(|| "tok-alpha".to_string())));
299 let d = OnePasswordDriver::new(token).with_binary(&fake);
300 assert_eq!(d.get(&alpha, "v/i/f").unwrap(), (b"s3cret".to_vec(), 7));
301 assert_eq!(d.version(&alpha, "v/i/f").unwrap(), 7);
302 assert_eq!(
303 d.inspect(&alpha, "v/i/f").unwrap().updated_at,
304 1_791_000_000
305 );
306 assert!(d.list(&alpha).unwrap().is_empty());
307 let e = d
308 .get(&OrgId::new("beta").unwrap(), "v/i/f")
309 .unwrap_err()
310 .to_string();
311 assert!(e.contains("no 1Password token"), "{e}");
312 assert!(d.set(&alpha, "v/i/f", b"x").is_err());
313 }
314}