Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle/sigrefs: Rewrite Signed References
Fintan Halpenny committed 1 month ago
commit d3bc868e84c334f113806df1737f52cc57c5453d
parent 52a660f
33 files changed +3962 -2
modified CHANGELOG.md
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Release Highlights

+
- The "Signed References" feature was reimplemented. The commits in
+
  `refs/rad/sigrefs` will now only verify if they carry an appropriate value for
+
  `refs/rad/root` in the associated `refs` blob. This reference was introduced
+
  in commit `989edacd564fa658358f5ccfd08c243c5ebd8cda`, which was released via
+
  version 1.1.0.
+
  Also, the new reference `refs/rad/sigrefs-parent` is introduced. If present,
+
  its target must match the parent commit. This is to prevent replay. It is
+
  optional to maintain backwards compatibility, and might become mandatory
+
  (like `refs/rad/root` does in this release) in the future.
+
  Further, the new implementation detects replay of `refs` blobs. In order to do
+
  so, it walks the history of `refs/rad/sigrefs` backwards to the root commit,
+
  if `refs/rad/sigrefs-parent` is not set.
+

## New Features

- The block policy for `NodeId`'s is used for limiting the namespaces fetched
modified crates/radicle/Cargo.toml
@@ -46,6 +46,7 @@ radicle-cob = { workspace = true, features = ["git2"] }
radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] }
radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] }
radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] }
+
radicle-git-metadata = { workspace = true }
radicle-localtime = { workspace = true, features = ["serde"] }
radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] }
radicle-ssh = { workspace = true }
@@ -74,5 +75,4 @@ qcheck-macros = { workspace = true }
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
radicle-core = {workspace = true, features = ["qcheck"] }
radicle-crypto = { workspace = true, features = ["test"] }
-
radicle-git-metadata = { workspace = true }
tempfile = { workspace = true }
modified crates/radicle/src/git.rs
@@ -239,6 +239,14 @@ pub mod refs {
            Qualified::from_components(component!("rad"), component!("sigrefs"), None)
        });

+
        /// A reference to the parent commit.
+
        ///
+
        /// `refs/rad/sigrefs-parent`
+
        ///
+
        pub static SIGREFS_PARENT: LazyLock<Qualified> = LazyLock::new(|| {
+
            Qualified::from_components(component!("rad"), component!("sigrefs-parent"), None)
+
        });
+

        /// The set of special references used in the Heartwood protocol.
        #[derive(Clone, Copy, Debug)]
        pub enum Special {
modified crates/radicle/src/git/canonical/rules.rs
@@ -753,7 +753,7 @@ mod tests {
    use crate::identity::Visibility;
    use crate::node::device::Device;
    use crate::rad;
-
    use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH};
+
    use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH, SIGREFS_PARENT};
    use crate::storage::{git::transport, ReadStorage};
    use crate::test::{arbitrary, fixtures};
    use crate::Storage;
@@ -1230,6 +1230,7 @@ mod tests {
    fn test_special_branches() {
        assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err());
        assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err());
+
        assert!(Pattern::try_from((*SIGREFS_PARENT).clone()).is_err());
        assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err());
    }
}
modified crates/radicle/src/storage/refs.rs
@@ -1,3 +1,5 @@
+
pub mod sigrefs;
+

#[cfg(any(test, feature = "test"))]
pub mod arbitrary;

@@ -191,6 +193,21 @@ impl Refs {
    pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> {
        self.0.remove(&SIGREFS_BRANCH.to_ref_string())
    }
+

+
    /// Add a reference with name [`crate::git::refs::storage::SIGREFS_PARENT`]
+
    /// and given target OID to this set of refs.
+
    #[inline]
+
    fn add_parent(&mut self, commit: Oid) -> Option<Oid> {
+
        self.0.insert(SIGREFS_PARENT.to_ref_string(), commit)
+
    }
+

+
    /// Removes reference with name [`crate::git::refs::storage::SIGREFS_PARENT`]
+
    /// from this set of refs, if it exists.
+
    /// Absence of a reference with such name is ignored.
+
    #[inline]
+
    fn remove_parent(&mut self) -> Option<Oid> {
+
        self.0.remove(&SIGREFS_PARENT.to_ref_string())
+
    }
}

