git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit8359f6d129641e05a7fa940ac59a267f4458ab7d
parent23a8c5cee8
authorLucas Galante <[email protected]>
date2026-09-06 11:42
fix: reconnect to the Secret Service and retry once when a secret op fails

The worker connected once at startup and kept that connection for the life
of the app. gnome-keyring aborted on a GLib assertion during a session
negotiation (2026-09-06 10:37) and systemd relaunched it a second later;
listing kept working, because collections and items are addressed by
stable paths, but the session negotiated at connect died with the old
daemon and every GetSecret / SetSecret names that session. So the entry
list looked healthy while Copy, Reveal, Save and Delete all failed with
"Secret fetch failed: zbus error: …" until the app was restarted.

fetch_secret / create_item / update_item / delete_item now return the
status line as Err instead of sending it themselves, and the worker loop
answers a failure by opening a fresh SecretService connection (new session)
and running the command exactly once more. Only the retry's error reaches
the status line; a reconnect failure reports both.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/main.rs | 166 ++++++++++++++++++++++++++++++++----------------------------
 1 file changed, 89 insertions(+), 77 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 5622918..3b5a359 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -55,6 +55,7 @@ enum Purpose {
     Reveal,
 }
 
+#[derive(Clone)]
 enum Cmd {
     Reload,
     /// Run one `cce-keyring-sync sync` pass and reload. The sync logic stays
@@ -143,7 +144,7 @@ fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender
             }
         };
         rt.block_on(async move {
-            let ss = match SecretService::connect(EncryptionType::Dh).await {
+            let mut ss = match SecretService::connect(EncryptionType::Dh).await {
                 Ok(ss) => ss,
                 Err(e) => {
                     let _ = tx.send(AppMessage::Status(
@@ -161,20 +162,59 @@ fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender
                         run_sync(&tx).await;
                         load_entries(&ss, &tx).await;
                     }
-                    Cmd::GetSecret { path, purpose } => fetch_secret(&ss, &tx, path, purpose).await,
-                    Cmd::CreateItem { label, attrs, secret } => {
-                        create_item(&ss, &tx, label, attrs, secret).await
-                    }
-                    Cmd::UpdateItem { path, label, attrs, secret } => {
-                        update_item(&ss, &tx, path, label, attrs, secret).await
+                    op => {
+                        let Err(first) = run_secret_op(&ss, &tx, op.clone()).await else {
+                            continue;
+                        };
+                        // The daemon may have restarted underneath us
+                        // (gnome-keyring aborted on a GLib assertion and was
+                        // relaunched, 2026-09-06). Listing survives that, but
+                        // the session negotiated at connect died with the old
+                        // process, and every secret transfer names it — so the
+                        // list looks fine while Copy/Reveal/Save fail. Take a
+                        // fresh connection (new session) and try exactly once
+                        // more; a failure on the retry is a real one.
+                        log::info!("secret op failed ({first}); reconnecting to the Secret Service and retrying once");
+                        match SecretService::connect(EncryptionType::Dh).await {
+                            Ok(fresh) => {
+                                ss = fresh;
+                                if let Err(second) = run_secret_op(&ss, &tx, op).await {
+                                    let _ = tx.send(AppMessage::Status(second, true));
+                                }
+                            }
+                            Err(e) => {
+                                let _ = tx.send(AppMessage::Status(
+                                    format!("{first} (reconnect to Secret Service failed: {e})"),
+                                    true,
+                                ));
+                            }
+                        }
                     }
-                    Cmd::DeleteItem { path } => delete_item(&ss, &tx, path).await,
                 }
             }
         });
     });
 }
 
