git.lucas.co / cce-secrets
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 }