impl IntoIterator for Refs {
modified crates/radicle/src/storage/refs/arbitrary.rs
@@ -12,6 +12,12 @@ where
{
    let mut refs = Refs::arbitrary(g);
    refs.insert(IDENTITY_ROOT.to_ref_string(), root);
+

+
    if bool::arbitrary(g) {
+
        let parent = Oid::from_sha1(Arbitrary::arbitrary(g));
+
        refs.insert(SIGREFS_PARENT.to_ref_string(), parent);
+
    }
+

    let signature = crypto::signature::Signer::sign(signer, &refs.canonical());
    let sigrefs = SignedRefs {
        refs,
added crates/radicle/src/storage/refs/sigrefs.rs
@@ -0,0 +1,9 @@
+
pub mod read;
+
pub use read::{SignedRefsReader, VerifiedCommit};
+

+
pub mod write;
+

+
pub mod git;
+

+
#[cfg(test)]
+
mod property;
added crates/radicle/src/storage/refs/sigrefs/git.rs
@@ -0,0 +1,256 @@
+
//! The transparency log of Radicle signed references is encoded in the Git
+
//! commit graph. This module provides traits for interacting with a Git
+
//! repository to read and write data for the transparency log process.
+

+
pub mod object;
+
pub mod reference;
+

+
pub use git2_impls::committer;
+

+
use crate::profile::env;
+
use crypto::PublicKey;
+
use radicle_git_metadata::author;
+
use radicle_git_metadata::author::Author;
+

+
/// Convenience type that corresponds to an [`Author`].
+
///
+
/// If [`env::GIT_COMMITTER_DATE`] is set, then [`Committer::from_env`] can be
+
/// used to construct a stable [`Author`].
+
///
+
/// Otherwise, an [`Author`] can be provided via [`Committer::new`].
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct Committer {
+
    pub author: Author,
+
}
+

+
impl Committer {
+
    /// Construct a [`Committer`] using [`Committer::from_env`], if possible,
+
    /// using `default` if not.
+
    pub fn from_env_or_else<F>(public_key: &PublicKey, default: F) -> Self
+
    where
+
        F: FnOnce() -> Author,
+
    {
+
        Self::from_env(public_key).unwrap_or_else(|| Self::new(default()))
+
    }
+

+
    /// Construct a [`Committer`] with the provided [`Author`].
+
    pub fn new(author: Author) -> Self {
+
        Self { author }
+
    }
+

+
    /// Construct a [`Committer`] using the timestamp found at
+
    /// [`env::GIT_COMMITTER_DATE`], and the given [`PublicKey`] for the email.
+
    pub fn from_env(public_key: &PublicKey) -> Option<Self> {
+
        let s = env::var(env::GIT_COMMITTER_DATE).ok()?;
+
        let Ok(timestamp) = s.trim().parse::<i64>() else {
+
            panic!(
+
                "Invalid timestamp value {s:?} for `{}`",
+
                env::GIT_COMMITTER_DATE
+
            );
+
        };
+
        let time = author::Time::new(timestamp, 0);
+
        let author = Author {
+
            name: "radicle".to_string(),
+
            email: public_key.to_human(),
+
            time,
+
        };
+
        Some(Self::new(author))
+
    }
+

+
    pub fn into_inner(self) -> Author {
+
        self.author
+
    }
+
}
+

+
mod git2_impls {
+
    //! [`git2::Repository`] implementations of the [`object`] and [`reference`] traits.
+
    //!
+
    //! [`object`]: super::object
+
    //! [`reference`]: super::reference
+

+
    use std::path::Path;
+

+
    use radicle_core::NodeId;
+
    use radicle_git_metadata::author::{Author, Time};
+
    use radicle_oid::Oid;
+

+
    use crate::git;
+

+
    use super::object;
+
    use super::object::{RefsEntry, SignatureEntry};
+
    use super::reference;
+
    use super::Committer;
+

+
    pub fn committer(node: &NodeId, signature: &git2::Signature) -> Result<Committer, git2::Error> {
+
        let default = {
+
            let name = signature
+
                .name()
+
                .map(|name| name.to_string())
+
                .ok_or(git2::Error::new(
+
                    git2::ErrorCode::Invalid,
+
                    git2::ErrorClass::Invalid,
+
                    "Invalid UTF-8 of Git signature name",
+
                ))?;
+
            let email =
+
                signature
+
                    .email()
+
                    .map(|email| email.to_string())
+
                    .ok_or(git2::Error::new(
+
                        git2::ErrorCode::Invalid,
+
                        git2::ErrorClass::Invalid,
+
                        "Invalid UTF-8 of Git signature email",
+
                    ))?;
+
            Author {
+
                name,
+
                email,
+
                time: Time::new(
+
                    signature.when().seconds(),
+
                    signature.when().offset_minutes(),
+
                ),
+
            }
+
        };
+
        Ok(Committer::from_env_or_else(node, || default))
+
    }
+

+
    impl object::Reader for git2::Repository {
+
        fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
+
            use object::error::ReadCommit;
+

+
            let odb = self.odb().map_err(ReadCommit::other)?;
+
            let object = odb.read(git2::Oid::from(*oid));
+
            match object {
+
                Ok(object) => {
+
                    if object.kind() != git2::ObjectType::Commit {
+
                        return Err(ReadCommit::incorrect_object_error(*oid, object.kind()));
+
                    }
+
                    Ok(Some(object.data().to_vec()))
+
                }
+
                Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
+
                Err(e) => Err(ReadCommit::other(e)),
+
            }
+
        }
+

+
        fn read_blob(
+
            &self,
+
            oid: &Oid,
+
            path: &Path,
+
        ) -> Result<Option<object::Blob>, object::error::ReadBlob> {
+
            use object::error::ReadBlob;
+

+
            let commit = match self.find_commit(git2::Oid::from(*oid)) {
+
                Ok(c) => c,
+
                Err(e) if e.code() == git2::ErrorCode::NotFound => {
+
                    return Err(ReadBlob::commit_not_found_error(*oid))
+
                }
+
                Err(e) => return Err(ReadBlob::other(e)),
+
            };
+

+
            let tree = commit.tree().map_err(ReadBlob::other)?;
+

+
            let entry = match tree.get_path(path) {
+
                Ok(e) => e,
+
                Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None),
+
                Err(e) => return Err(ReadBlob::other(e)),
+
            };
+

+
            let object = entry.to_object(self).map_err(ReadBlob::other)?;
+
            let blob = object.as_blob().ok_or(ReadBlob::incorrect_object_error(
+
                *oid,
+
                path.to_path_buf(),
+
                object.kind().unwrap_or(git2::ObjectType::Any),
+
            ))?;
+

+
            Ok(Some(object::Blob {
+
                oid: blob.id().into(),
+
                bytes: blob.content().to_vec(),
+
            }))
+
        }
+
    }
+

+
    impl object::Writer for git2::Repository {
+
        fn write_tree(
+
            &self,
+
            refs: RefsEntry,
+
            signature: SignatureEntry,
+
        ) -> Result<Oid, object::error::WriteTree> {
+
            use object::error::WriteTree;
+

+
            let odb = self.odb().map_err(WriteTree::write_error)?;
+

+
            let refs_oid = odb
+
                .write(git2::ObjectType::Blob, &refs.content)
+
                .map_err(WriteTree::refs_error)?;
+

+
            let sig_oid = odb
+
                .write(git2::ObjectType::Blob, &signature.content)
+
                .map_err(WriteTree::signature_error)?;
+

+
            let mut builder = self.treebuilder(None).map_err(WriteTree::write_error)?;
+

+
            builder
+
                .insert(&refs.path, refs_oid, git2::FileMode::Blob.into())
+
                .map_err(WriteTree::refs_error)?;
+

+
            builder
+
                .insert(&signature.path, sig_oid, git2::FileMode::Blob.into())
+
                .map_err(WriteTree::signature_error)?;
+

+
            let tree_oid = builder.write().map_err(WriteTree::write_error)?;
+

+
            Ok(Oid::from(tree_oid))
+
        }
+

+
        fn write_commit(&self, bytes: &[u8]) -> Result<Oid, object::error::WriteCommit> {
+
            use object::error::WriteCommit;
+

+
            let odb = self.odb().map_err(WriteCommit::other)?;
+

+
            let oid = odb
+
                .write(git2::ObjectType::Commit, bytes)
+
                .map_err(WriteCommit::other)?;
+

+
            Ok(Oid::from(oid))
+
        }
+
    }
+

+
    impl reference::Reader for git2::Repository {
+
        fn find_reference(
+
            &self,
+
            reference: &git::fmt::Namespaced,
+
        ) -> Result<Option<Oid>, reference::error::FindReference> {
+
            match self.refname_to_id(reference.as_str()) {
+
                Ok(oid) => Ok(Some(Oid::from(oid))),
+
                Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
+
                Err(e) => Err(reference::error::FindReference::other(e)),
+
            }
+
        }
+
    }
+

+
    impl reference::Writer for git2::Repository {
+
        fn write_reference(
+
            &self,
+
            reference: &git::fmt::Namespaced,
+
            commit: Oid,
+
            parent: Option<Oid>,
+
            reflog: String,
+
        ) -> Result<(), reference::error::WriteReference> {
+
            let new = git2::Oid::from(commit);
+

+
            match parent {
+
                Some(parent) => {
+
                    let old = git2::Oid::from(parent);
+
                    // The old OID provides a guard, which gives us a compare-and-swap —
+
                    // the write will fail if the ref has moved since we read it.
+
                    self.reference_matching(reference.as_str(), new, true, old, &reflog)
+
                        .map_err(reference::error::WriteReference::other)?;
+
                }
+
                None => {
+
                    self.reference(reference.as_str(), new, false, &reflog)
+
                        .map_err(reference::error::WriteReference::other)?;
+
                }
+
            }
+

+
            Ok(())
+
        }
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/git/object.rs
@@ -0,0 +1,102 @@
+
//! Traits for interacting with Git objects, necessary for implementing Radicle
+
//! signed references.
+
// TODO(finto): I think these are more generally useful than just being used for
+
// signed references. They might be worth moving into a crate,
+
// `radicle-git-traits`, but for now they can live here.
+

+
pub mod error;
+

+
use std::path::{Path, PathBuf};
+

+
use radicle_oid::Oid;
+

+
/// A Git blob object, returned by [`Reader::read_blob`].
+
pub struct Blob {
+
    /// The [`Oid`] of the Git blob.
+
    pub oid: Oid,
+
    /// The contents of the Git blob.
+
    pub bytes: Vec<u8>,
+
}
+

+
/// Git object reader, generally a Git repository, or its corresponding Object
+
/// Database (ODB).
+
pub trait Reader {
+
    /// Read the raw bytes of a commit object identified by `oid`.
+
    ///
+
    /// Returns `None` if no such object exists.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`error::ReadCommit::IncorrectObject`]: the object identified by the
+
    ///   [`Oid`] was found, but was not a commit.
+
    /// - [`error::ReadCommit::Other`]: failed to read the Git commit.
+
    fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, error::ReadCommit>;
+

+
    /// Read the raw bytes of the blob at `path` within the tree of `commit`.
+
    ///
+
    /// Returns `None` if the path does not exist in that tree.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`error::ReadBlob::CommitNotFound`]: failed to find the commit
+
    ///   identified by the [`Oid`].
+
    /// - [`error::ReadBlob::IncorrectObject`]: the object identified by the
+
    ///   [`Oid`] was found, but was not a commit.
+
    /// - [`error::ReadBlob::Other`]: failed to read the Git blob.
+
    fn read_blob(&self, commit: &Oid, path: &Path) -> Result<Option<Blob>, error::ReadBlob>;
+
}
+

+
/// Input to the [`Writer::write_tree`] method.
+
///
+
/// The entry describes where in the Git tree to write the [`Refs`] content
+
/// blob.
+
///
+
/// [`Refs`]: crate::storage::refs::Refs
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct RefsEntry {
+
    /// Path in the Git tree to write to.
+
    pub path: PathBuf,
+
    /// The contents of the Git blob.
+
    pub content: Vec<u8>,
+
}
+

+
/// Input to the [`Writer::write_tree`] method.
+
///
+
/// The entry describes where in the Git tree to write the signature content
+
/// blob.
+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+
pub struct SignatureEntry {
+
    /// Path in the Git tree to write to.
+
    pub path: PathBuf,
+
    /// The contents of the Git blob.
+
    pub content: Vec<u8>,
+
}
+

+
/// Git object writer, generally a Git repository, or its corresponding Object
+
/// Database (ODB).
+
pub trait Writer {
+
    /// Write the [`RefsEntry`] and [`SignatureEntry`] to two separate Git blobs
+
    /// within a shared Git tree.
+
    ///
+
    /// Returns the [`Oid`] of the Git tree.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`error::WriteTree::Refs`]: failed to write the references Git blob.
+
    /// - [`error::WriteTree::Signature`]: failed to write the signature Git blob.
+
    /// - [`error::WriteTree::Write`]: failed to write the Git tree.
+
    fn write_tree(
+
        &self,
+
        refs: RefsEntry,
+
        signature: SignatureEntry,
+
    ) -> Result<Oid, error::WriteTree>;
+

+
    /// Write the given Git commit, as bytes, to the Git object database.
+
    ///
+
    /// Returns the [`Oid`] of the Git commit.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`error::WriteCommit`]: failed to write the Git commit.
+
    fn write_commit(&self, bytes: &[u8]) -> Result<Oid, error::WriteCommit>;
+
}
added crates/radicle/src/storage/refs/sigrefs/git/object/error.rs
@@ -0,0 +1,146 @@
+
use std::path::PathBuf;
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
type StdError = dyn std::error::Error + Send + Sync + 'static;
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum ReadCommit {
+
    #[error(transparent)]
+
    IncorrectObject(NotCommit),
+
    #[error(transparent)]
+
    Other(Box<StdError>),
+
}
+

+
impl ReadCommit {
+
    pub fn incorrect_object_error<K>(oid: Oid, kind: K) -> Self
+
    where
+
        K: ToString,
+
    {
+
        Self::IncorrectObject(NotCommit {
+
            oid,
+
            kind: kind.to_string(),
+
        })
+
    }
+

+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Other(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error("the object {oid} is a {kind}, not a commit")]
+
pub struct NotCommit {
+
    oid: Oid,
+
    kind: String,
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error(transparent)]
+
pub enum ReadBlob {
+
    #[error(transparent)]
+
    CommitNotFound(CommitNotFound),
+
    #[error(transparent)]
+
    IncorrectObject(NotBlob),
+
    #[error(transparent)]
+
    Other(Box<StdError>),
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error("could not find commit {oid}")]
+
pub struct CommitNotFound {
+
    oid: Oid,
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error("the object at {path:?} in commit {commit} is a {kind}, not a blob")]
+
pub struct NotBlob {
+
    commit: Oid,
+
    path: PathBuf,
+
    kind: String,
+
}
+

+
impl ReadBlob {
+
    pub fn commit_not_found_error(oid: Oid) -> Self {
+
        Self::CommitNotFound(CommitNotFound { oid })
+
    }
+

+
    pub fn incorrect_object_error<K>(commit: Oid, path: PathBuf, kind: K) -> Self
+
    where
+
        K: ToString,
+
    {
+
        Self::IncorrectObject(NotBlob {
+
            commit,
+
            path,
+
            kind: kind.to_string(),
+
        })
+
    }
+

+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Other(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum WriteTree {
+
    #[error("failed to write reference blob for signed references")]
+
    Refs(Box<StdError>),
+
    #[error("failed to write signature blob for signed references")]
+
    Signature(Box<StdError>),
+
    #[error(transparent)]
+
    Write(Box<StdError>),
+
}
+

+
impl WriteTree {
+
    pub fn refs_error<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Refs(Box::new(err))
+
    }
+

+
    pub fn signature_error<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Signature(Box::new(err))
+
    }
+

+
    pub fn write_error<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self::Write(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error(transparent)]
+
pub struct WriteCommit {
+
    source: Box<StdError>,
+
}
+

+
impl WriteCommit {
+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self {
+
            source: Box::new(err),
+
        }
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/git/properties.rs
@@ -0,0 +1,180 @@
+
#![allow(clippy::unwrap_used)]
+

+
use crypto::{signature, test::signer::MockSigner, PublicKey, Signer as _};
+
use qcheck::TestResult;
+
use qcheck_macros::quickcheck;
+
use radicle_core::{NodeId, RepoId};
+
use radicle_git_metadata::author::{Author, Time};
+
use radicle_oid::Oid;
+
use tempfile::TempDir;
+

+
use crate::storage::refs::sigrefs::{
+
    read::{CheckpointReason, Latest, SignedRefsReader, Tip},
+
    write::{Committer, SignedRefsWriter, Update},
+
};
+
use crate::storage::refs::Refs;
+

+
#[derive(Clone, Debug)]
+
struct BoundedVec<T>(Vec<T>);
+

+
impl<T: qcheck::Arbitrary> qcheck::Arbitrary for BoundedVec<T> {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        let size = usize::arbitrary(g) % 24;
+
        BoundedVec((0..size).map(|_| T::arbitrary(g)).collect())
+
    }
+

+
    fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
+
        Box::new(self.0.shrink().map(BoundedVec))
+
    }
+
}
+

+
struct Verifier {
+
    key: PublicKey,
+
}
+

+
impl Verifier {
+
    fn new(signer: &MockSigner) -> Self {
+
        Self {
+
            key: *signer.public_key(),
+
        }
+
    }
+
}
+

+
impl signature::Verifier<crypto::Signature> for Verifier {
+
    fn verify(&self, msg: &[u8], signature: &crypto::Signature) -> Result<(), signature::Error> {
+
        self.key
+
            .verify(msg, signature)
+
            .map_err(signature::Error::from_source)
+
    }
+
}
+

+
fn mock_author() -> Author {
+
    Author {
+
        name: "testy".to_string(),
+
        email: "testy@example.com".to_string(),
+
        time: Time::new(6400, 0),
+
    }
+
}
+

+
fn mock_committer() -> Committer {
+
    Committer::new(mock_author())
+
}
+

+
fn setup() -> (TempDir, git2::Repository) {
+
    let dir = TempDir::new().unwrap();
+
    let repo = git2::Repository::init_bare(dir.path()).unwrap();
+
    (dir, repo)
+
}
+

+
fn mock_root() -> RepoId {
+
    RepoId::from(Oid::from_sha1([1; 20]))
+
}
+

+
fn write_log(
+
    refs: Refs,
+
    namespace: NodeId,
+
    signer: &MockSigner,
+
    repo: &git2::Repository,
+
) -> Update {
+
    SignedRefsWriter::new(namespace, signer, repo)
+
        .with_refs(refs)
+
        .write(
+
            mock_committer(),
+
            "test commit".to_string(),
+
            "test reflog".to_string(),
+
        )
+
        .unwrap()
+
}
+

+
fn read_log(namespace: NodeId, verifier: &Verifier, repo: &git2::Repository) -> Latest {
+
    SignedRefsReader::new(mock_root(), Tip::Reference(namespace), repo, verifier)
+
        .read()
+
        .unwrap()
+
        .unwrap()
+
}
+

+
#[quickcheck]
+
fn initial_commit_roundtrip(refs: Refs) -> bool {
+
    let (_dir, repo) = setup();
+
    let signer = MockSigner::default();
+
    let namespace = *signer.public_key();
+
    let verifier = Verifier::new(&signer);
+

+
    let update = write_log(refs.clone(), namespace, &signer, &repo);
+
    let head_oid = match update {
+
        Update::Changed { ref entry } => *entry.oid(),
+
        Update::Unchanged { .. } => return false,
+
    };
+

+
    let Latest {
+
        refs: expected,
+
        checkpoint,
+
        ..
+
    } = read_log(namespace, &verifier, &repo);
+

+
    checkpoint.head() == head_oid
+
        && checkpoint.ancestor() == head_oid
+
        && checkpoint.reason() == CheckpointReason::Root
+
        && expected == refs
+
}
+

+
#[quickcheck]
+
fn chain_roundtrip(chain: BoundedVec<Refs>) -> TestResult {
+
    let chain = chain.0;
+
    if chain.is_empty() {
+
        return TestResult::discard();
+
    }
+

+
    let (_dir, repo) = setup();
+
    let signer = MockSigner::default();
+
    let namespace = *signer.public_key();
+
    let verifier = Verifier::new(&signer);
+

+
    let mut last_changed_head = None;
+

+
    for refs in chain {
+
        let update = write_log(refs.clone(), namespace, &signer, &repo);
+

+
        if let Update::Changed { ref entry } = update {
+
            last_changed_head = Some(*entry.oid());
+
        }
+

+
        let Latest {
+
            refs: expected,
+
            checkpoint,
+
            ..
+
        } = read_log(namespace, &verifier, &repo);
+

+
        if refs != expected {
+
            return TestResult::failed();
+
        }
+

+
        if checkpoint.reason() != CheckpointReason::Root {
+
            return TestResult::failed();
+
        }
+

+
        if let Some(expected_head) = last_changed_head {
+
            if checkpoint.head() != expected_head {
+
                return TestResult::failed();
+
            }
+
        }
+
    }
+

+
    TestResult::passed()
+
}
+

+
#[quickcheck]
+
fn idempotent_write(refs: Refs) -> bool {
+
    let (_dir, repo) = setup();
+
    let signer = MockSigner::default();
+
    let namespace = *signer.public_key();
+

+
    let first = write_log(refs.clone(), namespace, &signer, &repo);
+
    let head_oid = match first {
+
        Update::Changed { ref entry } => *entry.oid(),
+
        Update::Unchanged { .. } => return false,
+
    };
+

+
    let second = write_log(refs, namespace, &signer, &repo);
+
    matches!(second, Update::Unchanged { commit, .. } if commit == head_oid)
+
}
added crates/radicle/src/storage/refs/sigrefs/git/reference.rs
@@ -0,0 +1,51 @@
+
//! Traits for interacting with Git references, necessary for implementing
+
//! Radicle signed references.
+
// TODO(finto): I think these are more generally useful than just being used for
+
// signed references. They might be worth moving into a crate,
+
// `radicle-git-traits`, but for now they can live here.
+

+
pub mod error;
+

+
use radicle_oid::Oid;
+

+
use crate::git;
+

+
/// Git reference reader, generally a Git repository, or its corresponding Reference
+
/// Database (Ref DB).
+
pub trait Reader {
+
    /// Find the head [`Oid`] of the sigrefs reference for the given namespace.
+
    ///
+
    /// Returns `None` if the reference does not yet exist.
+
    /// # Errors
+
    ///
+
    /// - [`error::FindReference`]: failed to write the Git reference.
+
    fn find_reference(
+
        &self,
+
        reference: &git::fmt::Namespaced,
+
    ) -> Result<Option<Oid>, error::FindReference>;
+
}
+

+
/// Git reference writer, generally a Git repository, or its corresponding Reference
+
/// Database (Ref DB).
+
pub trait Writer {
+
    /// Write the given commit [`Oid`], and its parent, to the given
+
    /// `reference`.
+
    ///
+
    /// The `reflog` given can used as the Git reflog message of the reference.
+
    ///
+
    /// # Concurrency
+
    ///
+
    /// It is up to the implementer to ensure the safety of writing the
+
    /// reference safely in a concurrent environment.
+
    ///
+
    /// # Errors
+
    ///
+
    /// - [`error::WriteReference`]: failed to write the Git reference.
+
    fn write_reference(
+
        &self,
+
        reference: &git::fmt::Namespaced,
+
        commit: Oid,
+
        parent: Option<Oid>,
+
        reflog: String,
+
    ) -> Result<(), error::WriteReference>;
+
}
added crates/radicle/src/storage/refs/sigrefs/git/reference/error.rs
@@ -0,0 +1,29 @@
+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error(transparent)]
+
pub struct FindReference(Box<dyn std::error::Error + Send + Sync + 'static>);
+

+
impl FindReference {
+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error(transparent)]
+
pub struct WriteReference(Box<dyn std::error::Error + Send + Sync + 'static>);
+

+
impl WriteReference {
+
    pub fn other<E>(err: E) -> Self
+
    where
+
        E: std::error::Error + Send + Sync + 'static,
+
    {
+
        Self(Box::new(err))
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/property.rs
@@ -0,0 +1,90 @@
+
#![allow(clippy::unwrap_used)]
+

+
mod mock;
+
use mock::*;
+

+
use crypto::test::signer::MockSigner;
+
use crypto::Signer as _;
+
use qcheck::TestResult;
+
use qcheck_macros::quickcheck;
+

+
use crate::storage::refs::sigrefs::read::{SignedRefsReader, Tip};
+
use crate::storage::refs::sigrefs::write::{SignedRefsWriter, Update};
+
use crate::storage::refs::Refs;
+

+
#[quickcheck]
+
fn roundtrip(BoundedVec(all_refs): BoundedVec<Refs>) -> TestResult {
+
    if all_refs.is_empty() {
+
        return TestResult::discard();
+
    }
+

+
    let fixture = Fixture::new();
+
    let signer = MockSigner::default();
+
    let node_id = *signer.public_key();
+

+
    for refs in all_refs {
+
        let refs = fixture.with_identity_root(refs);
+

+
        let writer = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer);
+
        let update = match writer.write(
+
            fixture.committer(),
+
            "roundtrip write".into(),
+
            "roundtrip reflog".into(),
+
        ) {
+
            Ok(u) => u,
+
            Err(e) => return TestResult::error(format!("write error: {e}")),
+
        };
+

+
        let written_refs = match update {
+
            Update::Changed { ref entry } => entry.clone().into_refs(),
+
            Update::Unchanged { ref refs, .. } => refs.clone(),
+
        };
+

+
        assert_eq!(refs, written_refs);
+

+
        let reader = SignedRefsReader::new(
+
            fixture.rid(),
+
            Tip::Reference(node_id),
+
            fixture.repo(),
+
            &node_id,
+
        );
+
        let verified = match reader.read() {
+
            Ok(v) => v,
+
            Err(e) => return TestResult::error(format!("read error: {e}")),
+
        };
+

+
        if written_refs != verified.into_refs() {
+
            return TestResult::failed();
+
        }
+
    }
+

+
    TestResult::passed()
+
}
+

+
#[quickcheck]
+
fn idempotent(refs: Refs) -> TestResult {
+
    let fixture = Fixture::new();
+
    let refs = fixture.with_identity_root(refs);
+
    let signer = MockSigner::default();
+
    let node_id = *signer.public_key();
+

+
    if let Err(e) = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write(
+
        fixture.committer(),
+
        "first write".into(),
+
        "first reflog".into(),
+
    ) {
+
        return TestResult::error(format!("first write error: {e}"));
+
    }
+

+
    match SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write(
+
        fixture.committer(),
+
        "second write".into(),
+
        "second reflog".into(),
+
    ) {
+
        Ok(Update::Unchanged { .. }) => TestResult::passed(),
+
        Ok(Update::Changed { .. }) => {
+
            TestResult::error("expected Update::Unchanged on second write with identical refs")
+
        }
+
        Err(e) => TestResult::error(format!("second write error: {e}")),
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/property/mock.rs
@@ -0,0 +1,132 @@
+
use qcheck::{Arbitrary, Gen};
+
use radicle_core::RepoId;
+
use radicle_git_metadata::author::{Author, Time};
+
use radicle_oid::Oid;
+
use tempfile::TempDir;
+

+
use crate::identity::doc;
+
use crate::storage::refs::sigrefs::git::Committer;
+
use crate::storage::refs::{Refs, IDENTITY_ROOT};
+

+
/// A `Vec<T>` whose [`Arbitrary`] instance caps the length at
+
/// [`Self::MAX_LEN`], preventing the property runner from generating inputs
+
/// that would make the test prohibitively slow.
+
#[derive(Clone, Debug)]
+
pub struct BoundedVec<T>(pub Vec<T>);
+

+
impl<T> BoundedVec<T> {
+
    const MAX_LEN: usize = 16;
+
}
+

+
impl<T: Arbitrary> Arbitrary for BoundedVec<T> {
+
    fn arbitrary(g: &mut Gen) -> Self {
+
        let len = usize::arbitrary(g) % (Self::MAX_LEN + 1);
+
        BoundedVec((0..len).map(|_| T::arbitrary(g)).collect())
+
    }
+

+
    fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
+
        let inner: Vec<Vec<T>> = self.0.shrink().collect();
+
        Box::new(inner.into_iter().map(BoundedVec))
+
    }
+
}
+

+
/// A Radicle Git repository fixture.
+
///
+
/// It is initialized in a [`TempDir`], and starts off with a single blob to
+
/// emulate the identity document.
+
pub struct Fixture {
+
    /// The underlying Git repository.
+
    repo: git2::Repository,
+
    /// The [`RepoId`] of the initial identity document blob.
+
    rid: RepoId,
+
    /// The commit that points to the identity document, which provides the
+
    /// [`RepoId`].
+
    identity_commit: Oid,
+
    _dir: TempDir,
+
}
+

+
impl Fixture {
+
    /// Initialise a bare git repository and write the minimal object graph
+
    /// required for identity-root verification to succeed:
+
    ///
+
    /// ```text
+
    /// identity-commit
+
    ///   └─ tree
+
    ///        └─ embeds/
+
    ///             └─ <doc::PATH>  (blob whose OID becomes the RepoId)
+
    /// ```
+
    pub fn new() -> Self {
+
        let dir = TempDir::new().unwrap();
+
        let repo = git2::Repository::init_bare(dir.path()).unwrap();
+

+
        let (identity_commit, rid) = Self::identity_commit(&repo);
+

+
        Self {
+
            _dir: dir,
+
            repo,
+
            rid,
+
            identity_commit,
+
        }
+
    }
+

+
    /// Return the [`RepoId`] of the fixture.
+
    pub fn rid(&self) -> RepoId {
+
        self.rid
+
    }
+

+
    /// Return the underlying Git repository of the fixture.
+
    pub fn repo(&self) -> &git2::Repository {
+
        &self.repo
+
    }
+

+
    /// Return a [`Committer`] with a fixed, stable [`Author`].
+
    pub fn committer(&self) -> Committer {
+
        Committer::new(Author {
+
            name: "radicle".to_string(),
+
            email: "radicle@test".to_string(),
+
            time: Time::new(0, 0),
+
        })
+
    }
+

+
    /// Patch an arbitrary [`Refs`] so that its [`IDENTITY_ROOT`] entry
+
    /// points at the fixture's identity commit, satisfying the
+
    /// `debug_assert` in [`SignedRefsWriter::new`] and the identity
+
    /// verification in [`crate::storage::refs::sigrefs::read`].
+
    pub fn with_identity_root(&self, mut refs: Refs) -> Refs {
+
        refs.insert(IDENTITY_ROOT.to_ref_string(), self.identity_commit);
+
        refs
+
    }
+

+
    fn identity_commit(repo: &git2::Repository) -> (Oid, RepoId) {
+
        let (doc_tree, rid) = Self::write_doc_blob(repo);
+
        let tree = Self::write_embeds(repo, doc_tree);
+
        let tree = repo.find_tree(tree).unwrap();
+
        let sig = git2::Signature::new("radicle", "radicle@test", &git2::Time::new(0, 0)).unwrap();
+
        let oid = repo
+
            .commit(None, &sig, &sig, "identity root", &tree, &[])
+
            .unwrap();
+
        (oid.into(), rid)
+
    }
+

+
    fn write_doc_blob(repo: &git2::Repository) -> (git2::Oid, RepoId) {
+
        let doc_blob_oid = repo.blob(b"identity").unwrap();
+
        let rid = RepoId::from(Oid::from(doc_blob_oid));
+

+
        let mut tb = repo.treebuilder(None).unwrap();
+
        tb.insert(
+
            doc::PATH.as_os_str(),
+
            doc_blob_oid,
+
            git2::FileMode::Blob.into(),
+
        )
+
        .unwrap();
+
        let oid = tb.write().unwrap();
+
        (oid, rid)
+
    }
+

+
    fn write_embeds(repo: &git2::Repository, doc: git2::Oid) -> git2::Oid {
+
        let mut tb = repo.treebuilder(None).unwrap();
+
        tb.insert("embeds", doc, git2::FileMode::Tree.into())
+
            .unwrap();
+
        tb.write().unwrap()
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/read.rs
@@ -0,0 +1,459 @@
+
pub mod error;
+

+
mod iter;
+

+
#[cfg(test)]
+
mod test;
+

+
use std::collections::HashMap;
+
use std::num::NonZeroUsize;
+
use std::path::Path;
+

+
use crypto::signature;
+
use nonempty::NonEmpty;
+
use radicle_core::{NodeId, RepoId};
+
use radicle_git_metadata::commit::CommitData;
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::identity::doc;
+
use crate::storage::refs::sigrefs::git::{object, reference};
+
use crate::storage::refs::{
+
    Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH,
+
};
+

+
/// A `rad/sigrefs` that has passed the following verification checks:
+
///
+
/// - Has a valid `/signature` blob, which is verified by the signing key.
+
/// - Contains the `refs/rad/root` entry under `/refs`, which matches the
+
///   [`RepoId`] of the local repository.
+
/// - The `refs/rad/sigrefs-parent` entry matches the commit's parent, if the
+
///   entry exists.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct VerifiedCommit {
+
    /// The commit that was verified.
+
    commit: Commit,
+
    /// Whether verification successfully found the correct
+
    /// value for [`SIGREFS_PARENT`] in the refs of [`Self::commit`].
+
    parent: bool,
+
}
+

+
impl VerifiedCommit {
+
    /// The [`Oid`] of the commit.
+
    pub fn id(&self) -> &Oid {
+
        &self.commit.oid
+
    }
+

+
    /// The [`crypto::Signature`] found in the tree of the commit.
+
    pub fn signature(&self) -> &crypto::Signature {
+
        &self.commit.signature
+
    }
+

+
    /// The [`Refs`] found in the tree of the commit.
+
    pub fn into_refs(self) -> Refs {
+
        self.commit.refs
+
    }
+

+
    /// The parent [`Oid`] of the commit, unless it is the root commit.
+
    pub fn parent(&self) -> Option<&Oid> {
+
        self.commit.parent.as_ref()
+
    }
+
}
+

+
/// A [`SignedRefsReader`] reads and verifies a commit chain for a `rad/sigrefs`
+
/// entry.
+
///
+
/// To create a new reader, use [`SignedRefsReader::new`].
+
///
+
/// The construction expects:
+
/// - A [`RepoId`] which is the repository identifier of the Radicle repository.
+
/// - A [`Tip`] which describes where and how to start the verification.
+
/// - A `repository` which is the Git repository that is being used for the reading.
+
/// - A `verifier` which is the entity that verifies the cryptographic signatures.
+
pub struct SignedRefsReader<'a, R, V> {
+
    rid: RepoId,
+
    tip: Tip,
+
    repository: &'a R,
+
    verifier: &'a V,
+
}
+

+
/// Describe where to start a [`SignedRefsReader`]'s commit chain.
+
pub enum Tip {
+
    /// Use the namespace of the given [`NodeId`], resolving their `rad/sigrefs`
+
    /// to its commit [`Oid`].
+
    Reference(NodeId),
+
    /// Use the supplied commit [`Oid`].
+
    Commit(Oid),
+
}
+

+
impl<'a, R, V> SignedRefsReader<'a, R, V>
+
where
+
    R: object::Reader + reference::Reader,
+
    V: signature::Verifier<crypto::Signature>,
+
{
+
    /// Construct a new [`SignedRefsReader`].
+
    pub fn new(rid: RepoId, tip: Tip, repository: &'a R, verifier: &'a V) -> Self {
+
        Self {
+
            rid,
+
            tip,
+
            repository,
+
            verifier,
+
        }
+
    }
+

+
    /// Read a [`VerifiedCommit`] using the [`SignedRefsReader`].
+
    ///
+
    /// The [`VerifiedCommit`] will be the first commit, if the commit verifies
+
    /// and contains its parent in its [`Refs`] entry.
+
    /// If the commit does not contain a parent, but its signature is not
+
    /// repeated, then it is still returned.
+
    /// Otherwise, the commit that is returned is either:
+
    /// - The first commit which has no repeated signatures, i.e. it has no replay attacks.
+
    /// - The first commit which is not a replay commit, i.e. the commit that
+
    ///   replay attacks are based on.
+
    ///
+
    /// # Replay Attacks
+
    ///
+
    /// The [`SignedRefsReader`] prevents replay attacks via two mechanisms:
+
    /// - The first is recording the parent commit in the `/refs` blob. This
+
    ///   prevents a replay by not allowing the same signature payload to be
+
    ///   used in a new commit, since the parents would not match. Note that
+
    ///   this does not detect replays by older clients, since they will not
+
    ///   include this entry in `/refs`.
+
    /// - The second mechanism uses the fact that a replay will give duplicate
+
    ///   signatures. This means that any repeated signatures will be skipped,
+
    ///   and the commit returned will be the first valid commit, that was not a
+
    ///   replay.
+
    pub fn read(self) -> Result<VerifiedCommit, error::Read> {
+
        const ONE: NonZeroUsize = NonZeroUsize::new(1).expect("one is non-zero");
+
        const SIGNATURES_COLLECTED: &str = "all signatures were collected";
+

+
        let head = CommitReader::new(self.resolve_tip()?, self.repository)
+
            .read()
+
            .map_err(error::Read::Commit)?
+
            .verify(self.rid, self.verifier)
+
            .map_err(error::Read::Verify)?;
+

+
        #[cfg(not(debug_assertions))]
+
        if head.parent {
+
            // `head` is verified, thus we know that if the parent reference
+
            // exists, its target actually matches the parent OID.
+
            // The fact that the parent OID is a hash over all previous history
+
            // makes it *incredibley unlikely* or rather *practically impossible*
+
            // that the same `/refs` blob re-appears in previous history.
+
            // Thus, we can spare oureselves walking the history.
+
            return Ok(head);
+
        }
+

+
        let seen = iter::Walk::new(*head.id(), self.repository).try_fold(
+
            HashMap::<crypto::Signature, NonEmpty<Oid>>::new(),
+
            |mut seen, commit| {
+
                let current = commit.map_err(error::Read::Commit)?;
+
                seen.entry(current.signature)
+
                    .and_modify(|value| value.push(current.oid))
+
                    .or_insert_with(|| NonEmpty::new(current.oid));
+
                Ok(seen)
+
            },
+
        )?;
+

+
        let parent = if seen
+
            .get(head.signature())
+
            .expect(SIGNATURES_COLLECTED)
+
            .len_nonzero()
+
            == ONE
+
        {
+
            // `head` has a verified, non-repeated signature, but does not
+
            // include the parent reference in the `/refs` blob. Maintains
+
            // backwards-compatibility.
+
            return Ok(head);
+
        } else {
+
            #[cfg(debug_assertions)]
+
            {
+
                if head.parent {
+
                    panic!("duplicate signature found even though parent ref did verify")
+
                }
+
            }
+

+
            // If the signature in head was seen twice, then
+
            // head must have a parent.
+
            *head.parent().expect("parent must exist")
+
        };
+

+
        // The second walk can start from the parent of head. We do not need to
+
        // verify head twice, and we already know that the parent exists.
+
        let mut last = None;
+
        for commit in iter::Walk::new(parent, self.repository) {
+
            let commit = commit
+
                .map_err(error::Read::Commit)?
+
                .verify(self.rid, self.verifier)
+
                .map_err(error::Read::Verify)?;
+

+
            let commits = seen.get(commit.signature()).expect(SIGNATURES_COLLECTED);
+

+
            if commits.len_nonzero() == ONE {
+
                return Ok(commit);
+
            } else {
+
                log::warn!("Duplicate sigrefs found in commits {commits:?}");
+
                last = Some(commit);
+
            }
+
        }
+

+
        // In the extreme case where all commits in the walk contain duplicate
+
        // signatures, return the oldest commit reached — the last one visited
+
        // by the walk, which follows the chain from newest to oldest.
+
        // `last` is always `Some` here because `parent` is guaranteed to exist
+
        // (head had a duplicate signature, so it must have a parent), meaning
+
        // the walk yields at least one commit.
+
        Ok(last.unwrap_or(head))
+
    }
+

+
    fn resolve_tip(&self) -> Result<Oid, error::Read> {
+
        match self.tip {
+
            Tip::Commit(oid) => Ok(oid),
+
            Tip::Reference(namespace) => {
+
                let reference =
+
                    SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace));
+
                let head = self
+
                    .repository
+
                    .find_reference(&reference)
+
                    .map_err(error::Read::FindReference)?
+
                    .ok_or_else(|| error::Read::MissingSigrefs { namespace })?;
+
                Ok(head)
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
struct Commit {
+
    oid: Oid,
+
    parent: Option<Oid>,
+
    refs: Refs,
+
    signature: crypto::Signature,
+
    identity_root: Option<IdentityRoot>,
+
}
+

+
impl Commit {
+
    fn verify<V>(mut self, expected: RepoId, verifier: &V) -> Result<VerifiedCommit, error::Verify>
+
    where
+
        V: signature::Verifier<crypto::Signature>,
+
    {
+
        verifier
+
            .verify(&self.refs.canonical(), &self.signature)
+
            .map_err(error::Verify::Signature)?;
+

+
        if let Some(IdentityRoot {
+
            commit: identity_commit,
+
            rid,
+
        }) = self.identity_root
+
        {
+
            if rid != expected {
+
                return Err(error::Verify::MismatchedIdentity {
+
                    identity_commit,
+
                    sigrefs_commit: self.oid,
+
                    expected,
+
                    found: rid,
+
                });
+
            } else {
+
                // Identity verification succeeds.
+
            }
+
        } else {
+
            return Err(error::Verify::MissingIdentity(error::MissingIdentity {
+
                sigrefs_commit: self.oid,
+
                expected,
+
            }));
+
        }
+

+
        self.refs.remove_sigrefs();
+

+
        let parent = match (self.parent, self.refs.remove_parent()) {
+
            (None, None) => true,
+
            (Some(_), None) => false,
+
            (None, Some(actual)) => {
+
                return Err(error::Verify::DanglingParent {
+
                    sigrefs_commit: self.oid,
+
                    actual,
+
                })
+
            }
+
            (Some(expected), Some(actual)) if expected == actual => true,
+
            (Some(expected), Some(actual)) => {
+
                return Err(error::Verify::MismatchedParent {
+
                    sigrefs_commit: self.oid,
+
                    expected,
+
                    actual,
+
                })
+
            }
+
        };
+

+
        Ok(VerifiedCommit {
+
            commit: self,
+
            parent,
+
        })
+
    }
+
}
+

+
struct CommitReader<'a, R> {
+
    commit: Oid,
+
    repository: &'a R,
+
}
+

+
impl<'a, R> CommitReader<'a, R>
+
where
+
    R: object::Reader,
+
{
+
    fn new(commit: Oid, repository: &'a R) -> Self {
+
        Self { commit, repository }
+
    }
+

+
    fn read(self) -> Result<Commit, error::Commit> {
+
        let commit = self.read_commit_data()?;
+
        let Tree { refs, signature } = TreeReader::new(self.commit, self.repository)
+
            .read()
+
            .map_err(error::Commit::Tree)?;
+
        let identity_root = IdentityRootReader::new(&refs, self.repository)
+
            .read()
+
            .map_err(error::Commit::IdentityRoot)?;
+
        let parent = Self::get_parent(&commit).transpose()?;
+

+
        Ok(Commit {
+
            oid: self.commit,
+
            parent,
+
            refs,
+
            signature,
+
            identity_root,
+
        })
+
    }
+

+
    fn read_commit_data(&self) -> Result<CommitData<Oid, Oid>, error::Commit> {
+
        let bytes = self
+
            .repository
+
            .read_commit(&self.commit)
+
            .map_err(error::Commit::Read)?
+
            .ok_or(error::Commit::Missing { oid: self.commit })?;
+
        CommitData::from_bytes(&bytes).map_err(|err| error::Commit::Parse {
+
            oid: self.commit,
+
            source: err,
+
        })
+
    }
+

+
    /// Extract the single parent [`Oid`] from a [`CommitData`], if any.
+
    ///
+
    /// Returns `None` if the commit has no parents (i.e. it is a root commit).
+
    /// Returns an error if the commit has more than one parent, since the
+
    /// transparency log is a linear chain.
+
    fn get_parent(commit: &CommitData<Oid, Oid>) -> Option<Result<Oid, error::Commit>> {
+
        let NonEmpty {
+
            head: parent,
+
            tail: mut rest,
+
        } = NonEmpty::collect(commit.parents())?;
+
        if rest.is_empty() {
+
            Some(Ok(parent))
+
        } else {
+
            rest.insert(0, parent);
+
            let err = error::Commit::TooManyParents(error::Parent { parents: rest });
+
            Some(Err(err))
+
        }
+
    }
+
}
+

+
struct Tree {
+
    refs: Refs,
+
    signature: crypto::Signature,
+
}
+

+
struct TreeReader<'a, R> {
+
    commit: Oid,
+
    repository: &'a R,
+
}
+

+
impl<'a, R> TreeReader<'a, R>
+
where
+
    R: object::Reader,
+
{
+
    fn new(commit: Oid, repository: &'a R) -> Self {
+
        Self { commit, repository }
+
    }
+

+
    fn read(self) -> Result<Tree, error::Tree> {
+
        let (refs, signature) = self.try_handle_blobs()?;
+
        let refs = Refs::from_canonical(&refs.bytes).map_err(error::Tree::ParseRefs)?;
+
        let signature = crypto::Signature::try_from(signature.bytes.as_slice())
+
            .map_err(error::Tree::ParseSignature)?;
+
        Ok(Tree { refs, signature })
+
    }
+

+
    /// Fetch the refs blob and signature blob from the repository, returning a
+
    /// descriptive error if either or both are missing.
+
    fn try_handle_blobs(&self) -> Result<(object::Blob, object::Blob), error::Tree> {
+
        let commit = &self.commit;
+
        let refs_path = Path::new(REFS_BLOB_PATH);
+
        let sig_path = Path::new(SIGNATURE_BLOB_PATH);
+

+
        let refs_bytes = self
+
            .repository
+
            .read_blob(commit, refs_path)
+
            .map_err(error::Tree::Refs)?;
+
        let sig_bytes = self
+
            .repository
+
            .read_blob(commit, sig_path)
+
            .map_err(error::Tree::Signature)?;
+

+
        let result = match (refs_bytes, sig_bytes) {
+
            (None, None) => Err(error::MissingBlobs::Both {
+
                commit: *commit,
+
                refs: refs_path.to_path_buf(),
+
                signature: sig_path.to_path_buf(),
+
            }),
+
            (None, Some(_)) => Err(error::MissingBlobs::Signature {
+
                commit: *commit,
+
                path: sig_path.to_path_buf(),
+
            }),
+
            (Some(_), None) => Err(error::MissingBlobs::Refs {
+
                commit: *commit,
+
                path: refs_path.to_path_buf(),
+
            }),
+
            (Some(refs), Some(sig)) => Ok((refs, sig)),
+
        };
+

+
        result.map_err(error::Tree::from)
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+
struct IdentityRoot {
+
    commit: Oid,
+
    rid: RepoId,
+
}
+

+
struct IdentityRootReader<'a, 'b, R> {
+
    refs: &'a Refs,
+
    repository: &'b R,
+
}
+

+
impl<'a, 'b, R> IdentityRootReader<'a, 'b, R>
+
where
+
    R: object::Reader,
+
{
+
    fn new(refs: &'a Refs, repository: &'b R) -> Self {
+
        Self { refs, repository }
+
    }
+

+
    fn read(self) -> Result<Option<IdentityRoot>, error::IdentityRoot> {
+
        match self.refs.get(&IDENTITY_ROOT) {
+
            Some(commit) => self
+
                .read_blob(&commit)
+
                .map(|rid| Some(IdentityRoot { commit, rid })),
+
            None => Ok(None),
+
        }
+
    }
+

+
    fn read_blob(&self, commit: &Oid) -> Result<RepoId, error::IdentityRoot> {
+
        let path = Path::new("embeds").join(*doc::PATH);
+
        let object::Blob { oid, .. } = self
+
            .repository
+
            .read_blob(commit, &path)
+
            .map_err(error::IdentityRoot::Blob)?
+
            .ok_or_else(|| error::IdentityRoot::MissingIdentity { commit: *commit })?;
+
        Ok(RepoId::from(oid))
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/read/error.rs
@@ -0,0 +1,138 @@
+
use std::fmt;
+
use std::path::PathBuf;
+

+
use radicle_core::{NodeId, RepoId};
+
use radicle_git_metadata::commit;
+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
use crate::storage::refs::canonical;
+
use crate::storage::refs::sigrefs::git::{object, reference};
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Read {
+
    #[error(transparent)]
+
    Commit(Commit),
+
    #[error(transparent)]
+
    FindReference(reference::error::FindReference),
+
    #[error("failed to find `refs/namespaces/{namespace}/refs/rad/sigrefs`")]
+
    MissingSigrefs { namespace: NodeId },
+
    #[error(transparent)]
+
    Verify(Verify),
+
    #[error("failed to find a valid set of signed references starting from {head}")]
+
    NoValidCommit { head: Oid },
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Commit {
+
    #[error(transparent)]
+
    Tree(Tree),
+
    #[error(transparent)]
+
    IdentityRoot(IdentityRoot),
+
    #[error("missing commit '{oid}'")]
+
    Missing { oid: Oid },
+
    #[error("invalid commit '{oid}': {source}")]
+
    Parse {
+
        oid: Oid,
+
        source: commit::ParseError,
+
    },
+
    #[error(transparent)]
+
    TooManyParents(Parent),
+
    #[error(transparent)]
+
    Read(object::error::ReadCommit),
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub struct Parent {
+
    pub(crate) parents: Vec<Oid>,
+
}
+

+
impl fmt::Display for Parent {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(
+
            f,
+
            "{}",
+
            self.parents
+
                .iter()
+
                .map(|oid| oid.to_string())
+
                .collect::<Vec<_>>()
+
                .join(", ")
+
        )
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Tree {
+
    #[error(transparent)]
+
    Refs(object::error::ReadBlob),
+
    #[error(transparent)]
+
    Signature(object::error::ReadBlob),
+
    #[error(transparent)]
+
    ParseRefs(canonical::Error),
+
    #[error(transparent)]
+
    ParseSignature(crypto::Error),
+
    #[error(transparent)]
+
    MissingBlobs(#[from] MissingBlobs),
+
}
+

+
#[derive(Debug, Error)]
+
pub enum MissingBlobs {
+
    #[error("failed to find {path:?} in commit {commit}")]
+
    Refs { commit: Oid, path: PathBuf },
+
    #[error("failed to find {path:?} in commit {commit}")]
+
    Signature { commit: Oid, path: PathBuf },
+
    #[error("failed to find {refs:?} and {signature:?} in commit {commit}")]
+
    Both {
+
        commit: Oid,
+
        refs: PathBuf,
+
        signature: PathBuf,
+
    },
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum IdentityRoot {
+
    #[error(transparent)]
+
    Blob(object::error::ReadBlob),
+
    #[error("missing repository identity commit '{commit}'")]
+
    MissingIdentity { commit: Oid },
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Verify {
+
    #[error("failed to verify signature over signed references")]
+
    Signature(crypto::signature::Error),
+
    #[error("expected repository identity {expected}, but found {found} under commit '{identity_commit}' during verification of '{sigrefs_commit}")]
+
    MismatchedIdentity {
+
        identity_commit: Oid,
+
        sigrefs_commit: Oid,
+
        expected: RepoId,
+
        found: RepoId,
+
    },
+
    #[error(transparent)]
+
    MissingIdentity(MissingIdentity),
+
    #[error(
+
        "expected no parent reference in refs commit '{sigrefs_commit}', but found target '{actual}'"
+
    )]
+
    DanglingParent { sigrefs_commit: Oid, actual: Oid },
+
    #[error(
+
        "expected parent reference with target '{expected}' in refs commit '{sigrefs_commit}', but found target '{actual}'"
+
    )]
+
    MismatchedParent {
+
        sigrefs_commit: Oid,
+
        expected: Oid,
+
        actual: Oid,
+
    },
+
}
+

+
#[derive(Debug, Error, Clone, PartialEq, Eq)]
+
#[error("expected repository identity {expected}, but found none under commit '{sigrefs_commit}'")]
+
pub struct MissingIdentity {
+
    pub(super) sigrefs_commit: Oid,
+
    pub(super) expected: RepoId,
+
}
added crates/radicle/src/storage/refs/sigrefs/read/iter.rs
@@ -0,0 +1,37 @@
+
use radicle_oid::Oid;
+

+
use crate::storage::refs::sigrefs::git::object;
+

+
use super::{error, Commit, CommitReader};
+

+
pub(super) struct Walk<'a, R> {
+
    repository: &'a R,
+
    cursor: Option<Oid>,
+
}
+

+
impl<'a, R> Walk<'a, R> {
+
    pub fn new(head: Oid, repository: &'a R) -> Self {
+
        Self {
+
            repository,
+
            cursor: Some(head),
+
        }
+
    }
+
}
+

+
impl<'a, R: object::Reader> Iterator for Walk<'a, R> {
+
    type Item = Result<Commit, error::Commit>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        match self
+
            .cursor
+
            .map(|commit| CommitReader::new(commit, self.repository).read())
+
        {
+
            None => None,
+
            Some(Ok(commit)) => {
+
                self.cursor = commit.parent;
+
                Some(Ok(commit))
+
            }
+
            Some(Err(err)) => Some(Err(err)),
+
        }
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test.rs
@@ -0,0 +1,9 @@
+
#![allow(clippy::unwrap_used)]
+

+
mod mock;
+

+
mod commit_reader;
+
mod identity_root_reader;
+
mod resolve_tip;
+
mod signed_refs_reader;
+
mod tree_reader;
added crates/radicle/src/storage/refs/sigrefs/read/test/commit_reader.rs
@@ -0,0 +1,108 @@
+
use radicle_oid::Oid;
+

+
use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip};
+
use crate::storage::refs::sigrefs::VerifiedCommit;
+
use crate::storage::refs::IDENTITY_ROOT;
+

+
use super::mock;
+
use super::mock::{AlwaysVerify, MockRepository};
+

+
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
+
    SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
+
}
+

+
#[test]
+
fn tree_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_missing_refs(head)
+
        .with_missing_signature(head);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(err, error::Read::Commit(error::Commit::Tree(_))));
+
}
+

+
#[test]
+
fn identity_root_error() {
+
    let head = mock::oid(1);
+
    let identity_root = mock::oid(2);
+
    let refs = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (IDENTITY_ROOT.to_ref_string(), identity_root),
+
    ];
+

+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs)
+
        .with_signature(head, 1)
+
        .with_identity_error(identity_root);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::IdentityRoot(_))
+
    ));
+
}
+

+
#[test]
+
fn too_many_parents() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([mock::oid(2), mock::oid(3)]))
+
        .with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
+
        .with_signature(head, 1);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::TooManyParents(_))
+
    ));
