secrets manager
git clone https://git.lucas.co/cce-secrets.git
src/bin/cce-keyring-sync/op.rs (24.2K)
1 //! The interchange seam, and 1Password behind it.
2 //!
3 //! KEYRING-SYNC.md ("Scoping: 1Password as the interchange") is the design.
4 //! Everything 1Password-specific is a child `op` process with JSON on stdout
5 //! and, for writes, a JSON item template on stdin — **never a value on
6 //! argv**, which every same-user process can read. Each spawn runs under
7 //! [`OP_TIMEOUT`]: an unanswered Authorize dialog holds `op` for 60 s before
8 //! it gives up, and a wedged app must not hold a tick forever.
9 //!
10 //! The `Interchange` trait is what the merge loop (sync.rs) calls; 1Password
11 //! is its only implementation since the kdbx backend retired.
12
13 use std::time::Duration;
14
15 use serde_json::{json, Value};
16
17 /// Longer than the app's own 60-second dialog timeout, so a dismissed
18 /// prompt reports itself as such instead of as a kill.
19 pub const OP_TIMEOUT: Duration = Duration::from_secs(75);
20
21 /// Serializes every `op` spawn in this process (see `run_as`).
22 static OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
23
24 /// The text `op` prints when the Authorize dialog timed out unanswered.
25 const DISMISSED: &str = "authorization prompt dismissed";
26 /// What `op` prints when nobody unlocked the app in time: the first call of
27 /// a login, against an app that starts locked and wants its account
28 /// password before system unlock works (measured 2026-10-01).
29 const TIMED_OUT: &str = "authorization timeout";
30 /// The app is not running (yet — the daemon starts before it at login).
31 const APP_DOWN: &str = "cannot connect to 1Password app";
32 /// `op item get --otp` on an item without a one-time password field.
33 const NO_OTP: &str = "doesn't contain any OTP-type fields";
34
35 /// One remote entry, whole: the six fields the merge hashes plus identity.
36 #[derive(Clone, Debug, PartialEq, Default)]
37 pub struct RemoteEntry {
38 pub id: String,
39 /// 1Password: the vault name (stored keyring-side as `op-vault`).
40 pub vault: String,
41 pub title: String,
42 pub username: String,
43 pub password: String,
44 pub url: String,
45 pub notes: String,
46 /// Server-side modification time, unix seconds (0 when unparseable).
47 pub updated: i64,
48 /// `updated` as canonical RFC 3339 UTC text (`2026-09-21T16:27:42Z`),
49 /// the form the base snapshot stores. Canonical because `op` itself is
50 /// not consistent: `item list` prints UTC to the second, `item get` and
51 /// `item edit` print local time with an offset and nanoseconds, and a
52 /// base written from one must still match a list read from the other.
53 pub updated_raw: String,
54 }
55
56 /// What `list` returns: everything but the secret fields, so a quiet run
57 /// never touches a password.
58 #[derive(Clone, Debug, PartialEq, Default)]
59 pub struct RemoteSummary {
60 pub id: String,
61 pub vault: String,
62 pub title: String,
63 pub username: String,
64 pub url: String,
65 pub updated: i64,
66 pub updated_raw: String,
67 }
68
69 /// The cross-machine store the keyring is mirrored against.
70 pub trait Interchange {
71 /// Every login the store holds — no secrets.
72 async fn list(&mut self) -> Result<Vec<RemoteSummary>, String>;
73 /// One entry in full.
74 async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>;
75 /// Store a new entry; returns its id and its timestamp text. `e.id` is ignored.
76 async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String>;
77 /// Overwrite an existing entry's synced fields, leaving the rest alone;
78 /// returns the entry's new timestamp text, so the base can record it
79 /// without another fetch.
80 async fn update(&mut self, e: &RemoteEntry) -> Result<String, String>;
81 /// Soft-delete: 1Password's Archive.
82 async fn recycle(&mut self, id: &str) -> Result<(), String>;
83 }
84
85 /// True when the error text is a prompt nobody answered — the Authorize
86 /// dialog, or the app's own unlock — a refusal to back off from, not a
87 /// fault to log as one.
88 pub fn is_dismissed(err: &str) -> bool {
89 err.contains(DISMISSED) || err.contains(TIMED_OUT)
90 }
91
92 /// True when `op` found no app to talk to.
93 pub fn is_app_down(err: &str) -> bool {
94 err.contains(APP_DOWN)
95 }
96
97 // ───────────────────────────── 1Password ─────────────────────────────
98
99 pub struct OnePassword {
100 /// Vault to list from and create into. Empty means every vault `op`
101 /// can read; creates then need a name, so `create` refuses.
102 pub vault: String,
103 /// `--account`, for a person with several signed in. Empty: op's default.
104 pub account: String,
105 /// The program run: `op` from PATH; a stand-in script under test.
106 pub bin: String,
107 }
108
109 impl OnePassword {
110 pub fn new(vault: &str) -> Self {
111 OnePassword { vault: vault.to_string(), account: String::new(), bin: "op".to_string() }
112 }
113
114 /// Spawn `op` as a direct child (the authorization is keyed to *our*
115 /// pid as its parent — never via a shell, setsid, or a double fork),
116 /// feed `stdin`, and return stdout. Stderr's last line is the error.
117 async fn run(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
118 self.run_as(args, stdin, true).await
119 }
120
121 /// `run`, with `--format json` optional: `--otp` refuses it.
122 async fn run_as(&self, args: &[&str], stdin: Option<Vec<u8>>, json: bool) -> Result<Vec<u8>, String> {
123 use tokio::io::AsyncWriteExt;
124 // One `op` at a time per process. Each call still waiting on the
125 // Authorize dialog gets a dialog of its own: at login the first
126 // tick's `item list` sat on its dialog while a cce-secrets code
127 // request spawned `item get --otp`, and the user answered two
128 // identical dialogs (2026-10-02, both logins that day). Queued
129 // behind the pending call, the second inherits its authorization.
130 let _one_at_a_time = OP_LOCK.lock().await;
131 let mut cmd = tokio::process::Command::new(&self.bin);
132 cmd.args(args);
133 if json {
134 cmd.arg("--format").arg("json");
135 }
136 cmd.arg("--no-color");
137 if !self.account.is_empty() {
138 cmd.arg("--account").arg(&self.account);
139 }
140 cmd.stdin(if stdin.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() })
141 .stdout(std::process::Stdio::piped())
142 .stderr(std::process::Stdio::piped())
143 .kill_on_drop(true);
144 let mut child = cmd.spawn().map_err(|e| format!("op not runnable: {e}"))?;
145 if let Some(bytes) = stdin {
146 let mut pipe = child.stdin.take().expect("piped stdin");
147 // A closed pipe (op exited early) is reported by wait, not here.
148 let _ = pipe.write_all(&bytes).await;
149 drop(pipe);
150 }
151 let out = match tokio::time::timeout(OP_TIMEOUT, child.wait_with_output()).await {
152 Ok(Ok(out)) => out,
153 Ok(Err(e)) => return Err(format!("op failed to run: {e}")),
154 Err(_) => return Err(format!("op timed out after {}s (app wedged?)", OP_TIMEOUT.as_secs())),
155 };
156 if out.status.success() {
157 return Ok(out.stdout);
158 }
159 let err = String::from_utf8_lossy(&out.stderr);
160 let last = err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
161 // op prefixes "[ERROR] 2026/09/21 10:15:39 "; keep what follows.
162 let msg = last.splitn(4, ' ').nth(3).unwrap_or(last);
163 Err(format!("op {}: {msg}", args.first().copied().unwrap_or("")))
164 }
165
166 async fn run_json(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Value, String> {
167 let bytes = self.run(args, stdin).await?;
168 serde_json::from_slice(&bytes).map_err(|e| format!("op {}: unparseable JSON: {e}", args.join(" ")))
169 }
170
171 /// An item's current one-time code; `None` when it has no OTP field.
172 /// `--otp` has op compute the code, so the seed (the field's value,
173 /// which `item get --format json` would print) never enters this
174 /// process. Not part of `Interchange`: the merge never touches OTP.
175 pub async fn otp(&self, id: &str) -> Result<Option<String>, String> {
176 match self.run_as(&["item", "get", id, "--otp"], None, false).await {
177 Ok(out) => match String::from_utf8_lossy(&out).trim() {
178 "" => Err("op item: empty one-time code".into()),
179 code => Ok(Some(code.to_string())),
180 },
181 Err(e) if e.contains(NO_OTP) => Ok(None),
182 Err(e) => Err(e),
183 }
184 }
185 }
186
187 impl Interchange for OnePassword {
188 async fn list(&mut self) -> Result<Vec<RemoteSummary>, String> {
189 let mut args = vec!["item", "list", "--categories", "Login"];
190 if !self.vault.is_empty() {
191 args.extend(["--vault", self.vault.as_str()]);
192 }
193 let v = self.run_json(&args, None).await?;
194 let items = v.as_array().ok_or("op item list: not an array")?;
195 Ok(items.iter().map(summary_from_json).collect())
196 }
197
198 async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String> {
199 let v = self.run_json(&["item", "get", id], None).await?;
200 Ok(entry_from_json(&v))
201 }
202
203 async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String> {
204 if self.vault.is_empty() {
205 return Err("no vault configured for new entries (adopt --vault <name>)".into());
206 }
207 let template = serde_json::to_vec(&create_template(e)).unwrap();
208 let v = self
209 .run_json(&["item", "create", "--vault", self.vault.as_str(), "-"], Some(template))
210 .await?;
211 let id = v.get("id").and_then(Value::as_str).ok_or("op item create: no id in reply")?;
212 Ok((id.to_string(), updated_of(&v).1))
213 }
214
215 async fn update(&mut self, e: &RemoteEntry) -> Result<String, String> {
216 // Round-trip the whole item so sections, custom fields and tags
217 // survive; only the synced fields are rewritten.
218 let mut v = self.run_json(&["item", "get", &e.id], None).await?;
219 apply_entry(&mut v, e);
220 let body = serde_json::to_vec(&v).unwrap();
221 let reply = self.run_json(&["item", "edit", &e.id], Some(body)).await?;
222 Ok(updated_of(&reply).1)
223 }
224
225 async fn recycle(&mut self, id: &str) -> Result<(), String> {
226 // `delete --archive` prints nothing; run, not run_json.
227 self.run(&["item", "delete", id, "--archive"], None).await.map(|_| ())
228 }
229 }
230
231 // ───────────────────────────── JSON shapes ─────────────────────────────
232 //
233 // Captured from op 2.39.0 (2026-09-21). `item list` gives id, title,
234 // vault{id,name}, category, urls[{href,primary}], additional_information
235 // (the username for logins), created_at, updated_at. `item get` adds
236 // fields[{id,type,purpose,label,value,…}] with purpose USERNAME / PASSWORD /
237 // NOTES; a NOTES field with no value has no `value` key at all.
238
239 fn s(v: &Value, key: &str) -> String {
240 v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
241 }
242
243 fn primary_url(v: &Value) -> String {
244 let Some(urls) = v.get("urls").and_then(Value::as_array) else { return String::new() };
245 urls.iter()
246 .find(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
247 .or_else(|| urls.first())
248 .map(|u| s(u, "href"))
249 .unwrap_or_default()
250 }
251
252 fn field_by_purpose<'a>(v: &'a Value, purpose: &str) -> Option<&'a Value> {
253 v.get("fields")?
254 .as_array()?
255 .iter()
256 .find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose))
257 }
258
259 /// The (unix, canonical text) pair for an item's `updated_at`.
260 pub fn updated_of(v: &Value) -> (i64, String) {
261 match parse_rfc3339(&s(v, "updated_at")) {
262 Some(t) => (t, format_rfc3339(t)),
263 None => (0, String::new()),
264 }
265 }
266
267 pub fn summary_from_json(v: &Value) -> RemoteSummary {
268 let (updated, updated_raw) = updated_of(v);
269 RemoteSummary {
270 id: s(v, "id"),
271 vault: v.get("vault").map(|x| s(x, "name")).unwrap_or_default(),
272 title: s(v, "title"),
273 username: s(v, "additional_information"),
274 url: primary_url(v),
275 updated,
276 updated_raw,
277 }
278 }
279
280 pub fn entry_from_json(v: &Value) -> RemoteEntry {
281 let sum = summary_from_json(v);
282 let field = |p: &str| field_by_purpose(v, p).map(|f| s(f, "value")).unwrap_or_default();
283 RemoteEntry {
284 id: sum.id,
285 vault: sum.vault,
286 title: sum.title,
287 // The field is authoritative; additional_information is its echo.
288 username: field("USERNAME"),
289 password: field("PASSWORD"),
290 url: sum.url,
291 notes: field("NOTES"),
292 updated: sum.updated,
293 updated_raw: sum.updated_raw,
294 }
295 }
296
297 /// The Login template `op item template get Login` prints, filled in.
298 pub fn create_template(e: &RemoteEntry) -> Value {
299 let mut t = json!({
300 "title": e.title,
301 "category": "LOGIN",
302 "fields": [
303 {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": e.username},
304 {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": e.password},
305 {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain", "value": e.notes},
306 ]
307 });
308 if !e.url.is_empty() {
309 t["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
310 }
311 t
312 }
313
314 /// Rewrite the synced fields of a fetched item in place.
315 pub fn apply_entry(v: &mut Value, e: &RemoteEntry) {
316 v["title"] = json!(e.title);
317 // The primary URL is replaced (or added); other URLs are left alone.
318 let urls = v.get_mut("urls").and_then(Value::as_array_mut);
319 match urls {
320 Some(list) if !list.is_empty() => {
321 let idx = list
322 .iter()
323 .position(|u| u.get("primary").and_then(Value::as_bool) == Some(true))
324 .unwrap_or(0);
325 if e.url.is_empty() {
326 list.remove(idx);
327 } else {
328 list[idx]["href"] = json!(e.url);
329 }
330 }
331 _ => {
332 if !e.url.is_empty() {
333 v["urls"] = json!([{"label": "website", "primary": true, "href": e.url}]);
334 }
335 }
336 }
337 let set = |v: &mut Value, purpose: &str, id: &str, kind: &str, value: &str| {
338 let fields = v
339 .as_object_mut()
340 .expect("item object")
341 .entry("fields")
342 .or_insert_with(|| json!([]));
343 let list = fields.as_array_mut().expect("fields array");
344 match list.iter_mut().find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose)) {
345 Some(f) => f["value"] = json!(value),
346 None => list.push(json!({"id": id, "type": kind, "purpose": purpose, "label": id, "value": value})),
347 }
348 };
349 set(v, "USERNAME", "username", "STRING", &e.username);
350 set(v, "PASSWORD", "password", "CONCEALED", &e.password);
351 set(v, "NOTES", "notesPlain", "STRING", &e.notes);
352 }
353
354 /// RFC 3339 → unix seconds: `2026-09-21T15:41:14Z`, or with a fraction,
355 /// or with a `±HH:MM` offset (what `op item get`/`edit` print). Fractions
356 /// are dropped: the list side only has seconds, and the two must agree.
357 pub fn parse_rfc3339(t: &str) -> Option<i64> {
358 let (date, rest) = t.split_once('T')?;
359 let (time, offset_secs) = if let Some(r) = rest.strip_suffix('Z') {
360 (r, 0)
361 } else {
362 let i = rest.rfind(['+', '-'])?;
363 let (r, off) = rest.split_at(i);
364 let sign = if off.starts_with('-') { -1 } else { 1 };
365 let (oh, om) = off[1..].split_once(':')?;
366 (r, sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60))
367 };
368 let mut d = date.split('-').map(|p| p.parse::<i64>().ok());
369 let (y, m, day) = (d.next()??, d.next()??, d.next()??);
370 let time = time.split('.').next()?;
371 let mut c = time.split(':').map(|p| p.parse::<i64>().ok());
372 let (h, mi, sec) = (c.next()??, c.next()??, c.next()??);
373 Some(days_from_civil(y, m, day) * 86400 + h * 3600 + mi * 60 + sec - offset_secs)
374 }
375
376 /// unix seconds → `2026-09-21T16:27:42Z`, the canonical base form.
377 pub fn format_rfc3339(t: i64) -> String {
378 let days = t.div_euclid(86400);
379 let rem = t.rem_euclid(86400);
380 let (y, m, d) = civil_from_days(days);
381 format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
382 }
383
384 // Howard Hinnant's civil-date algorithms.
385 fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
386 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
387 let era = y.div_euclid(400);
388 let yoe = y - era * 400;
389 let doy = (153 * m + 2) / 5 + d - 1;
390 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
391 era * 146097 + doe - 719468
392 }
393
394 fn civil_from_days(z: i64) -> (i64, i64, i64) {
395 let z = z + 719468;
396 let era = z.div_euclid(146097);
397 let doe = z - era * 146097;
398 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
399 let y = yoe + era * 400;
400 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
401 let mp = (5 * doy + 2) / 153;
402 let d = doy - (153 * mp + 2) / 5 + 1;
403 let m = if mp < 10 { mp + 3 } else { mp - 9 };
404 (if m <= 2 { y + 1 } else { y }, m, d)
405 }
406
407 #[cfg(test)]
408 mod tests {
409 use super::*;
410
411 const LIST_ITEM: &str = r#"{
412 "id": "abc", "title": "Example", "tags": [], "version": 1,
413 "vault": {"id": "v1", "name": "Personal"}, "category": "LOGIN",
414 "created_at": "2026-09-21T15:41:14Z", "updated_at": "2026-09-21T15:41:14Z",
415 "additional_information": "someone",
416 "urls": [{"href": "https://old.example.com"}, {"primary": true, "href": "https://example.com"}]
417 }"#;
418
419 #[test]
420 fn a_list_item_yields_a_summary_with_no_secret() {
421 let v: Value = serde_json::from_str(LIST_ITEM).unwrap();
422 let s = summary_from_json(&v);
423 assert_eq!(s.id, "abc");
424 assert_eq!(s.vault, "Personal");
425 assert_eq!(s.username, "someone");
426 assert_eq!(s.url, "https://example.com", "the primary url wins over the first");
427 assert_eq!(s.updated, 1790005274);
428 assert_eq!(s.updated_raw, "2026-09-21T15:41:14Z");
429 }
430
431 #[test]
432 fn a_full_item_reads_its_fields_by_purpose() {
433 let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
434 v["fields"] = json!([
435 {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
436 {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "hunter2"},
437 {"id": "notesPlain", "type": "STRING", "purpose": "NOTES", "label": "notesPlain"}
438 ]);
439 let e = entry_from_json(&v);
440 assert_eq!(e.password, "hunter2");
441 assert_eq!(e.notes, "", "a notes field without a value is empty, not missing");
442 assert_eq!(e.username, "someone");
443 }
444
445 #[test]
446 fn a_missing_url_list_is_empty() {
447 let v: Value = json!({"id": "x", "title": "t", "updated_at": "nope"});
448 let s = summary_from_json(&v);
449 assert_eq!(s.url, "");
450 assert_eq!(s.updated, 0);
451 }
452
453 #[test]
454 fn the_create_template_matches_op_s_login_shape() {
455 let e = RemoteEntry {
456 title: "T".into(),
457 username: "u".into(),
458 password: "p".into(),
459 url: "https://x.example".into(),
460 notes: "n".into(),
461 ..Default::default()
462 };
463 let t = create_template(&e);
464 assert_eq!(t["category"], "LOGIN");
465 assert_eq!(t["urls"][0]["primary"], true);
466 assert_eq!(t["urls"][0]["href"], "https://x.example");
467 let e2 = entry_from_json(&t);
468 assert_eq!((e2.title, e2.username, e2.password, e2.url, e2.notes), ("T".into(), "u".into(), "p".into(), "https://x.example".into(), "n".into()));
469 let no_url = create_template(&RemoteEntry::default());
470 assert!(no_url.get("urls").is_none(), "an empty url adds no urls key");
471 }
472
473 #[test]
474 fn apply_entry_rewrites_synced_fields_and_keeps_the_rest() {
475 let mut v: Value = serde_json::from_str(LIST_ITEM).unwrap();
476 v["fields"] = json!([
477 {"id": "username", "type": "STRING", "purpose": "USERNAME", "label": "username", "value": "someone"},
478 {"id": "password", "type": "CONCEALED", "purpose": "PASSWORD", "label": "password", "value": "old"},
479 {"id": "custom", "type": "STRING", "label": "pin", "value": "1234", "section": {"id": "s1"}}
480 ]);
481 let e = RemoteEntry {
482 id: "abc".into(),
483 title: "Renamed".into(),
484 username: "someone".into(),
485 password: "new".into(),
486 url: "https://new.example.com".into(),
487 notes: "added".into(),
488 ..Default::default()
489 };
490 apply_entry(&mut v, &e);
491 assert_eq!(v["title"], "Renamed");
492 assert_eq!(v["urls"][1]["href"], "https://new.example.com", "the primary url is replaced in place");
493 assert_eq!(v["urls"][0]["href"], "https://old.example.com", "other urls survive");
494 let got = entry_from_json(&v);
495 assert_eq!(got.password, "new");
496 assert_eq!(got.notes, "added", "a missing purpose field is appended");
497 assert_eq!(v["fields"][2]["value"], "1234", "custom fields survive");
498 assert_eq!(v["tags"], json!([]), "unrelated keys survive");
499 }
500
501 #[test]
502 fn rfc3339_parses_both_forms_op_prints_to_the_same_second() {
503 assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
504 assert_eq!(parse_rfc3339("2026-09-21T15:41:14Z"), Some(1790005274));
505 assert_eq!(parse_rfc3339("2026-09-21T15:41:14.5Z"), Some(1790005274));
506 // `op item edit` printed this for the item `op item list` showed as 2026-09-21T16:27:42Z.
507 assert_eq!(parse_rfc3339("2026-09-21T12:27:42.39627885-04:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
508 assert_eq!(parse_rfc3339("2026-09-21T18:27:42+02:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
509 assert_eq!(parse_rfc3339(""), None);
510 assert_eq!(parse_rfc3339("nope"), None);
511 }
512
513 #[test]
514 fn the_canonical_form_round_trips() {
515 for t in [0i64, 951782400, 1790005274, 1790008062, 4102444799] {
516 assert_eq!(parse_rfc3339(&format_rfc3339(t)), Some(t), "{t}");
517 }
518 assert_eq!(format_rfc3339(1790005274), "2026-09-21T15:41:14Z");
519 assert_eq!(format_rfc3339(951782400), "2000-02-29T00:00:00Z");
520 let v: Value = json!({"updated_at": "2026-09-21T12:27:42.39627885-04:00"});
521 assert_eq!(updated_of(&v).1, "2026-09-21T16:27:42Z", "a get/edit reply stores as the list form");
522 }
523
524 #[test]
525 fn a_dismissed_prompt_is_recognised() {
526 assert!(is_dismissed("op item list: authorization prompt dismissed, please try again"));
527 assert!(!is_dismissed("op item list: account is not signed in"));
528 // The first call of a login, against the still-locked app.
529 assert!(is_dismissed("op item: authorization timeout"));
530 assert!(is_app_down("op item: connecting to desktop app: cannot connect to 1Password app, make sure it is running"));
531 assert!(!is_app_down("op item: authorization timeout"));
532 }
533
534 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
535 async fn op_calls_never_overlap() {
536 // A code request arriving while the tick's call waits on its dialog
537 // must queue behind it, not raise a second dialog of its own.
538 let dir = std::env::temp_dir().join(format!("op-serial-{}", std::process::id()));
539 std::fs::create_dir_all(&dir).unwrap();
540 let log = dir.join("log");
541 let bin = dir.join("op");
542 std::fs::write(&bin, format!("#!/bin/sh\necho start >> '{0}'\nsleep 0.3\necho end >> '{0}'\n", log.display())).unwrap();
543 use std::os::unix::fs::PermissionsExt;
544 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
545 let mut op = OnePassword::new("v");
546 op.bin = bin.to_str().unwrap().to_string();
547 let calls = (0..3).map(|_| {
548 let op = OnePassword { bin: op.bin.clone(), ..OnePassword::new("v") };
549 tokio::spawn(async move { op.run(&["item", "list"], None).await })
550 });
551 for c in calls.collect::<Vec<_>>() {
552 c.await.unwrap().unwrap();
553 }
554 let lines = std::fs::read_to_string(&log).unwrap();
555 std::fs::remove_dir_all(&dir).unwrap();
556 assert_eq!(lines, "start\nend\n".repeat(3), "op calls overlapped");
557 }
558 }