From 5971b51e6504eefdd742bee5f2a723ce2bdb91ce Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 3 Oct 2026 13:49:02 +0200 Subject: [PATCH] swarm-controller: refuse a link over any stored object, decodable or not The existence check read the path as the route's credential type, so an object stored there that no longer decodes as one answered 500 instead of 409. It now reads the path untyped: anything stored holds the name. --- swarm-controller/src/linked_accounts.rs | 45 ++++++++++++++++++++----- swarm-controller/src/matrix_account.rs | 4 +-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 9e0de5b7..99ca8f14 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -24,7 +24,7 @@ use std::future::Future; use axum::Json; use axum::extract::State; use axum::http::StatusCode; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, IgnoredAny}; use serde::{Deserialize, Serialize}; use swarm_secret_client::{Error, SecretStore, forge, github, matrix}; use utoipa::{IntoParams, ToSchema}; @@ -226,12 +226,10 @@ impl Linking { } } -/// [`Linking::Exists`] when an account is stored at `path`. -pub(crate) async fn refuse_linked( - store: &impl AccountStore, - path: &str, -) -> Result<(), Linking> { - match store.read_optional::(path).await { +/// [`Linking::Exists`] when any object is stored at `path`, whatever its +/// shape: one that no longer decodes as an account still holds the name. +pub(crate) async fn refuse_linked(store: &impl AccountStore, path: &str) -> Result<(), Linking> { + match store.read_optional::(path).await { Ok(None) => Ok(()), Ok(Some(_)) => Err(Linking::Exists), Err(e) => Err(Linking::Store(e)), @@ -242,12 +240,12 @@ pub(crate) async fn refuse_linked( /// /// A read then a write, not one atomic step: two links racing for one path can /// both pass the check, and the later write wins. -pub(crate) async fn link( +pub(crate) async fn link( store: &impl AccountStore, path: &str, value: &T, ) -> Result<(), Linking> { - refuse_linked::(store, path).await?; + refuse_linked(store, path).await?; store.write(path, value).await.map_err(Linking::Store) } @@ -846,6 +844,35 @@ pub(crate) mod tests { assert!(matches!(removal, Removal::Store(_)), "{removal:?}"); } + #[tokio::test] + async fn an_object_that_no_longer_decodes_still_refuses_a_link() { + let path = "swarm/agents/atlas/github-token"; + let stale = json!({"token": 1}); + // The control: a typed read of this object fails, so the 409 below is + // not a decode that happened to succeed. + assert!(serde_json::from_value::(stale.clone()).is_err()); + let store = FakeStore::default().with(path, stale); + + let linking = super::link( + &store, + path, + &github::Credential { + value: "t0k3n-new".to_owned(), + }, + ) + .await + .expect_err("an object is stored"); + + assert!(matches!(linking, super::Linking::Exists), "{linking:?}"); + assert_eq!( + linking + .problem("agent atlas already has a github token") + .status, + Some(axum::http::StatusCode::CONFLICT) + ); + assert!(store.written().is_empty()); + } + /// Bare-minimum `AppState`, as `matrix_account`'s tests build it. fn state() -> super::super::AppState { super::super::AppState { diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 96f7dc2e..f53b06ac 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -200,9 +200,7 @@ async fn link_matrix( tracing::warn!(path, error = ?e, "linking the matrix account failed"); Box::new(e.problem(existing)) }; - refuse_linked::(store, path) - .await - .map_err(refused)?; + refuse_linked(store, path).await.map_err(refused)?; let (token, homeserver, user_id) = resolve_credential(req).await?; link( store,