+
}
+

+
#[test]
+
fn missing_commit() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new().with_missing_commit(head);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Missing { .. })
+
    ));
+
}
+

+
#[test]
+
fn missing_identity() {
+
    let head = mock::oid(1);
+
    let refs = [(mock::refs_heads_main(), mock::oid(10))];
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs)
+
        .with_signature(head, 1);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Verify(error::Verify::MissingIdentity { .. })
+
    ));
+
}
+

+
#[test]
+
fn read_ok() {
+
    let head = mock::oid(1);
+
    let refs = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ];
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs)
+
        .with_signature(head, 1);
+

+
    let vc = read_at(head, repo).unwrap();
+
    assert_eq!(vc.id(), &head);
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test/identity_root_reader.rs
@@ -0,0 +1,103 @@
+
use radicle_core::RepoId;
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::storage::refs::sigrefs::read::{
+
    error, IdentityRoot, IdentityRootReader, SignedRefsReader, Tip,
+
};
+
use crate::storage::refs::sigrefs::VerifiedCommit;
+
use crate::storage::refs::{Refs, IDENTITY_ROOT};
+

+
use super::mock;
+
use super::mock::{AlwaysVerify, MockRepository};
+

+
fn refs_with_identity(oid: Oid) -> [(git::fmt::RefString, Oid); 2] {
+
    [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (IDENTITY_ROOT.to_ref_string(), oid),
+
    ]
+
}
+

