From d3bc868e84c334f113806df1737f52cc57c5453d Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Tue, 24 Feb 2026 15:16:36 +0000 Subject: [PATCH] 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`. --- CHANGELOG.md | 13 + crates/radicle/Cargo.toml | 2 +- crates/radicle/src/git.rs | 8 + crates/radicle/src/git/canonical/rules.rs | 3 +- crates/radicle/src/storage/refs.rs | 17 + crates/radicle/src/storage/refs/arbitrary.rs | 6 + crates/radicle/src/storage/refs/sigrefs.rs | 9 + .../radicle/src/storage/refs/sigrefs/git.rs | 256 ++++++++++ .../src/storage/refs/sigrefs/git/object.rs | 102 ++++ .../storage/refs/sigrefs/git/object/error.rs | 146 ++++++ .../storage/refs/sigrefs/git/properties.rs | 180 +++++++ .../src/storage/refs/sigrefs/git/reference.rs | 51 ++ .../refs/sigrefs/git/reference/error.rs | 29 ++ .../src/storage/refs/sigrefs/property.rs | 90 ++++ .../src/storage/refs/sigrefs/property/mock.rs | 132 +++++ .../radicle/src/storage/refs/sigrefs/read.rs | 459 ++++++++++++++++++ .../src/storage/refs/sigrefs/read/error.rs | 138 ++++++ .../src/storage/refs/sigrefs/read/iter.rs | 37 ++ .../src/storage/refs/sigrefs/read/test.rs | 9 + .../refs/sigrefs/read/test/commit_reader.rs | 108 +++++ .../sigrefs/read/test/identity_root_reader.rs | 103 ++++ .../storage/refs/sigrefs/read/test/mock.rs | 335 +++++++++++++ .../refs/sigrefs/read/test/resolve_tip.rs | 67 +++ .../sigrefs/read/test/signed_refs_reader.rs | 315 ++++++++++++ .../refs/sigrefs/read/test/tree_reader.rs | 148 ++++++ .../radicle/src/storage/refs/sigrefs/write.rs | 396 +++++++++++++++ .../src/storage/refs/sigrefs/write/error.rs | 58 +++ .../src/storage/refs/sigrefs/write/test.rs | 8 + .../refs/sigrefs/write/test/commit_writer.rs | 115 +++++ .../refs/sigrefs/write/test/head_reader.rs | 101 ++++ .../storage/refs/sigrefs/write/test/mock.rs | 311 ++++++++++++ .../sigrefs/write/test/signed_refs_writer.rs | 171 +++++++ .../refs/sigrefs/write/test/tree_writer.rs | 41 ++ 33 files changed, 3962 insertions(+), 2 deletions(-) create mode 100644 crates/radicle/src/storage/refs/sigrefs.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git/object.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git/object/error.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git/properties.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git/reference.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/git/reference/error.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/property.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/property/mock.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/error.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/iter.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/commit_reader.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/identity_root_reader.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/mock.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/resolve_tip.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/read/test/tree_reader.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/error.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test/commit_writer.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test/head_reader.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test/mock.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test/signed_refs_writer.rs create mode 100644 crates/radicle/src/storage/refs/sigrefs/write/test/tree_writer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6906d01e..35b68c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Release Highlights +- The "Signed References" feature was reimplemented. The commits in + `refs/rad/sigrefs` will now only verify if they carry an appropriate value for + `refs/rad/root` in the associated `refs` blob. This reference was introduced + in commit `989edacd564fa658358f5ccfd08c243c5ebd8cda`, which was released via + version 1.1.0. + Also, the new reference `refs/rad/sigrefs-parent` is introduced. If present, + its target must match the parent commit. This is to prevent replay. It is + optional to maintain backwards compatibility, and might become mandatory + (like `refs/rad/root` does in this release) in the future. + Further, the new implementation detects replay of `refs` blobs. In order to do + so, it walks the history of `refs/rad/sigrefs` backwards to the root commit, + if `refs/rad/sigrefs-parent` is not set. + ## New Features - The block policy for `NodeId`'s is used for limiting the namespaces fetched diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml index dd32e771..2336c6d6 100644 --- a/crates/radicle/Cargo.toml +++ b/crates/radicle/Cargo.toml @@ -46,6 +46,7 @@ radicle-cob = { workspace = true, features = ["git2"] } radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] } radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] } radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] } +radicle-git-metadata = { workspace = true } radicle-localtime = { workspace = true, features = ["serde"] } radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] } radicle-ssh = { workspace = true } @@ -74,5 +75,4 @@ qcheck-macros = { workspace = true } radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] } radicle-core = {workspace = true, features = ["qcheck"] } radicle-crypto = { workspace = true, features = ["test"] } -radicle-git-metadata = { workspace = true } tempfile = { workspace = true } diff --git a/crates/radicle/src/git.rs b/crates/radicle/src/git.rs index 1c0e1fe4..0cfd2de1 100644 --- a/crates/radicle/src/git.rs +++ b/crates/radicle/src/git.rs @@ -239,6 +239,14 @@ pub mod refs { Qualified::from_components(component!("rad"), component!("sigrefs"), None) }); + /// A reference to the parent commit. + /// + /// `refs/rad/sigrefs-parent` + /// + pub static SIGREFS_PARENT: LazyLock = LazyLock::new(|| { + Qualified::from_components(component!("rad"), component!("sigrefs-parent"), None) + }); + /// The set of special references used in the Heartwood protocol. #[derive(Clone, Copy, Debug)] pub enum Special { diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index ee676b45..f4c50acb 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -753,7 +753,7 @@ mod tests { use crate::identity::Visibility; use crate::node::device::Device; use crate::rad; - use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH}; + use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH, SIGREFS_PARENT}; use crate::storage::{git::transport, ReadStorage}; use crate::test::{arbitrary, fixtures}; use crate::Storage; @@ -1230,6 +1230,7 @@ mod tests { fn test_special_branches() { assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err()); assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err()); + assert!(Pattern::try_from((*SIGREFS_PARENT).clone()).is_err()); assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err()); } } diff --git a/crates/radicle/src/storage/refs.rs b/crates/radicle/src/storage/refs.rs index 434efdf6..a18a6970 100644 --- a/crates/radicle/src/storage/refs.rs +++ b/crates/radicle/src/storage/refs.rs @@ -1,3 +1,5 @@ +pub mod sigrefs; + #[cfg(any(test, feature = "test"))] pub mod arbitrary; @@ -191,6 +193,21 @@ impl Refs { pub(super) fn remove_sigrefs(&mut self) -> Option { 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 { + 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 { + self.0.remove(&SIGREFS_PARENT.to_ref_string()) + } } impl IntoIterator for Refs { diff --git a/crates/radicle/src/storage/refs/arbitrary.rs b/crates/radicle/src/storage/refs/arbitrary.rs index 5fc690b3..a4a37be1 100644 --- a/crates/radicle/src/storage/refs/arbitrary.rs +++ b/crates/radicle/src/storage/refs/arbitrary.rs @@ -12,6 +12,12 @@ where { let mut refs = Refs::arbitrary(g); refs.insert(IDENTITY_ROOT.to_ref_string(), root); + + if bool::arbitrary(g) { + let parent = Oid::from_sha1(Arbitrary::arbitrary(g)); + refs.insert(SIGREFS_PARENT.to_ref_string(), parent); + } + let signature = crypto::signature::Signer::sign(signer, &refs.canonical()); let sigrefs = SignedRefs { refs, diff --git a/crates/radicle/src/storage/refs/sigrefs.rs b/crates/radicle/src/storage/refs/sigrefs.rs new file mode 100644 index 00000000..9a383204 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs.rs @@ -0,0 +1,9 @@ +pub mod read; +pub use read::{SignedRefsReader, VerifiedCommit}; + +pub mod write; + +pub mod git; + +#[cfg(test)] +mod property; diff --git a/crates/radicle/src/storage/refs/sigrefs/git.rs b/crates/radicle/src/storage/refs/sigrefs/git.rs new file mode 100644 index 00000000..74d6d2ac --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git.rs @@ -0,0 +1,256 @@ +//! The transparency log of Radicle signed references is encoded in the Git +//! commit graph. This module provides traits for interacting with a Git +//! repository to read and write data for the transparency log process. + +pub mod object; +pub mod reference; + +pub use git2_impls::committer; + +use crate::profile::env; +use crypto::PublicKey; +use radicle_git_metadata::author; +use radicle_git_metadata::author::Author; + +/// Convenience type that corresponds to an [`Author`]. +/// +/// If [`env::GIT_COMMITTER_DATE`] is set, then [`Committer::from_env`] can be +/// used to construct a stable [`Author`]. +/// +/// Otherwise, an [`Author`] can be provided via [`Committer::new`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Committer { + pub author: Author, +} + +impl Committer { + /// Construct a [`Committer`] using [`Committer::from_env`], if possible, + /// using `default` if not. + pub fn from_env_or_else(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 { + let s = env::var(env::GIT_COMMITTER_DATE).ok()?; + let Ok(timestamp) = s.trim().parse::() 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 { + 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>, 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, 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 { + 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 { + 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, 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, + 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(()) + } + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/git/object.rs b/crates/radicle/src/storage/refs/sigrefs/git/object.rs new file mode 100644 index 00000000..c567bdf3 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git/object.rs @@ -0,0 +1,102 @@ +//! Traits for interacting with Git objects, necessary for implementing Radicle +//! signed references. +// TODO(finto): I think these are more generally useful than just being used for +// signed references. They might be worth moving into a crate, +// `radicle-git-traits`, but for now they can live here. + +pub mod error; + +use std::path::{Path, PathBuf}; + +use radicle_oid::Oid; + +/// A Git blob object, returned by [`Reader::read_blob`]. +pub struct Blob { + /// The [`Oid`] of the Git blob. + pub oid: Oid, + /// The contents of the Git blob. + pub bytes: Vec, +} + +/// 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>, 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, 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, +} + +/// 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, +} + +/// 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; + + /// 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; +} diff --git a/crates/radicle/src/storage/refs/sigrefs/git/object/error.rs b/crates/radicle/src/storage/refs/sigrefs/git/object/error.rs new file mode 100644 index 00000000..583048d5 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git/object/error.rs @@ -0,0 +1,146 @@ +use std::path::PathBuf; + +use radicle_oid::Oid; +use thiserror::Error; + +type StdError = dyn std::error::Error + Send + Sync + 'static; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ReadCommit { + #[error(transparent)] + IncorrectObject(NotCommit), + #[error(transparent)] + Other(Box), +} + +impl ReadCommit { + pub fn incorrect_object_error(oid: Oid, kind: K) -> Self + where + K: ToString, + { + Self::IncorrectObject(NotCommit { + oid, + kind: kind.to_string(), + }) + } + + pub fn other(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), +} + +#[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(commit: Oid, path: PathBuf, kind: K) -> Self + where + K: ToString, + { + Self::IncorrectObject(NotBlob { + commit, + path, + kind: kind.to_string(), + }) + } + + pub fn other(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), + #[error("failed to write signature blob for signed references")] + Signature(Box), + #[error(transparent)] + Write(Box), +} + +impl WriteTree { + pub fn refs_error(err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Refs(Box::new(err)) + } + + pub fn signature_error(err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Signature(Box::new(err)) + } + + pub fn write_error(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, +} + +impl WriteCommit { + pub fn other(err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self { + source: Box::new(err), + } + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/git/properties.rs b/crates/radicle/src/storage/refs/sigrefs/git/properties.rs new file mode 100644 index 00000000..55fbba87 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git/properties.rs @@ -0,0 +1,180 @@ +#![allow(clippy::unwrap_used)] + +use crypto::{signature, test::signer::MockSigner, PublicKey, Signer as _}; +use qcheck::TestResult; +use qcheck_macros::quickcheck; +use radicle_core::{NodeId, RepoId}; +use radicle_git_metadata::author::{Author, Time}; +use radicle_oid::Oid; +use tempfile::TempDir; + +use crate::storage::refs::sigrefs::{ + read::{CheckpointReason, Latest, SignedRefsReader, Tip}, + write::{Committer, SignedRefsWriter, Update}, +}; +use crate::storage::refs::Refs; + +#[derive(Clone, Debug)] +struct BoundedVec(Vec); + +impl qcheck::Arbitrary for BoundedVec { + 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> { + 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 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) -> 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) +} diff --git a/crates/radicle/src/storage/refs/sigrefs/git/reference.rs b/crates/radicle/src/storage/refs/sigrefs/git/reference.rs new file mode 100644 index 00000000..87f61730 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git/reference.rs @@ -0,0 +1,51 @@ +//! Traits for interacting with Git references, necessary for implementing +//! Radicle signed references. +// TODO(finto): I think these are more generally useful than just being used for +// signed references. They might be worth moving into a crate, +// `radicle-git-traits`, but for now they can live here. + +pub mod error; + +use radicle_oid::Oid; + +use crate::git; + +/// Git reference reader, generally a Git repository, or its corresponding Reference +/// Database (Ref DB). +pub trait Reader { + /// Find the head [`Oid`] of the sigrefs reference for the given namespace. + /// + /// Returns `None` if the reference does not yet exist. + /// # Errors + /// + /// - [`error::FindReference`]: failed to write the Git reference. + fn find_reference( + &self, + reference: &git::fmt::Namespaced, + ) -> Result, 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, + reflog: String, + ) -> Result<(), error::WriteReference>; +} diff --git a/crates/radicle/src/storage/refs/sigrefs/git/reference/error.rs b/crates/radicle/src/storage/refs/sigrefs/git/reference/error.rs new file mode 100644 index 00000000..cd6af9fd --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/git/reference/error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +#[error(transparent)] +pub struct FindReference(Box); + +impl FindReference { + pub fn other(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); + +impl WriteReference { + pub fn other(err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self(Box::new(err)) + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/property.rs b/crates/radicle/src/storage/refs/sigrefs/property.rs new file mode 100644 index 00000000..715580b3 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/property.rs @@ -0,0 +1,90 @@ +#![allow(clippy::unwrap_used)] + +mod mock; +use mock::*; + +use crypto::test::signer::MockSigner; +use crypto::Signer as _; +use qcheck::TestResult; +use qcheck_macros::quickcheck; + +use crate::storage::refs::sigrefs::read::{SignedRefsReader, Tip}; +use crate::storage::refs::sigrefs::write::{SignedRefsWriter, Update}; +use crate::storage::refs::Refs; + +#[quickcheck] +fn roundtrip(BoundedVec(all_refs): BoundedVec) -> 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}")), + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/property/mock.rs b/crates/radicle/src/storage/refs/sigrefs/property/mock.rs new file mode 100644 index 00000000..f2c04590 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/property/mock.rs @@ -0,0 +1,132 @@ +use qcheck::{Arbitrary, Gen}; +use radicle_core::RepoId; +use radicle_git_metadata::author::{Author, Time}; +use radicle_oid::Oid; +use tempfile::TempDir; + +use crate::identity::doc; +use crate::storage::refs::sigrefs::git::Committer; +use crate::storage::refs::{Refs, IDENTITY_ROOT}; + +/// A `Vec` 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(pub Vec); + +impl BoundedVec { + const MAX_LEN: usize = 16; +} + +impl Arbitrary for BoundedVec { + 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> { + let inner: Vec> = 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/ + /// └─ (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() + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read.rs b/crates/radicle/src/storage/refs/sigrefs/read.rs new file mode 100644 index 00000000..54b178bc --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read.rs @@ -0,0 +1,459 @@ +pub mod error; + +mod iter; + +#[cfg(test)] +mod test; + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::Path; + +use crypto::signature; +use nonempty::NonEmpty; +use radicle_core::{NodeId, RepoId}; +use radicle_git_metadata::commit::CommitData; +use radicle_oid::Oid; + +use crate::git; +use crate::identity::doc; +use crate::storage::refs::sigrefs::git::{object, reference}; +use crate::storage::refs::{ + Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH, +}; + +/// A `rad/sigrefs` that has passed the following verification checks: +/// +/// - Has a valid `/signature` blob, which is verified by the signing key. +/// - Contains the `refs/rad/root` entry under `/refs`, which matches the +/// [`RepoId`] of the local repository. +/// - The `refs/rad/sigrefs-parent` entry matches the commit's parent, if the +/// entry exists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedCommit { + /// The commit that was verified. + commit: Commit, + /// Whether verification successfully found the correct + /// value for [`SIGREFS_PARENT`] in the refs of [`Self::commit`]. + parent: bool, +} + +impl VerifiedCommit { + /// The [`Oid`] of the commit. + pub fn id(&self) -> &Oid { + &self.commit.oid + } + + /// The [`crypto::Signature`] found in the tree of the commit. + pub fn signature(&self) -> &crypto::Signature { + &self.commit.signature + } + + /// The [`Refs`] found in the tree of the commit. + pub fn into_refs(self) -> Refs { + self.commit.refs + } + + /// The parent [`Oid`] of the commit, unless it is the root commit. + pub fn parent(&self) -> Option<&Oid> { + self.commit.parent.as_ref() + } +} + +/// A [`SignedRefsReader`] reads and verifies a commit chain for a `rad/sigrefs` +/// entry. +/// +/// To create a new reader, use [`SignedRefsReader::new`]. +/// +/// The construction expects: +/// - A [`RepoId`] which is the repository identifier of the Radicle repository. +/// - A [`Tip`] which describes where and how to start the verification. +/// - A `repository` which is the Git repository that is being used for the reading. +/// - A `verifier` which is the entity that verifies the cryptographic signatures. +pub struct SignedRefsReader<'a, R, V> { + rid: RepoId, + tip: Tip, + repository: &'a R, + verifier: &'a V, +} + +/// Describe where to start a [`SignedRefsReader`]'s commit chain. +pub enum Tip { + /// Use the namespace of the given [`NodeId`], resolving their `rad/sigrefs` + /// to its commit [`Oid`]. + Reference(NodeId), + /// Use the supplied commit [`Oid`]. + Commit(Oid), +} + +impl<'a, R, V> SignedRefsReader<'a, R, V> +where + R: object::Reader + reference::Reader, + V: signature::Verifier, +{ + /// 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 { + 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::>::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 { + 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, + refs: Refs, + signature: crypto::Signature, + identity_root: Option, +} + +impl Commit { + fn verify(mut self, expected: RepoId, verifier: &V) -> Result + where + V: signature::Verifier, + { + 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 { + 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, 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) -> Option> { + 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 { + 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, 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 { + 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)) + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/error.rs b/crates/radicle/src/storage/refs/sigrefs/read/error.rs new file mode 100644 index 00000000..ef0e0bd0 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/error.rs @@ -0,0 +1,138 @@ +use std::fmt; +use std::path::PathBuf; + +use radicle_core::{NodeId, RepoId}; +use radicle_git_metadata::commit; +use radicle_oid::Oid; +use thiserror::Error; + +use crate::storage::refs::canonical; +use crate::storage::refs::sigrefs::git::{object, reference}; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Read { + #[error(transparent)] + Commit(Commit), + #[error(transparent)] + FindReference(reference::error::FindReference), + #[error("failed to find `refs/namespaces/{namespace}/refs/rad/sigrefs`")] + MissingSigrefs { namespace: NodeId }, + #[error(transparent)] + Verify(Verify), + #[error("failed to find a valid set of signed references starting from {head}")] + NoValidCommit { head: Oid }, +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Commit { + #[error(transparent)] + Tree(Tree), + #[error(transparent)] + IdentityRoot(IdentityRoot), + #[error("missing commit '{oid}'")] + Missing { oid: Oid }, + #[error("invalid commit '{oid}': {source}")] + Parse { + oid: Oid, + source: commit::ParseError, + }, + #[error(transparent)] + TooManyParents(Parent), + #[error(transparent)] + Read(object::error::ReadCommit), +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub struct Parent { + pub(crate) parents: Vec, +} + +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::>() + .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, +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/iter.rs b/crates/radicle/src/storage/refs/sigrefs/read/iter.rs new file mode 100644 index 00000000..61decc26 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/iter.rs @@ -0,0 +1,37 @@ +use radicle_oid::Oid; + +use crate::storage::refs::sigrefs::git::object; + +use super::{error, Commit, CommitReader}; + +pub(super) struct Walk<'a, R> { + repository: &'a R, + cursor: Option, +} + +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; + + fn next(&mut self) -> Option { + 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)), + } + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test.rs b/crates/radicle/src/storage/refs/sigrefs/read/test.rs new file mode 100644 index 00000000..49872d2c --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test.rs @@ -0,0 +1,9 @@ +#![allow(clippy::unwrap_used)] + +mod mock; + +mod commit_reader; +mod identity_root_reader; +mod resolve_tip; +mod signed_refs_reader; +mod tree_reader; diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/commit_reader.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/commit_reader.rs new file mode 100644 index 00000000..623f16ae --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/commit_reader.rs @@ -0,0 +1,108 @@ +use radicle_oid::Oid; + +use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip}; +use crate::storage::refs::sigrefs::VerifiedCommit; +use crate::storage::refs::IDENTITY_ROOT; + +use super::mock; +use super::mock::{AlwaysVerify, MockRepository}; + +fn read_at(tip: Oid, repo: MockRepository) -> Result { + 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); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/identity_root_reader.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/identity_root_reader.rs new file mode 100644 index 00000000..b2098c1d --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/identity_root_reader.rs @@ -0,0 +1,103 @@ +use radicle_core::RepoId; +use radicle_oid::Oid; + +use crate::git; +use crate::storage::refs::sigrefs::read::{ + error, IdentityRoot, IdentityRootReader, SignedRefsReader, Tip, +}; +use crate::storage::refs::sigrefs::VerifiedCommit; +use crate::storage::refs::{Refs, IDENTITY_ROOT}; + +use super::mock; +use super::mock::{AlwaysVerify, MockRepository}; + +fn refs_with_identity(oid: Oid) -> [(git::fmt::RefString, Oid); 2] { + [ + (mock::refs_heads_main(), mock::oid(10)), + (IDENTITY_ROOT.to_ref_string(), oid), + ] +} + +fn read_at(tip: Oid, repo: MockRepository) -> Result { + 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); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/mock.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/mock.rs new file mode 100644 index 00000000..5424bef3 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/mock.rs @@ -0,0 +1,335 @@ +//! Mock implementations of [`object::Reader`] and [`reference::Reader`] for +//! unit-testing. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use radicle_core::NodeId; +use radicle_git_metadata::author::{Author, Time}; +use radicle_git_metadata::commit::headers::Headers; +use radicle_git_metadata::commit::trailers::OwnedTrailer; +use radicle_git_metadata::commit::CommitData; +use radicle_oid::Oid; + +use crate::git; +use crate::identity::doc; +use crate::storage::refs::sigrefs::git::{object, reference}; +use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH}; + +pub(crate) const MOCKED_IDENTITY: u8 = 99u8; + +/// A configurable in-memory repository implementing [`object::Reader`] and +/// [`reference::Reader`]. +/// All behaviour is set at construction time via the builder methods; the mock +/// is fully deterministic. +pub struct MockRepository { + commits: HashMap, + blobs: HashMap<(Oid, PathBuf), BlobBehavior>, + references: HashMap, +} + +enum CommitBehavior { + /// [`object::Reader::read_commit`] returns `Ok(Some(bytes))`. + Present(Box>), + /// [`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), + /// [`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) -> 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, + ) -> 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

