radicle/sigrefs: Rewrite Signed References

Split up signed references into its read and write components.

On the write side:
 - Preserve the old behavior of writing references to the blob `/refs`
   and sign over the blob.
 - Ensure `refs/rad/root` is contained in the `/refs` blob.
 - Ensure `refs/rad/sigrefs` is *not* contained in the `/refs` blob.
 - Introduce a new (internal) reference `refs/rad/sigrefs-parent`
   so that no two `/refs` blob are equal, even if they contain
   the same set of (non-internal) refs.

On the read side:
 - Preserve the verification of the signature in `/signature` and
   the reference `refs/rad/root` (if present).
 - Fail verification of `refs/rad/root` is not present.
 - Protect against replay attacks by walking the history of the
   head of `refs/rad/sigrefs`, skipping interpretation of `/refs`
   blobs in case they are identical to a previous `/refs` blob.
   This is achieved by searching for repeated contents of the
   `/signature` blob.

The reference `refs/rad/sigrefs-parent` is never read from or written to
the Git repository in storage.

The pre-existing implementation of signed references did not include
a nonce, thus duplicate but legitimate sets of references could not
be distinguished from maliciously replayed sets of references.

The new implementation uses `radicle-git-metadata` which is moved from
`dev-dependencies` to `dependencies`.
This commit is contained in:
Fintan Halpenny 2026-02-24 15:16:36 +00:00 committed by Lorenz Leutgeb
parent 52a660fd3b
commit d3bc868e84
33 changed files with 3962 additions and 2 deletions

View File

@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Release Highlights ## 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 ## New Features
- The block policy for `NodeId`'s is used for limiting the namespaces fetched - The block policy for `NodeId`'s is used for limiting the namespaces fetched

View File

@ -46,6 +46,7 @@ radicle-cob = { workspace = true, features = ["git2"] }
radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] } radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] }
radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] } radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] }
radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] } radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] }
radicle-git-metadata = { workspace = true }
radicle-localtime = { workspace = true, features = ["serde"] } radicle-localtime = { workspace = true, features = ["serde"] }
radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] } radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] }
radicle-ssh = { workspace = true } radicle-ssh = { workspace = true }
@ -74,5 +75,4 @@ qcheck-macros = { workspace = true }
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] } radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
radicle-core = {workspace = true, features = ["qcheck"] } radicle-core = {workspace = true, features = ["qcheck"] }
radicle-crypto = { workspace = true, features = ["test"] } radicle-crypto = { workspace = true, features = ["test"] }
radicle-git-metadata = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@ -239,6 +239,14 @@ pub mod refs {
Qualified::from_components(component!("rad"), component!("sigrefs"), None) 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. /// The set of special references used in the Heartwood protocol.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Special { pub enum Special {

View File

@ -753,7 +753,7 @@ mod tests {
use crate::identity::Visibility; use crate::identity::Visibility;
use crate::node::device::Device; use crate::node::device::Device;
use crate::rad; 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::storage::{git::transport, ReadStorage};
use crate::test::{arbitrary, fixtures}; use crate::test::{arbitrary, fixtures};
use crate::Storage; use crate::Storage;
@ -1230,6 +1230,7 @@ mod tests {
fn test_special_branches() { fn test_special_branches() {
assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err()); assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err());
assert!(Pattern::try_from((*SIGREFS_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()); assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err());
} }
} }

View File

@ -1,3 +1,5 @@
pub mod sigrefs;
#[cfg(any(test, feature = "test"))] #[cfg(any(test, feature = "test"))]
pub mod arbitrary; pub mod arbitrary;
@ -191,6 +193,21 @@ impl Refs {
pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> { pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> {
self.0.remove(&SIGREFS_BRANCH.to_ref_string()) 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 { impl IntoIterator for Refs {

View File

@ -12,6 +12,12 @@ where
{ {
let mut refs = Refs::arbitrary(g); let mut refs = Refs::arbitrary(g);
refs.insert(IDENTITY_ROOT.to_ref_string(), root); 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 signature = crypto::signature::Signer::sign(signer, &refs.canonical());
let sigrefs = SignedRefs { let sigrefs = SignedRefs {
refs, refs,

View File

@ -0,0 +1,9 @@
pub mod read;
pub use read::{SignedRefsReader, VerifiedCommit};
pub mod write;
pub mod git;
#[cfg(test)]
mod property;

View File

@ -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(())
}
}
}

View File

@ -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>;
}

View File

@ -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),
}
}
}

View File

@ -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)
}

View File

@ -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>;
}

View File

@ -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))
}
}

View File

@ -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}")),
}
}

View File

@ -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()
}
}

View File

@ -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))
}
}

View File

@ -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,
}

View File

@ -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)),
}
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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)
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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,
})
}
}

View File

@ -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 },
}

View File

@ -0,0 +1,8 @@
#![allow(clippy::unwrap_used)]
mod mock;
mod commit_writer;
mod head_reader;
mod signed_refs_writer;
mod tree_writer;

View File

@ -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);
}

View File

@ -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]),
})
);
}

View File

@ -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()
}

View File

@ -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);
}

View File

@ -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(),
}
);
}