+
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
+
    SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
+
}
+

+
#[test]
+
fn doc_blob_error() {
+
    let root = mock::oid(1);
+
    let identity_root = mock::oid(2);
+

+
    let repo = MockRepository::new()
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, refs_with_identity(identity_root))
+
        .with_signature(root, 1)
+
        .with_identity_error(identity_root);
+

+
    let err = read_at(root, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::IdentityRoot(error::IdentityRoot::Blob(_)))
+
    ));
+
}
+

+
#[test]
+
fn missing_identity() {
+
    let head = mock::oid(1);
+
    let dangling = mock::oid(2);
+

+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs_with_identity(dangling))
+
        .with_signature(head, 1)
+
        .with_missing_identity(dangling);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::IdentityRoot(
+
            error::IdentityRoot::MissingIdentity { .. }
+
        ))
+
    ));
+
}
+

+
#[test]
+
fn read_ok_some() {
+
    let root = mock::oid(1);
+
    let identity_root = mock::oid(99);
+

+
    let repo = MockRepository::new()
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, refs_with_identity(identity_root))
+
        .with_signature(root, 1)
+
        .with_identity(identity_root);
+

+
    let result = IdentityRootReader::new(
+
        &Refs::from(refs_with_identity(identity_root).into_iter()),
+
        &repo,
+
    )
+
    .read()
+
    .unwrap();
+
    assert_eq!(
+
        result,
+
        Some(IdentityRoot {
+
            commit: identity_root,
+
            rid: RepoId::from(identity_root)
+
        })
+
    )
+
}
+

+
#[test]
+
fn read_ok_none() {
+
    let root = mock::oid(1);
+
    let refs = [(mock::refs_heads_main(), mock::oid(10))];
+

+
    let repo = MockRepository::new()
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, refs.clone())
+
        .with_signature(root, 1);
+

+
    let result = IdentityRootReader::new(&Refs::from(refs.into_iter()), &repo)
+
        .read()
+
        .unwrap();
+
    assert_eq!(result, None);
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test/mock.rs
@@ -0,0 +1,335 @@
+
//! Mock implementations of [`object::Reader`] and [`reference::Reader`] for
+
//! unit-testing.
+

+
use std::collections::HashMap;
+
use std::path::{Path, PathBuf};
+

+
use radicle_core::NodeId;
+
use radicle_git_metadata::author::{Author, Time};
+
use radicle_git_metadata::commit::headers::Headers;
+
use radicle_git_metadata::commit::trailers::OwnedTrailer;
+
use radicle_git_metadata::commit::CommitData;
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::identity::doc;
+
use crate::storage::refs::sigrefs::git::{object, reference};
+
use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH};
+

+
pub(crate) const MOCKED_IDENTITY: u8 = 99u8;
+

+
/// A configurable in-memory repository implementing [`object::Reader`] and
+
/// [`reference::Reader`].
+
/// All behaviour is set at construction time via the builder methods; the mock
+
/// is fully deterministic.
+
pub struct MockRepository {
+
    commits: HashMap<Oid, CommitBehavior>,
+
    blobs: HashMap<(Oid, PathBuf), BlobBehavior>,
+
    references: HashMap<String, RefBehavior>,
+
}
+

+
enum CommitBehavior {
+
    /// [`object::Reader::read_commit`] returns `Ok(Some(bytes))`.
+
    Present(Box<CommitData<Oid, Oid>>),
+
    /// [`object::Reader::read_commit`] returns `Ok(None)`.
+
    Missing,
+
    /// [`object::Reader::read_commit`] returns `Err(…)`.
+
    Error,
+
}
+

+
enum BlobBehavior {
+
    /// [`object::Reader::read_blob`] returns `Ok(Some(blob))`.
+
    Present(Vec<u8>),
+
    /// [`object::Reader::read_blob`] returns `Ok(None)`.
+
    Missing,
+
    /// [`object::Reader::read_blob`] returns `Err(…)`.
+
    Error,
+
}
+