(mut self, commit: Oid, path: &P, bytes: Vec) -> Self + where + P: AsRef, + { + 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

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

(mut self, commit: Oid, path: &P) -> Self + where + P: AsRef, + { + 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>, 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, 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, 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 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 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) -> CommitData { + 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 { + 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(chain: impl IntoIterator) -> MockRepository +where + I: IntoIterator, +{ + 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) -> Vec { + let refs = Refs::from(entries.into_iter()); + refs.canonical() +} + +fn identity_path() -> PathBuf { + Path::new("embeds").join(*doc::PATH) +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/resolve_tip.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/resolve_tip.rs new file mode 100644 index 00000000..df2569cb --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/resolve_tip.rs @@ -0,0 +1,67 @@ +use super::mock; +use super::mock::{AlwaysVerify, MockRepository}; +use crate::storage::refs::sigrefs::read::error; +use crate::storage::refs::sigrefs::read::Tip; +use crate::storage::refs::sigrefs::SignedRefsReader; +use crate::storage::refs::IDENTITY_ROOT; + +#[test] +fn missing_sigrefs() { + let namespace = mock::node_id(); + let repo = MockRepository::new().with_missing_rad_sigrefs(&namespace); + + let result = SignedRefsReader::new( + mock::rid(99), + Tip::Reference(namespace), + &repo, + &AlwaysVerify, + ) + .read(); + + assert!(matches!(result, Err(error::Read::MissingSigrefs { .. }))); +} + +#[test] +fn find_reference_error() { + let namespace = mock::node_id(); + let repo = MockRepository::new().with_rad_sigrefs_error(&namespace); + + let result = SignedRefsReader::new( + mock::rid(99), + Tip::Reference(namespace), + &repo, + &AlwaysVerify, + ) + .read(); + + assert!(matches!(result, Err(error::Read::FindReference(_)))); +} + +#[test] +fn resolve_tip_ok() { + let namespace = mock::node_id(); + let root = mock::oid(1); + let refs = [ + (mock::refs_heads_main(), mock::oid(10)), + ( + IDENTITY_ROOT.to_ref_string(), + mock::oid(mock::MOCKED_IDENTITY), + ), + ]; + + let repo = MockRepository::new() + .with_rad_sigrefs(&namespace, root) + .with_commit(root, mock::commit_data([])) + .with_refs(root, refs) + .with_signature(root, 1); + + let vc = SignedRefsReader::new( + mock::rid(99), + Tip::Reference(namespace), + &repo, + &AlwaysVerify, + ) + .read() + .unwrap(); + assert_eq!(*vc.id(), root); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs new file mode 100644 index 00000000..6e530b71 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs @@ -0,0 +1,315 @@ +use radicle_oid::Oid; + +use crate::storage::refs::sigrefs::read::error::{Read, Verify}; +use crate::storage::refs::sigrefs::read::{error, Commit, SignedRefsReader, Tip}; +use crate::storage::refs::sigrefs::VerifiedCommit; +use crate::storage::refs::{IDENTITY_ROOT, SIGREFS_PARENT}; +use crate::{assert_matches, git}; + +use super::mock; +use super::mock::{AlwaysVerify, MockRepository}; + +fn refs_without_parent(head_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> { + vec![ + (mock::refs_heads_main(), head_oid), + ( + IDENTITY_ROOT.to_ref_string(), + mock::oid(mock::MOCKED_IDENTITY), + ), + ] +} + +fn refs(head_oid: Oid, parent_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> { + vec![ + (mock::refs_heads_main(), head_oid), + ( + IDENTITY_ROOT.to_ref_string(), + mock::oid(mock::MOCKED_IDENTITY), + ), + (SIGREFS_PARENT.to_ref_string(), parent_oid), + ] +} + +fn read(tip: Oid, repo: MockRepository) -> Result { + 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); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/read/test/tree_reader.rs b/crates/radicle/src/storage/refs/sigrefs/read/test/tree_reader.rs new file mode 100644 index 00000000..44880303 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/read/test/tree_reader.rs @@ -0,0 +1,148 @@ +use radicle_oid::Oid; + +use crate::git; +use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip}; +use crate::storage::refs::sigrefs::VerifiedCommit; +use crate::storage::refs::{IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH}; + +use super::mock; +use super::mock::{AlwaysVerify, MockRepository}; + +fn refs_heads_main() -> [(git::fmt::RefString, Oid); 1] { + [(mock::refs_heads_main(), mock::oid(10))] +} + +fn read_at(tip: Oid, repo: MockRepository) -> Result { + 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); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write.rs b/crates/radicle/src/storage/refs/sigrefs/write.rs new file mode 100644 index 00000000..59ce3123 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write.rs @@ -0,0 +1,396 @@ +pub mod error; + +#[cfg(test)] +mod test; + +use std::path::Path; + +use crypto::signature::Signer; +use radicle_core::NodeId; +use radicle_git_metadata::author::Author; +use radicle_git_metadata::commit::{headers::Headers, trailers::OwnedTrailer, CommitData}; +use radicle_oid::Oid; + +use crate::git; +use crate::storage::refs::sigrefs::git::{object, reference, Committer}; +use crate::storage::refs::{ + Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH, SIGREFS_PARENT, +}; + +/// The result of calling [`SignedRefsWriter::write`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Update { + /// The new signed references commit was written to the Git repository. + Changed { entry: Box }, + /// The provided [`Refs`] were equal to the current [`Refs`], so the process + /// exited early. + Unchanged { + commit: Oid, + refs: Refs, + signature: crypto::Signature, + }, +} + +impl From for Update { + fn from(commit: Commit) -> Self { + Self::Changed { + entry: Box::new(commit), + } + } +} + +impl From 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, +{ + /// 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//refs/rad/sigrefs + /// ``` + pub fn write( + self, + committer: Committer, + message: String, + reflog: String, + ) -> Result { + 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, + /// 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, + author: Author, + message: String, + repository: &'a R, + signer: &'a S, +} + +impl<'a, R, S> CommitWriter<'a, R, S> +where + R: object::Writer, + S: Signer, +{ + 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 { + 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, +{ + fn new(refs: Refs, repository: &'a R, signer: &'a S) -> Self { + Self { + refs, + repository, + signer, + } + } + + fn write(self) -> Result { + 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, 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 { + let refs = self.refs(commit)?; + let signature = self.refs_signature(commit)?; + Ok(Head { + commit, + refs, + signature, + }) + } + + fn refs(&self, commit: Oid) -> Result { + 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 { + 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, + }) + } +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/error.rs b/crates/radicle/src/storage/refs/sigrefs/write/error.rs new file mode 100644 index 00000000..47128780 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/error.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +use radicle_oid::Oid; +use thiserror::Error; + +use crate::storage::refs::canonical; +use crate::storage::refs::sigrefs::git::{object, reference}; + +// TODO: use commit NID (and RID?) for traceability +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Write { + #[error(transparent)] + Head(Head), + #[error(transparent)] + Commit(Commit), + #[error(transparent)] + Reference(reference::error::WriteReference), +} + +// TODO: use commit OID for traceability +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Commit { + #[error(transparent)] + Tree(Tree), + #[error(transparent)] + Write(object::error::WriteCommit), +} + +// TODO: use commit OID for traceability +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Tree { + #[error("failed to sign references payload")] + Sign(crypto::signature::Error), + #[error(transparent)] + Write(object::error::WriteTree), +} + +// TODO: use commit OID for traceability +#[derive(Debug, Error)] +#[non_exhaustive] +#[error(transparent)] +pub enum Head { + #[error(transparent)] + Reference(reference::error::FindReference), + #[error(transparent)] + Blob(object::error::ReadBlob), + #[error(transparent)] + Refs(canonical::Error), + #[error("failed to parse refs signature in commit {commit}")] + Signature { commit: Oid, source: crypto::Error }, + #[error( + "could not find the references blob, within the commit '{commit}', under the path {path:?}" + )] + MissingPath { commit: Oid, path: PathBuf }, +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test.rs b/crates/radicle/src/storage/refs/sigrefs/write/test.rs new file mode 100644 index 00000000..f65969c4 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test.rs @@ -0,0 +1,8 @@ +#![allow(clippy::unwrap_used)] + +mod mock; + +mod commit_writer; +mod head_reader; +mod signed_refs_writer; +mod tree_writer; diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test/commit_writer.rs b/crates/radicle/src/storage/refs/sigrefs/write/test/commit_writer.rs new file mode 100644 index 00000000..51813a0d --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test/commit_writer.rs @@ -0,0 +1,115 @@ +use super::mock; +use super::mock::MockRepository; +use crate::storage::refs::sigrefs::write::{error, CommitWriter}; +use crate::storage::refs::{Refs, IDENTITY_ROOT}; + +fn mock_refs() -> Refs { + Refs::from( + [ + (mock::refs_heads_main(), mock::oid(10)), + (IDENTITY_ROOT.to_ref_string(), mock::oid(99)), + ] + .into_iter(), + ) +} + +#[test] +fn tree_error() { + // `NeverSign` causes the sign step inside `TreeWriter` to fail, which + // propagates as `error::Commit::Tree`. The repository needs no + // configuration because signing fails before `write_tree` is called. + let repo = MockRepository::new(); + let result = CommitWriter::root( + mock_refs(), + mock::author(), + "msg".into(), + &repo, + &mock::NeverSign, + ) + .write(); + assert!(matches!(result, Err(error::Commit::Tree(_)))); +} + +#[test] +fn write_commit_error() { + // `write_tree` succeeds; `write_commit` returns an error by default + // (no `with_write_commit_ok` configured). + let repo = MockRepository::new().with_write_tree_ok(mock::oid(99)); + let result = CommitWriter::root( + mock_refs(), + mock::author(), + "msg".into(), + &repo, + &mock::AlwaysSign, + ) + .write(); + assert!(matches!(result, Err(error::Commit::Write(_)))); +} + +#[test] +fn write_root_ok() { + let refs = mock_refs(); + let commit_oid = mock::oid(42); + let repo = MockRepository::new() + .with_write_tree_ok(mock::oid(99)) + .with_write_commit_ok(commit_oid); + let commit = CommitWriter::root( + refs.clone(), + mock::author(), + "msg".into(), + &repo, + &mock::AlwaysSign, + ) + .write() + .unwrap(); + assert_eq!(commit.parent, None); + assert_eq!(commit.oid, commit_oid); + assert_eq!(commit.signature, mock::AlwaysSign::signature()); + assert_eq!(commit.into_refs(), refs); +} + +#[test] +fn write_with_parent_ok() { + let refs = mock_refs(); + let parent_oid = mock::oid(1); + let commit_oid = mock::oid(42); + let repo = MockRepository::new() + .with_write_tree_ok(mock::oid(99)) + .with_write_commit_ok(commit_oid); + let commit = CommitWriter::with_parent( + refs.clone(), + parent_oid, + mock::author(), + "msg".into(), + &repo, + &mock::AlwaysSign, + ) + .write() + .unwrap(); + assert_eq!(commit.parent, Some(parent_oid)); + assert_eq!(commit.oid, commit_oid); + assert_eq!(commit.signature, mock::AlwaysSign::signature()); + assert_eq!(commit.into_refs(), refs); +} + +// TODO: We should error on empty `Refs` writes +#[test] +fn write_empty_refs() { + let refs = Refs::from([(IDENTITY_ROOT.to_ref_string(), mock::oid(99))].into_iter()); + let commit_oid = mock::oid(42); + let repo = MockRepository::new() + .with_write_tree_ok(mock::oid(99)) + .with_write_commit_ok(commit_oid); + let commit = CommitWriter::root( + refs.clone(), + mock::author(), + "msg".into(), + &repo, + &mock::AlwaysSign, + ) + .write() + .unwrap(); + assert_eq!(commit.parent, None); + assert_eq!(commit.oid, commit_oid); + assert_eq!(commit.into_refs(), refs); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test/head_reader.rs b/crates/radicle/src/storage/refs/sigrefs/write/test/head_reader.rs new file mode 100644 index 00000000..46c16590 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test/head_reader.rs @@ -0,0 +1,101 @@ +use radicle_git_ref_format::Component; + +use super::mock::{self, MockRepository}; +use crate::storage::refs::sigrefs::write::{error, Head, HeadReader}; +use crate::storage::refs::{Refs, SIGREFS_BRANCH}; + +/// Drive `HeadReader` directly via the sigrefs reference for `mock::node_id()`. +fn read(repo: &MockRepository) -> Result, 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]), + }) + ); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test/mock.rs b/crates/radicle/src/storage/refs/sigrefs/write/test/mock.rs new file mode 100644 index 00000000..02e3fef1 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test/mock.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr as _; + +use radicle_core::NodeId; +use radicle_git_metadata::author::{Author, Time}; +use radicle_oid::Oid; + +use crate::git; +use crate::storage::refs::sigrefs::git::{object, reference}; +use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH}; + +enum WriteTreeBehavior { + /// [`object::Writer::write_tree`] returns `Ok(oid)`. + Ok(Oid), + /// [`object::Writer::write_tree`] returns `Err(…)`. + Error, +} + +/// [`object::Writer::write_commit`] returns `Ok(oid)`. +struct WriteCommitBehavior(Oid); + +enum WriteReferenceBehavior { + /// [`reference::Writer::write_reference`] returns `Ok(())`. + Ok, + /// [`reference::Writer::write_reference`] returns `Err(…)`. + Error, +} + +enum BlobBehavior { + /// [`object::Reader::read_blob`] returns `Ok(Some(blob))`. + Present(Vec), + /// [`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, + write_tree: Option, + write_commit: Option, + write_reference: Option, +} + +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, + ) -> 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) -> 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>, 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, 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, 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 { + 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 { + 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, + _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 for AlwaysSign { + fn try_sign(&self, _msg: &[u8]) -> Result { + Ok(Self::signature()) + } +} + +/// Always fails to sign. +pub struct NeverSign; + +impl crypto::signature::Signer for NeverSign { + fn try_sign(&self, _msg: &[u8]) -> Result { + 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() +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test/signed_refs_writer.rs b/crates/radicle/src/storage/refs/sigrefs/write/test/signed_refs_writer.rs new file mode 100644 index 00000000..f60e3cf2 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test/signed_refs_writer.rs @@ -0,0 +1,171 @@ +use radicle_oid::Oid; + +use crate::storage::refs::sigrefs::git::Committer; +use crate::storage::refs::sigrefs::write::{error, SignedRefsWriter, Update}; +use crate::storage::refs::{Refs, IDENTITY_ROOT, SIGREFS_BRANCH}; + +use super::mock; +use super::mock::MockRepository; + +fn some_refs(identity_root: Oid) -> Refs { + Refs::from( + [ + (mock::refs_heads_main(), mock::oid(10)), + (IDENTITY_ROOT.to_ref_string(), identity_root), + ] + .into_iter(), + ) +} + +fn other_refs() -> Refs { + Refs::from([(mock::refs_heads_main(), mock::oid(20))].into_iter()) +} + +fn refs_with_rad_sigrefs() -> Refs { + Refs::from( + [ + (mock::refs_heads_main(), mock::oid(10)), + (IDENTITY_ROOT.to_ref_string(), mock::oid(99)), + (SIGREFS_BRANCH.to_ref_string(), mock::oid(20)), + ] + .into_iter(), + ) +} + +fn write(refs: Refs, repo: &MockRepository) -> Result { + 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); +} diff --git a/crates/radicle/src/storage/refs/sigrefs/write/test/tree_writer.rs b/crates/radicle/src/storage/refs/sigrefs/write/test/tree_writer.rs new file mode 100644 index 00000000..76db0071 --- /dev/null +++ b/crates/radicle/src/storage/refs/sigrefs/write/test/tree_writer.rs @@ -0,0 +1,41 @@ +use super::mock; +use super::mock::MockRepository; +use crate::storage::refs::sigrefs::write::{error, Tree, TreeWriter}; +use crate::storage::refs::Refs; + +fn mock_refs() -> Refs { + Refs::from([(mock::refs_heads_main(), mock::oid(10))].into_iter()) +} + +#[test] +fn sign_error() { + // `NeverSign` fails before `write_tree` is ever called, so the + // repository needs no configuration. + let result = TreeWriter::new(mock_refs(), &MockRepository::new(), &mock::NeverSign).write(); + assert!(matches!(result, Err(error::Tree::Sign(_)))); +} + +#[test] +fn write_tree_error() { + let repo = MockRepository::new().with_write_tree_error(); + let result = TreeWriter::new(mock_refs(), &repo, &mock::AlwaysSign).write(); + assert!(matches!(result, Err(error::Tree::Write(_)))); +} + +#[test] +fn write_ok() { + let refs = mock_refs(); + let expected_oid = mock::oid(1); + let repo = MockRepository::new().with_write_tree_ok(expected_oid); + let tree = TreeWriter::new(refs.clone(), &repo, &mock::AlwaysSign) + .write() + .unwrap(); + assert_eq!( + tree, + Tree { + oid: expected_oid, + refs, + signature: mock::AlwaysSign::signature(), + } + ); +}