+/// The session-bound commands: anything that transfers a secret (or edits
+/// an item) through the session opened at connect. `Err` is the status line
+/// to show; the caller decides whether to retry on a fresh connection first.
+async fn run_secret_op(
+    ss: &SecretService<'_>,
+    tx: &calloop::channel::Sender<AppMessage>,
+    cmd: Cmd,
+) -> Result<(), String> {
+    match cmd {
+        Cmd::Reload | Cmd::Sync => Ok(()),
+        Cmd::GetSecret { path, purpose } => fetch_secret(ss, tx, path, purpose).await,
+        Cmd::CreateItem { label, attrs, secret } => create_item(ss, tx, label, attrs, secret).await,
+        Cmd::UpdateItem { path, label, attrs, secret } => {
+            update_item(ss, tx, path, label, attrs, secret).await
+        }
+        Cmd::DeleteItem { path } => delete_item(ss, tx, path).await,
+    }
+}
+
 async fn load_entries(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppMessage>) {
     let _ = tx.send(AppMessage::Status("Loading entries…".to_string(), false));
     let collections = match ss.get_all_collections().await {
@@ -236,19 +276,13 @@ async fn fetch_secret(
     tx: &calloop::channel::Sender<AppMessage>,
     path: String,
     purpose: Purpose,
-) {
-    let status_err = |msg: String| {
-        let _ = tx.send(AppMessage::Status(msg, true));
-    };
-    let item = match resolve_item(ss, &path).await {
-        Ok(i) => i,
-        Err(e) => return status_err(e),
-    };
+) -> Result<(), String> {
+    let item = resolve_item(ss, &path).await?;
     let _ = item.ensure_unlocked().await;
-    let bytes = match item.get_secret().await {
-        Ok(b) => b,
-        Err(e) => return status_err(format!("Secret fetch failed: {e}")),
-    };
+    let bytes = item
+        .get_secret()
+        .await
+        .map_err(|e| format!("Secret fetch failed: {e}"))?;
     let secret = String::from_utf8_lossy(&bytes).to_string();
     match purpose {
         Purpose::Copy => {
@@ -269,6 +303,7 @@ async fn fetch_secret(
             let _ = tx.send(AppMessage::Revealed { path, secret });
         }
     }
+    Ok(())
 }
 
 async fn resolve_item<'a>(
@@ -288,30 +323,22 @@ async fn create_item(
     label: String,
     attrs: Vec<(String, String)>,
     secret: String,
-) {
-    let collection = match ss.get_default_collection().await {
-        Ok(c) => c,
-        Err(e) => {
-            let _ = tx.send(AppMessage::Status(format!("No default collection: {e}"), true));
-            return;
-        }
-    };
+) -> Result<(), String> {
+    let collection = ss
+        .get_default_collection()
+        .await
+        .map_err(|e| format!("No default collection: {e}"))?;
     let _ = collection.ensure_unlocked().await;
     let attr_map = attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
-    match collection
+    let item = collection
         .create_item(&label, attr_map, secret.as_bytes(), false, "text/plain")
         .await
-    {
-        Ok(item) => {
-            let new_path = item.item_path.to_string();
-            let _ = tx.send(AppMessage::Status(format!("Created \"{label}\""), false));
-            load_entries(ss, tx).await;
-            let _ = tx.send(AppMessage::SelectPath(new_path));
-        }
-        Err(e) => {
-            let _ = tx.send(AppMessage::Status(format!("Create failed: {e}"), true));
-        }
-    }
+        .map_err(|e| format!("Create failed: {e}"))?;
+    let new_path = item.item_path.to_string();
+    let _ = tx.send(AppMessage::Status(format!("Created \"{label}\""), false));
+    load_entries(ss, tx).await;
+    let _ = tx.send(AppMessage::SelectPath(new_path));
+    Ok(())
 }
 
 async fn update_item(
@@ -321,52 +348,37 @@ async fn update_item(
     label: String,
     attrs: Vec<(String, String)>,
     secret: Option<String>,
-) {
-    let item = match resolve_item(ss, &path).await {
-        Ok(i) => i,
-        Err(e) => {
-            let _ = tx.send(AppMessage::Status(e, true));
-            return;
-        }
-    };
+) -> Result<(), String> {
+    let item = resolve_item(ss, &path).await?;
     let _ = item.ensure_unlocked().await;
-    if let Err(e) = item.set_label(&label).await {
-        let _ = tx.send(AppMessage::Status(format!("Saving label failed: {e}"), true));
-        return;
-    }
+    item.set_label(&label)
+        .await
+        .map_err(|e| format!("Saving label failed: {e}"))?;
     let attr_map = attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
-    if let Err(e) = item.set_attributes(attr_map).await {
-        let _ = tx.send(AppMessage::Status(format!("Saving attributes failed: {e}"), true));
-        return;
-    }
+    item.set_attributes(attr_map)
+        .await
+        .map_err(|e| format!("Saving attributes failed: {e}"))?;
     if let Some(secret) = secret {
-        if let Err(e) = item.set_secret(secret.as_bytes(), "text/plain").await {
-            let _ = tx.send(AppMessage::Status(format!("Saving secret failed: {e}"), true));
-            return;
-        }
+        item.set_secret(secret.as_bytes(), "text/plain")
+            .await
+            .map_err(|e| format!("Saving secret failed: {e}"))?;
     }
     let _ = tx.send(AppMessage::Status(format!("Saved \"{label}\""), false));
     load_entries(ss, tx).await;
     let _ = tx.send(AppMessage::SelectPath(path));
+    Ok(())
 }
 
-async fn delete_item(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppMessage>, path: String) {
-    let item = match resolve_item(ss, &path).await {
-        Ok(i) => i,
-        Err(e) => {
-            let _ = tx.send(AppMessage::Status(e, true));
-            return;
-        }
-    };
-    match item.delete().await {
-        Ok(()) => {
-            let _ = tx.send(AppMessage::Status("Entry deleted".to_string(), false));
-            load_entries(ss, tx).await;
-        }
-        Err(e) => {
-            let _ = tx.send(AppMessage::Status(format!("Delete failed: {e}"), true));
-        }
-    }
+async fn delete_item(
+    ss: &SecretService<'_>,
+    tx: &calloop::channel::Sender<AppMessage>,
+    path: String,
+) -> Result<(), String> {
+    let item = resolve_item(ss, &path).await?;
+    item.delete().await.map_err(|e| format!("Delete failed: {e}"))?;
+    let _ = tx.send(AppMessage::Status("Entry deleted".to_string(), false));
+    load_entries(ss, tx).await;
+    Ok(())
 }
 
 // ── Application ───────────────────────────────────────────────────────────