+
enum RefBehavior {
+
    /// [`reference::Reader::find_reference`] returns `Ok(Some(oid))`.
+
    Present(Oid),
+
    /// [`reference::Reader::find_reference`] returns `Ok(None)`.
+
    Missing,
+
    /// [`reference::Reader::find_reference`] returns `Err(…)`.
+
    Error,
+
}
+

+
impl MockRepository {
+
    pub fn new() -> Self {
+
        Self {
+
            commits: HashMap::new(),
+
            blobs: HashMap::new(),
+
            references: HashMap::new(),
+
        }
+
        .with_identity(oid(MOCKED_IDENTITY))
+
    }
+

+
    pub fn with_commit(mut self, oid: Oid, data: CommitData<Oid, Oid>) -> Self {
+
        self.commits
+
            .insert(oid, CommitBehavior::Present(Box::new(data)));
+
        self
+
    }
+

+
    pub fn with_missing_commit(mut self, oid: Oid) -> Self {
+
        self.commits.insert(oid, CommitBehavior::Missing);
+
        self
+
    }
+

+
    pub fn with_commit_error(mut self, oid: Oid) -> Self {
+
        self.commits.insert(oid, CommitBehavior::Error);
+
        self
+
    }
+

+
    pub fn with_refs(
+
        self,
+
        commit: Oid,
+
        refs: impl IntoIterator<Item = (git::fmt::RefString, Oid)>,
+
    ) -> Self {
+
        self.with_blob(commit, &REFS_BLOB_PATH, refs_bytes(refs))
+
    }
+

+
    pub fn with_signature(self, commit: Oid, id: u8) -> Self {
+
        self.with_blob(commit, &SIGNATURE_BLOB_PATH, sig_bytes(id))
+
    }
+

+
    pub fn with_blob<P>(mut self, commit: Oid, path: &P, bytes: Vec<u8>) -> Self
+
    where
+
        P: AsRef<Path>,
+
    {
+
        self.blobs.insert(
+
            (commit, path.as_ref().to_path_buf()),
+
            BlobBehavior::Present(bytes),
+
        );
+
        self
+
    }
+

+
    pub fn with_missing_refs(self, commit: Oid) -> Self {
+
        self.with_missing_blob(commit, &REFS_BLOB_PATH)
+
    }
+

+
    pub fn with_missing_signature(self, commit: Oid) -> Self {
+
        self.with_missing_blob(commit, &SIGNATURE_BLOB_PATH)
+
    }
+

+
    pub fn with_missing_identity(self, commit: Oid) -> Self {
+
        self.with_missing_blob(commit, &identity_path())
+
    }
+

+
    pub fn with_identity_error(self, commit: Oid) -> Self {
+
        self.with_blob_error(commit, &identity_path())
+
    }
+

+
    pub fn with_identity(self, commit: Oid) -> Self {
+
        self.with_blob(commit, &identity_path(), vec![])
+
    }
+

+
    fn with_missing_blob<P>(mut self, commit: Oid, path: &P) -> Self
+
    where
+
        P: AsRef<Path>,
+
    {
+
        self.blobs
+
            .insert((commit, path.as_ref().to_path_buf()), BlobBehavior::Missing);
+
        self
+
    }
+

+
    pub fn with_blob_error<P>(mut self, commit: Oid, path: &P) -> Self
+
    where
+
        P: AsRef<Path>,
+
    {
+
        self.blobs
+
            .insert((commit, path.as_ref().to_path_buf()), BlobBehavior::Error);
+
        self
+
    }
+

+
    /// The `name` must be the exact string returned by `Namespaced::as_str()`.
+
    pub fn with_rad_sigrefs(mut self, namespace: &NodeId, oid: Oid) -> Self {
+
        self.references.insert(
+
            sigrefs_ref_name(namespace).to_string(),
+
            RefBehavior::Present(oid),
+
        );
+
        self
+
    }
+

+
    pub fn with_missing_rad_sigrefs(mut self, namespace: &NodeId) -> Self {
+
        self.references
+
            .insert(sigrefs_ref_name(namespace), RefBehavior::Missing);
+
        self
+
    }
+

+
    pub fn with_rad_sigrefs_error(mut self, namespace: &NodeId) -> Self {
+
        self.references
+
            .insert(sigrefs_ref_name(namespace), RefBehavior::Error);
+
        self
+
    }
+
}
+

+
impl object::Reader for MockRepository {
+
    fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
+
        match self.commits.get(oid) {
+
            Some(CommitBehavior::Present(data)) => Ok(Some(data.to_string().as_bytes().to_vec())),
+
            Some(CommitBehavior::Missing) | None => Ok(None),
+
            Some(CommitBehavior::Error) => Err(object::error::ReadCommit::other(
+
                std::io::Error::other("mock commit error"),
+
            )),
+
        }
+
    }
+

+
    fn read_blob(
+
        &self,
+
        commit: &Oid,
+
        path: &Path,
+
    ) -> Result<Option<object::Blob>, object::error::ReadBlob> {
+
        let key = (*commit, path.to_path_buf());
+
        match self.blobs.get(&key) {
+
            Some(BlobBehavior::Present(bytes)) => Ok(Some(object::Blob {
+
                // The blob OID is returned as the commit OID.  This is
+
                // intentional: IdentityRootReader converts blob.oid into a
+
                // RepoId, so callers can predict which RepoId results from a
+
                // given identity-root commit OID.
+
                oid: *commit,
+
                bytes: bytes.clone(),
+
            })),
+
            Some(BlobBehavior::Missing) | None => Ok(None),
+
            Some(BlobBehavior::Error) => Err(object::error::ReadBlob::other(
+
                std::io::Error::other("mock blob error"),
+
            )),
+
        }
+
    }
+
}
+

+
impl reference::Reader for MockRepository {
+
    fn find_reference(
+
        &self,
+
        reference: &git::fmt::Namespaced,
+
    ) -> Result<Option<Oid>, reference::error::FindReference> {
+
        match self.references.get(reference.as_str()) {
+
            Some(RefBehavior::Present(oid)) => Ok(Some(*oid)),
+
            Some(RefBehavior::Missing) | None => Ok(None),
+
            Some(RefBehavior::Error) => Err(reference::error::FindReference::other(
+
                std::io::Error::other("mock reference error"),
+
            )),
+
        }
+
    }
+
}
+

+
/// Accepts every (message, signature) pair without inspecting either.
+
pub struct AlwaysVerify;
+

+
impl crypto::signature::Verifier<crypto::Signature> for AlwaysVerify {
+
    fn verify(
+
        &self,
+
        _msg: &[u8],
+
        _sig: &crypto::Signature,
+
    ) -> Result<(), crypto::signature::Error> {
+
        Ok(())
+
    }
+
}
+

+
/// Rejects every (message, signature) pair.
+
pub struct NeverVerify;
+

+
impl crypto::signature::Verifier<crypto::Signature> for NeverVerify {
+
    fn verify(
+
        &self,
+
        _msg: &[u8],
+
        _sig: &crypto::Signature,
+
    ) -> Result<(), crypto::signature::Error> {
+
        Err(crypto::signature::Error::new())
+
    }
+
}
+

+
/// Construct an [`Oid`] from a single repeated byte.
+
///
+
/// `oid(1) != oid(2)` is guaranteed; use distinct values for distinct objects.
+
pub fn oid(n: u8) -> Oid {
+
    Oid::from_sha1([n; 20])
+
}
+

+
/// Construct a [`radicle_core::RepoId`] from a single repeated byte.
+
pub fn rid(n: u8) -> radicle_core::RepoId {
+
    radicle_core::RepoId::from(oid(n))
+
}
+

+
/// Construct a [`radicle_core::NodeId`] from a single repeated byte.
+
pub fn node_id() -> NodeId {
+
    NodeId::from([1u8; 32])
+
}
+

+
pub fn refs_heads_main() -> git::fmt::RefString {
+
    git::fmt::refname!("refs/heads/main")
+
}
+

+
/// Compute the namespaced sigrefs reference string for `namespace`, matching
+
/// the string that `SignedRefsReader::resolve_tip` will look up.
+
fn sigrefs_ref_name(namespace: &NodeId) -> String {
+
    SIGREFS_BRANCH
+
        .with_namespace(git::fmt::Component::from(namespace))
+
        .as_str()
+
        .to_owned()
+
}
+

+
fn test_author() -> Author {
+
    Author {
+
        name: "test".to_owned(),
+
        email: "test@example.com".to_owned(),
+
        time: Time::new(0, 0),
+
    }
+
}
+

+
/// Build a minimal [`CommitData`] with the given parents and a zero tree OID.
+
pub fn commit_data(parents: impl IntoIterator<Item = Oid>) -> CommitData<Oid, Oid> {
+
    let tree = oid(0);
+
    let author = test_author();
+
    let message = "test\n".to_owned();
+

+
    CommitData::new::<_, _, OwnedTrailer>(
+
        tree,
+
        parents,
+
        author.clone(),
+
        author,
+
        Headers::new(),
+
        message,
+
        vec![],
+
    )
+
}
+

+
/// Returns 64 bytes all equal to `id`.
+
///
+
/// With [`AlwaysVerify`] any 64-byte sequence is accepted as a valid signature.
+
/// Different `id` values are treated as distinct signatures by the
+
/// deduplication logic inside [`SignedRefsReader`].
+
pub fn sig_bytes(id: u8) -> Vec<u8> {
+
    vec![id; 64]
+
}
+

+
/// Set up a linear commit chain in the mock repository.
+
///
+
/// `chain` is ordered oldest-first: `chain[0]` is the root (no parent),
+
/// and each subsequent commit's parent is the preceding entry.
+
/// Each element is `(commit_oid, sig_id, refs)`.
+
pub fn setup_chain<I>(chain: impl IntoIterator<Item = (Oid, u8, I)>) -> MockRepository
+
where
+
    I: IntoIterator<Item = (git::fmt::RefString, Oid)>,
+
{
+
    let mut repo = MockRepository::new();
+
    let mut parent = None;
+
    for (commit_oid, sig_id, refs) in chain.into_iter() {
+
        repo = repo
+
            .with_commit(commit_oid, commit_data(parent))
+
            .with_refs(commit_oid, refs)
+
            .with_signature(commit_oid, sig_id);
+
        parent = Some(commit_oid);
+
    }
+
    repo
+
}
+

+
/// Construct the canonical bytes of a [`Refs`] from the given entries.
+
fn refs_bytes(entries: impl IntoIterator<Item = (git::fmt::RefString, Oid)>) -> Vec<u8> {
+
    let refs = Refs::from(entries.into_iter());
+
    refs.canonical()
+
}
+

+
fn identity_path() -> PathBuf {
+
    Path::new("embeds").join(*doc::PATH)
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test/resolve_tip.rs
@@ -0,0 +1,67 @@
+
use super::mock;
+
use super::mock::{AlwaysVerify, MockRepository};
+
use crate::storage::refs::sigrefs::read::error;
+
use crate::storage::refs::sigrefs::read::Tip;
+
use crate::storage::refs::sigrefs::SignedRefsReader;
+
use crate::storage::refs::IDENTITY_ROOT;
+

+
#[test]
+
fn missing_sigrefs() {
+
    let namespace = mock::node_id();
+
    let repo = MockRepository::new().with_missing_rad_sigrefs(&namespace);
+

+
    let result = SignedRefsReader::new(
+
        mock::rid(99),
+
        Tip::Reference(namespace),
+
        &repo,
+
        &AlwaysVerify,
+
    )
+
    .read();
+

+
    assert!(matches!(result, Err(error::Read::MissingSigrefs { .. })));
+
}
+

+
#[test]
+
fn find_reference_error() {
+
    let namespace = mock::node_id();
+
    let repo = MockRepository::new().with_rad_sigrefs_error(&namespace);
+

+
    let result = SignedRefsReader::new(
+
        mock::rid(99),
+
        Tip::Reference(namespace),
+
        &repo,
+
        &AlwaysVerify,
+
    )
+
    .read();
+

+
    assert!(matches!(result, Err(error::Read::FindReference(_))));
+
}
+

+
#[test]
+
fn resolve_tip_ok() {
+
    let namespace = mock::node_id();
+
    let root = mock::oid(1);
+
    let refs = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ];
+

+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&namespace, root)
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, refs)
+
        .with_signature(root, 1);
+

+
    let vc = SignedRefsReader::new(
+
        mock::rid(99),
+
        Tip::Reference(namespace),
+
        &repo,
+
        &AlwaysVerify,
+
    )
+
    .read()
+
    .unwrap();
+
    assert_eq!(*vc.id(), root);
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs
@@ -0,0 +1,315 @@
+
use radicle_oid::Oid;
+

+
use crate::storage::refs::sigrefs::read::error::{Read, Verify};
+
use crate::storage::refs::sigrefs::read::{error, Commit, SignedRefsReader, Tip};
+
use crate::storage::refs::sigrefs::VerifiedCommit;
+
use crate::storage::refs::{IDENTITY_ROOT, SIGREFS_PARENT};
+
use crate::{assert_matches, git};
+

+
use super::mock;
+
use super::mock::{AlwaysVerify, MockRepository};
+

+
fn refs_without_parent(head_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> {
+
    vec![
+
        (mock::refs_heads_main(), head_oid),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ]
+
}
+

+
fn refs(head_oid: Oid, parent_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> {
+
    vec![
+
        (mock::refs_heads_main(), head_oid),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
        (SIGREFS_PARENT.to_ref_string(), parent_oid),
+
    ]
+
}
+

+
fn read(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
+
    SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
+
}
+

+
#[test]
+
fn head_commit_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new().with_commit_error(head);
+

+
    let err = read(head, repo).unwrap_err();
+
    assert!(matches!(err, error::Read::Commit(_)));
+
}
+

+
#[test]
+
fn walk_commit_error() {
+
    let root = mock::oid(1);
+
    let head = mock::oid(2);
+
    let r2 = refs_without_parent(head);
+

+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([root]))
+
        .with_refs(head, r2)
+
        .with_signature(head, 1)
+
        .with_commit_error(root);
+

+
    let err = read(head, repo).unwrap_err();
+
    assert!(matches!(err, error::Read::Commit(_)));
+
}
+

+
#[test]
+
fn head_verify_signature_error() {
+
    // The verifier always rejects the signature → `error::Verify::Signature`.
+
    let head = mock::oid(1);
+
    let repo = mock::setup_chain([(head, 1, refs_without_parent(head))]);
+

+
    let err = SignedRefsReader::new(mock::rid(99), Tip::Commit(head), &repo, &mock::NeverVerify)
+
        .read()
+
        .unwrap_err();
+

+
    assert!(matches!(
+
        err,
+
        error::Read::Verify(error::Verify::Signature(_))
+
    ));
+
}
+

+
#[test]
+
fn head_verify_mismatched_identity_error() {
+
    let head = mock::oid(1);
+
    // RepoId in test scenario is rid(99), so not equal to rid(50)
+
    let mismatched_identity_root = mock::oid(50);
+
    let refs = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (IDENTITY_ROOT.to_ref_string(), mismatched_identity_root),
+
    ];
+

+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs)
+
        .with_signature(head, 1)
+
        .with_identity(mismatched_identity_root);
+

+
    let err = read(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Verify(error::Verify::MismatchedIdentity { .. })
+
    ));
+
}
+

+
#[test]
+
fn walk_verify_error() {
+
    let root = mock::oid(1);
+
    let commit1 = mock::oid(2);
+
    let commit2 = mock::oid(3);
+
    let identity_root_mismatch = mock::oid(50);
+

+
    let r1 = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ];
+
    let r2 = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (IDENTITY_ROOT.to_ref_string(), identity_root_mismatch),
+
    ];
+
    let r3 = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ];
+

+
    let repo = MockRepository::new()
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, r1)
+
        .with_signature(root, 1)
+
        .with_commit(commit1, mock::commit_data([root]))
+
        .with_refs(commit1, r2)
+
        .with_signature(commit1, 1)
+
        .with_identity(identity_root_mismatch)
+
        .with_commit(commit2, mock::commit_data([commit1]))
+
        .with_refs(commit2, r3)
+
        .with_signature(commit2, 1);
