radicle/sigrefs: Rewrite Signed References
Split up signed references into its read and write components. On the write side: - Preserve the old behavior of writing references to the blob `/refs` and sign over the blob. - Ensure `refs/rad/root` is contained in the `/refs` blob. - Ensure `refs/rad/sigrefs` is *not* contained in the `/refs` blob. - Introduce a new (internal) reference `refs/rad/sigrefs-parent` so that no two `/refs` blob are equal, even if they contain the same set of (non-internal) refs. On the read side: - Preserve the verification of the signature in `/signature` and the reference `refs/rad/root` (if present). - Fail verification of `refs/rad/root` is not present. - Protect against replay attacks by walking the history of the head of `refs/rad/sigrefs`, skipping interpretation of `/refs` blobs in case they are identical to a previous `/refs` blob. This is achieved by searching for repeated contents of the `/signature` blob. The reference `refs/rad/sigrefs-parent` is never read from or written to the Git repository in storage. The pre-existing implementation of signed references did not include a nonce, thus duplicate but legitimate sets of references could not be distinguished from maliciously replayed sets of references. The new implementation uses `radicle-git-metadata` which is moved from `dev-dependencies` to `dependencies`.
This commit is contained in:
parent
52a660fd3b
commit
d3bc868e84
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
- The "Signed References" feature was reimplemented. The commits in
|
||||||
|
`refs/rad/sigrefs` will now only verify if they carry an appropriate value for
|
||||||
|
`refs/rad/root` in the associated `refs` blob. This reference was introduced
|
||||||
|
in commit `989edacd564fa658358f5ccfd08c243c5ebd8cda`, which was released via
|
||||||
|
version 1.1.0.
|
||||||
|
Also, the new reference `refs/rad/sigrefs-parent` is introduced. If present,
|
||||||
|
its target must match the parent commit. This is to prevent replay. It is
|
||||||
|
optional to maintain backwards compatibility, and might become mandatory
|
||||||
|
(like `refs/rad/root` does in this release) in the future.
|
||||||
|
Further, the new implementation detects replay of `refs` blobs. In order to do
|
||||||
|
so, it walks the history of `refs/rad/sigrefs` backwards to the root commit,
|
||||||
|
if `refs/rad/sigrefs-parent` is not set.
|
||||||
|
|
||||||
## New Features
|
## New Features
|
||||||
|
|
||||||
- The block policy for `NodeId`'s is used for limiting the namespaces fetched
|
- The block policy for `NodeId`'s is used for limiting the namespaces fetched
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ radicle-cob = { workspace = true, features = ["git2"] }
|
||||||
radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] }
|
radicle-core = { workspace = true, features = ["git2", "serde", "sqlite"] }
|
||||||
radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] }
|
radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] }
|
||||||
radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] }
|
radicle-git-ref-format = { workspace = true, features = ["macro", "serde"] }
|
||||||
|
radicle-git-metadata = { workspace = true }
|
||||||
radicle-localtime = { workspace = true, features = ["serde"] }
|
radicle-localtime = { workspace = true, features = ["serde"] }
|
||||||
radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] }
|
radicle-oid = { workspace = true, features = ["git2", "serde", "std", "sha1"] }
|
||||||
radicle-ssh = { workspace = true }
|
radicle-ssh = { workspace = true }
|
||||||
|
|
@ -74,5 +75,4 @@ qcheck-macros = { workspace = true }
|
||||||
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
|
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
|
||||||
radicle-core = {workspace = true, features = ["qcheck"] }
|
radicle-core = {workspace = true, features = ["qcheck"] }
|
||||||
radicle-crypto = { workspace = true, features = ["test"] }
|
radicle-crypto = { workspace = true, features = ["test"] }
|
||||||
radicle-git-metadata = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,14 @@ pub mod refs {
|
||||||
Qualified::from_components(component!("rad"), component!("sigrefs"), None)
|
Qualified::from_components(component!("rad"), component!("sigrefs"), None)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// A reference to the parent commit.
|
||||||
|
///
|
||||||
|
/// `refs/rad/sigrefs-parent`
|
||||||
|
///
|
||||||
|
pub static SIGREFS_PARENT: LazyLock<Qualified> = LazyLock::new(|| {
|
||||||
|
Qualified::from_components(component!("rad"), component!("sigrefs-parent"), None)
|
||||||
|
});
|
||||||
|
|
||||||
/// The set of special references used in the Heartwood protocol.
|
/// The set of special references used in the Heartwood protocol.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub enum Special {
|
pub enum Special {
|
||||||
|
|
|
||||||
|
|
@ -753,7 +753,7 @@ mod tests {
|
||||||
use crate::identity::Visibility;
|
use crate::identity::Visibility;
|
||||||
use crate::node::device::Device;
|
use crate::node::device::Device;
|
||||||
use crate::rad;
|
use crate::rad;
|
||||||
use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH};
|
use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH, SIGREFS_PARENT};
|
||||||
use crate::storage::{git::transport, ReadStorage};
|
use crate::storage::{git::transport, ReadStorage};
|
||||||
use crate::test::{arbitrary, fixtures};
|
use crate::test::{arbitrary, fixtures};
|
||||||
use crate::Storage;
|
use crate::Storage;
|
||||||
|
|
@ -1230,6 +1230,7 @@ mod tests {
|
||||||
fn test_special_branches() {
|
fn test_special_branches() {
|
||||||
assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err());
|
assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err());
|
||||||
assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err());
|
assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err());
|
||||||
|
assert!(Pattern::try_from((*SIGREFS_PARENT).clone()).is_err());
|
||||||
assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err());
|
assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
pub mod sigrefs;
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test"))]
|
#[cfg(any(test, feature = "test"))]
|
||||||
pub mod arbitrary;
|
pub mod arbitrary;
|
||||||
|
|
||||||
|
|
@ -191,6 +193,21 @@ impl Refs {
|
||||||
pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> {
|
pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> {
|
||||||
self.0.remove(&SIGREFS_BRANCH.to_ref_string())
|
self.0.remove(&SIGREFS_BRANCH.to_ref_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a reference with name [`crate::git::refs::storage::SIGREFS_PARENT`]
|
||||||
|
/// and given target OID to this set of refs.
|
||||||
|
#[inline]
|
||||||
|
fn add_parent(&mut self, commit: Oid) -> Option<Oid> {
|
||||||
|
self.0.insert(SIGREFS_PARENT.to_ref_string(), commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes reference with name [`crate::git::refs::storage::SIGREFS_PARENT`]
|
||||||
|
/// from this set of refs, if it exists.
|
||||||
|
/// Absence of a reference with such name is ignored.
|
||||||
|
#[inline]
|
||||||
|
fn remove_parent(&mut self) -> Option<Oid> {
|
||||||
|
self.0.remove(&SIGREFS_PARENT.to_ref_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoIterator for Refs {
|
impl IntoIterator for Refs {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ where
|
||||||
{
|
{
|
||||||
let mut refs = Refs::arbitrary(g);
|
let mut refs = Refs::arbitrary(g);
|
||||||
refs.insert(IDENTITY_ROOT.to_ref_string(), root);
|
refs.insert(IDENTITY_ROOT.to_ref_string(), root);
|
||||||
|
|
||||||
|
if bool::arbitrary(g) {
|
||||||
|
let parent = Oid::from_sha1(Arbitrary::arbitrary(g));
|
||||||
|
refs.insert(SIGREFS_PARENT.to_ref_string(), parent);
|
||||||
|
}
|
||||||
|
|
||||||
let signature = crypto::signature::Signer::sign(signer, &refs.canonical());
|
let signature = crypto::signature::Signer::sign(signer, &refs.canonical());
|
||||||
let sigrefs = SignedRefs {
|
let sigrefs = SignedRefs {
|
||||||
refs,
|
refs,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
pub mod read;
|
||||||
|
pub use read::{SignedRefsReader, VerifiedCommit};
|
||||||
|
|
||||||
|
pub mod write;
|
||||||
|
|
||||||
|
pub mod git;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod property;
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
//! The transparency log of Radicle signed references is encoded in the Git
|
||||||
|
//! commit graph. This module provides traits for interacting with a Git
|
||||||
|
//! repository to read and write data for the transparency log process.
|
||||||
|
|
||||||
|
pub mod object;
|
||||||
|
pub mod reference;
|
||||||
|
|
||||||
|
pub use git2_impls::committer;
|
||||||
|
|
||||||
|
use crate::profile::env;
|
||||||
|
use crypto::PublicKey;
|
||||||
|
use radicle_git_metadata::author;
|
||||||
|
use radicle_git_metadata::author::Author;
|
||||||
|
|
||||||
|
/// Convenience type that corresponds to an [`Author`].
|
||||||
|
///
|
||||||
|
/// If [`env::GIT_COMMITTER_DATE`] is set, then [`Committer::from_env`] can be
|
||||||
|
/// used to construct a stable [`Author`].
|
||||||
|
///
|
||||||
|
/// Otherwise, an [`Author`] can be provided via [`Committer::new`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Committer {
|
||||||
|
pub author: Author,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Committer {
|
||||||
|
/// Construct a [`Committer`] using [`Committer::from_env`], if possible,
|
||||||
|
/// using `default` if not.
|
||||||
|
pub fn from_env_or_else<F>(public_key: &PublicKey, default: F) -> Self
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Author,
|
||||||
|
{
|
||||||
|
Self::from_env(public_key).unwrap_or_else(|| Self::new(default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a [`Committer`] with the provided [`Author`].
|
||||||
|
pub fn new(author: Author) -> Self {
|
||||||
|
Self { author }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a [`Committer`] using the timestamp found at
|
||||||
|
/// [`env::GIT_COMMITTER_DATE`], and the given [`PublicKey`] for the email.
|
||||||
|
pub fn from_env(public_key: &PublicKey) -> Option<Self> {
|
||||||
|
let s = env::var(env::GIT_COMMITTER_DATE).ok()?;
|
||||||
|
let Ok(timestamp) = s.trim().parse::<i64>() else {
|
||||||
|
panic!(
|
||||||
|
"Invalid timestamp value {s:?} for `{}`",
|
||||||
|
env::GIT_COMMITTER_DATE
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let time = author::Time::new(timestamp, 0);
|
||||||
|
let author = Author {
|
||||||
|
name: "radicle".to_string(),
|
||||||
|
email: public_key.to_human(),
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
Some(Self::new(author))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> Author {
|
||||||
|
self.author
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod git2_impls {
|
||||||
|
//! [`git2::Repository`] implementations of the [`object`] and [`reference`] traits.
|
||||||
|
//!
|
||||||
|
//! [`object`]: super::object
|
||||||
|
//! [`reference`]: super::reference
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use radicle_core::NodeId;
|
||||||
|
use radicle_git_metadata::author::{Author, Time};
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::object;
|
||||||
|
use super::object::{RefsEntry, SignatureEntry};
|
||||||
|
use super::reference;
|
||||||
|
use super::Committer;
|
||||||
|
|
||||||
|
pub fn committer(node: &NodeId, signature: &git2::Signature) -> Result<Committer, git2::Error> {
|
||||||
|
let default = {
|
||||||
|
let name = signature
|
||||||
|
.name()
|
||||||
|
.map(|name| name.to_string())
|
||||||
|
.ok_or(git2::Error::new(
|
||||||
|
git2::ErrorCode::Invalid,
|
||||||
|
git2::ErrorClass::Invalid,
|
||||||
|
"Invalid UTF-8 of Git signature name",
|
||||||
|
))?;
|
||||||
|
let email =
|
||||||
|
signature
|
||||||
|
.email()
|
||||||
|
.map(|email| email.to_string())
|
||||||
|
.ok_or(git2::Error::new(
|
||||||
|
git2::ErrorCode::Invalid,
|
||||||
|
git2::ErrorClass::Invalid,
|
||||||
|
"Invalid UTF-8 of Git signature email",
|
||||||
|
))?;
|
||||||
|
Author {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
time: Time::new(
|
||||||
|
signature.when().seconds(),
|
||||||
|
signature.when().offset_minutes(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Committer::from_env_or_else(node, || default))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl object::Reader for git2::Repository {
|
||||||
|
fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
|
||||||
|
use object::error::ReadCommit;
|
||||||
|
|
||||||
|
let odb = self.odb().map_err(ReadCommit::other)?;
|
||||||
|
let object = odb.read(git2::Oid::from(*oid));
|
||||||
|
match object {
|
||||||
|
Ok(object) => {
|
||||||
|
if object.kind() != git2::ObjectType::Commit {
|
||||||
|
return Err(ReadCommit::incorrect_object_error(*oid, object.kind()));
|
||||||
|
}
|
||||||
|
Ok(Some(object.data().to_vec()))
|
||||||
|
}
|
||||||
|
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(ReadCommit::other(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_blob(
|
||||||
|
&self,
|
||||||
|
oid: &Oid,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<Option<object::Blob>, object::error::ReadBlob> {
|
||||||
|
use object::error::ReadBlob;
|
||||||
|
|
||||||
|
let commit = match self.find_commit(git2::Oid::from(*oid)) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) if e.code() == git2::ErrorCode::NotFound => {
|
||||||
|
return Err(ReadBlob::commit_not_found_error(*oid))
|
||||||
|
}
|
||||||
|
Err(e) => return Err(ReadBlob::other(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tree = commit.tree().map_err(ReadBlob::other)?;
|
||||||
|
|
||||||
|
let entry = match tree.get_path(path) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(ReadBlob::other(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let object = entry.to_object(self).map_err(ReadBlob::other)?;
|
||||||
|
let blob = object.as_blob().ok_or(ReadBlob::incorrect_object_error(
|
||||||
|
*oid,
|
||||||
|
path.to_path_buf(),
|
||||||
|
object.kind().unwrap_or(git2::ObjectType::Any),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(Some(object::Blob {
|
||||||
|
oid: blob.id().into(),
|
||||||
|
bytes: blob.content().to_vec(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl object::Writer for git2::Repository {
|
||||||
|
fn write_tree(
|
||||||
|
&self,
|
||||||
|
refs: RefsEntry,
|
||||||
|
signature: SignatureEntry,
|
||||||
|
) -> Result<Oid, object::error::WriteTree> {
|
||||||
|
use object::error::WriteTree;
|
||||||
|
|
||||||
|
let odb = self.odb().map_err(WriteTree::write_error)?;
|
||||||
|
|
||||||
|
let refs_oid = odb
|
||||||
|
.write(git2::ObjectType::Blob, &refs.content)
|
||||||
|
.map_err(WriteTree::refs_error)?;
|
||||||
|
|
||||||
|
let sig_oid = odb
|
||||||
|
.write(git2::ObjectType::Blob, &signature.content)
|
||||||
|
.map_err(WriteTree::signature_error)?;
|
||||||
|
|
||||||
|
let mut builder = self.treebuilder(None).map_err(WriteTree::write_error)?;
|
||||||
|
|
||||||
|
builder
|
||||||
|
.insert(&refs.path, refs_oid, git2::FileMode::Blob.into())
|
||||||
|
.map_err(WriteTree::refs_error)?;
|
||||||
|
|
||||||
|
builder
|
||||||
|
.insert(&signature.path, sig_oid, git2::FileMode::Blob.into())
|
||||||
|
.map_err(WriteTree::signature_error)?;
|
||||||
|
|
||||||
|
let tree_oid = builder.write().map_err(WriteTree::write_error)?;
|
||||||
|
|
||||||
|
Ok(Oid::from(tree_oid))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_commit(&self, bytes: &[u8]) -> Result<Oid, object::error::WriteCommit> {
|
||||||
|
use object::error::WriteCommit;
|
||||||
|
|
||||||
|
let odb = self.odb().map_err(WriteCommit::other)?;
|
||||||
|
|
||||||
|
let oid = odb
|
||||||
|
.write(git2::ObjectType::Commit, bytes)
|
||||||
|
.map_err(WriteCommit::other)?;
|
||||||
|
|
||||||
|
Ok(Oid::from(oid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl reference::Reader for git2::Repository {
|
||||||
|
fn find_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
) -> Result<Option<Oid>, reference::error::FindReference> {
|
||||||
|
match self.refname_to_id(reference.as_str()) {
|
||||||
|
Ok(oid) => Ok(Some(Oid::from(oid))),
|
||||||
|
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(reference::error::FindReference::other(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl reference::Writer for git2::Repository {
|
||||||
|
fn write_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
commit: Oid,
|
||||||
|
parent: Option<Oid>,
|
||||||
|
reflog: String,
|
||||||
|
) -> Result<(), reference::error::WriteReference> {
|
||||||
|
let new = git2::Oid::from(commit);
|
||||||
|
|
||||||
|
match parent {
|
||||||
|
Some(parent) => {
|
||||||
|
let old = git2::Oid::from(parent);
|
||||||
|
// The old OID provides a guard, which gives us a compare-and-swap —
|
||||||
|
// the write will fail if the ref has moved since we read it.
|
||||||
|
self.reference_matching(reference.as_str(), new, true, old, &reflog)
|
||||||
|
.map_err(reference::error::WriteReference::other)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.reference(reference.as_str(), new, false, &reflog)
|
||||||
|
.map_err(reference::error::WriteReference::other)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! Traits for interacting with Git objects, necessary for implementing Radicle
|
||||||
|
//! signed references.
|
||||||
|
// TODO(finto): I think these are more generally useful than just being used for
|
||||||
|
// signed references. They might be worth moving into a crate,
|
||||||
|
// `radicle-git-traits`, but for now they can live here.
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
/// A Git blob object, returned by [`Reader::read_blob`].
|
||||||
|
pub struct Blob {
|
||||||
|
/// The [`Oid`] of the Git blob.
|
||||||
|
pub oid: Oid,
|
||||||
|
/// The contents of the Git blob.
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git object reader, generally a Git repository, or its corresponding Object
|
||||||
|
/// Database (ODB).
|
||||||
|
pub trait Reader {
|
||||||
|
/// Read the raw bytes of a commit object identified by `oid`.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no such object exists.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::ReadCommit::IncorrectObject`]: the object identified by the
|
||||||
|
/// [`Oid`] was found, but was not a commit.
|
||||||
|
/// - [`error::ReadCommit::Other`]: failed to read the Git commit.
|
||||||
|
fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, error::ReadCommit>;
|
||||||
|
|
||||||
|
/// Read the raw bytes of the blob at `path` within the tree of `commit`.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the path does not exist in that tree.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::ReadBlob::CommitNotFound`]: failed to find the commit
|
||||||
|
/// identified by the [`Oid`].
|
||||||
|
/// - [`error::ReadBlob::IncorrectObject`]: the object identified by the
|
||||||
|
/// [`Oid`] was found, but was not a commit.
|
||||||
|
/// - [`error::ReadBlob::Other`]: failed to read the Git blob.
|
||||||
|
fn read_blob(&self, commit: &Oid, path: &Path) -> Result<Option<Blob>, error::ReadBlob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input to the [`Writer::write_tree`] method.
|
||||||
|
///
|
||||||
|
/// The entry describes where in the Git tree to write the [`Refs`] content
|
||||||
|
/// blob.
|
||||||
|
///
|
||||||
|
/// [`Refs`]: crate::storage::refs::Refs
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RefsEntry {
|
||||||
|
/// Path in the Git tree to write to.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The contents of the Git blob.
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input to the [`Writer::write_tree`] method.
|
||||||
|
///
|
||||||
|
/// The entry describes where in the Git tree to write the signature content
|
||||||
|
/// blob.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SignatureEntry {
|
||||||
|
/// Path in the Git tree to write to.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The contents of the Git blob.
|
||||||
|
pub content: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git object writer, generally a Git repository, or its corresponding Object
|
||||||
|
/// Database (ODB).
|
||||||
|
pub trait Writer {
|
||||||
|
/// Write the [`RefsEntry`] and [`SignatureEntry`] to two separate Git blobs
|
||||||
|
/// within a shared Git tree.
|
||||||
|
///
|
||||||
|
/// Returns the [`Oid`] of the Git tree.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::WriteTree::Refs`]: failed to write the references Git blob.
|
||||||
|
/// - [`error::WriteTree::Signature`]: failed to write the signature Git blob.
|
||||||
|
/// - [`error::WriteTree::Write`]: failed to write the Git tree.
|
||||||
|
fn write_tree(
|
||||||
|
&self,
|
||||||
|
refs: RefsEntry,
|
||||||
|
signature: SignatureEntry,
|
||||||
|
) -> Result<Oid, error::WriteTree>;
|
||||||
|
|
||||||
|
/// Write the given Git commit, as bytes, to the Git object database.
|
||||||
|
///
|
||||||
|
/// Returns the [`Oid`] of the Git commit.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::WriteCommit`]: failed to write the Git commit.
|
||||||
|
fn write_commit(&self, bytes: &[u8]) -> Result<Oid, error::WriteCommit>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
type StdError = dyn std::error::Error + Send + Sync + 'static;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ReadCommit {
|
||||||
|
#[error(transparent)]
|
||||||
|
IncorrectObject(NotCommit),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(Box<StdError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadCommit {
|
||||||
|
pub fn incorrect_object_error<K>(oid: Oid, kind: K) -> Self
|
||||||
|
where
|
||||||
|
K: ToString,
|
||||||
|
{
|
||||||
|
Self::IncorrectObject(NotCommit {
|
||||||
|
oid,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn other<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::Other(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error("the object {oid} is a {kind}, not a commit")]
|
||||||
|
pub struct NotCommit {
|
||||||
|
oid: Oid,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error(transparent)]
|
||||||
|
pub enum ReadBlob {
|
||||||
|
#[error(transparent)]
|
||||||
|
CommitNotFound(CommitNotFound),
|
||||||
|
#[error(transparent)]
|
||||||
|
IncorrectObject(NotBlob),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(Box<StdError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error("could not find commit {oid}")]
|
||||||
|
pub struct CommitNotFound {
|
||||||
|
oid: Oid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error("the object at {path:?} in commit {commit} is a {kind}, not a blob")]
|
||||||
|
pub struct NotBlob {
|
||||||
|
commit: Oid,
|
||||||
|
path: PathBuf,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadBlob {
|
||||||
|
pub fn commit_not_found_error(oid: Oid) -> Self {
|
||||||
|
Self::CommitNotFound(CommitNotFound { oid })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incorrect_object_error<K>(commit: Oid, path: PathBuf, kind: K) -> Self
|
||||||
|
where
|
||||||
|
K: ToString,
|
||||||
|
{
|
||||||
|
Self::IncorrectObject(NotBlob {
|
||||||
|
commit,
|
||||||
|
path,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn other<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::Other(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum WriteTree {
|
||||||
|
#[error("failed to write reference blob for signed references")]
|
||||||
|
Refs(Box<StdError>),
|
||||||
|
#[error("failed to write signature blob for signed references")]
|
||||||
|
Signature(Box<StdError>),
|
||||||
|
#[error(transparent)]
|
||||||
|
Write(Box<StdError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WriteTree {
|
||||||
|
pub fn refs_error<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::Refs(Box::new(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn signature_error<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::Signature(Box::new(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_error<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::Write(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error(transparent)]
|
||||||
|
pub struct WriteCommit {
|
||||||
|
source: Box<StdError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WriteCommit {
|
||||||
|
pub fn other<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
source: Box::new(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use crypto::{signature, test::signer::MockSigner, PublicKey, Signer as _};
|
||||||
|
use qcheck::TestResult;
|
||||||
|
use qcheck_macros::quickcheck;
|
||||||
|
use radicle_core::{NodeId, RepoId};
|
||||||
|
use radicle_git_metadata::author::{Author, Time};
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::{
|
||||||
|
read::{CheckpointReason, Latest, SignedRefsReader, Tip},
|
||||||
|
write::{Committer, SignedRefsWriter, Update},
|
||||||
|
};
|
||||||
|
use crate::storage::refs::Refs;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct BoundedVec<T>(Vec<T>);
|
||||||
|
|
||||||
|
impl<T: qcheck::Arbitrary> qcheck::Arbitrary for BoundedVec<T> {
|
||||||
|
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
||||||
|
let size = usize::arbitrary(g) % 24;
|
||||||
|
BoundedVec((0..size).map(|_| T::arbitrary(g)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
|
||||||
|
Box::new(self.0.shrink().map(BoundedVec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Verifier {
|
||||||
|
key: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verifier {
|
||||||
|
fn new(signer: &MockSigner) -> Self {
|
||||||
|
Self {
|
||||||
|
key: *signer.public_key(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl signature::Verifier<crypto::Signature> for Verifier {
|
||||||
|
fn verify(&self, msg: &[u8], signature: &crypto::Signature) -> Result<(), signature::Error> {
|
||||||
|
self.key
|
||||||
|
.verify(msg, signature)
|
||||||
|
.map_err(signature::Error::from_source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_author() -> Author {
|
||||||
|
Author {
|
||||||
|
name: "testy".to_string(),
|
||||||
|
email: "testy@example.com".to_string(),
|
||||||
|
time: Time::new(6400, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_committer() -> Committer {
|
||||||
|
Committer::new(mock_author())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, git2::Repository) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let repo = git2::Repository::init_bare(dir.path()).unwrap();
|
||||||
|
(dir, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_root() -> RepoId {
|
||||||
|
RepoId::from(Oid::from_sha1([1; 20]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_log(
|
||||||
|
refs: Refs,
|
||||||
|
namespace: NodeId,
|
||||||
|
signer: &MockSigner,
|
||||||
|
repo: &git2::Repository,
|
||||||
|
) -> Update {
|
||||||
|
SignedRefsWriter::new(namespace, signer, repo)
|
||||||
|
.with_refs(refs)
|
||||||
|
.write(
|
||||||
|
mock_committer(),
|
||||||
|
"test commit".to_string(),
|
||||||
|
"test reflog".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_log(namespace: NodeId, verifier: &Verifier, repo: &git2::Repository) -> Latest {
|
||||||
|
SignedRefsReader::new(mock_root(), Tip::Reference(namespace), repo, verifier)
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn initial_commit_roundtrip(refs: Refs) -> bool {
|
||||||
|
let (_dir, repo) = setup();
|
||||||
|
let signer = MockSigner::default();
|
||||||
|
let namespace = *signer.public_key();
|
||||||
|
let verifier = Verifier::new(&signer);
|
||||||
|
|
||||||
|
let update = write_log(refs.clone(), namespace, &signer, &repo);
|
||||||
|
let head_oid = match update {
|
||||||
|
Update::Changed { ref entry } => *entry.oid(),
|
||||||
|
Update::Unchanged { .. } => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Latest {
|
||||||
|
refs: expected,
|
||||||
|
checkpoint,
|
||||||
|
..
|
||||||
|
} = read_log(namespace, &verifier, &repo);
|
||||||
|
|
||||||
|
checkpoint.head() == head_oid
|
||||||
|
&& checkpoint.ancestor() == head_oid
|
||||||
|
&& checkpoint.reason() == CheckpointReason::Root
|
||||||
|
&& expected == refs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn chain_roundtrip(chain: BoundedVec<Refs>) -> TestResult {
|
||||||
|
let chain = chain.0;
|
||||||
|
if chain.is_empty() {
|
||||||
|
return TestResult::discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_dir, repo) = setup();
|
||||||
|
let signer = MockSigner::default();
|
||||||
|
let namespace = *signer.public_key();
|
||||||
|
let verifier = Verifier::new(&signer);
|
||||||
|
|
||||||
|
let mut last_changed_head = None;
|
||||||
|
|
||||||
|
for refs in chain {
|
||||||
|
let update = write_log(refs.clone(), namespace, &signer, &repo);
|
||||||
|
|
||||||
|
if let Update::Changed { ref entry } = update {
|
||||||
|
last_changed_head = Some(*entry.oid());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Latest {
|
||||||
|
refs: expected,
|
||||||
|
checkpoint,
|
||||||
|
..
|
||||||
|
} = read_log(namespace, &verifier, &repo);
|
||||||
|
|
||||||
|
if refs != expected {
|
||||||
|
return TestResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkpoint.reason() != CheckpointReason::Root {
|
||||||
|
return TestResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expected_head) = last_changed_head {
|
||||||
|
if checkpoint.head() != expected_head {
|
||||||
|
return TestResult::failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestResult::passed()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn idempotent_write(refs: Refs) -> bool {
|
||||||
|
let (_dir, repo) = setup();
|
||||||
|
let signer = MockSigner::default();
|
||||||
|
let namespace = *signer.public_key();
|
||||||
|
|
||||||
|
let first = write_log(refs.clone(), namespace, &signer, &repo);
|
||||||
|
let head_oid = match first {
|
||||||
|
Update::Changed { ref entry } => *entry.oid(),
|
||||||
|
Update::Unchanged { .. } => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let second = write_log(refs, namespace, &signer, &repo);
|
||||||
|
matches!(second, Update::Unchanged { commit, .. } if commit == head_oid)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
//! Traits for interacting with Git references, necessary for implementing
|
||||||
|
//! Radicle signed references.
|
||||||
|
// TODO(finto): I think these are more generally useful than just being used for
|
||||||
|
// signed references. They might be worth moving into a crate,
|
||||||
|
// `radicle-git-traits`, but for now they can live here.
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
/// Git reference reader, generally a Git repository, or its corresponding Reference
|
||||||
|
/// Database (Ref DB).
|
||||||
|
pub trait Reader {
|
||||||
|
/// Find the head [`Oid`] of the sigrefs reference for the given namespace.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the reference does not yet exist.
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::FindReference`]: failed to write the Git reference.
|
||||||
|
fn find_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
) -> Result<Option<Oid>, error::FindReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git reference writer, generally a Git repository, or its corresponding Reference
|
||||||
|
/// Database (Ref DB).
|
||||||
|
pub trait Writer {
|
||||||
|
/// Write the given commit [`Oid`], and its parent, to the given
|
||||||
|
/// `reference`.
|
||||||
|
///
|
||||||
|
/// The `reflog` given can used as the Git reflog message of the reference.
|
||||||
|
///
|
||||||
|
/// # Concurrency
|
||||||
|
///
|
||||||
|
/// It is up to the implementer to ensure the safety of writing the
|
||||||
|
/// reference safely in a concurrent environment.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`error::WriteReference`]: failed to write the Git reference.
|
||||||
|
fn write_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
commit: Oid,
|
||||||
|
parent: Option<Oid>,
|
||||||
|
reflog: String,
|
||||||
|
) -> Result<(), error::WriteReference>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error(transparent)]
|
||||||
|
pub struct FindReference(Box<dyn std::error::Error + Send + Sync + 'static>);
|
||||||
|
|
||||||
|
impl FindReference {
|
||||||
|
pub fn other<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[error(transparent)]
|
||||||
|
pub struct WriteReference(Box<dyn std::error::Error + Send + Sync + 'static>);
|
||||||
|
|
||||||
|
impl WriteReference {
|
||||||
|
pub fn other<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
use mock::*;
|
||||||
|
|
||||||
|
use crypto::test::signer::MockSigner;
|
||||||
|
use crypto::Signer as _;
|
||||||
|
use qcheck::TestResult;
|
||||||
|
use qcheck_macros::quickcheck;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::read::{SignedRefsReader, Tip};
|
||||||
|
use crate::storage::refs::sigrefs::write::{SignedRefsWriter, Update};
|
||||||
|
use crate::storage::refs::Refs;
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn roundtrip(BoundedVec(all_refs): BoundedVec<Refs>) -> TestResult {
|
||||||
|
if all_refs.is_empty() {
|
||||||
|
return TestResult::discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let signer = MockSigner::default();
|
||||||
|
let node_id = *signer.public_key();
|
||||||
|
|
||||||
|
for refs in all_refs {
|
||||||
|
let refs = fixture.with_identity_root(refs);
|
||||||
|
|
||||||
|
let writer = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer);
|
||||||
|
let update = match writer.write(
|
||||||
|
fixture.committer(),
|
||||||
|
"roundtrip write".into(),
|
||||||
|
"roundtrip reflog".into(),
|
||||||
|
) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => return TestResult::error(format!("write error: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let written_refs = match update {
|
||||||
|
Update::Changed { ref entry } => entry.clone().into_refs(),
|
||||||
|
Update::Unchanged { ref refs, .. } => refs.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(refs, written_refs);
|
||||||
|
|
||||||
|
let reader = SignedRefsReader::new(
|
||||||
|
fixture.rid(),
|
||||||
|
Tip::Reference(node_id),
|
||||||
|
fixture.repo(),
|
||||||
|
&node_id,
|
||||||
|
);
|
||||||
|
let verified = match reader.read() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return TestResult::error(format!("read error: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
if written_refs != verified.into_refs() {
|
||||||
|
return TestResult::failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestResult::passed()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn idempotent(refs: Refs) -> TestResult {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let refs = fixture.with_identity_root(refs);
|
||||||
|
let signer = MockSigner::default();
|
||||||
|
let node_id = *signer.public_key();
|
||||||
|
|
||||||
|
if let Err(e) = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write(
|
||||||
|
fixture.committer(),
|
||||||
|
"first write".into(),
|
||||||
|
"first reflog".into(),
|
||||||
|
) {
|
||||||
|
return TestResult::error(format!("first write error: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
match SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write(
|
||||||
|
fixture.committer(),
|
||||||
|
"second write".into(),
|
||||||
|
"second reflog".into(),
|
||||||
|
) {
|
||||||
|
Ok(Update::Unchanged { .. }) => TestResult::passed(),
|
||||||
|
Ok(Update::Changed { .. }) => {
|
||||||
|
TestResult::error("expected Update::Unchanged on second write with identical refs")
|
||||||
|
}
|
||||||
|
Err(e) => TestResult::error(format!("second write error: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
use qcheck::{Arbitrary, Gen};
|
||||||
|
use radicle_core::RepoId;
|
||||||
|
use radicle_git_metadata::author::{Author, Time};
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::identity::doc;
|
||||||
|
use crate::storage::refs::sigrefs::git::Committer;
|
||||||
|
use crate::storage::refs::{Refs, IDENTITY_ROOT};
|
||||||
|
|
||||||
|
/// A `Vec<T>` whose [`Arbitrary`] instance caps the length at
|
||||||
|
/// [`Self::MAX_LEN`], preventing the property runner from generating inputs
|
||||||
|
/// that would make the test prohibitively slow.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BoundedVec<T>(pub Vec<T>);
|
||||||
|
|
||||||
|
impl<T> BoundedVec<T> {
|
||||||
|
const MAX_LEN: usize = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Arbitrary> Arbitrary for BoundedVec<T> {
|
||||||
|
fn arbitrary(g: &mut Gen) -> Self {
|
||||||
|
let len = usize::arbitrary(g) % (Self::MAX_LEN + 1);
|
||||||
|
BoundedVec((0..len).map(|_| T::arbitrary(g)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
|
||||||
|
let inner: Vec<Vec<T>> = self.0.shrink().collect();
|
||||||
|
Box::new(inner.into_iter().map(BoundedVec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Radicle Git repository fixture.
|
||||||
|
///
|
||||||
|
/// It is initialized in a [`TempDir`], and starts off with a single blob to
|
||||||
|
/// emulate the identity document.
|
||||||
|
pub struct Fixture {
|
||||||
|
/// The underlying Git repository.
|
||||||
|
repo: git2::Repository,
|
||||||
|
/// The [`RepoId`] of the initial identity document blob.
|
||||||
|
rid: RepoId,
|
||||||
|
/// The commit that points to the identity document, which provides the
|
||||||
|
/// [`RepoId`].
|
||||||
|
identity_commit: Oid,
|
||||||
|
_dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Initialise a bare git repository and write the minimal object graph
|
||||||
|
/// required for identity-root verification to succeed:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// identity-commit
|
||||||
|
/// └─ tree
|
||||||
|
/// └─ embeds/
|
||||||
|
/// └─ <doc::PATH> (blob whose OID becomes the RepoId)
|
||||||
|
/// ```
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let repo = git2::Repository::init_bare(dir.path()).unwrap();
|
||||||
|
|
||||||
|
let (identity_commit, rid) = Self::identity_commit(&repo);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
_dir: dir,
|
||||||
|
repo,
|
||||||
|
rid,
|
||||||
|
identity_commit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the [`RepoId`] of the fixture.
|
||||||
|
pub fn rid(&self) -> RepoId {
|
||||||
|
self.rid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the underlying Git repository of the fixture.
|
||||||
|
pub fn repo(&self) -> &git2::Repository {
|
||||||
|
&self.repo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a [`Committer`] with a fixed, stable [`Author`].
|
||||||
|
pub fn committer(&self) -> Committer {
|
||||||
|
Committer::new(Author {
|
||||||
|
name: "radicle".to_string(),
|
||||||
|
email: "radicle@test".to_string(),
|
||||||
|
time: Time::new(0, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch an arbitrary [`Refs`] so that its [`IDENTITY_ROOT`] entry
|
||||||
|
/// points at the fixture's identity commit, satisfying the
|
||||||
|
/// `debug_assert` in [`SignedRefsWriter::new`] and the identity
|
||||||
|
/// verification in [`crate::storage::refs::sigrefs::read`].
|
||||||
|
pub fn with_identity_root(&self, mut refs: Refs) -> Refs {
|
||||||
|
refs.insert(IDENTITY_ROOT.to_ref_string(), self.identity_commit);
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_commit(repo: &git2::Repository) -> (Oid, RepoId) {
|
||||||
|
let (doc_tree, rid) = Self::write_doc_blob(repo);
|
||||||
|
let tree = Self::write_embeds(repo, doc_tree);
|
||||||
|
let tree = repo.find_tree(tree).unwrap();
|
||||||
|
let sig = git2::Signature::new("radicle", "radicle@test", &git2::Time::new(0, 0)).unwrap();
|
||||||
|
let oid = repo
|
||||||
|
.commit(None, &sig, &sig, "identity root", &tree, &[])
|
||||||
|
.unwrap();
|
||||||
|
(oid.into(), rid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_doc_blob(repo: &git2::Repository) -> (git2::Oid, RepoId) {
|
||||||
|
let doc_blob_oid = repo.blob(b"identity").unwrap();
|
||||||
|
let rid = RepoId::from(Oid::from(doc_blob_oid));
|
||||||
|
|
||||||
|
let mut tb = repo.treebuilder(None).unwrap();
|
||||||
|
tb.insert(
|
||||||
|
doc::PATH.as_os_str(),
|
||||||
|
doc_blob_oid,
|
||||||
|
git2::FileMode::Blob.into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let oid = tb.write().unwrap();
|
||||||
|
(oid, rid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_embeds(repo: &git2::Repository, doc: git2::Oid) -> git2::Oid {
|
||||||
|
let mut tb = repo.treebuilder(None).unwrap();
|
||||||
|
tb.insert("embeds", doc, git2::FileMode::Tree.into())
|
||||||
|
.unwrap();
|
||||||
|
tb.write().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
mod iter;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crypto::signature;
|
||||||
|
use nonempty::NonEmpty;
|
||||||
|
use radicle_core::{NodeId, RepoId};
|
||||||
|
use radicle_git_metadata::commit::CommitData;
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::identity::doc;
|
||||||
|
use crate::storage::refs::sigrefs::git::{object, reference};
|
||||||
|
use crate::storage::refs::{
|
||||||
|
Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A `rad/sigrefs` that has passed the following verification checks:
|
||||||
|
///
|
||||||
|
/// - Has a valid `/signature` blob, which is verified by the signing key.
|
||||||
|
/// - Contains the `refs/rad/root` entry under `/refs`, which matches the
|
||||||
|
/// [`RepoId`] of the local repository.
|
||||||
|
/// - The `refs/rad/sigrefs-parent` entry matches the commit's parent, if the
|
||||||
|
/// entry exists.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct VerifiedCommit {
|
||||||
|
/// The commit that was verified.
|
||||||
|
commit: Commit,
|
||||||
|
/// Whether verification successfully found the correct
|
||||||
|
/// value for [`SIGREFS_PARENT`] in the refs of [`Self::commit`].
|
||||||
|
parent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerifiedCommit {
|
||||||
|
/// The [`Oid`] of the commit.
|
||||||
|
pub fn id(&self) -> &Oid {
|
||||||
|
&self.commit.oid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`crypto::Signature`] found in the tree of the commit.
|
||||||
|
pub fn signature(&self) -> &crypto::Signature {
|
||||||
|
&self.commit.signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Refs`] found in the tree of the commit.
|
||||||
|
pub fn into_refs(self) -> Refs {
|
||||||
|
self.commit.refs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parent [`Oid`] of the commit, unless it is the root commit.
|
||||||
|
pub fn parent(&self) -> Option<&Oid> {
|
||||||
|
self.commit.parent.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`SignedRefsReader`] reads and verifies a commit chain for a `rad/sigrefs`
|
||||||
|
/// entry.
|
||||||
|
///
|
||||||
|
/// To create a new reader, use [`SignedRefsReader::new`].
|
||||||
|
///
|
||||||
|
/// The construction expects:
|
||||||
|
/// - A [`RepoId`] which is the repository identifier of the Radicle repository.
|
||||||
|
/// - A [`Tip`] which describes where and how to start the verification.
|
||||||
|
/// - A `repository` which is the Git repository that is being used for the reading.
|
||||||
|
/// - A `verifier` which is the entity that verifies the cryptographic signatures.
|
||||||
|
pub struct SignedRefsReader<'a, R, V> {
|
||||||
|
rid: RepoId,
|
||||||
|
tip: Tip,
|
||||||
|
repository: &'a R,
|
||||||
|
verifier: &'a V,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describe where to start a [`SignedRefsReader`]'s commit chain.
|
||||||
|
pub enum Tip {
|
||||||
|
/// Use the namespace of the given [`NodeId`], resolving their `rad/sigrefs`
|
||||||
|
/// to its commit [`Oid`].
|
||||||
|
Reference(NodeId),
|
||||||
|
/// Use the supplied commit [`Oid`].
|
||||||
|
Commit(Oid),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R, V> SignedRefsReader<'a, R, V>
|
||||||
|
where
|
||||||
|
R: object::Reader + reference::Reader,
|
||||||
|
V: signature::Verifier<crypto::Signature>,
|
||||||
|
{
|
||||||
|
/// Construct a new [`SignedRefsReader`].
|
||||||
|
pub fn new(rid: RepoId, tip: Tip, repository: &'a R, verifier: &'a V) -> Self {
|
||||||
|
Self {
|
||||||
|
rid,
|
||||||
|
tip,
|
||||||
|
repository,
|
||||||
|
verifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a [`VerifiedCommit`] using the [`SignedRefsReader`].
|
||||||
|
///
|
||||||
|
/// The [`VerifiedCommit`] will be the first commit, if the commit verifies
|
||||||
|
/// and contains its parent in its [`Refs`] entry.
|
||||||
|
/// If the commit does not contain a parent, but its signature is not
|
||||||
|
/// repeated, then it is still returned.
|
||||||
|
/// Otherwise, the commit that is returned is either:
|
||||||
|
/// - The first commit which has no repeated signatures, i.e. it has no replay attacks.
|
||||||
|
/// - The first commit which is not a replay commit, i.e. the commit that
|
||||||
|
/// replay attacks are based on.
|
||||||
|
///
|
||||||
|
/// # Replay Attacks
|
||||||
|
///
|
||||||
|
/// The [`SignedRefsReader`] prevents replay attacks via two mechanisms:
|
||||||
|
/// - The first is recording the parent commit in the `/refs` blob. This
|
||||||
|
/// prevents a replay by not allowing the same signature payload to be
|
||||||
|
/// used in a new commit, since the parents would not match. Note that
|
||||||
|
/// this does not detect replays by older clients, since they will not
|
||||||
|
/// include this entry in `/refs`.
|
||||||
|
/// - The second mechanism uses the fact that a replay will give duplicate
|
||||||
|
/// signatures. This means that any repeated signatures will be skipped,
|
||||||
|
/// and the commit returned will be the first valid commit, that was not a
|
||||||
|
/// replay.
|
||||||
|
pub fn read(self) -> Result<VerifiedCommit, error::Read> {
|
||||||
|
const ONE: NonZeroUsize = NonZeroUsize::new(1).expect("one is non-zero");
|
||||||
|
const SIGNATURES_COLLECTED: &str = "all signatures were collected";
|
||||||
|
|
||||||
|
let head = CommitReader::new(self.resolve_tip()?, self.repository)
|
||||||
|
.read()
|
||||||
|
.map_err(error::Read::Commit)?
|
||||||
|
.verify(self.rid, self.verifier)
|
||||||
|
.map_err(error::Read::Verify)?;
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
if head.parent {
|
||||||
|
// `head` is verified, thus we know that if the parent reference
|
||||||
|
// exists, its target actually matches the parent OID.
|
||||||
|
// The fact that the parent OID is a hash over all previous history
|
||||||
|
// makes it *incredibley unlikely* or rather *practically impossible*
|
||||||
|
// that the same `/refs` blob re-appears in previous history.
|
||||||
|
// Thus, we can spare oureselves walking the history.
|
||||||
|
return Ok(head);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = iter::Walk::new(*head.id(), self.repository).try_fold(
|
||||||
|
HashMap::<crypto::Signature, NonEmpty<Oid>>::new(),
|
||||||
|
|mut seen, commit| {
|
||||||
|
let current = commit.map_err(error::Read::Commit)?;
|
||||||
|
seen.entry(current.signature)
|
||||||
|
.and_modify(|value| value.push(current.oid))
|
||||||
|
.or_insert_with(|| NonEmpty::new(current.oid));
|
||||||
|
Ok(seen)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let parent = if seen
|
||||||
|
.get(head.signature())
|
||||||
|
.expect(SIGNATURES_COLLECTED)
|
||||||
|
.len_nonzero()
|
||||||
|
== ONE
|
||||||
|
{
|
||||||
|
// `head` has a verified, non-repeated signature, but does not
|
||||||
|
// include the parent reference in the `/refs` blob. Maintains
|
||||||
|
// backwards-compatibility.
|
||||||
|
return Ok(head);
|
||||||
|
} else {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
if head.parent {
|
||||||
|
panic!("duplicate signature found even though parent ref did verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the signature in head was seen twice, then
|
||||||
|
// head must have a parent.
|
||||||
|
*head.parent().expect("parent must exist")
|
||||||
|
};
|
||||||
|
|
||||||
|
// The second walk can start from the parent of head. We do not need to
|
||||||
|
// verify head twice, and we already know that the parent exists.
|
||||||
|
let mut last = None;
|
||||||
|
for commit in iter::Walk::new(parent, self.repository) {
|
||||||
|
let commit = commit
|
||||||
|
.map_err(error::Read::Commit)?
|
||||||
|
.verify(self.rid, self.verifier)
|
||||||
|
.map_err(error::Read::Verify)?;
|
||||||
|
|
||||||
|
let commits = seen.get(commit.signature()).expect(SIGNATURES_COLLECTED);
|
||||||
|
|
||||||
|
if commits.len_nonzero() == ONE {
|
||||||
|
return Ok(commit);
|
||||||
|
} else {
|
||||||
|
log::warn!("Duplicate sigrefs found in commits {commits:?}");
|
||||||
|
last = Some(commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the extreme case where all commits in the walk contain duplicate
|
||||||
|
// signatures, return the oldest commit reached — the last one visited
|
||||||
|
// by the walk, which follows the chain from newest to oldest.
|
||||||
|
// `last` is always `Some` here because `parent` is guaranteed to exist
|
||||||
|
// (head had a duplicate signature, so it must have a parent), meaning
|
||||||
|
// the walk yields at least one commit.
|
||||||
|
Ok(last.unwrap_or(head))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_tip(&self) -> Result<Oid, error::Read> {
|
||||||
|
match self.tip {
|
||||||
|
Tip::Commit(oid) => Ok(oid),
|
||||||
|
Tip::Reference(namespace) => {
|
||||||
|
let reference =
|
||||||
|
SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace));
|
||||||
|
let head = self
|
||||||
|
.repository
|
||||||
|
.find_reference(&reference)
|
||||||
|
.map_err(error::Read::FindReference)?
|
||||||
|
.ok_or_else(|| error::Read::MissingSigrefs { namespace })?;
|
||||||
|
Ok(head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct Commit {
|
||||||
|
oid: Oid,
|
||||||
|
parent: Option<Oid>,
|
||||||
|
refs: Refs,
|
||||||
|
signature: crypto::Signature,
|
||||||
|
identity_root: Option<IdentityRoot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Commit {
|
||||||
|
fn verify<V>(mut self, expected: RepoId, verifier: &V) -> Result<VerifiedCommit, error::Verify>
|
||||||
|
where
|
||||||
|
V: signature::Verifier<crypto::Signature>,
|
||||||
|
{
|
||||||
|
verifier
|
||||||
|
.verify(&self.refs.canonical(), &self.signature)
|
||||||
|
.map_err(error::Verify::Signature)?;
|
||||||
|
|
||||||
|
if let Some(IdentityRoot {
|
||||||
|
commit: identity_commit,
|
||||||
|
rid,
|
||||||
|
}) = self.identity_root
|
||||||
|
{
|
||||||
|
if rid != expected {
|
||||||
|
return Err(error::Verify::MismatchedIdentity {
|
||||||
|
identity_commit,
|
||||||
|
sigrefs_commit: self.oid,
|
||||||
|
expected,
|
||||||
|
found: rid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Identity verification succeeds.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(error::Verify::MissingIdentity(error::MissingIdentity {
|
||||||
|
sigrefs_commit: self.oid,
|
||||||
|
expected,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refs.remove_sigrefs();
|
||||||
|
|
||||||
|
let parent = match (self.parent, self.refs.remove_parent()) {
|
||||||
|
(None, None) => true,
|
||||||
|
(Some(_), None) => false,
|
||||||
|
(None, Some(actual)) => {
|
||||||
|
return Err(error::Verify::DanglingParent {
|
||||||
|
sigrefs_commit: self.oid,
|
||||||
|
actual,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(Some(expected), Some(actual)) if expected == actual => true,
|
||||||
|
(Some(expected), Some(actual)) => {
|
||||||
|
return Err(error::Verify::MismatchedParent {
|
||||||
|
sigrefs_commit: self.oid,
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(VerifiedCommit {
|
||||||
|
commit: self,
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommitReader<'a, R> {
|
||||||
|
commit: Oid,
|
||||||
|
repository: &'a R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> CommitReader<'a, R>
|
||||||
|
where
|
||||||
|
R: object::Reader,
|
||||||
|
{
|
||||||
|
fn new(commit: Oid, repository: &'a R) -> Self {
|
||||||
|
Self { commit, repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(self) -> Result<Commit, error::Commit> {
|
||||||
|
let commit = self.read_commit_data()?;
|
||||||
|
let Tree { refs, signature } = TreeReader::new(self.commit, self.repository)
|
||||||
|
.read()
|
||||||
|
.map_err(error::Commit::Tree)?;
|
||||||
|
let identity_root = IdentityRootReader::new(&refs, self.repository)
|
||||||
|
.read()
|
||||||
|
.map_err(error::Commit::IdentityRoot)?;
|
||||||
|
let parent = Self::get_parent(&commit).transpose()?;
|
||||||
|
|
||||||
|
Ok(Commit {
|
||||||
|
oid: self.commit,
|
||||||
|
parent,
|
||||||
|
refs,
|
||||||
|
signature,
|
||||||
|
identity_root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_commit_data(&self) -> Result<CommitData<Oid, Oid>, error::Commit> {
|
||||||
|
let bytes = self
|
||||||
|
.repository
|
||||||
|
.read_commit(&self.commit)
|
||||||
|
.map_err(error::Commit::Read)?
|
||||||
|
.ok_or(error::Commit::Missing { oid: self.commit })?;
|
||||||
|
CommitData::from_bytes(&bytes).map_err(|err| error::Commit::Parse {
|
||||||
|
oid: self.commit,
|
||||||
|
source: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the single parent [`Oid`] from a [`CommitData`], if any.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the commit has no parents (i.e. it is a root commit).
|
||||||
|
/// Returns an error if the commit has more than one parent, since the
|
||||||
|
/// transparency log is a linear chain.
|
||||||
|
fn get_parent(commit: &CommitData<Oid, Oid>) -> Option<Result<Oid, error::Commit>> {
|
||||||
|
let NonEmpty {
|
||||||
|
head: parent,
|
||||||
|
tail: mut rest,
|
||||||
|
} = NonEmpty::collect(commit.parents())?;
|
||||||
|
if rest.is_empty() {
|
||||||
|
Some(Ok(parent))
|
||||||
|
} else {
|
||||||
|
rest.insert(0, parent);
|
||||||
|
let err = error::Commit::TooManyParents(error::Parent { parents: rest });
|
||||||
|
Some(Err(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tree {
|
||||||
|
refs: Refs,
|
||||||
|
signature: crypto::Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TreeReader<'a, R> {
|
||||||
|
commit: Oid,
|
||||||
|
repository: &'a R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> TreeReader<'a, R>
|
||||||
|
where
|
||||||
|
R: object::Reader,
|
||||||
|
{
|
||||||
|
fn new(commit: Oid, repository: &'a R) -> Self {
|
||||||
|
Self { commit, repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(self) -> Result<Tree, error::Tree> {
|
||||||
|
let (refs, signature) = self.try_handle_blobs()?;
|
||||||
|
let refs = Refs::from_canonical(&refs.bytes).map_err(error::Tree::ParseRefs)?;
|
||||||
|
let signature = crypto::Signature::try_from(signature.bytes.as_slice())
|
||||||
|
.map_err(error::Tree::ParseSignature)?;
|
||||||
|
Ok(Tree { refs, signature })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the refs blob and signature blob from the repository, returning a
|
||||||
|
/// descriptive error if either or both are missing.
|
||||||
|
fn try_handle_blobs(&self) -> Result<(object::Blob, object::Blob), error::Tree> {
|
||||||
|
let commit = &self.commit;
|
||||||
|
let refs_path = Path::new(REFS_BLOB_PATH);
|
||||||
|
let sig_path = Path::new(SIGNATURE_BLOB_PATH);
|
||||||
|
|
||||||
|
let refs_bytes = self
|
||||||
|
.repository
|
||||||
|
.read_blob(commit, refs_path)
|
||||||
|
.map_err(error::Tree::Refs)?;
|
||||||
|
let sig_bytes = self
|
||||||
|
.repository
|
||||||
|
.read_blob(commit, sig_path)
|
||||||
|
.map_err(error::Tree::Signature)?;
|
||||||
|
|
||||||
|
let result = match (refs_bytes, sig_bytes) {
|
||||||
|
(None, None) => Err(error::MissingBlobs::Both {
|
||||||
|
commit: *commit,
|
||||||
|
refs: refs_path.to_path_buf(),
|
||||||
|
signature: sig_path.to_path_buf(),
|
||||||
|
}),
|
||||||
|
(None, Some(_)) => Err(error::MissingBlobs::Signature {
|
||||||
|
commit: *commit,
|
||||||
|
path: sig_path.to_path_buf(),
|
||||||
|
}),
|
||||||
|
(Some(_), None) => Err(error::MissingBlobs::Refs {
|
||||||
|
commit: *commit,
|
||||||
|
path: refs_path.to_path_buf(),
|
||||||
|
}),
|
||||||
|
(Some(refs), Some(sig)) => Ok((refs, sig)),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.map_err(error::Tree::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct IdentityRoot {
|
||||||
|
commit: Oid,
|
||||||
|
rid: RepoId,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IdentityRootReader<'a, 'b, R> {
|
||||||
|
refs: &'a Refs,
|
||||||
|
repository: &'b R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b, R> IdentityRootReader<'a, 'b, R>
|
||||||
|
where
|
||||||
|
R: object::Reader,
|
||||||
|
{
|
||||||
|
fn new(refs: &'a Refs, repository: &'b R) -> Self {
|
||||||
|
Self { refs, repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(self) -> Result<Option<IdentityRoot>, error::IdentityRoot> {
|
||||||
|
match self.refs.get(&IDENTITY_ROOT) {
|
||||||
|
Some(commit) => self
|
||||||
|
.read_blob(&commit)
|
||||||
|
.map(|rid| Some(IdentityRoot { commit, rid })),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_blob(&self, commit: &Oid) -> Result<RepoId, error::IdentityRoot> {
|
||||||
|
let path = Path::new("embeds").join(*doc::PATH);
|
||||||
|
let object::Blob { oid, .. } = self
|
||||||
|
.repository
|
||||||
|
.read_blob(commit, &path)
|
||||||
|
.map_err(error::IdentityRoot::Blob)?
|
||||||
|
.ok_or_else(|| error::IdentityRoot::MissingIdentity { commit: *commit })?;
|
||||||
|
Ok(RepoId::from(oid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use radicle_core::{NodeId, RepoId};
|
||||||
|
use radicle_git_metadata::commit;
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::storage::refs::canonical;
|
||||||
|
use crate::storage::refs::sigrefs::git::{object, reference};
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Read {
|
||||||
|
#[error(transparent)]
|
||||||
|
Commit(Commit),
|
||||||
|
#[error(transparent)]
|
||||||
|
FindReference(reference::error::FindReference),
|
||||||
|
#[error("failed to find `refs/namespaces/{namespace}/refs/rad/sigrefs`")]
|
||||||
|
MissingSigrefs { namespace: NodeId },
|
||||||
|
#[error(transparent)]
|
||||||
|
Verify(Verify),
|
||||||
|
#[error("failed to find a valid set of signed references starting from {head}")]
|
||||||
|
NoValidCommit { head: Oid },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Commit {
|
||||||
|
#[error(transparent)]
|
||||||
|
Tree(Tree),
|
||||||
|
#[error(transparent)]
|
||||||
|
IdentityRoot(IdentityRoot),
|
||||||
|
#[error("missing commit '{oid}'")]
|
||||||
|
Missing { oid: Oid },
|
||||||
|
#[error("invalid commit '{oid}': {source}")]
|
||||||
|
Parse {
|
||||||
|
oid: Oid,
|
||||||
|
source: commit::ParseError,
|
||||||
|
},
|
||||||
|
#[error(transparent)]
|
||||||
|
TooManyParents(Parent),
|
||||||
|
#[error(transparent)]
|
||||||
|
Read(object::error::ReadCommit),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct Parent {
|
||||||
|
pub(crate) parents: Vec<Oid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Parent {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.parents
|
||||||
|
.iter()
|
||||||
|
.map(|oid| oid.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Tree {
|
||||||
|
#[error(transparent)]
|
||||||
|
Refs(object::error::ReadBlob),
|
||||||
|
#[error(transparent)]
|
||||||
|
Signature(object::error::ReadBlob),
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseRefs(canonical::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseSignature(crypto::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
MissingBlobs(#[from] MissingBlobs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MissingBlobs {
|
||||||
|
#[error("failed to find {path:?} in commit {commit}")]
|
||||||
|
Refs { commit: Oid, path: PathBuf },
|
||||||
|
#[error("failed to find {path:?} in commit {commit}")]
|
||||||
|
Signature { commit: Oid, path: PathBuf },
|
||||||
|
#[error("failed to find {refs:?} and {signature:?} in commit {commit}")]
|
||||||
|
Both {
|
||||||
|
commit: Oid,
|
||||||
|
refs: PathBuf,
|
||||||
|
signature: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum IdentityRoot {
|
||||||
|
#[error(transparent)]
|
||||||
|
Blob(object::error::ReadBlob),
|
||||||
|
#[error("missing repository identity commit '{commit}'")]
|
||||||
|
MissingIdentity { commit: Oid },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Verify {
|
||||||
|
#[error("failed to verify signature over signed references")]
|
||||||
|
Signature(crypto::signature::Error),
|
||||||
|
#[error("expected repository identity {expected}, but found {found} under commit '{identity_commit}' during verification of '{sigrefs_commit}")]
|
||||||
|
MismatchedIdentity {
|
||||||
|
identity_commit: Oid,
|
||||||
|
sigrefs_commit: Oid,
|
||||||
|
expected: RepoId,
|
||||||
|
found: RepoId,
|
||||||
|
},
|
||||||
|
#[error(transparent)]
|
||||||
|
MissingIdentity(MissingIdentity),
|
||||||
|
#[error(
|
||||||
|
"expected no parent reference in refs commit '{sigrefs_commit}', but found target '{actual}'"
|
||||||
|
)]
|
||||||
|
DanglingParent { sigrefs_commit: Oid, actual: Oid },
|
||||||
|
#[error(
|
||||||
|
"expected parent reference with target '{expected}' in refs commit '{sigrefs_commit}', but found target '{actual}'"
|
||||||
|
)]
|
||||||
|
MismatchedParent {
|
||||||
|
sigrefs_commit: Oid,
|
||||||
|
expected: Oid,
|
||||||
|
actual: Oid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
#[error("expected repository identity {expected}, but found none under commit '{sigrefs_commit}'")]
|
||||||
|
pub struct MissingIdentity {
|
||||||
|
pub(super) sigrefs_commit: Oid,
|
||||||
|
pub(super) expected: RepoId,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::git::object;
|
||||||
|
|
||||||
|
use super::{error, Commit, CommitReader};
|
||||||
|
|
||||||
|
pub(super) struct Walk<'a, R> {
|
||||||
|
repository: &'a R,
|
||||||
|
cursor: Option<Oid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> Walk<'a, R> {
|
||||||
|
pub fn new(head: Oid, repository: &'a R) -> Self {
|
||||||
|
Self {
|
||||||
|
repository,
|
||||||
|
cursor: Some(head),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R: object::Reader> Iterator for Walk<'a, R> {
|
||||||
|
type Item = Result<Commit, error::Commit>;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
match self
|
||||||
|
.cursor
|
||||||
|
.map(|commit| CommitReader::new(commit, self.repository).read())
|
||||||
|
{
|
||||||
|
None => None,
|
||||||
|
Some(Ok(commit)) => {
|
||||||
|
self.cursor = commit.parent;
|
||||||
|
Some(Ok(commit))
|
||||||
|
}
|
||||||
|
Some(Err(err)) => Some(Err(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip};
|
||||||
|
use crate::storage::refs::sigrefs::VerifiedCommit;
|
||||||
|
use crate::storage::refs::IDENTITY_ROOT;
|
||||||
|
|
||||||
|
use super::mock;
|
||||||
|
use super::mock::{AlwaysVerify, MockRepository};
|
||||||
|
|
||||||
|
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
|
||||||
|
SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_missing_refs(head)
|
||||||
|
.with_missing_signature(head);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(err, error::Read::Commit(error::Commit::Tree(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_root_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let identity_root = mock::oid(2);
|
||||||
|
let refs = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), identity_root),
|
||||||
|
];
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs)
|
||||||
|
.with_signature(head, 1)
|
||||||
|
.with_identity_error(identity_root);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::IdentityRoot(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_many_parents() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([mock::oid(2), mock::oid(3)]))
|
||||||
|
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::TooManyParents(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_commit() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new().with_missing_commit(head);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Missing { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_identity() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let refs = [(mock::refs_heads_main(), mock::oid(10))];
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Verify(error::Verify::MissingIdentity { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let refs = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let vc = read_at(head, repo).unwrap();
|
||||||
|
assert_eq!(vc.id(), &head);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
use radicle_core::RepoId;
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::storage::refs::sigrefs::read::{
|
||||||
|
error, IdentityRoot, IdentityRootReader, SignedRefsReader, Tip,
|
||||||
|
};
|
||||||
|
use crate::storage::refs::sigrefs::VerifiedCommit;
|
||||||
|
use crate::storage::refs::{Refs, IDENTITY_ROOT};
|
||||||
|
|
||||||
|
use super::mock;
|
||||||
|
use super::mock::{AlwaysVerify, MockRepository};
|
||||||
|
|
||||||
|
fn refs_with_identity(oid: Oid) -> [(git::fmt::RefString, Oid); 2] {
|
||||||
|
[
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), oid),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
|
||||||
|
SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doc_blob_error() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let identity_root = mock::oid(2);
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(root, mock::commit_data([]))
|
||||||
|
.with_refs(root, refs_with_identity(identity_root))
|
||||||
|
.with_signature(root, 1)
|
||||||
|
.with_identity_error(identity_root);
|
||||||
|
|
||||||
|
let err = read_at(root, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::IdentityRoot(error::IdentityRoot::Blob(_)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_identity() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let dangling = mock::oid(2);
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs_with_identity(dangling))
|
||||||
|
.with_signature(head, 1)
|
||||||
|
.with_missing_identity(dangling);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::IdentityRoot(
|
||||||
|
error::IdentityRoot::MissingIdentity { .. }
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok_some() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let identity_root = mock::oid(99);
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(root, mock::commit_data([]))
|
||||||
|
.with_refs(root, refs_with_identity(identity_root))
|
||||||
|
.with_signature(root, 1)
|
||||||
|
.with_identity(identity_root);
|
||||||
|
|
||||||
|
let result = IdentityRootReader::new(
|
||||||
|
&Refs::from(refs_with_identity(identity_root).into_iter()),
|
||||||
|
&repo,
|
||||||
|
)
|
||||||
|
.read()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Some(IdentityRoot {
|
||||||
|
commit: identity_root,
|
||||||
|
rid: RepoId::from(identity_root)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok_none() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let refs = [(mock::refs_heads_main(), mock::oid(10))];
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(root, mock::commit_data([]))
|
||||||
|
.with_refs(root, refs.clone())
|
||||||
|
.with_signature(root, 1);
|
||||||
|
|
||||||
|
let result = IdentityRootReader::new(&Refs::from(refs.into_iter()), &repo)
|
||||||
|
.read()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
//! Mock implementations of [`object::Reader`] and [`reference::Reader`] for
|
||||||
|
//! unit-testing.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use radicle_core::NodeId;
|
||||||
|
use radicle_git_metadata::author::{Author, Time};
|
||||||
|
use radicle_git_metadata::commit::headers::Headers;
|
||||||
|
use radicle_git_metadata::commit::trailers::OwnedTrailer;
|
||||||
|
use radicle_git_metadata::commit::CommitData;
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::identity::doc;
|
||||||
|
use crate::storage::refs::sigrefs::git::{object, reference};
|
||||||
|
use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH};
|
||||||
|
|
||||||
|
pub(crate) const MOCKED_IDENTITY: u8 = 99u8;
|
||||||
|
|
||||||
|
/// A configurable in-memory repository implementing [`object::Reader`] and
|
||||||
|
/// [`reference::Reader`].
|
||||||
|
/// All behaviour is set at construction time via the builder methods; the mock
|
||||||
|
/// is fully deterministic.
|
||||||
|
pub struct MockRepository {
|
||||||
|
commits: HashMap<Oid, CommitBehavior>,
|
||||||
|
blobs: HashMap<(Oid, PathBuf), BlobBehavior>,
|
||||||
|
references: HashMap<String, RefBehavior>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommitBehavior {
|
||||||
|
/// [`object::Reader::read_commit`] returns `Ok(Some(bytes))`.
|
||||||
|
Present(Box<CommitData<Oid, Oid>>),
|
||||||
|
/// [`object::Reader::read_commit`] returns `Ok(None)`.
|
||||||
|
Missing,
|
||||||
|
/// [`object::Reader::read_commit`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BlobBehavior {
|
||||||
|
/// [`object::Reader::read_blob`] returns `Ok(Some(blob))`.
|
||||||
|
Present(Vec<u8>),
|
||||||
|
/// [`object::Reader::read_blob`] returns `Ok(None)`.
|
||||||
|
Missing,
|
||||||
|
/// [`object::Reader::read_blob`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RefBehavior {
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Ok(Some(oid))`.
|
||||||
|
Present(Oid),
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Ok(None)`.
|
||||||
|
Missing,
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockRepository {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
commits: HashMap::new(),
|
||||||
|
blobs: HashMap::new(),
|
||||||
|
references: HashMap::new(),
|
||||||
|
}
|
||||||
|
.with_identity(oid(MOCKED_IDENTITY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_commit(mut self, oid: Oid, data: CommitData<Oid, Oid>) -> Self {
|
||||||
|
self.commits
|
||||||
|
.insert(oid, CommitBehavior::Present(Box::new(data)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_commit(mut self, oid: Oid) -> Self {
|
||||||
|
self.commits.insert(oid, CommitBehavior::Missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_commit_error(mut self, oid: Oid) -> Self {
|
||||||
|
self.commits.insert(oid, CommitBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_refs(
|
||||||
|
self,
|
||||||
|
commit: Oid,
|
||||||
|
refs: impl IntoIterator<Item = (git::fmt::RefString, Oid)>,
|
||||||
|
) -> Self {
|
||||||
|
self.with_blob(commit, &REFS_BLOB_PATH, refs_bytes(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_signature(self, commit: Oid, id: u8) -> Self {
|
||||||
|
self.with_blob(commit, &SIGNATURE_BLOB_PATH, sig_bytes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_blob<P>(mut self, commit: Oid, path: &P, bytes: Vec<u8>) -> Self
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
self.blobs.insert(
|
||||||
|
(commit, path.as_ref().to_path_buf()),
|
||||||
|
BlobBehavior::Present(bytes),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_refs(self, commit: Oid) -> Self {
|
||||||
|
self.with_missing_blob(commit, &REFS_BLOB_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_signature(self, commit: Oid) -> Self {
|
||||||
|
self.with_missing_blob(commit, &SIGNATURE_BLOB_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_identity(self, commit: Oid) -> Self {
|
||||||
|
self.with_missing_blob(commit, &identity_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_identity_error(self, commit: Oid) -> Self {
|
||||||
|
self.with_blob_error(commit, &identity_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_identity(self, commit: Oid) -> Self {
|
||||||
|
self.with_blob(commit, &identity_path(), vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_missing_blob<P>(mut self, commit: Oid, path: &P) -> Self
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
self.blobs
|
||||||
|
.insert((commit, path.as_ref().to_path_buf()), BlobBehavior::Missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_blob_error<P>(mut self, commit: Oid, path: &P) -> Self
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
self.blobs
|
||||||
|
.insert((commit, path.as_ref().to_path_buf()), BlobBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `name` must be the exact string returned by `Namespaced::as_str()`.
|
||||||
|
pub fn with_rad_sigrefs(mut self, namespace: &NodeId, oid: Oid) -> Self {
|
||||||
|
self.references.insert(
|
||||||
|
sigrefs_ref_name(namespace).to_string(),
|
||||||
|
RefBehavior::Present(oid),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_rad_sigrefs(mut self, namespace: &NodeId) -> Self {
|
||||||
|
self.references
|
||||||
|
.insert(sigrefs_ref_name(namespace), RefBehavior::Missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_rad_sigrefs_error(mut self, namespace: &NodeId) -> Self {
|
||||||
|
self.references
|
||||||
|
.insert(sigrefs_ref_name(namespace), RefBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl object::Reader for MockRepository {
|
||||||
|
fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
|
||||||
|
match self.commits.get(oid) {
|
||||||
|
Some(CommitBehavior::Present(data)) => Ok(Some(data.to_string().as_bytes().to_vec())),
|
||||||
|
Some(CommitBehavior::Missing) | None => Ok(None),
|
||||||
|
Some(CommitBehavior::Error) => Err(object::error::ReadCommit::other(
|
||||||
|
std::io::Error::other("mock commit error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_blob(
|
||||||
|
&self,
|
||||||
|
commit: &Oid,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<Option<object::Blob>, object::error::ReadBlob> {
|
||||||
|
let key = (*commit, path.to_path_buf());
|
||||||
|
match self.blobs.get(&key) {
|
||||||
|
Some(BlobBehavior::Present(bytes)) => Ok(Some(object::Blob {
|
||||||
|
// The blob OID is returned as the commit OID. This is
|
||||||
|
// intentional: IdentityRootReader converts blob.oid into a
|
||||||
|
// RepoId, so callers can predict which RepoId results from a
|
||||||
|
// given identity-root commit OID.
|
||||||
|
oid: *commit,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
})),
|
||||||
|
Some(BlobBehavior::Missing) | None => Ok(None),
|
||||||
|
Some(BlobBehavior::Error) => Err(object::error::ReadBlob::other(
|
||||||
|
std::io::Error::other("mock blob error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl reference::Reader for MockRepository {
|
||||||
|
fn find_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
) -> Result<Option<Oid>, reference::error::FindReference> {
|
||||||
|
match self.references.get(reference.as_str()) {
|
||||||
|
Some(RefBehavior::Present(oid)) => Ok(Some(*oid)),
|
||||||
|
Some(RefBehavior::Missing) | None => Ok(None),
|
||||||
|
Some(RefBehavior::Error) => Err(reference::error::FindReference::other(
|
||||||
|
std::io::Error::other("mock reference error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts every (message, signature) pair without inspecting either.
|
||||||
|
pub struct AlwaysVerify;
|
||||||
|
|
||||||
|
impl crypto::signature::Verifier<crypto::Signature> for AlwaysVerify {
|
||||||
|
fn verify(
|
||||||
|
&self,
|
||||||
|
_msg: &[u8],
|
||||||
|
_sig: &crypto::Signature,
|
||||||
|
) -> Result<(), crypto::signature::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects every (message, signature) pair.
|
||||||
|
pub struct NeverVerify;
|
||||||
|
|
||||||
|
impl crypto::signature::Verifier<crypto::Signature> for NeverVerify {
|
||||||
|
fn verify(
|
||||||
|
&self,
|
||||||
|
_msg: &[u8],
|
||||||
|
_sig: &crypto::Signature,
|
||||||
|
) -> Result<(), crypto::signature::Error> {
|
||||||
|
Err(crypto::signature::Error::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct an [`Oid`] from a single repeated byte.
|
||||||
|
///
|
||||||
|
/// `oid(1) != oid(2)` is guaranteed; use distinct values for distinct objects.
|
||||||
|
pub fn oid(n: u8) -> Oid {
|
||||||
|
Oid::from_sha1([n; 20])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a [`radicle_core::RepoId`] from a single repeated byte.
|
||||||
|
pub fn rid(n: u8) -> radicle_core::RepoId {
|
||||||
|
radicle_core::RepoId::from(oid(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a [`radicle_core::NodeId`] from a single repeated byte.
|
||||||
|
pub fn node_id() -> NodeId {
|
||||||
|
NodeId::from([1u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refs_heads_main() -> git::fmt::RefString {
|
||||||
|
git::fmt::refname!("refs/heads/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the namespaced sigrefs reference string for `namespace`, matching
|
||||||
|
/// the string that `SignedRefsReader::resolve_tip` will look up.
|
||||||
|
fn sigrefs_ref_name(namespace: &NodeId) -> String {
|
||||||
|
SIGREFS_BRANCH
|
||||||
|
.with_namespace(git::fmt::Component::from(namespace))
|
||||||
|
.as_str()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_author() -> Author {
|
||||||
|
Author {
|
||||||
|
name: "test".to_owned(),
|
||||||
|
email: "test@example.com".to_owned(),
|
||||||
|
time: Time::new(0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal [`CommitData`] with the given parents and a zero tree OID.
|
||||||
|
pub fn commit_data(parents: impl IntoIterator<Item = Oid>) -> CommitData<Oid, Oid> {
|
||||||
|
let tree = oid(0);
|
||||||
|
let author = test_author();
|
||||||
|
let message = "test\n".to_owned();
|
||||||
|
|
||||||
|
CommitData::new::<_, _, OwnedTrailer>(
|
||||||
|
tree,
|
||||||
|
parents,
|
||||||
|
author.clone(),
|
||||||
|
author,
|
||||||
|
Headers::new(),
|
||||||
|
message,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns 64 bytes all equal to `id`.
|
||||||
|
///
|
||||||
|
/// With [`AlwaysVerify`] any 64-byte sequence is accepted as a valid signature.
|
||||||
|
/// Different `id` values are treated as distinct signatures by the
|
||||||
|
/// deduplication logic inside [`SignedRefsReader`].
|
||||||
|
pub fn sig_bytes(id: u8) -> Vec<u8> {
|
||||||
|
vec![id; 64]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up a linear commit chain in the mock repository.
|
||||||
|
///
|
||||||
|
/// `chain` is ordered oldest-first: `chain[0]` is the root (no parent),
|
||||||
|
/// and each subsequent commit's parent is the preceding entry.
|
||||||
|
/// Each element is `(commit_oid, sig_id, refs)`.
|
||||||
|
pub fn setup_chain<I>(chain: impl IntoIterator<Item = (Oid, u8, I)>) -> MockRepository
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (git::fmt::RefString, Oid)>,
|
||||||
|
{
|
||||||
|
let mut repo = MockRepository::new();
|
||||||
|
let mut parent = None;
|
||||||
|
for (commit_oid, sig_id, refs) in chain.into_iter() {
|
||||||
|
repo = repo
|
||||||
|
.with_commit(commit_oid, commit_data(parent))
|
||||||
|
.with_refs(commit_oid, refs)
|
||||||
|
.with_signature(commit_oid, sig_id);
|
||||||
|
parent = Some(commit_oid);
|
||||||
|
}
|
||||||
|
repo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the canonical bytes of a [`Refs`] from the given entries.
|
||||||
|
fn refs_bytes(entries: impl IntoIterator<Item = (git::fmt::RefString, Oid)>) -> Vec<u8> {
|
||||||
|
let refs = Refs::from(entries.into_iter());
|
||||||
|
refs.canonical()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_path() -> PathBuf {
|
||||||
|
Path::new("embeds").join(*doc::PATH)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::read::error::{Read, Verify};
|
||||||
|
use crate::storage::refs::sigrefs::read::{error, Commit, SignedRefsReader, Tip};
|
||||||
|
use crate::storage::refs::sigrefs::VerifiedCommit;
|
||||||
|
use crate::storage::refs::{IDENTITY_ROOT, SIGREFS_PARENT};
|
||||||
|
use crate::{assert_matches, git};
|
||||||
|
|
||||||
|
use super::mock;
|
||||||
|
use super::mock::{AlwaysVerify, MockRepository};
|
||||||
|
|
||||||
|
fn refs_without_parent(head_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> {
|
||||||
|
vec![
|
||||||
|
(mock::refs_heads_main(), head_oid),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refs(head_oid: Oid, parent_oid: Oid) -> Vec<(git::fmt::RefString, Oid)> {
|
||||||
|
vec![
|
||||||
|
(mock::refs_heads_main(), head_oid),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
(SIGREFS_PARENT.to_ref_string(), parent_oid),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
|
||||||
|
SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_commit_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new().with_commit_error(head);
|
||||||
|
|
||||||
|
let err = read(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(err, error::Read::Commit(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walk_commit_error() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let head = mock::oid(2);
|
||||||
|
let r2 = refs_without_parent(head);
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([root]))
|
||||||
|
.with_refs(head, r2)
|
||||||
|
.with_signature(head, 1)
|
||||||
|
.with_commit_error(root);
|
||||||
|
|
||||||
|
let err = read(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(err, error::Read::Commit(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_verify_signature_error() {
|
||||||
|
// The verifier always rejects the signature → `error::Verify::Signature`.
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = mock::setup_chain([(head, 1, refs_without_parent(head))]);
|
||||||
|
|
||||||
|
let err = SignedRefsReader::new(mock::rid(99), Tip::Commit(head), &repo, &mock::NeverVerify)
|
||||||
|
.read()
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Verify(error::Verify::Signature(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_verify_mismatched_identity_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
// RepoId in test scenario is rid(99), so not equal to rid(50)
|
||||||
|
let mismatched_identity_root = mock::oid(50);
|
||||||
|
let refs = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), mismatched_identity_root),
|
||||||
|
];
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs)
|
||||||
|
.with_signature(head, 1)
|
||||||
|
.with_identity(mismatched_identity_root);
|
||||||
|
|
||||||
|
let err = read(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Verify(error::Verify::MismatchedIdentity { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walk_verify_error() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let commit1 = mock::oid(2);
|
||||||
|
let commit2 = mock::oid(3);
|
||||||
|
let identity_root_mismatch = mock::oid(50);
|
||||||
|
|
||||||
|
let r1 = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let r2 = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), identity_root_mismatch),
|
||||||
|
];
|
||||||
|
let r3 = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(root, mock::commit_data([]))
|
||||||
|
.with_refs(root, r1)
|
||||||
|
.with_signature(root, 1)
|
||||||
|
.with_commit(commit1, mock::commit_data([root]))
|
||||||
|
.with_refs(commit1, r2)
|
||||||
|
.with_signature(commit1, 1)
|
||||||
|
.with_identity(identity_root_mismatch)
|
||||||
|
.with_commit(commit2, mock::commit_data([commit1]))
|
||||||
|
.with_refs(commit2, r3)
|
||||||
|
.with_signature(commit2, 1);
|
||||||
|
|
||||||
|
let err = read(commit2, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Verify(error::Verify::MismatchedIdentity { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_commit() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = mock::setup_chain([(head, 1, refs_without_parent(head))]);
|
||||||
|
|
||||||
|
let vc = read(head, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), head);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_commits() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let head = mock::oid(2);
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(root, 1, refs_without_parent(root)),
|
||||||
|
(head, 2, refs_without_parent(head)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let vc = read(head, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), head);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit chain:
|
||||||
|
/// `A <- B <- A`
|
||||||
|
/// Where A is replayed
|
||||||
|
///
|
||||||
|
/// Expected Result: B
|
||||||
|
#[test]
|
||||||
|
fn head_replays_root() {
|
||||||
|
const SIGNATURE_A: u8 = 1;
|
||||||
|
const SIGNATURE_B: u8 = 2;
|
||||||
|
|
||||||
|
let a1 = mock::oid(1);
|
||||||
|
let b = mock::oid(2);
|
||||||
|
let a2 = mock::oid(3);
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
|
||||||
|
(b, SIGNATURE_B, refs_without_parent(mock::oid(10))),
|
||||||
|
(a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let vc = read(a2, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit chain:
|
||||||
|
/// `A <- A <- A`
|
||||||
|
/// Where A is replayed twice
|
||||||
|
///
|
||||||
|
/// Expected Result: first A
|
||||||
|
#[test]
|
||||||
|
fn replay_chain() {
|
||||||
|
const SIGNATURE_A: u8 = 1;
|
||||||
|
let a1 = mock::oid(1);
|
||||||
|
let a2 = mock::oid(2);
|
||||||
|
let a3 = mock::oid(3);
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
|
||||||
|
(a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
|
||||||
|
(a3, SIGNATURE_A, refs_without_parent(mock::oid(10))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let vc = read(a3, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), a1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit chain:
|
||||||
|
/// `C <- C <- B <- A <- A`
|
||||||
|
/// Where C and A are replayed twice
|
||||||
|
///
|
||||||
|
/// Expected Result: first A
|
||||||
|
#[test]
|
||||||
|
fn multiple_replays() {
|
||||||
|
const SIGNATURE_A: u8 = 3;
|
||||||
|
const SIGNATURE_B: u8 = 2;
|
||||||
|
const SIGNATURE_C: u8 = 1;
|
||||||
|
|
||||||
|
let c1 = mock::oid(1);
|
||||||
|
let c2 = mock::oid(2);
|
||||||
|
let b = mock::oid(3);
|
||||||
|
let a1 = mock::oid(4);
|
||||||
|
let a2 = mock::oid(5);
|
||||||
|
let r = refs_without_parent(mock::oid(10));
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(a2, SIGNATURE_C, r.clone()),
|
||||||
|
(a1, SIGNATURE_C, r.clone()),
|
||||||
|
(b, SIGNATURE_B, r.clone()),
|
||||||
|
(c2, SIGNATURE_A, r.clone()),
|
||||||
|
(c1, SIGNATURE_A, r),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let vc = read(c1, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok_no_parent() {
|
||||||
|
const SIGNATURE_1: u8 = 1;
|
||||||
|
const SIGNATURE_2: u8 = 2;
|
||||||
|
|
||||||
|
let c1 = mock::oid(1);
|
||||||
|
let c2 = mock::oid(2);
|
||||||
|
|
||||||
|
let r = refs_without_parent(mock::oid(10));
|
||||||
|
let repo = mock::setup_chain([(c2, SIGNATURE_2, r.clone()), (c1, SIGNATURE_1, r)]);
|
||||||
|
|
||||||
|
let vc = read(c1, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), c1);
|
||||||
|
|
||||||
|
assert_matches!(
|
||||||
|
vc,
|
||||||
|
VerifiedCommit {
|
||||||
|
commit: Commit {
|
||||||
|
oid: _,
|
||||||
|
parent: Some(_),
|
||||||
|
refs: _,
|
||||||
|
signature: _,
|
||||||
|
identity_root: Some(_)
|
||||||
|
},
|
||||||
|
parent: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok_parent() {
|
||||||
|
const SIGNATURE_1: u8 = 1;
|
||||||
|
const SIGNATURE_2: u8 = 2;
|
||||||
|
|
||||||
|
let c1 = mock::oid(1);
|
||||||
|
let c2 = mock::oid(2);
|
||||||
|
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(c2, SIGNATURE_2, refs_without_parent(mock::oid(10))),
|
||||||
|
(c1, SIGNATURE_1, refs(mock::oid(20), c2)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let vc = read(c1, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), c1);
|
||||||
|
|
||||||
|
assert_matches!(vc, VerifiedCommit { commit: Commit {
|
||||||
|
oid,
|
||||||
|
parent: Some(parent),
|
||||||
|
refs: _,
|
||||||
|
signature: _,
|
||||||
|
identity_root: Some(_)
|
||||||
|
}, parent: true } if parent == c2 && oid == c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_parent() {
|
||||||
|
const SIGNATURE_1: u8 = 1;
|
||||||
|
const SIGNATURE_2: u8 = 2;
|
||||||
|
|
||||||
|
let c1 = mock::oid(1);
|
||||||
|
let c2 = mock::oid(2);
|
||||||
|
|
||||||
|
let wrong = mock::oid(42);
|
||||||
|
|
||||||
|
let repo = mock::setup_chain([
|
||||||
|
(c2, SIGNATURE_2, refs_without_parent(mock::oid(10))),
|
||||||
|
(c1, SIGNATURE_1, refs(mock::oid(20), wrong)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_matches!(read(c1, repo), Err(Read::Verify(Verify::MismatchedParent {
|
||||||
|
sigrefs_commit,
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
})) if sigrefs_commit == c1 && expected == c2 && actual == wrong);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::storage::refs::sigrefs::read::{error, SignedRefsReader, Tip};
|
||||||
|
use crate::storage::refs::sigrefs::VerifiedCommit;
|
||||||
|
use crate::storage::refs::{IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH};
|
||||||
|
|
||||||
|
use super::mock;
|
||||||
|
use super::mock::{AlwaysVerify, MockRepository};
|
||||||
|
|
||||||
|
fn refs_heads_main() -> [(git::fmt::RefString, Oid); 1] {
|
||||||
|
[(mock::refs_heads_main(), mock::oid(10))]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_at(tip: Oid, repo: MockRepository) -> Result<VerifiedCommit, error::Read> {
|
||||||
|
SignedRefsReader::new(mock::rid(99), Tip::Commit(tip), &repo, &AlwaysVerify).read()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_refs_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_blob_error(head, &REFS_BLOB_PATH)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::Refs(_)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_signature_error() {
|
||||||
|
let root = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(root, mock::commit_data([]))
|
||||||
|
.with_refs(root, refs_heads_main())
|
||||||
|
.with_blob_error(root, &SIGNATURE_BLOB_PATH);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
read_at(root, repo),
|
||||||
|
Err(error::Read::Commit(error::Commit::Tree(
|
||||||
|
error::Tree::Signature(_)
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_both() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_missing_refs(head)
|
||||||
|
.with_missing_signature(head);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
|
||||||
|
error::MissingBlobs::Both { .. }
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_signature() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs_heads_main())
|
||||||
|
.with_missing_signature(head);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
|
||||||
|
error::MissingBlobs::Refs { .. }
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_refs() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_missing_refs(head)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::MissingBlobs(
|
||||||
|
error::MissingBlobs::Signature { .. }
|
||||||
|
)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_refs_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_blob(head, &REFS_BLOB_PATH, b"NOT VALID REFS\n".to_vec())
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::ParseRefs(_)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_signature_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs_heads_main())
|
||||||
|
.with_blob(head, &SIGNATURE_BLOB_PATH, vec![0u8; 1]);
|
||||||
|
|
||||||
|
let err = read_at(head, repo).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
error::Read::Commit(error::Commit::Tree(error::Tree::ParseSignature(_)))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let refs = [
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(
|
||||||
|
IDENTITY_ROOT.to_ref_string(),
|
||||||
|
mock::oid(mock::MOCKED_IDENTITY),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_commit(head, mock::commit_data([]))
|
||||||
|
.with_refs(head, refs)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
|
||||||
|
let vc = read_at(head, repo).unwrap();
|
||||||
|
assert_eq!(*vc.id(), head);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crypto::signature::Signer;
|
||||||
|
use radicle_core::NodeId;
|
||||||
|
use radicle_git_metadata::author::Author;
|
||||||
|
use radicle_git_metadata::commit::{headers::Headers, trailers::OwnedTrailer, CommitData};
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::storage::refs::sigrefs::git::{object, reference, Committer};
|
||||||
|
use crate::storage::refs::{
|
||||||
|
Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH, SIGREFS_PARENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The result of calling [`SignedRefsWriter::write`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Update {
|
||||||
|
/// The new signed references commit was written to the Git repository.
|
||||||
|
Changed { entry: Box<Commit> },
|
||||||
|
/// The provided [`Refs`] were equal to the current [`Refs`], so the process
|
||||||
|
/// exited early.
|
||||||
|
Unchanged {
|
||||||
|
commit: Oid,
|
||||||
|
refs: Refs,
|
||||||
|
signature: crypto::Signature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Commit> for Update {
|
||||||
|
fn from(commit: Commit) -> Self {
|
||||||
|
Self::Changed {
|
||||||
|
entry: Box::new(commit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Head> for Update {
|
||||||
|
fn from(
|
||||||
|
Head {
|
||||||
|
commit,
|
||||||
|
refs,
|
||||||
|
signature,
|
||||||
|
}: Head,
|
||||||
|
) -> Self {
|
||||||
|
Self::Unchanged {
|
||||||
|
commit,
|
||||||
|
refs,
|
||||||
|
signature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`SignedRefsWriter`] write a commit to the `rad/sigrefs` reference of a
|
||||||
|
/// namespace.
|
||||||
|
///
|
||||||
|
/// To create a new reader, use [`SignedRefsWriter::new`].
|
||||||
|
///
|
||||||
|
/// The construction expects:
|
||||||
|
/// - A [`Refs`] to write to the commit.
|
||||||
|
/// - A [`NodeId`] which identifies the namespace for which `rad/sigrefs`
|
||||||
|
/// reference should be read and written to.
|
||||||
|
/// - A `repository` which is the Git repository being used for reading and
|
||||||
|
/// writing.
|
||||||
|
/// - A `signer` which is the entity that produces the cryptographic signature
|
||||||
|
/// over the [`Refs`].
|
||||||
|
pub struct SignedRefsWriter<'a, R, S> {
|
||||||
|
refs: Refs,
|
||||||
|
namespace: NodeId,
|
||||||
|
repository: &'a R,
|
||||||
|
signer: &'a S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R, S> SignedRefsWriter<'a, R, S>
|
||||||
|
where
|
||||||
|
R: object::Writer + object::Reader + reference::Writer + reference::Reader,
|
||||||
|
S: Signer<crypto::Signature>,
|
||||||
|
{
|
||||||
|
/// Construct a new [`SignedRefsWriter`].
|
||||||
|
///
|
||||||
|
/// The construction removes the ref [`SIGREFS_BRANCH`] from [`Refs`]
|
||||||
|
/// (if present).
|
||||||
|
///
|
||||||
|
/// When calling [`SignedRefsWriter::write`], if the process is successful,
|
||||||
|
/// the given [`Refs`] will be written to the provided `namespace`.
|
||||||
|
pub fn new(mut refs: Refs, namespace: NodeId, repository: &'a R, signer: &'a S) -> Self {
|
||||||
|
debug_assert!(refs.get(&IDENTITY_ROOT).is_some());
|
||||||
|
debug_assert!(refs.get(&SIGREFS_PARENT).is_none());
|
||||||
|
refs.remove_sigrefs();
|
||||||
|
Self {
|
||||||
|
refs,
|
||||||
|
namespace,
|
||||||
|
repository,
|
||||||
|
signer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a commit using the [`SignedRefsWriter`].
|
||||||
|
///
|
||||||
|
/// The commit written will be composed of:
|
||||||
|
/// - The parent commit of the previous entry, unless it is the root commit.
|
||||||
|
/// - The [`Refs`] under the `/refs` blob. The [`Refs`] must include:
|
||||||
|
/// - The [`SIGREFS_PARENT`] entry.
|
||||||
|
/// - The [`IDENTITY_ROOT`] entry.
|
||||||
|
/// - The [`crypto::Signature`] of the [`Refs`] bytes, under the
|
||||||
|
/// `/signature` blob.
|
||||||
|
///
|
||||||
|
/// Note that the [`SIGREFS_PARENT`] is not never included in the [`Refs`]
|
||||||
|
/// outside of this process.
|
||||||
|
///
|
||||||
|
/// This commit is then written to the reference:
|
||||||
|
/// ```text,no_run
|
||||||
|
/// refs/namespaces/<namespace>/refs/rad/sigrefs
|
||||||
|
/// ```
|
||||||
|
pub fn write(
|
||||||
|
self,
|
||||||
|
committer: Committer,
|
||||||
|
message: String,
|
||||||
|
reflog: String,
|
||||||
|
) -> Result<Update, error::Write> {
|
||||||
|
let author = committer.into_inner();
|
||||||
|
let Self {
|
||||||
|
refs,
|
||||||
|
namespace,
|
||||||
|
repository,
|
||||||
|
signer,
|
||||||
|
} = self;
|
||||||
|
let reference = SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace));
|
||||||
|
let head = HeadReader::new(&reference, repository)
|
||||||
|
.read()
|
||||||
|
.map_err(error::Write::Head)?;
|
||||||
|
let commit_writer = match head {
|
||||||
|
Some(head) if head.is_unchanged(&refs) => return Ok(Update::from(head)),
|
||||||
|
Some(head) => {
|
||||||
|
CommitWriter::with_parent(refs, head.commit, author, message, repository, signer)
|
||||||
|
}
|
||||||
|
None => CommitWriter::root(refs, author, message, repository, signer),
|
||||||
|
};
|
||||||
|
let commit = commit_writer.write().map_err(error::Write::Commit)?;
|
||||||
|
repository
|
||||||
|
.write_reference(&reference, commit.oid, commit.parent, reflog)
|
||||||
|
.map_err(error::Write::Reference)?;
|
||||||
|
Ok(Update::from(commit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Commit {
|
||||||
|
/// The [`Oid`] of the parent commit.
|
||||||
|
parent: Option<Oid>,
|
||||||
|
/// The [`Oid`] of this commit.
|
||||||
|
oid: Oid,
|
||||||
|
/// The [`Refs`] that were committed.
|
||||||
|
refs: Refs,
|
||||||
|
/// The [`Signature`] of the [`Refs`] and the [`CommitData`].
|
||||||
|
signature: crypto::Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Commit {
|
||||||
|
pub(super) fn into_refs(self) -> Refs {
|
||||||
|
self.refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommitWriter<'a, R, S> {
|
||||||
|
refs: Refs,
|
||||||
|
parent: Option<Oid>,
|
||||||
|
author: Author,
|
||||||
|
message: String,
|
||||||
|
repository: &'a R,
|
||||||
|
signer: &'a S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R, S> CommitWriter<'a, R, S>
|
||||||
|
where
|
||||||
|
R: object::Writer,
|
||||||
|
S: Signer<crypto::Signature>,
|
||||||
|
{
|
||||||
|
fn root(refs: Refs, author: Author, message: String, repository: &'a R, signer: &'a S) -> Self {
|
||||||
|
Self {
|
||||||
|
refs,
|
||||||
|
parent: None,
|
||||||
|
author,
|
||||||
|
message,
|
||||||
|
repository,
|
||||||
|
signer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_parent(
|
||||||
|
refs: Refs,
|
||||||
|
parent: Oid,
|
||||||
|
author: Author,
|
||||||
|
message: String,
|
||||||
|
repository: &'a R,
|
||||||
|
signer: &'a S,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
refs,
|
||||||
|
parent: Some(parent),
|
||||||
|
author,
|
||||||
|
message,
|
||||||
|
repository,
|
||||||
|
signer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(mut self) -> Result<Commit, error::Commit> {
|
||||||
|
if let Some(parent) = self.parent {
|
||||||
|
let prev = self.refs.add_parent(parent);
|
||||||
|
debug_assert!(prev.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tree = TreeWriter::new(self.refs, self.repository, self.signer)
|
||||||
|
.write()
|
||||||
|
.map_err(error::Commit::Tree)?;
|
||||||
|
|
||||||
|
let commit = CommitData::new::<_, _, OwnedTrailer>(
|
||||||
|
tree.oid,
|
||||||
|
self.parent,
|
||||||
|
self.author.clone(),
|
||||||
|
self.author,
|
||||||
|
Headers::new(),
|
||||||
|
self.message,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let oid = self
|
||||||
|
.repository
|
||||||
|
.write_commit(commit.to_string().as_bytes())
|
||||||
|
.map_err(error::Commit::Write)?;
|
||||||
|
|
||||||
|
tree.refs.remove_parent();
|
||||||
|
|
||||||
|
Ok(Commit {
|
||||||
|
parent: self.parent,
|
||||||
|
oid,
|
||||||
|
refs: tree.refs,
|
||||||
|
signature: tree.signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct Tree {
|
||||||
|
oid: Oid,
|
||||||
|
refs: Refs,
|
||||||
|
signature: crypto::Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TreeWriter<'a, R, S> {
|
||||||
|
refs: Refs,
|
||||||
|
repository: &'a R,
|
||||||
|
signer: &'a S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R, S> TreeWriter<'a, R, S>
|
||||||
|
where
|
||||||
|
R: object::Writer,
|
||||||
|
S: Signer<crypto::Signature>,
|
||||||
|
{
|
||||||
|
fn new(refs: Refs, repository: &'a R, signer: &'a S) -> Self {
|
||||||
|
Self {
|
||||||
|
refs,
|
||||||
|
repository,
|
||||||
|
signer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(self) -> Result<Tree, error::Tree> {
|
||||||
|
let canonical = self.refs.canonical();
|
||||||
|
let signature = self
|
||||||
|
.signer
|
||||||
|
.try_sign(&canonical)
|
||||||
|
.map_err(error::Tree::Sign)?;
|
||||||
|
let refs = object::RefsEntry {
|
||||||
|
path: Path::new(REFS_BLOB_PATH).to_path_buf(),
|
||||||
|
content: canonical,
|
||||||
|
};
|
||||||
|
let sig = object::SignatureEntry {
|
||||||
|
path: Path::new(SIGNATURE_BLOB_PATH).to_path_buf(),
|
||||||
|
content: signature.to_vec(),
|
||||||
|
};
|
||||||
|
let oid = self
|
||||||
|
.repository
|
||||||
|
.write_tree(refs, sig)
|
||||||
|
.map_err(error::Tree::Write)?;
|
||||||
|
Ok(Tree {
|
||||||
|
oid,
|
||||||
|
refs: self.refs,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current head commit of the reference that points to the signed
|
||||||
|
/// references payload.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct Head {
|
||||||
|
/// The commit [`Oid`] at the head of the reference.
|
||||||
|
commit: Oid,
|
||||||
|
/// The [`Refs`] found within the head commit.
|
||||||
|
refs: Refs,
|
||||||
|
/// The [`crypto::Signature`] over the [`Refs`] blob.
|
||||||
|
signature: crypto::Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Head {
|
||||||
|
/// Returns `true` if the `proposed` [`Refs`] are equal to the [`Refs`]
|
||||||
|
/// of the [`Head`].
|
||||||
|
fn is_unchanged(&self, proposed: &Refs) -> bool {
|
||||||
|
self.refs == *proposed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeadReader<'a, 'b, R> {
|
||||||
|
reference: &'a git::fmt::Namespaced<'a>,
|
||||||
|
repository: &'b R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b, R> HeadReader<'a, 'b, R>
|
||||||
|
where
|
||||||
|
R: object::Reader + reference::Reader,
|
||||||
|
{
|
||||||
|
/// Construct a [`HeadReader`] with the `reference` that is being read from
|
||||||
|
/// the `repository.`
|
||||||
|
fn new(reference: &'a git::fmt::Namespaced<'a>, repository: &'b R) -> Self {
|
||||||
|
Self {
|
||||||
|
reference,
|
||||||
|
repository,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the [`Head`] that is found in the repository under the given
|
||||||
|
/// reference.
|
||||||
|
///
|
||||||
|
/// Returns `None` if no such reference exists.
|
||||||
|
///
|
||||||
|
/// The returned [`Refs`] do not contain the [`SIGREFS_PARENT`] reference.
|
||||||
|
fn read(self) -> Result<Option<Head>, error::Head> {
|
||||||
|
self.repository
|
||||||
|
.find_reference(self.reference)
|
||||||
|
.map_err(error::Head::Reference)?
|
||||||
|
.map(|commit| self.with_refs(commit))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_refs(&self, commit: Oid) -> Result<Head, error::Head> {
|
||||||
|
let refs = self.refs(commit)?;
|
||||||
|
let signature = self.refs_signature(commit)?;
|
||||||
|
Ok(Head {
|
||||||
|
commit,
|
||||||
|
refs,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refs(&self, commit: Oid) -> Result<Refs, error::Head> {
|
||||||
|
let path = Path::new(REFS_BLOB_PATH);
|
||||||
|
let object::Blob { bytes, .. } = self
|
||||||
|
.repository
|
||||||
|
.read_blob(&commit, path)
|
||||||
|
.map_err(error::Head::Blob)?
|
||||||
|
.ok_or(error::Head::MissingPath {
|
||||||
|
commit,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut refs = Refs::from_canonical(&bytes).map_err(error::Head::Refs)?;
|
||||||
|
refs.remove_parent();
|
||||||
|
Ok(refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refs_signature(&self, commit: Oid) -> Result<crypto::Signature, error::Head> {
|
||||||
|
let path = Path::new(SIGNATURE_BLOB_PATH);
|
||||||
|
let object::Blob {
|
||||||
|
bytes: sig_bytes, ..
|
||||||
|
} = self
|
||||||
|
.repository
|
||||||
|
.read_blob(&commit, path)
|
||||||
|
.map_err(error::Head::Blob)?
|
||||||
|
.ok_or(error::Head::MissingPath {
|
||||||
|
commit,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
})?;
|
||||||
|
crypto::Signature::try_from(sig_bytes.as_slice()).map_err(|err| error::Head::Signature {
|
||||||
|
commit,
|
||||||
|
source: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
|
||||||
|
mod commit_writer;
|
||||||
|
mod head_reader;
|
||||||
|
mod signed_refs_writer;
|
||||||
|
mod tree_writer;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
use radicle_git_ref_format::Component;
|
||||||
|
|
||||||
|
use super::mock::{self, MockRepository};
|
||||||
|
use crate::storage::refs::sigrefs::write::{error, Head, HeadReader};
|
||||||
|
use crate::storage::refs::{Refs, SIGREFS_BRANCH};
|
||||||
|
|
||||||
|
/// Drive `HeadReader` directly via the sigrefs reference for `mock::node_id()`.
|
||||||
|
fn read(repo: &MockRepository) -> Result<Option<Head>, error::Head> {
|
||||||
|
let namespace = mock::node_id();
|
||||||
|
let reference = SIGREFS_BRANCH.with_namespace(Component::from(&namespace));
|
||||||
|
HeadReader::new(&reference, repo).read()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_error() {
|
||||||
|
let repo = MockRepository::new().with_rad_sigrefs_error(&mock::node_id());
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::Reference(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_head() {
|
||||||
|
let repo = MockRepository::new().with_missing_rad_sigrefs(&mock::node_id());
|
||||||
|
assert!(matches!(read(&repo), Ok(None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refs_blob_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs_error(head);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::Blob(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refs_blob_missing() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_missing_refs(head)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refs_parse_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_invalid_refs(head)
|
||||||
|
.with_signature(head, 1);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::Refs(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_blob_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
|
||||||
|
.with_signature_error(head);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::Blob(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_blob_missing() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
|
||||||
|
.with_missing_signature(head);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_parse_error() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))])
|
||||||
|
.with_invalid_signature(head);
|
||||||
|
assert!(matches!(read(&repo), Err(error::Head::Signature { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_ok() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let refs = [(mock::refs_heads_main(), mock::oid(10))];
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, refs.clone())
|
||||||
|
.with_signature(head, 1);
|
||||||
|
assert_eq!(
|
||||||
|
read(&repo).unwrap(),
|
||||||
|
Some(Head {
|
||||||
|
commit: head,
|
||||||
|
refs: Refs::from(refs.into_iter()),
|
||||||
|
signature: crypto::Signature::from([1; 64]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr as _;
|
||||||
|
|
||||||
|
use radicle_core::NodeId;
|
||||||
|
use radicle_git_metadata::author::{Author, Time};
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::storage::refs::sigrefs::git::{object, reference};
|
||||||
|
use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH};
|
||||||
|
|
||||||
|
enum WriteTreeBehavior {
|
||||||
|
/// [`object::Writer::write_tree`] returns `Ok(oid)`.
|
||||||
|
Ok(Oid),
|
||||||
|
/// [`object::Writer::write_tree`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`object::Writer::write_commit`] returns `Ok(oid)`.
|
||||||
|
struct WriteCommitBehavior(Oid);
|
||||||
|
|
||||||
|
enum WriteReferenceBehavior {
|
||||||
|
/// [`reference::Writer::write_reference`] returns `Ok(())`.
|
||||||
|
Ok,
|
||||||
|
/// [`reference::Writer::write_reference`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BlobBehavior {
|
||||||
|
/// [`object::Reader::read_blob`] returns `Ok(Some(blob))`.
|
||||||
|
Present(Vec<u8>),
|
||||||
|
/// [`object::Reader::read_blob`] returns `Ok(None)`.
|
||||||
|
Missing,
|
||||||
|
/// [`object::Reader::read_blob`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RefBehavior {
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Ok(Some(oid))`.
|
||||||
|
Present(Oid),
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Ok(None)`.
|
||||||
|
Missing,
|
||||||
|
/// [`reference::Reader::find_reference`] returns `Err(…)`.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MockRepository {
|
||||||
|
blobs: HashMap<(Oid, PathBuf), BlobBehavior>,
|
||||||
|
references: HashMap<String, RefBehavior>,
|
||||||
|
write_tree: Option<WriteTreeBehavior>,
|
||||||
|
write_commit: Option<WriteCommitBehavior>,
|
||||||
|
write_reference: Option<WriteReferenceBehavior>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockRepository {
|
||||||
|
pub fn new() -> MockRepository {
|
||||||
|
MockRepository {
|
||||||
|
blobs: HashMap::new(),
|
||||||
|
references: HashMap::new(),
|
||||||
|
write_tree: None,
|
||||||
|
write_commit: None,
|
||||||
|
write_reference: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_rad_sigrefs(mut self, namespace: &NodeId, commit: Oid) -> MockRepository {
|
||||||
|
self.references
|
||||||
|
.insert(sigrefs_ref_name(namespace), RefBehavior::Present(commit));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_rad_sigrefs(mut self, namespace: &NodeId) -> MockRepository {
|
||||||
|
self.references
|
||||||
|
.insert(sigrefs_ref_name(namespace), RefBehavior::Missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_rad_sigrefs_error(mut self, namespace: &NodeId) -> MockRepository {
|
||||||
|
self.references
|
||||||
|
.insert(sigrefs_ref_name(namespace), RefBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_refs(
|
||||||
|
self,
|
||||||
|
commit: Oid,
|
||||||
|
refs: impl IntoIterator<Item = (git::fmt::RefString, Oid)>,
|
||||||
|
) -> MockRepository {
|
||||||
|
let refs = Refs::from(refs.into_iter());
|
||||||
|
self.with_blob(commit, Path::new(REFS_BLOB_PATH), refs.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_refs_error(self, commit: Oid) -> MockRepository {
|
||||||
|
self.with_blob_error(commit, Path::new(REFS_BLOB_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_refs(self, commit: Oid) -> MockRepository {
|
||||||
|
self.with_missing_blob(commit, Path::new(REFS_BLOB_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_invalid_refs(self, commit: Oid) -> MockRepository {
|
||||||
|
self.with_blob(
|
||||||
|
commit,
|
||||||
|
Path::new(REFS_BLOB_PATH),
|
||||||
|
b"NOT VALID REFS\n".to_vec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_signature(self, commit: Oid, sig_id: u8) -> MockRepository {
|
||||||
|
self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), vec![sig_id; 64])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_signature_error(self, commit: Oid) -> MockRepository {
|
||||||
|
self.with_blob_error(commit, Path::new(SIGNATURE_BLOB_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_missing_signature(self, commit: Oid) -> MockRepository {
|
||||||
|
self.with_missing_blob(commit, Path::new(SIGNATURE_BLOB_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_invalid_signature(self, commit: Oid) -> MockRepository {
|
||||||
|
let bytes = vec![0u8; 1];
|
||||||
|
assert!(crypto::Signature::from_str(std::str::from_utf8(&bytes).unwrap()).is_err());
|
||||||
|
self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_blob(mut self, commit: Oid, path: &Path, bytes: Vec<u8>) -> Self {
|
||||||
|
self.blobs
|
||||||
|
.insert((commit, path.to_path_buf()), BlobBehavior::Present(bytes));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_blob_error(mut self, commit: Oid, path: &Path) -> Self {
|
||||||
|
self.blobs
|
||||||
|
.insert((commit, path.to_path_buf()), BlobBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_missing_blob(mut self, commit: Oid, path: &Path) -> Self {
|
||||||
|
self.blobs
|
||||||
|
.insert((commit, path.to_path_buf()), BlobBehavior::Missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_write_tree_error(mut self) -> Self {
|
||||||
|
self.write_tree = Some(WriteTreeBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_write_tree_ok(mut self, oid: Oid) -> Self {
|
||||||
|
self.write_tree = Some(WriteTreeBehavior::Ok(oid));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_write_commit_ok(mut self, oid: Oid) -> Self {
|
||||||
|
self.write_commit = Some(WriteCommitBehavior(oid));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_write_reference_ok(mut self) -> Self {
|
||||||
|
self.write_reference = Some(WriteReferenceBehavior::Ok);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_write_reference_error(mut self) -> Self {
|
||||||
|
self.write_reference = Some(WriteReferenceBehavior::Error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl object::Reader for MockRepository {
|
||||||
|
fn read_commit(&self, _oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
|
||||||
|
// HeadReader never calls read_commit; this is a no-op placeholder.
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_blob(
|
||||||
|
&self,
|
||||||
|
commit: &Oid,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<Option<object::Blob>, object::error::ReadBlob> {
|
||||||
|
let key = (*commit, path.to_path_buf());
|
||||||
|
match self.blobs.get(&key) {
|
||||||
|
Some(BlobBehavior::Present(bytes)) => Ok(Some(object::Blob {
|
||||||
|
oid: *commit,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
})),
|
||||||
|
Some(BlobBehavior::Missing) | None => Ok(None),
|
||||||
|
Some(BlobBehavior::Error) => Err(object::error::ReadBlob::other(
|
||||||
|
std::io::Error::other("mock blob error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl reference::Reader for MockRepository {
|
||||||
|
fn find_reference(
|
||||||
|
&self,
|
||||||
|
reference: &git::fmt::Namespaced,
|
||||||
|
) -> Result<Option<Oid>, reference::error::FindReference> {
|
||||||
|
match self.references.get(reference.as_str()) {
|
||||||
|
Some(RefBehavior::Present(oid)) => Ok(Some(*oid)),
|
||||||
|
Some(RefBehavior::Missing) | None => Ok(None),
|
||||||
|
Some(RefBehavior::Error) => Err(reference::error::FindReference::other(
|
||||||
|
std::io::Error::other("mock reference error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl object::Writer for MockRepository {
|
||||||
|
fn write_tree(
|
||||||
|
&self,
|
||||||
|
_refs: object::RefsEntry,
|
||||||
|
_signature: object::SignatureEntry,
|
||||||
|
) -> Result<Oid, object::error::WriteTree> {
|
||||||
|
match &self.write_tree {
|
||||||
|
Some(WriteTreeBehavior::Ok(oid)) => Ok(*oid),
|
||||||
|
Some(WriteTreeBehavior::Error) | None => Err(object::error::WriteTree::write_error(
|
||||||
|
std::io::Error::other("mock write_tree error"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_commit(&self, _bytes: &[u8]) -> Result<Oid, object::error::WriteCommit> {
|
||||||
|
match &self.write_commit {
|
||||||
|
Some(WriteCommitBehavior(oid)) => Ok(*oid),
|
||||||
|
None => Err(object::error::WriteCommit::other(std::io::Error::other(
|
||||||
|
"mock write_commit error",
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl reference::Writer for MockRepository {
|
||||||
|
fn write_reference(
|
||||||
|
&self,
|
||||||
|
_reference: &git::fmt::Namespaced,
|
||||||
|
_commit: Oid,
|
||||||
|
_parent: Option<Oid>,
|
||||||
|
_reflog: String,
|
||||||
|
) -> Result<(), reference::error::WriteReference> {
|
||||||
|
match &self.write_reference {
|
||||||
|
Some(WriteReferenceBehavior::Ok) => Ok(()),
|
||||||
|
Some(WriteReferenceBehavior::Error) | None => {
|
||||||
|
Err(reference::error::WriteReference::other(
|
||||||
|
std::io::Error::other("mock write_reference error"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always signs successfully, returning a fixed 64-byte signature.
|
||||||
|
pub struct AlwaysSign;
|
||||||
|
|
||||||
|
impl AlwaysSign {
|
||||||
|
const SIGNATURE: [u8; 64] = [1u8; 64];
|
||||||
|
|
||||||
|
pub fn signature() -> crypto::Signature {
|
||||||
|
crypto::Signature::from(Self::SIGNATURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crypto::signature::Signer<crypto::Signature> for AlwaysSign {
|
||||||
|
fn try_sign(&self, _msg: &[u8]) -> Result<crypto::Signature, crypto::signature::Error> {
|
||||||
|
Ok(Self::signature())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always fails to sign.
|
||||||
|
pub struct NeverSign;
|
||||||
|
|
||||||
|
impl crypto::signature::Signer<crypto::Signature> for NeverSign {
|
||||||
|
fn try_sign(&self, _msg: &[u8]) -> Result<crypto::Signature, crypto::signature::Error> {
|
||||||
|
Err(crypto::signature::Error::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct an [`Oid`] from a single repeated byte.
|
||||||
|
///
|
||||||
|
/// `oid(1) != oid(2)` is guaranteed; use distinct values for distinct objects.
|
||||||
|
pub fn oid(n: u8) -> Oid {
|
||||||
|
Oid::from_sha1([n; 20])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixed [`NodeId`] for tests.
|
||||||
|
pub fn node_id() -> NodeId {
|
||||||
|
NodeId::from([1u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refs_heads_main() -> git::fmt::RefString {
|
||||||
|
git::fmt::refname!("refs/heads/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal [`radicle_git_metadata::author::Author`] for use in tests.
|
||||||
|
pub fn author() -> Author {
|
||||||
|
Author {
|
||||||
|
name: "test".to_owned(),
|
||||||
|
email: "test@example.com".to_owned(),
|
||||||
|
time: Time::new(0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sigrefs_ref_name(namespace: &NodeId) -> String {
|
||||||
|
SIGREFS_BRANCH
|
||||||
|
.with_namespace(git::fmt::Component::from(namespace))
|
||||||
|
.as_str()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
use radicle_oid::Oid;
|
||||||
|
|
||||||
|
use crate::storage::refs::sigrefs::git::Committer;
|
||||||
|
use crate::storage::refs::sigrefs::write::{error, SignedRefsWriter, Update};
|
||||||
|
use crate::storage::refs::{Refs, IDENTITY_ROOT, SIGREFS_BRANCH};
|
||||||
|
|
||||||
|
use super::mock;
|
||||||
|
use super::mock::MockRepository;
|
||||||
|
|
||||||
|
fn some_refs(identity_root: Oid) -> Refs {
|
||||||
|
Refs::from(
|
||||||
|
[
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), identity_root),
|
||||||
|
]
|
||||||
|
.into_iter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other_refs() -> Refs {
|
||||||
|
Refs::from([(mock::refs_heads_main(), mock::oid(20))].into_iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refs_with_rad_sigrefs() -> Refs {
|
||||||
|
Refs::from(
|
||||||
|
[
|
||||||
|
(mock::refs_heads_main(), mock::oid(10)),
|
||||||
|
(IDENTITY_ROOT.to_ref_string(), mock::oid(99)),
|
||||||
|
(SIGREFS_BRANCH.to_ref_string(), mock::oid(20)),
|
||||||
|
]
|
||||||
|
.into_iter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> {
|
||||||
|
SignedRefsWriter::new(refs, mock::node_id(), repo, &mock::AlwaysSign).write(
|
||||||
|
Committer::new(mock::author()),
|
||||||
|
"msg".into(),
|
||||||
|
"reflog".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_error() {
|
||||||
|
let repo = MockRepository::new().with_rad_sigrefs_error(&mock::node_id());
|
||||||
|
assert!(matches!(
|
||||||
|
write(some_refs(mock::oid(99)), &repo),
|
||||||
|
Err(error::Write::Head(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let refs = some_refs(mock::oid(99));
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, refs.clone())
|
||||||
|
.with_signature(head, 1);
|
||||||
|
assert_eq!(
|
||||||
|
write(refs.clone(), &repo).unwrap(),
|
||||||
|
Update::Unchanged {
|
||||||
|
commit: head,
|
||||||
|
refs,
|
||||||
|
signature: crypto::Signature::from([1; 64]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_error() {
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_missing_rad_sigrefs(&mock::node_id())
|
||||||
|
.with_write_tree_ok(mock::oid(99));
|
||||||
|
assert!(matches!(
|
||||||
|
write(some_refs(mock::oid(99)), &repo),
|
||||||
|
Err(error::Write::Commit(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_error() {
|
||||||
|
let commit_oid = mock::oid(42);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_missing_rad_sigrefs(&mock::node_id())
|
||||||
|
.with_write_tree_ok(mock::oid(99))
|
||||||
|
.with_write_commit_ok(commit_oid)
|
||||||
|
.with_write_reference_error();
|
||||||
|
assert!(matches!(
|
||||||
|
write(some_refs(mock::oid(99)), &repo),
|
||||||
|
Err(error::Write::Reference(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_root_ok() {
|
||||||
|
let commit_oid = mock::oid(42);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_missing_rad_sigrefs(&mock::node_id())
|
||||||
|
.with_write_tree_ok(mock::oid(99))
|
||||||
|
.with_write_commit_ok(commit_oid)
|
||||||
|
.with_write_reference_ok();
|
||||||
|
let refs = some_refs(mock::oid(99));
|
||||||
|
let update = write(refs.clone(), &repo).unwrap();
|
||||||
|
let Update::Changed { entry } = update else {
|
||||||
|
panic!("expected Update::Changed, got {update:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(entry.parent, None);
|
||||||
|
assert_eq!(entry.oid, commit_oid);
|
||||||
|
assert_eq!(entry.into_refs(), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_with_parent_ok() {
|
||||||
|
let head = mock::oid(1);
|
||||||
|
let commit_oid = mock::oid(42);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_rad_sigrefs(&mock::node_id(), head)
|
||||||
|
.with_refs(head, other_refs())
|
||||||
|
.with_signature(head, 1)
|
||||||
|
.with_write_tree_ok(mock::oid(99))
|
||||||
|
.with_write_commit_ok(commit_oid)
|
||||||
|
.with_write_reference_ok();
|
||||||
|
let refs = some_refs(mock::oid(99));
|
||||||
|
let update = write(refs.clone(), &repo).unwrap();
|
||||||
|
let Update::Changed { entry } = update else {
|
||||||
|
panic!("expected Update::Changed, got {update:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(entry.parent, Some(head));
|
||||||
|
assert_eq!(entry.oid, commit_oid);
|
||||||
|
assert_eq!(entry.into_refs(), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We should error on empty `Refs` writes
|
||||||
|
#[test]
|
||||||
|
fn write_empty_refs() {
|
||||||
|
let refs = Refs::from([(IDENTITY_ROOT.to_ref_string(), mock::oid(99))].into_iter());
|
||||||
|
let commit_oid = mock::oid(42);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_missing_rad_sigrefs(&mock::node_id())
|
||||||
|
.with_write_tree_ok(mock::oid(99))
|
||||||
|
.with_write_commit_ok(commit_oid)
|
||||||
|
.with_write_reference_ok();
|
||||||
|
let update = write(refs.clone(), &repo).unwrap();
|
||||||
|
let Update::Changed { entry } = update else {
|
||||||
|
panic!("expected Update::Changed, got {update:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(entry.parent, None);
|
||||||
|
assert_eq!(entry.oid, commit_oid);
|
||||||
|
assert_eq!(entry.into_refs(), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn never_write_rad_sigrefs() {
|
||||||
|
let commit_oid = mock::oid(42);
|
||||||
|
let repo = MockRepository::new()
|
||||||
|
.with_missing_rad_sigrefs(&mock::node_id())
|
||||||
|
.with_write_tree_ok(mock::oid(99))
|
||||||
|
.with_write_commit_ok(commit_oid)
|
||||||
|
.with_write_reference_ok();
|
||||||
|
let mut refs = refs_with_rad_sigrefs();
|
||||||
|
let update = write(refs.clone(), &repo).unwrap();
|
||||||
|
let Update::Changed { entry } = update else {
|
||||||
|
panic!("expected Update::Changed, got {update:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(entry.parent, None);
|
||||||
|
assert_eq!(entry.oid, commit_oid);
|
||||||
|
|
||||||
|
refs.remove_sigrefs();
|
||||||
|
assert_eq!(entry.into_refs(), refs);
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue