From 388d98557809fe24878e2c44b4a9b7cb13ba7485 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Thu, 13 Aug 2026 14:32:21 +0200 Subject: [PATCH] radicle/storage: Secure `canonical_identity_head` Do not iterate over all user namespaces to find the ID of the identity COB, but load from the founder's namespace specifically. If the namespace of the founder is gone, attempt to recover by walking back from `rad/id`. --- crates/radicle-node/src/tests/e2e.rs | 4 +- crates/radicle/src/storage/git.rs | 62 +++++++++++++++++++++------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/crates/radicle-node/src/tests/e2e.rs b/crates/radicle-node/src/tests/e2e.rs index 850c86846..54fc6e447 100644 --- a/crates/radicle-node/src/tests/e2e.rs +++ b/crates/radicle-node/src/tests/e2e.rs @@ -271,11 +271,9 @@ fn public_to_private_to_public_replay() { // Recompute and set the identity head, just like `rad id cache` would do. repo.set_identity_head().unwrap(); - // Even though Alice made the repository private, it still appears - // as public! assert_eq!( Identity::load(&repo).unwrap().doc().visibility(), - &Visibility::Public + &Visibility::private([]) ); } diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs index 956a013fc..3331bd52a 100644 --- a/crates/radicle/src/storage/git.rs +++ b/crates/radicle/src/storage/git.rs @@ -907,23 +907,57 @@ impl ReadRepository for Repository { } fn canonical_identity_head(&self) -> Result { - for remote in self.remote_ids()? { - let remote = remote?; - // Nb. A remote may not have an identity document if the user has not contributed - // any changes to the identity COB. - let Ok(root) = self.identity_root_of(&remote) else { - continue; - }; - let blob = Doc::blob_at(root, self)?; + let blob = match self.backend.find_blob(self.id.deref().into()) { + Ok(blob) => blob, + Err(err) if err.is_not_found() => return Err(RepositoryError::Doc(DocError::Missing)), + Err(err) => return Err(err.into()), + }; - // We've got an identity that goes back to the correct root. - if *self.id == blob.id() { - let identity = Identity::get(&root.into(), self)?; + let doc = Doc::from_blob(&blob)?; + let delegates = doc.delegates(); - return Ok(identity.head()); - } + if delegates.len() != 1 { + log::debug!(target: "storage", "Root identity document (blob '{}') has {} delegates, expected exactly 1.", self.id, delegates.len()); + return Err(RepositoryError::Doc(DocError::Missing)); } - Err(DocError::Missing.into()) + + let founder = delegates.first(); + + let root = match self.reference_oid(founder, &git::refs::storage::IDENTITY_ROOT) { + Ok(root) => { + log::debug!(target: "storage", "Obtained identity COB root commit '{root}' via reference '{}' in namespace of founder '{founder}' of identity document '{}'.", git::refs::storage::IDENTITY_ROOT.as_str(), self.id); + root + } + Err(err) if err.is_not_found() => { + // In case the namespace of the founder does not exist, + // or does not have `rad/root`, attempt to find the root + // by walking backwards from `rad/id`, which tracks the + // head of the identity COB. + let root = self.identity_root().map_err(|err| { + if err.is_not_found() { + RepositoryError::Doc(DocError::Missing) + } else { + err + } + })?; + + log::debug!(target: "storage", "Obtained identity COB root commit '{root}' via identity root of the repository."); + root + } + Err(err) => { + log::debug!(target: "storage", "Failed to find identity root for founder '{founder}' of identity document '{}': {err}", self.id); + return Err(RepositoryError::Doc(DocError::Missing)); + } + }; + + let doc_at_root = Doc::load_at(root, self)?; + + if doc_at_root.blob != *self.id { + log::debug!(target: "storage", "Root identity document (blob '{}') resolved via '{}' does not match the expected identity document (blob '{}').", doc_at_root.blob, CANONICAL_IDENTITY.as_str(), self.id); + return Err(RepositoryError::Doc(DocError::Missing)); + } + + Ok(Identity::get(&root.into(), self)?.head()) } fn merge_base(&self, left: &Oid, right: &Oid) -> Result {