+

+
    let err = read(commit2, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Verify(error::Verify::MismatchedIdentity { .. })
+
    ));
+
}
+

+
#[test]
+
fn single_commit() {
+
    let head = mock::oid(1);
+
    let repo = mock::setup_chain([(head, 1, refs_without_parent(head))]);
+

+
    let vc = read(head, repo).unwrap();
+
    assert_eq!(*vc.id(), head);
+
}
+

+
#[test]
+
fn two_commits() {
+
    let root = mock::oid(1);
+
    let head = mock::oid(2);
+
    let repo = mock::setup_chain([
+
        (root, 1, refs_without_parent(root)),
+
        (head, 2, refs_without_parent(head)),
+
    ]);
+

+
    let vc = read(head, repo).unwrap();
+
    assert_eq!(*vc.id(), head);
+
}
+

+
/// Commit chain:
+
/// `A <- B <- A`
+
/// Where A is replayed
+
///
+
/// Expected Result: B
+
#[test]
+
fn head_replays_root() {
+
    const SIGNATURE_A: u8 = 1;
+
    const SIGNATURE_B: u8 = 2;
+

+
    let a1 = mock::oid(1);
+
    let b = mock::oid(2);
+
    let a2 = mock::oid(3);
+
    let repo = mock::setup_chain([
+
        (a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
+
        (b, SIGNATURE_B, refs_without_parent(mock::oid(10))),
+
        (a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
+
    ]);
+

+
    let vc = read(a2, repo).unwrap();
+
    assert_eq!(*vc.id(), b);
+
}
+

+
/// Commit chain:
+
/// `A <- A <- A`
+
/// Where A is replayed twice
+
///
+
/// Expected Result: first A
+
#[test]
+
fn replay_chain() {
+
    const SIGNATURE_A: u8 = 1;
+
    let a1 = mock::oid(1);
+
    let a2 = mock::oid(2);
+
    let a3 = mock::oid(3);
+
    let repo = mock::setup_chain([
+
        (a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
+
        (a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
+
        (a3, SIGNATURE_A, refs_without_parent(mock::oid(10))),
+
    ]);
+

+
    let vc = read(a3, repo).unwrap();
+
    assert_eq!(*vc.id(), a1);
+
}
+

+
/// Commit chain:
+
/// `C <- C <- B <- A <- A`
+
/// Where C and A are replayed twice
+
///
+
/// Expected Result: first A
+
#[test]
+
fn multiple_replays() {
+
    const SIGNATURE_A: u8 = 3;
+
    const SIGNATURE_B: u8 = 2;
+
    const SIGNATURE_C: u8 = 1;
+

+
    let c1 = mock::oid(1);
+
    let c2 = mock::oid(2);
+
    let b = mock::oid(3);
+
    let a1 = mock::oid(4);
+
    let a2 = mock::oid(5);
+
    let r = refs_without_parent(mock::oid(10));
+
    let repo = mock::setup_chain([
+
        (a2, SIGNATURE_C, r.clone()),
+
        (a1, SIGNATURE_C, r.clone()),
+
        (b, SIGNATURE_B, r.clone()),
+
        (c2, SIGNATURE_A, r.clone()),
+
        (c1, SIGNATURE_A, r),
+
    ]);
+

+
    let vc = read(c1, repo).unwrap();
+
    assert_eq!(*vc.id(), b);
+
}
+

+
#[test]
+
fn read_ok_no_parent() {
+
    const SIGNATURE_1: u8 = 1;
+
    const SIGNATURE_2: u8 = 2;
+

+
    let c1 = mock::oid(1);
+
    let c2 = mock::oid(2);
+

+
    let r = refs_without_parent(mock::oid(10));
+
    let repo = mock::setup_chain([(c2, SIGNATURE_2, r.clone()), (c1, SIGNATURE_1, r)]);
+

+
    let vc = read(c1, repo).unwrap();
+
    assert_eq!(*vc.id(), c1);
+

+
    assert_matches!(
+
        vc,
+
        VerifiedCommit {
+
            commit: Commit {
+
                oid: _,
+
                parent: Some(_),
+
                refs: _,
+
                signature: _,
+
                identity_root: Some(_)
+
            },
+
            parent: false
+
        }
+
    );
+
}
+

+
#[test]
+
fn read_ok_parent() {
+
    const SIGNATURE_1: u8 = 1;
+
    const SIGNATURE_2: u8 = 2;
+

+
    let c1 = mock::oid(1);
+
    let c2 = mock::oid(2);
+

+
    let repo = mock::setup_chain([
+
        (c2, SIGNATURE_2, refs_without_parent(mock::oid(10))),
+
        (c1, SIGNATURE_1, refs(mock::oid(20), c2)),
+
    ]);
+

+
    let vc = read(c1, repo).unwrap();
+
    assert_eq!(*vc.id(), c1);
+

+
    assert_matches!(vc, VerifiedCommit { commit: Commit {
+
        oid,
+
        parent: Some(parent),
+
        refs: _,
+
        signature: _,
+
        identity_root: Some(_)
+
    }, parent: true } if parent == c2 && oid == c1);
+
}
+

+
#[test]
+
fn invalid_parent() {
+
    const SIGNATURE_1: u8 = 1;
+
    const SIGNATURE_2: u8 = 2;
+

+
    let c1 = mock::oid(1);
+
    let c2 = mock::oid(2);
+

+
    let wrong = mock::oid(42);
+

+
    let repo = mock::setup_chain([
+
        (c2, SIGNATURE_2, refs_without_parent(mock::oid(10))),
+
        (c1, SIGNATURE_1, refs(mock::oid(20), wrong)),
+
    ]);
+

+
    assert_matches!(read(c1, repo), Err(Read::Verify(Verify::MismatchedParent {
+
        sigrefs_commit,
+
        expected,
+
        actual,
+
    })) if sigrefs_commit == c1 && expected == c2 && actual == wrong);
+
}
added crates/radicle/src/storage/refs/sigrefs/read/test/tree_reader.rs
@@ -0,0 +1,148 @@
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip};
+
use crate::storage::refs::sigrefs::VerifiedCommit;
+
use crate::storage::refs::{IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH};
+

+
use super::mock;
+
use super::mock::{AlwaysVerify, MockRepository};
+

+
fn refs_heads_main() -> [(git::fmt::RefString, Oid); 1] {
+
    [(mock::refs_heads_main(), mock::oid(10))]
+
}
+

+
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
+
    SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
+
}
+

+
#[test]
+
fn read_refs_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_blob_error(head, &REFS_BLOB_PATH)
+
        .with_signature(head, 1);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::Refs(_)))
+
    ));
+
}
+

+
#[test]
+
fn read_signature_error() {
+
    let root = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(root, mock::commit_data([]))
+
        .with_refs(root, refs_heads_main())
+
        .with_blob_error(root, &SIGNATURE_BLOB_PATH);
+

+
    assert!(matches!(
+
        read_at(root, repo),
+
        Err(error::Read::Commit(error::Commit::Tree(
+
            error::Tree::Signature(_)
+
        )))
+
    ));
+
}
+

+
#[test]
+
fn missing_both() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_missing_refs(head)
+
        .with_missing_signature(head);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
+
            error::MissingBlobs::Both { .. }
+
        )))
+
    ));
+
}
+

+
#[test]
+
fn missing_signature() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs_heads_main())
+
        .with_missing_signature(head);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
+
            error::MissingBlobs::Refs { .. }
+
        )))
+
    ));
+
}
+

+
#[test]
+
fn missing_refs() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_missing_refs(head)
+
        .with_signature(head, 1);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
+
            error::MissingBlobs::Signature { .. }
+
        )))
+
    ));
+
}
+

+
#[test]
+
fn parse_refs_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_blob(head, &REFS_BLOB_PATH, b"NOT VALID REFS\n".to_vec())
+
        .with_signature(head, 1);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::ParseRefs(_)))
+
    ));
+
}
+

+
#[test]
+
fn parse_signature_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs_heads_main())
+
        .with_blob(head, &SIGNATURE_BLOB_PATH, vec![0u8; 1]);
+

+
    let err = read_at(head, repo).unwrap_err();
+
    assert!(matches!(
+
        err,
+
        error::Read::Commit(error::Commit::Tree(error::Tree::ParseSignature(_)))
+
    ));
+
}
+

+
#[test]
+
fn read_ok() {
+
    let head = mock::oid(1);
+
    let refs = [
+
        (mock::refs_heads_main(), mock::oid(10)),
+
        (
+
            IDENTITY_ROOT.to_ref_string(),
+
            mock::oid(mock::MOCKED_IDENTITY),
+
        ),
+
    ];
+
    let repo = MockRepository::new()
+
        .with_commit(head, mock::commit_data([]))
+
        .with_refs(head, refs)
+
        .with_signature(head, 1);
+

+
    let vc = read_at(head, repo).unwrap();
+
    assert_eq!(*vc.id(), head);
+
}
added crates/radicle/src/storage/refs/sigrefs/write.rs
@@ -0,0 +1,396 @@
+
pub mod error;
+

+
#[cfg(test)]
+
mod test;
+

+
use std::path::Path;
+

+
use crypto::signature::Signer;
+
use radicle_core::NodeId;
+
use radicle_git_metadata::author::Author;
+
use radicle_git_metadata::commit::{headers::Headers, trailers::OwnedTrailer, CommitData};
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::storage::refs::sigrefs::git::{object, reference, Committer};
+
use crate::storage::refs::{
+
    Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH, SIGREFS_PARENT,
+
};
+

+
/// The result of calling [`SignedRefsWriter::write`].
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Update {
+
    /// The new signed references commit was written to the Git repository.
+
    Changed { entry: Box<Commit> },
+
    /// The provided [`Refs`] were equal to the current [`Refs`], so the process
+
    /// exited early.
+
    Unchanged {
+
        commit: Oid,
+
        refs: Refs,
+
        signature: crypto::Signature,
+
    },
+
}
+

+
impl From<Commit> for Update {
+
    fn from(commit: Commit) -> Self {
+
        Self::Changed {
+
            entry: Box::new(commit),
+
        }
+
    }
+
}
+

+
impl From<Head> for Update {
+
    fn from(
+
        Head {
+
            commit,
+
            refs,
+
            signature,
+
        }: Head,
+
    ) -> Self {
+
        Self::Unchanged {
+
            commit,
+
            refs,
+
            signature,
+
        }
+
    }
+
}
+

+
/// A [`SignedRefsWriter`] write a commit to the `rad/sigrefs` reference of a
+
/// namespace.
+
///
+
/// To create a new reader, use [`SignedRefsWriter::new`].
+
///
+
/// The construction expects:
+
/// - A [`Refs`] to write to the commit.
+
/// - A [`NodeId`] which identifies the namespace for which `rad/sigrefs`
+
///   reference should be read and written to.
+
/// - A `repository` which is the Git repository being used for reading and
+
///   writing.
+
/// - A `signer` which is the entity that produces the cryptographic signature
+
///   over the [`Refs`].
+
pub struct SignedRefsWriter<'a, R, S> {
+
    refs: Refs,
+
    namespace: NodeId,
+
    repository: &'a R,
+
    signer: &'a S,
+
}
+

+
impl<'a, R, S> SignedRefsWriter<'a, R, S>
+
where
+
    R: object::Writer + object::Reader + reference::Writer + reference::Reader,
+
    S: Signer<crypto::Signature>,
+
{
+
    /// Construct a new [`SignedRefsWriter`].
+
    ///
+
    /// The construction removes the ref [`SIGREFS_BRANCH`] from [`Refs`]
+
    /// (if present).
+
    ///
+
    /// When calling [`SignedRefsWriter::write`], if the process is successful,
+
    /// the given [`Refs`] will be written to the provided `namespace`.
+
    pub fn new(mut refs: Refs, namespace: NodeId, repository: &'a R, signer: &'a S) -> Self {
+
        debug_assert!(refs.get(&IDENTITY_ROOT).is_some());
+
        debug_assert!(refs.get(&SIGREFS_PARENT).is_none());
+
        refs.remove_sigrefs();
+
        Self {
+
            refs,
+
            namespace,
+
            repository,
+
            signer,
+
        }
+
    }
+

+
    /// Write a commit using the [`SignedRefsWriter`].
+
    ///
+
    /// The commit written will be composed of:
+
    /// - The parent commit of the previous entry, unless it is the root commit.
+
    /// - The [`Refs`] under the `/refs` blob. The [`Refs`] must include:
+
    ///   - The [`SIGREFS_PARENT`] entry.
+
    ///   - The [`IDENTITY_ROOT`] entry.
+
    /// - The [`crypto::Signature`] of the [`Refs`] bytes, under the
+
    ///   `/signature` blob.
+
    ///
+
    /// Note that the [`SIGREFS_PARENT`] is not never included in the [`Refs`]
+
    /// outside of this process.
+
    ///
+
    /// This commit is then written to the reference:
+
    /// ```text,no_run
+
    /// refs/namespaces/<namespace>/refs/rad/sigrefs
+
    /// ```
+
    pub fn write(
+
        self,
+
        committer: Committer,
+
        message: String,
+
        reflog: String,
+
    ) -> Result<Update, error::Write> {
+
        let author = committer.into_inner();
+
        let Self {
+
            refs,
+
            namespace,
+
            repository,
+
            signer,
+
        } = self;
+
        let reference = SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace));
+
        let head = HeadReader::new(&reference, repository)
+
            .read()
+
            .map_err(error::Write::Head)?;
+
        let commit_writer = match head {
+
            Some(head) if head.is_unchanged(&refs) => return Ok(Update::from(head)),
+
            Some(head) => {
+
                CommitWriter::with_parent(refs, head.commit, author, message, repository, signer)
+
            }
+
            None => CommitWriter::root(refs, author, message, repository, signer),
+
        };
+
        let commit = commit_writer.write().map_err(error::Write::Commit)?;
+
        repository
+
            .write_reference(&reference, commit.oid, commit.parent, reflog)
+
            .map_err(error::Write::Reference)?;
+
        Ok(Update::from(commit))
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Commit {
+
    /// The [`Oid`] of the parent commit.
+
    parent: Option<Oid>,
+
    /// The [`Oid`] of this commit.
+
    oid: Oid,
+
    /// The [`Refs`] that were committed.
+
    refs: Refs,
+
    /// The [`Signature`] of the [`Refs`] and the [`CommitData`].
+
    signature: crypto::Signature,
+
}
+

+
#[cfg(test)]
+
impl Commit {
+
    pub(super) fn into_refs(self) -> Refs {
+
        self.refs
+
    }
+
}
+

+
struct CommitWriter<'a, R, S> {
+
    refs: Refs,
+
    parent: Option<Oid>,
+
    author: Author,
+
    message: String,
+
    repository: &'a R,
+
    signer: &'a S,
+
}
+

+
impl<'a, R, S> CommitWriter<'a, R, S>
+
where
+
    R: object::Writer,
+
    S: Signer<crypto::Signature>,
+
{
+
    fn root(refs: Refs, author: Author, message: String, repository: &'a R, signer: &'a S) -> Self {
+
        Self {
+
            refs,
+
            parent: None,
+
            author,
+
            message,
+
            repository,
+
            signer,
+
        }
+
    }
+

+
    fn with_parent(
+
        refs: Refs,
+
        parent: Oid,
+
        author: Author,
+
        message: String,
+
        repository: &'a R,
+
        signer: &'a S,
+
    ) -> Self {
+
        Self {
+
            refs,
+
            parent: Some(parent),
+
            author,
+
            message,
+
            repository,
+
            signer,
+
        }
+
    }
+

+
    fn write(mut self) -> Result<Commit, error::Commit> {
+
        if let Some(parent) = self.parent {
+
            let prev = self.refs.add_parent(parent);
+
            debug_assert!(prev.is_none());
+
        }
+

+
        let mut tree = TreeWriter::new(self.refs, self.repository, self.signer)
+
            .write()
+
            .map_err(error::Commit::Tree)?;
+

+
        let commit = CommitData::new::<_, _, OwnedTrailer>(
+
            tree.oid,
+
            self.parent,
+
            self.author.clone(),
+
            self.author,
+
            Headers::new(),
+
            self.message,
+
            vec![],
+
        );
+

+
        let oid = self
+
            .repository
+
            .write_commit(commit.to_string().as_bytes())
+
            .map_err(error::Commit::Write)?;
+

+
        tree.refs.remove_parent();
+

+
        Ok(Commit {
+
            parent: self.parent,
+
            oid,
+
            refs: tree.refs,
+
            signature: tree.signature,
+
        })
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
struct Tree {
+
    oid: Oid,
+
    refs: Refs,
+
    signature: crypto::Signature,
+
}
+

+
struct TreeWriter<'a, R, S> {
+
    refs: Refs,
+
    repository: &'a R,
+
    signer: &'a S,
+
}
+

+
impl<'a, R, S> TreeWriter<'a, R, S>
+
where
+
    R: object::Writer,
+
    S: Signer<crypto::Signature>,
+
{
+
    fn new(refs: Refs, repository: &'a R, signer: &'a S) -> Self {
+
        Self {
+
            refs,
+
            repository,
+
            signer,
+
        }
+
    }
+

+
    fn write(self) -> Result<Tree, error::Tree> {
+
        let canonical = self.refs.canonical();
+
        let signature = self
+
            .signer
+
            .try_sign(&canonical)
+
            .map_err(error::Tree::Sign)?;
+
        let refs = object::RefsEntry {
+
            path: Path::new(REFS_BLOB_PATH).to_path_buf(),
+
            content: canonical,
+
        };
+
        let sig = object::SignatureEntry {
+
            path: Path::new(SIGNATURE_BLOB_PATH).to_path_buf(),
+
            content: signature.to_vec(),
+
        };
+
        let oid = self
+
            .repository
+
            .write_tree(refs, sig)
+
            .map_err(error::Tree::Write)?;
+
        Ok(Tree {
+
            oid,
+
            refs: self.refs,
+
            signature,
+
        })
+
    }
+
}
+

+
/// The current head commit of the reference that points to the signed
+
/// references payload.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
struct Head {
+
    /// The commit [`Oid`] at the head of the reference.
+
    commit: Oid,
+
    /// The [`Refs`] found within the head commit.
+
    refs: Refs,
+
    /// The [`crypto::Signature`] over the [`Refs`] blob.
+
    signature: crypto::Signature,
+
}
+

+
impl Head {
+
    /// Returns `true` if the `proposed` [`Refs`] are equal to the [`Refs`]
+
    /// of the [`Head`].
+
    fn is_unchanged(&self, proposed: &Refs) -> bool {
+
        self.refs == *proposed
+
    }
+
}
+

+
struct HeadReader<'a, 'b, R> {
+
    reference: &'a git::fmt::Namespaced<'a>,
+
    repository: &'b R,
+
}
+

+
impl<'a, 'b, R> HeadReader<'a, 'b, R>
+
where
+
    R: object::Reader + reference::Reader,
+
{
+
    /// Construct a [`HeadReader`] with the `reference` that is being read from
+
    /// the `repository.`
+
    fn new(reference: &'a git::fmt::Namespaced<'a>, repository: &'b R) -> Self {
+
        Self {
+
            reference,
+
            repository,
+
        }
+
    }
+

+
    /// Read the [`Head`] that is found in the repository under the given
+
    /// reference.
+
    ///
+
    /// Returns `None` if no such reference exists.
+
    ///
+
    /// The returned [`Refs`] do not contain the [`SIGREFS_PARENT`] reference.
+
    fn read(self) -> Result<Option<Head>, error::Head> {
+
        self.repository
+
            .find_reference(self.reference)
+
            .map_err(error::Head::Reference)?
+
            .map(|commit| self.with_refs(commit))
+
            .transpose()
+
    }
+

+
    fn with_refs(&self, commit: Oid) -> Result<Head, error::Head> {
+
        let refs = self.refs(commit)?;
+
        let signature = self.refs_signature(commit)?;
+
        Ok(Head {
+
            commit,
+
            refs,
+
            signature,
+
        })
+
    }
+

+
    fn refs(&self, commit: Oid) -> Result<Refs, error::Head> {
+
        let path = Path::new(REFS_BLOB_PATH);
+
        let object::Blob { bytes, .. } = self
+
            .repository
+
            .read_blob(&commit, path)
+
            .map_err(error::Head::Blob)?
+
            .ok_or(error::Head::MissingPath {
+
                commit,
+
                path: path.to_path_buf(),
+
            })?;
+

+
        let mut refs = Refs::from_canonical(&bytes).map_err(error::Head::Refs)?;
+
        refs.remove_parent();
+
        Ok(refs)
+
    }
+

+
    fn refs_signature(&self, commit: Oid) -> Result<crypto::Signature, error::Head> {
+
        let path = Path::new(SIGNATURE_BLOB_PATH);
+
        let object::Blob {
+
            bytes: sig_bytes, ..
+
        } = self
+
            .repository
+
            .read_blob(&commit, path)
+
            .map_err(error::Head::Blob)?
+
            .ok_or(error::Head::MissingPath {
+
                commit,
+
                path: path.to_path_buf(),
+
            })?;
+
        crypto::Signature::try_from(sig_bytes.as_slice()).map_err(|err| error::Head::Signature {
+
            commit,
+
            source: err,
+
        })
+
    }
+
}
added crates/radicle/src/storage/refs/sigrefs/write/error.rs
@@ -0,0 +1,58 @@
+
use std::path::PathBuf;
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
use crate::storage::refs::canonical;
+
use crate::storage::refs::sigrefs::git::{object, reference};
+

+
// TODO: use commit NID (and RID?) for traceability
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Write {
+
    #[error(transparent)]
+
    Head(Head),
+
    #[error(transparent)]
+
    Commit(Commit),
+
    #[error(transparent)]
+
    Reference(reference::error::WriteReference),
+
}
+

+
// TODO: use commit OID for traceability
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Commit {
+
    #[error(transparent)]
+
    Tree(Tree),
+
    #[error(transparent)]
+
    Write(object::error::WriteCommit),
+
}
+

+
// TODO: use commit OID for traceability
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Tree {
+
    #[error("failed to sign references payload")]
+
    Sign(crypto::signature::Error),
+
    #[error(transparent)]
+
    Write(object::error::WriteTree),
+
}
+

+
// TODO: use commit OID for traceability
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
#[error(transparent)]
+
pub enum Head {
+
    #[error(transparent)]
+
    Reference(reference::error::FindReference),
+
    #[error(transparent)]
+
    Blob(object::error::ReadBlob),
+
    #[error(transparent)]
+
    Refs(canonical::Error),
+
    #[error("failed to parse refs signature in commit {commit}")]
+
    Signature { commit: Oid, source: crypto::Error },
+
    #[error(
+
        "could not find the references blob, within the commit '{commit}', under the path {path:?}"
+
    )]
+
    MissingPath { commit: Oid, path: PathBuf },
+
}
added crates/radicle/src/storage/refs/sigrefs/write/test.rs
@@ -0,0 +1,8 @@
+
#![allow(clippy::unwrap_used)]
+

+
mod mock;
+

+
mod commit_writer;
+
mod head_reader;
+
mod signed_refs_writer;
+
mod tree_writer;
added crates/radicle/src/storage/refs/sigrefs/write/test/commit_writer.rs
@@ -0,0 +1,115 @@
+
use super::mock;
+
use super::mock::MockRepository;
+
use crate::storage::refs::sigrefs::write::{error, CommitWriter};
+
use crate::storage::refs::{Refs, IDENTITY_ROOT};
+

+
fn mock_refs() -> Refs {
+
    Refs::from(
+
        [
+
            (mock::refs_heads_main(), mock::oid(10)),
+
            (IDENTITY_ROOT.to_ref_string(), mock::oid(99)),
+
        ]
+
        .into_iter(),
+
    )
+
}
+

+
#[test]
+
fn tree_error() {
+
    // `NeverSign` causes the sign step inside `TreeWriter` to fail, which
+
    // propagates as `error::Commit::Tree`. The repository needs no
+
    // configuration because signing fails before `write_tree` is called.
+
    let repo = MockRepository::new();
+
    let result = CommitWriter::root(
+
        mock_refs(),
+
        mock::author(),
+
        "msg".into(),
+
        &repo,
+
        &mock::NeverSign,
+
    )
+
    .write();
+
    assert!(matches!(result, Err(error::Commit::Tree(_))));
+
}
+

+
#[test]
+
fn write_commit_error() {
+
    // `write_tree` succeeds; `write_commit` returns an error by default
+
    // (no `with_write_commit_ok` configured).
+
    let repo = MockRepository::new().with_write_tree_ok(mock::oid(99));
+
    let result = CommitWriter::root(
+
        mock_refs(),
+
        mock::author(),
+
        "msg".into(),
+
        &repo,
+
        &mock::AlwaysSign,
+
    )
+
    .write();
+
    assert!(matches!(result, Err(error::Commit::Write(_))));
+
}
+

+
#[test]
+
fn write_root_ok() {
+
    let refs = mock_refs();
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid);
+
    let commit = CommitWriter::root(
+
        refs.clone(),
+
        mock::author(),
+
        "msg".into(),
+
        &repo,
+
        &mock::AlwaysSign,
+
    )
+
    .write()
+
    .unwrap();
+
    assert_eq!(commit.parent, None);
+
    assert_eq!(commit.oid, commit_oid);
+
    assert_eq!(commit.signature, mock::AlwaysSign::signature());
+
    assert_eq!(commit.into_refs(), refs);
+
}
+

+
#[test]
+
fn write_with_parent_ok() {
+
    let refs = mock_refs();
+
    let parent_oid = mock::oid(1);
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid);
+
    let commit = CommitWriter::with_parent(
+
        refs.clone(),
+
        parent_oid,
+
        mock::author(),
+
        "msg".into(),
+
        &repo,
+
        &mock::AlwaysSign,
+
    )
+
    .write()
+
    .unwrap();
+
    assert_eq!(commit.parent, Some(parent_oid));
+
    assert_eq!(commit.oid, commit_oid);
+
    assert_eq!(commit.signature, mock::AlwaysSign::signature());
+
    assert_eq!(commit.into_refs(), refs);
+
}
+

+
// TODO: We should error on empty `Refs` writes
+
#[test]
+
fn write_empty_refs() {
+
    let refs = Refs::from([(IDENTITY_ROOT.to_ref_string(), mock::oid(99))].into_iter());
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid);
+
    let commit = CommitWriter::root(
+
        refs.clone(),
+
        mock::author(),
+
        "msg".into(),
+
        &repo,
+
        &mock::AlwaysSign,
+
    )
+
    .write()
+
    .unwrap();
+
    assert_eq!(commit.parent, None);
+
    assert_eq!(commit.oid, commit_oid);
+
    assert_eq!(commit.into_refs(), refs);
+
}
added crates/radicle/src/storage/refs/sigrefs/write/test/head_reader.rs
@@ -0,0 +1,101 @@
+
use radicle_git_ref_format::Component;
+

+
use super::mock::{self, MockRepository};
+
use crate::storage::refs::sigrefs::write::{error, Head, HeadReader};
+
use crate::storage::refs::{Refs, SIGREFS_BRANCH};
+

+
/// Drive `HeadReader` directly via the sigrefs reference for `mock::node_id()`.
+
fn read(repo: &MockRepository) -> Result<Option<Head>, error::Head> {
+
    let namespace = mock::node_id();
+
    let reference = SIGREFS_BRANCH.with_namespace(Component::from(&namespace));
+
    HeadReader::new(&reference, repo).read()
+
}
+

+
#[test]
+
fn reference_error() {
+
    let repo = MockRepository::new().with_rad_sigrefs_error(&mock::node_id());
+
    assert!(matches!(read(&repo), Err(error::Head::Reference(_))));
+
}
+

+
#[test]
+
fn no_head() {
+
    let repo = MockRepository::new().with_missing_rad_sigrefs(&mock::node_id());
+
    assert!(matches!(read(&repo), Ok(None)));
+
}
+

+
#[test]
+
fn refs_blob_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs_error(head);
+
    assert!(matches!(read(&repo), Err(error::Head::Blob(_))));
+
}
+

+
#[test]
+
fn refs_blob_missing() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_missing_refs(head)
+
        .with_signature(head, 1);
+
    assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. })));
+
}
+

+
#[test]
+
fn refs_parse_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_invalid_refs(head)
+
        .with_signature(head, 1);
+
    assert!(matches!(read(&repo), Err(error::Head::Refs(_))));
+
}
+

+
#[test]
+
fn signature_blob_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
+
        .with_signature_error(head);
+
    assert!(matches!(read(&repo), Err(error::Head::Blob(_))));
+
}
+

+
#[test]
+
fn signature_blob_missing() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
+
        .with_missing_signature(head);
+
    assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. })));
+
}
+

+
#[test]
+
fn signature_parse_error() {
+
    let head = mock::oid(1);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
+
        .with_invalid_signature(head);
+
    assert!(matches!(read(&repo), Err(error::Head::Signature { .. })));
+
}
+

+
#[test]
+
fn read_ok() {
+
    let head = mock::oid(1);
+
    let refs = [(mock::refs_heads_main(), mock::oid(10))];
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, refs.clone())
+
        .with_signature(head, 1);
+
    assert_eq!(
+
        read(&repo).unwrap(),
+
        Some(Head {
+
            commit: head,
+
            refs: Refs::from(refs.into_iter()),
+
            signature: crypto::Signature::from([1; 64]),
+
        })
+
    );
+
}
added crates/radicle/src/storage/refs/sigrefs/write/test/mock.rs
@@ -0,0 +1,311 @@
+
use std::collections::HashMap;
+
use std::path::{Path, PathBuf};
+
use std::str::FromStr as _;
+

+
use radicle_core::NodeId;
+
use radicle_git_metadata::author::{Author, Time};
+
use radicle_oid::Oid;
+

+
use crate::git;
+
use crate::storage::refs::sigrefs::git::{object, reference};
+
use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH};
+

+
enum WriteTreeBehavior {
+
    /// [`object::Writer::write_tree`] returns `Ok(oid)`.
+
    Ok(Oid),
+
    /// [`object::Writer::write_tree`] returns `Err(…)`.
+
    Error,
+
}
+

+
/// [`object::Writer::write_commit`] returns `Ok(oid)`.
+
struct WriteCommitBehavior(Oid);
+

+
enum WriteReferenceBehavior {
+
    /// [`reference::Writer::write_reference`] returns `Ok(())`.
+
    Ok,
+
    /// [`reference::Writer::write_reference`] returns `Err(…)`.
+
    Error,
+
}
+

+
enum BlobBehavior {
+
    /// [`object::Reader::read_blob`] returns `Ok(Some(blob))`.
+
    Present(Vec<u8>),
+
    /// [`object::Reader::read_blob`] returns `Ok(None)`.
+
    Missing,
+
    /// [`object::Reader::read_blob`] returns `Err(…)`.
+
    Error,
+
}
+

+
enum RefBehavior {
+
    /// [`reference::Reader::find_reference`] returns `Ok(Some(oid))`.
+
    Present(Oid),
+
    /// [`reference::Reader::find_reference`] returns `Ok(None)`.
+
    Missing,
+
    /// [`reference::Reader::find_reference`] returns `Err(…)`.
+
    Error,
+
}
+

+
pub struct MockRepository {
+
    blobs: HashMap<(Oid, PathBuf), BlobBehavior>,
+
    references: HashMap<String, RefBehavior>,
+
    write_tree: Option<WriteTreeBehavior>,
+
    write_commit: Option<WriteCommitBehavior>,
+
    write_reference: Option<WriteReferenceBehavior>,
+
}
+

+
impl MockRepository {
+
    pub fn new() -> MockRepository {
+
        MockRepository {
+
            blobs: HashMap::new(),
+
            references: HashMap::new(),
+
            write_tree: None,
+
            write_commit: None,
+
            write_reference: None,
+
        }
+
    }
+

+
    pub fn with_rad_sigrefs(mut self, namespace: &NodeId, commit: Oid) -> MockRepository {
+
        self.references
+
            .insert(sigrefs_ref_name(namespace), RefBehavior::Present(commit));
+
        self
+
    }
+

+
    pub fn with_missing_rad_sigrefs(mut self, namespace: &NodeId) -> MockRepository {
+
        self.references
+
            .insert(sigrefs_ref_name(namespace), RefBehavior::Missing);
+
        self
+
    }
+

+
    pub fn with_rad_sigrefs_error(mut self, namespace: &NodeId) -> MockRepository {
+
        self.references
+
            .insert(sigrefs_ref_name(namespace), RefBehavior::Error);
+
        self
+
    }
+

+
    pub fn with_refs(
+
        self,
+
        commit: Oid,
+
        refs: impl IntoIterator<Item = (git::fmt::RefString, Oid)>,
+
    ) -> MockRepository {
+
        let refs = Refs::from(refs.into_iter());
+
        self.with_blob(commit, Path::new(REFS_BLOB_PATH), refs.canonical())
+
    }
+

+
    pub fn with_refs_error(self, commit: Oid) -> MockRepository {
+
        self.with_blob_error(commit, Path::new(REFS_BLOB_PATH))
+
    }
+

+
    pub fn with_missing_refs(self, commit: Oid) -> MockRepository {
+
        self.with_missing_blob(commit, Path::new(REFS_BLOB_PATH))
+
    }
+

+
    pub fn with_invalid_refs(self, commit: Oid) -> MockRepository {
+
        self.with_blob(
+
            commit,
+
            Path::new(REFS_BLOB_PATH),
+
            b"NOT VALID REFS\n".to_vec(),
+
        )
+
    }
+

+
    pub fn with_signature(self, commit: Oid, sig_id: u8) -> MockRepository {
+
        self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), vec![sig_id; 64])
+
    }
+

+
    pub fn with_signature_error(self, commit: Oid) -> MockRepository {
+
        self.with_blob_error(commit, Path::new(SIGNATURE_BLOB_PATH))
+
    }
+

+
    pub fn with_missing_signature(self, commit: Oid) -> MockRepository {
+
        self.with_missing_blob(commit, Path::new(SIGNATURE_BLOB_PATH))
+
    }
+

+
    pub fn with_invalid_signature(self, commit: Oid) -> MockRepository {
+
        let bytes = vec![0u8; 1];
+
        assert!(crypto::Signature::from_str(std::str::from_utf8(&bytes).unwrap()).is_err());
+
        self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), bytes)
+
    }
+

+
    fn with_blob(mut self, commit: Oid, path: &Path, bytes: Vec<u8>) -> Self {
+
        self.blobs
+
            .insert((commit, path.to_path_buf()), BlobBehavior::Present(bytes));
+
        self
+
    }
+

+
    fn with_blob_error(mut self, commit: Oid, path: &Path) -> Self {
+
        self.blobs
+
            .insert((commit, path.to_path_buf()), BlobBehavior::Error);
+
        self
+
    }
+

+
    fn with_missing_blob(mut self, commit: Oid, path: &Path) -> Self {
+
        self.blobs
+
            .insert((commit, path.to_path_buf()), BlobBehavior::Missing);
+
        self
+
    }
+

+
    pub fn with_write_tree_error(mut self) -> Self {
+
        self.write_tree = Some(WriteTreeBehavior::Error);
+
        self
+
    }
+

+
    pub fn with_write_tree_ok(mut self, oid: Oid) -> Self {
+
        self.write_tree = Some(WriteTreeBehavior::Ok(oid));
+
        self
+
    }
+

+
    pub fn with_write_commit_ok(mut self, oid: Oid) -> Self {
+
        self.write_commit = Some(WriteCommitBehavior(oid));
+
        self
+
    }
+

+
    pub fn with_write_reference_ok(mut self) -> Self {
+
        self.write_reference = Some(WriteReferenceBehavior::Ok);
+
        self
+
    }
+

+
    pub fn with_write_reference_error(mut self) -> Self {
+
        self.write_reference = Some(WriteReferenceBehavior::Error);
+
        self
+
    }
+
}
+

+
impl object::Reader for MockRepository {
+
    fn read_commit(&self, _oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
+
        // HeadReader never calls read_commit; this is a no-op placeholder.
+
        Ok(None)
+
    }
+

+
    fn read_blob(
+
        &self,
+
        commit: &Oid,
+
        path: &Path,
+
    ) -> Result<Option<object::Blob>, object::error::ReadBlob> {
+
        let key = (*commit, path.to_path_buf());
+
        match self.blobs.get(&key) {
+
            Some(BlobBehavior::Present(bytes)) => Ok(Some(object::Blob {
+
                oid: *commit,
+
                bytes: bytes.clone(),
+
            })),
+
            Some(BlobBehavior::Missing) | None => Ok(None),
+
            Some(BlobBehavior::Error) => Err(object::error::ReadBlob::other(
+
                std::io::Error::other("mock blob error"),
+
            )),
+
        }
+
    }
+
}
+

+
impl reference::Reader for MockRepository {
+
    fn find_reference(
+
        &self,
+
        reference: &git::fmt::Namespaced,
+
    ) -> Result<Option<Oid>, reference::error::FindReference> {
+
        match self.references.get(reference.as_str()) {
+
            Some(RefBehavior::Present(oid)) => Ok(Some(*oid)),
+
            Some(RefBehavior::Missing) | None => Ok(None),
+
            Some(RefBehavior::Error) => Err(reference::error::FindReference::other(
+
                std::io::Error::other("mock reference error"),
+
            )),
+
        }
+
    }
+
}
+

+
impl object::Writer for MockRepository {
+
    fn write_tree(
+
        &self,
+
        _refs: object::RefsEntry,
+
        _signature: object::SignatureEntry,
+
    ) -> Result<Oid, object::error::WriteTree> {
+
        match &self.write_tree {
+
            Some(WriteTreeBehavior::Ok(oid)) => Ok(*oid),
+
            Some(WriteTreeBehavior::Error) | None => Err(object::error::WriteTree::write_error(
+
                std::io::Error::other("mock write_tree error"),
+
            )),
+
        }
+
    }
+

+
    fn write_commit(&self, _bytes: &[u8]) -> Result<Oid, object::error::WriteCommit> {
+
        match &self.write_commit {
+
            Some(WriteCommitBehavior(oid)) => Ok(*oid),
+
            None => Err(object::error::WriteCommit::other(std::io::Error::other(
+
                "mock write_commit error",
+
            ))),
+
        }
+
    }
+
}
+

+
impl reference::Writer for MockRepository {
+
    fn write_reference(
+
        &self,
+
        _reference: &git::fmt::Namespaced,
+
        _commit: Oid,
+
        _parent: Option<Oid>,
+
        _reflog: String,
+
    ) -> Result<(), reference::error::WriteReference> {
+
        match &self.write_reference {
+
            Some(WriteReferenceBehavior::Ok) => Ok(()),
+
            Some(WriteReferenceBehavior::Error) | None => {
+
                Err(reference::error::WriteReference::other(
+
                    std::io::Error::other("mock write_reference error"),
+
                ))
+
            }
+
        }
+
    }
+
}
+

+
/// Always signs successfully, returning a fixed 64-byte signature.
+
pub struct AlwaysSign;
+

+
impl AlwaysSign {
+
    const SIGNATURE: [u8; 64] = [1u8; 64];
+

+
    pub fn signature() -> crypto::Signature {
+
        crypto::Signature::from(Self::SIGNATURE)
+
    }
+
}
+

+
impl crypto::signature::Signer<crypto::Signature> for AlwaysSign {
+
    fn try_sign(&self, _msg: &[u8]) -> Result<crypto::Signature, crypto::signature::Error> {
+
        Ok(Self::signature())
+
    }
+
}
+

+
/// Always fails to sign.
+
pub struct NeverSign;
+

+
impl crypto::signature::Signer<crypto::Signature> for NeverSign {
+
    fn try_sign(&self, _msg: &[u8]) -> Result<crypto::Signature, crypto::signature::Error> {
+
        Err(crypto::signature::Error::new())
+
    }
+
}
+

+
/// Construct an [`Oid`] from a single repeated byte.
+
///
+
/// `oid(1) != oid(2)` is guaranteed; use distinct values for distinct objects.
+
pub fn oid(n: u8) -> Oid {
+
    Oid::from_sha1([n; 20])
+
}
+

+
/// A fixed [`NodeId`] for tests.
+
pub fn node_id() -> NodeId {
+
    NodeId::from([1u8; 32])
+
}
+

+
pub fn refs_heads_main() -> git::fmt::RefString {
+
    git::fmt::refname!("refs/heads/main")
+
}
+

+
/// A minimal [`radicle_git_metadata::author::Author`] for use in tests.
+
pub fn author() -> Author {
+
    Author {
+
        name: "test".to_owned(),
+
        email: "test@example.com".to_owned(),
+
        time: Time::new(0, 0),
+
    }
+
}
+

+
fn sigrefs_ref_name(namespace: &NodeId) -> String {
+
    SIGREFS_BRANCH
+
        .with_namespace(git::fmt::Component::from(namespace))
+
        .as_str()
+
        .to_owned()
+
}
added crates/radicle/src/storage/refs/sigrefs/write/test/signed_refs_writer.rs
@@ -0,0 +1,171 @@
+
use radicle_oid::Oid;
+

+
use crate::storage::refs::sigrefs::git::Committer;
+
use crate::storage::refs::sigrefs::write::{error, SignedRefsWriter, Update};
+
use crate::storage::refs::{Refs, IDENTITY_ROOT, SIGREFS_BRANCH};
+

+
use super::mock;
+
use super::mock::MockRepository;
+

+
fn some_refs(identity_root: Oid) -> Refs {
+
    Refs::from(
+
        [
+
            (mock::refs_heads_main(), mock::oid(10)),
+
            (IDENTITY_ROOT.to_ref_string(), identity_root),
+
        ]
+
        .into_iter(),
+
    )
+
}
+

+
fn other_refs() -> Refs {
+
    Refs::from([(mock::refs_heads_main(), mock::oid(20))].into_iter())
+
}
+

+
fn refs_with_rad_sigrefs() -> Refs {
+
    Refs::from(
+
        [
+
            (mock::refs_heads_main(), mock::oid(10)),
+
            (IDENTITY_ROOT.to_ref_string(), mock::oid(99)),
+
            (SIGREFS_BRANCH.to_ref_string(), mock::oid(20)),
+
        ]
+
        .into_iter(),
+
    )
+
}
+

+
fn write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> {
+
    SignedRefsWriter::new(refs, mock::node_id(), repo, &mock::AlwaysSign).write(
+
        Committer::new(mock::author()),
+
        "msg".into(),
+
        "reflog".into(),
+
    )
+
}
+

+
#[test]
+
fn head_error() {
+
    let repo = MockRepository::new().with_rad_sigrefs_error(&mock::node_id());
+
    assert!(matches!(
+
        write(some_refs(mock::oid(99)), &repo),
+
        Err(error::Write::Head(_))
+
    ));
+
}
+

+
#[test]
+
fn unchanged() {
+
    let head = mock::oid(1);
+
    let refs = some_refs(mock::oid(99));
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, refs.clone())
+
        .with_signature(head, 1);
+
    assert_eq!(
+
        write(refs.clone(), &repo).unwrap(),
+
        Update::Unchanged {
+
            commit: head,
+
            refs,
+
            signature: crypto::Signature::from([1; 64]),
+
        }
+
    );
+
}
+

+
#[test]
+
fn commit_error() {
+
    let repo = MockRepository::new()
+
        .with_missing_rad_sigrefs(&mock::node_id())
+
        .with_write_tree_ok(mock::oid(99));
+
    assert!(matches!(
+
        write(some_refs(mock::oid(99)), &repo),
+
        Err(error::Write::Commit(_))
+
    ));
+
}
+

+
#[test]
+
fn reference_error() {
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_missing_rad_sigrefs(&mock::node_id())
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid)
+
        .with_write_reference_error();
+
    assert!(matches!(
+
        write(some_refs(mock::oid(99)), &repo),
+
        Err(error::Write::Reference(_))
+
    ));
+
}
+

+
#[test]
+
fn write_root_ok() {
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_missing_rad_sigrefs(&mock::node_id())
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid)
+
        .with_write_reference_ok();
+
    let refs = some_refs(mock::oid(99));
+
    let update = write(refs.clone(), &repo).unwrap();
+
    let Update::Changed { entry } = update else {
+
        panic!("expected Update::Changed, got {update:?}");
+
    };
+
    assert_eq!(entry.parent, None);
+
    assert_eq!(entry.oid, commit_oid);
+
    assert_eq!(entry.into_refs(), refs);
+
}
+

+
#[test]
+
fn write_with_parent_ok() {
+
    let head = mock::oid(1);
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_rad_sigrefs(&mock::node_id(), head)
+
        .with_refs(head, other_refs())
+
        .with_signature(head, 1)
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid)
+
        .with_write_reference_ok();
+
    let refs = some_refs(mock::oid(99));
+
    let update = write(refs.clone(), &repo).unwrap();
+
    let Update::Changed { entry } = update else {
+
        panic!("expected Update::Changed, got {update:?}");
+
    };
+
    assert_eq!(entry.parent, Some(head));
+
    assert_eq!(entry.oid, commit_oid);
+
    assert_eq!(entry.into_refs(), refs);
+
}
+

+
// TODO: We should error on empty `Refs` writes
+
#[test]
+
fn write_empty_refs() {
+
    let refs = Refs::from([(IDENTITY_ROOT.to_ref_string(), mock::oid(99))].into_iter());
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_missing_rad_sigrefs(&mock::node_id())
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid)
+
        .with_write_reference_ok();
+
    let update = write(refs.clone(), &repo).unwrap();
+
    let Update::Changed { entry } = update else {
+
        panic!("expected Update::Changed, got {update:?}");
+
    };
+
    assert_eq!(entry.parent, None);
+
    assert_eq!(entry.oid, commit_oid);
+
    assert_eq!(entry.into_refs(), refs);
+
}
+

+
#[test]
+
fn never_write_rad_sigrefs() {
+
    let commit_oid = mock::oid(42);
+
    let repo = MockRepository::new()
+
        .with_missing_rad_sigrefs(&mock::node_id())
+
        .with_write_tree_ok(mock::oid(99))
+
        .with_write_commit_ok(commit_oid)
+
        .with_write_reference_ok();
+
    let mut refs = refs_with_rad_sigrefs();
+
    let update = write(refs.clone(), &repo).unwrap();
+
    let Update::Changed { entry } = update else {
+
        panic!("expected Update::Changed, got {update:?}");
+
    };
+
    assert_eq!(entry.parent, None);
+
    assert_eq!(entry.oid, commit_oid);
+

+
    refs.remove_sigrefs();
+
    assert_eq!(entry.into_refs(), refs);
+
}
added crates/radicle/src/storage/refs/sigrefs/write/test/tree_writer.rs
@@ -0,0 +1,41 @@
+
use super::mock;
+
use super::mock::MockRepository;
+
use crate::storage::refs::sigrefs::write::{error, Tree, TreeWriter};
+
use crate::storage::refs::Refs;
+

+
fn mock_refs() -> Refs {
+
    Refs::from([(mock::refs_heads_main(), mock::oid(10))].into_iter())
+
}
+

+
#[test]
+
fn sign_error() {
+
    // `NeverSign` fails before `write_tree` is ever called, so the
+
    // repository needs no configuration.
+
    let result = TreeWriter::new(mock_refs(), &MockRepository::new(), &mock::NeverSign).write();
+
    assert!(matches!(result, Err(error::Tree::Sign(_))));
+
}
+

+
#[test]
+
fn write_tree_error() {
+
    let repo = MockRepository::new().with_write_tree_error();
+
    let result = TreeWriter::new(mock_refs(), &repo, &mock::AlwaysSign).write();
+
    assert!(matches!(result, Err(error::Tree::Write(_))));
+
}
+

+
#[test]
+
fn write_ok() {
+
    let refs = mock_refs();
+
    let expected_oid = mock::oid(1);
+
    let repo = MockRepository::new().with_write_tree_ok(expected_oid);
+
    let tree = TreeWriter::new(refs.clone(), &repo, &mock::AlwaysSign)
+
        .write()
+
        .unwrap();
+
    assert_eq!(
+
        tree,
+
        Tree {
+
            oid: expected_oid,
+
            refs,
+
            signature: mock::AlwaysSign::signature(),
+
        }
+
    );
+
}