radicle/sigrefs: Detect features when writing

In order to respond to the caller with the correct feature level in
case sigrefs are to be saved but unchanged, the write side needs to
care about feature levels. This is done by sharing more code with the
read side.
This commit is contained in:
Lorenz Leutgeb 2026-03-22 15:02:27 +01:00 committed by Fintan Halpenny
parent 372a78754a
commit 8bc3ffc0b0
10 changed files with 308 additions and 211 deletions

View File

@ -22,6 +22,12 @@ impl signature::Signer<Signature> for MockSigner {
} }
} }
impl signature::Verifier<Signature> for MockSigner {
fn verify(&self, msg: &[u8], signature: &Signature) -> Result<(), signature::Error> {
self.pk.verify(msg, signature)
}
}
impl AsRef<PublicKey> for MockSigner { impl AsRef<PublicKey> for MockSigner {
fn as_ref(&self) -> &PublicKey { fn as_ref(&self) -> &PublicKey {
&self.pk &self.pk

View File

@ -78,40 +78,23 @@ impl Refs {
signer: &S, signer: &S,
) -> Result<SignedRefsAt, Error> ) -> Result<SignedRefsAt, Error>
where where
S: signature::Signer<crypto::Signature>,
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer, R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer, R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{ {
// FIXME(lorenz): We promise this feature level to the caller, but
// have not actually verified it.
const LEVEL_PROMISED: FeatureLevel = FeatureLevel::Parent;
let msg = "Update signed refs\n"; let msg = "Update signed refs\n";
let reflog = format!("Save {} signed references", self.len()); let reflog = format!("Save {} signed references", self.len());
let update = sigrefs::write::SignedRefsWriter::new(self, namespace, repo, signer).write( let update =
committer, sigrefs::write::SignedRefsWriter::new(self, repo.rid(), namespace, repo, signer)
msg.to_string(), .write(committer, msg.to_string(), reflog)?;
reflog,
)?;
match update { match update {
sigrefs::write::Update::Changed { entry } => { sigrefs::write::Update::Changed { entry, level } => {
Ok(entry.into_sigrefs_at(namespace, LEVEL_PROMISED)) Ok(entry.into_sigrefs_at(namespace, level))
} }
sigrefs::write::Update::Unchanged { sigrefs::write::Update::Unchanged { verified } => {
commit, Ok(verified.into_sigrefs_at(namespace))
refs,
signature,
} => {
let sigrefs = SignedRefs {
refs,
signature,
id: namespace,
level: LEVEL_PROMISED,
};
Ok(SignedRefsAt {
sigrefs,
at: commit,
})
} }
} }
} }

View File

@ -25,7 +25,13 @@ fn roundtrip(BoundedVec(all_refs): BoundedVec<Refs>) -> TestResult {
for refs in all_refs { for refs in all_refs {
let refs = fixture.with_identity_root(refs); let refs = fixture.with_identity_root(refs);
let writer = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer); let writer = SignedRefsWriter::new(
refs.clone(),
fixture.rid(),
node_id,
fixture.repo(),
&signer,
);
let update = match writer.write( let update = match writer.write(
fixture.committer(), fixture.committer(),
"roundtrip write".into(), "roundtrip write".into(),
@ -36,8 +42,8 @@ fn roundtrip(BoundedVec(all_refs): BoundedVec<Refs>) -> TestResult {
}; };
let written_refs = match update { let written_refs = match update {
Update::Changed { ref entry } => entry.clone().into_refs(), Update::Changed { ref entry, .. } => entry.clone().into_refs(),
Update::Unchanged { ref refs, .. } => refs.clone(), Update::Unchanged { ref verified, .. } => verified.commit().refs().clone(),
}; };
assert_eq!(refs, written_refs); assert_eq!(refs, written_refs);
@ -68,7 +74,14 @@ fn idempotent(refs: Refs) -> TestResult {
let signer = MockSigner::default(); let signer = MockSigner::default();
let node_id = *signer.public_key(); let node_id = *signer.public_key();
if let Err(e) = SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write( if let Err(e) = SignedRefsWriter::new(
refs.clone(),
fixture.rid(),
node_id,
fixture.repo(),
&signer,
)
.write(
fixture.committer(), fixture.committer(),
"first write".into(), "first write".into(),
"first reflog".into(), "first reflog".into(),
@ -76,7 +89,14 @@ fn idempotent(refs: Refs) -> TestResult {
return TestResult::error(format!("first write error: {e}")); return TestResult::error(format!("first write error: {e}"));
} }
match SignedRefsWriter::new(refs.clone(), node_id, fixture.repo(), &signer).write( match SignedRefsWriter::new(
refs.clone(),
fixture.rid(),
node_id,
fixture.repo(),
&signer,
)
.write(
fixture.committer(), fixture.committer(),
"second write".into(), "second write".into(),
"second reflog".into(), "second reflog".into(),

View File

@ -45,7 +45,8 @@ impl VerifiedCommit {
} }
// The [`FeatureLevel`] of the refs in this commit. // The [`FeatureLevel`] of the refs in this commit.
pub fn level(&self) -> FeatureLevel { #[cfg(test)]
pub(super) fn level(&self) -> FeatureLevel {
self.level self.level
} }
@ -273,13 +274,11 @@ where
let commit = verified.commit(); let commit = verified.commit();
let commits = seen.get(&commit.signature).expect(SIGNATURES_COLLECTED); let commits = seen.get(&commit.signature).expect(SIGNATURES_COLLECTED);
if commits.len_nonzero() == ONE { if commits.len_nonzero() == ONE {
return Ok(verified); return Ok(verified);
} }
let id = &commit.oid; let id = &commit.oid;
if id == commits.last() { if id == commits.last() {
// If this commit is the last element of `commits`, // If this commit is the last element of `commits`,
// then this means it is the earliest of all that share // then this means it is the earliest of all that share
@ -325,12 +324,33 @@ pub(super) struct Commit {
} }
impl Commit { impl Commit {
/// The [`Oid`] of this commit.
pub(super) fn oid(&self) -> &Oid {
&self.oid
}
/// The parent [`Oid`] of the commit, unless it is the root commit.
#[cfg(test)] #[cfg(test)]
pub(super) fn parent(&self) -> Option<&Oid> {
self.parent.as_ref()
}
/// The [`Refs`] found in the blob at `/refs` in in the tree of this commit.
pub(super) fn refs(&self) -> &Refs { pub(super) fn refs(&self) -> &Refs {
&self.refs &self.refs
} }
fn verify<V>(mut self, expected: RepoId, verifier: &V) -> Result<VerifiedCommit, error::Verify> /// The [`crypto::Signature`] found in blob at `/signature` in the tree of the commit.
#[cfg(test)]
pub(super) fn signature(&self) -> &crypto::Signature {
&self.signature
}
pub(super) fn verify<V>(
mut self,
expected: RepoId,
verifier: &V,
) -> Result<VerifiedCommit, error::Verify>
where where
V: signature::Verifier<crypto::Signature>, V: signature::Verifier<crypto::Signature>,
{ {
@ -424,7 +444,7 @@ impl Commit {
} }
} }
struct CommitReader<'a, R> { pub(super) struct CommitReader<'a, R> {
commit: Oid, commit: Oid,
repository: &'a R, repository: &'a R,
} }
@ -433,11 +453,11 @@ impl<'a, R> CommitReader<'a, R>
where where
R: object::Reader, R: object::Reader,
{ {
fn new(commit: Oid, repository: &'a R) -> Self { pub(super) fn new(commit: Oid, repository: &'a R) -> Self {
Self { commit, repository } Self { commit, repository }
} }
fn read(self) -> Result<Commit, error::Commit> { pub(super) fn read(self) -> Result<Commit, error::Commit> {
let commit = self.read_commit_data()?; let commit = self.read_commit_data()?;
let Tree { refs, signature } = TreeReader::new(self.commit, self.repository) let Tree { refs, signature } = TreeReader::new(self.commit, self.repository)
.read() .read()
@ -552,7 +572,7 @@ where
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct IdentityRoot { pub(super) struct IdentityRoot {
commit: Oid, commit: Oid,
rid: RepoId, rid: RepoId,
} }

View File

@ -1,7 +1,7 @@
use radicle_oid::Oid; use radicle_oid::Oid;
use crate::storage::refs::sigrefs::read::error::{Read, Verify}; use crate::storage::refs::sigrefs::read::error::{Read, Verify};
use crate::storage::refs::sigrefs::read::{error, Commit, FeatureLevels, SignedRefsReader, Tip}; use crate::storage::refs::sigrefs::read::{error, FeatureLevels, SignedRefsReader, Tip};
use crate::storage::refs::sigrefs::VerifiedCommit; use crate::storage::refs::sigrefs::VerifiedCommit;
use crate::storage::refs::{FeatureLevel, IDENTITY_ROOT, SIGREFS_PARENT}; use crate::storage::refs::{FeatureLevel, IDENTITY_ROOT, SIGREFS_PARENT};
use crate::{assert_matches, git}; use crate::{assert_matches, git};
@ -386,20 +386,8 @@ fn read_ok_no_parent() {
let vc = read(c1, repo).unwrap(); let vc = read(c1, repo).unwrap();
assert_eq!(vc.commit.oid, c1); assert_eq!(vc.commit.oid, c1);
assert_eq!(vc.level(), FeatureLevel::Root);
assert_matches!( assert_eq!(vc.commit.parent().copied(), Some(c2));
vc,
VerifiedCommit {
commit: Commit {
oid: _,
parent: Some(_),
refs: _,
signature: _,
identity_root: Some(_)
},
level: FeatureLevel::Root,
}
);
} }
#[test] #[test]
@ -418,14 +406,7 @@ fn read_ok_root() {
let vc = read(c1, repo).unwrap(); let vc = read(c1, repo).unwrap();
assert_eq!(vc.commit.oid, c1); assert_eq!(vc.commit.oid, c1);
assert_eq!(vc.commit.parent, Some(c2)); assert_eq!(vc.commit.parent, Some(c2));
assert_eq!(vc.level, FeatureLevel::Root);
assert_matches!(vc, VerifiedCommit { commit: Commit {
oid,
parent: Some(parent),
refs: _,
signature: _,
identity_root: Some(_)
}, level: FeatureLevel::Root } if parent == c2 && oid == c1);
} }
#[test] #[test]
@ -443,14 +424,8 @@ fn read_ok_parent() {
let vc = read(c1, repo).unwrap(); let vc = read(c1, repo).unwrap();
assert_eq!(vc.commit.oid, c1); assert_eq!(vc.commit.oid, c1);
assert_eq!(vc.level, FeatureLevel::Parent);
assert_matches!(vc, VerifiedCommit { commit: Commit { assert_eq!(vc.commit.parent, Some(c2));
oid,
parent: Some(parent),
refs: _,
signature: _,
identity_root: Some(_)
}, level: FeatureLevel::Parent } if parent == c2 && oid == c1);
} }
#[test] #[test]

View File

@ -5,56 +5,47 @@ mod test;
use std::path::Path; use std::path::Path;
use crypto::signature::Signer; use crypto::signature::{self, Signer};
use crypto::PublicKey; use crypto::PublicKey;
use radicle_core::NodeId; use radicle_core::{NodeId, RepoId};
use radicle_git_metadata::author::Author; use radicle_git_metadata::author::Author;
use radicle_git_metadata::commit::{headers::Headers, trailers::OwnedTrailer, CommitData}; use radicle_git_metadata::commit::{headers::Headers, trailers::OwnedTrailer, CommitData};
use radicle_oid::Oid; use radicle_oid::Oid;
use crate::git; use crate::git;
use crate::storage::refs::sigrefs::git::{object, reference, Committer}; use crate::storage::refs::sigrefs::git::{object, reference, Committer};
use crate::storage::refs::sigrefs::read::CommitReader;
use crate::storage::refs::sigrefs::{read, VerifiedCommit};
use crate::storage::refs::{ use crate::storage::refs::{
FeatureLevel, Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH, FeatureLevel, Refs, IDENTITY_ROOT, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH,
SIGREFS_PARENT, SIGREFS_PARENT,
}; };
use crate::storage::refs::{SignedRefs, SignedRefsAt}; use crate::storage::refs::{SignedRefs, SignedRefsAt};
/// The result of calling [`SignedRefsWriter::write`]. /// The result of attempting to write signed references using
/// [`SignedRefsWriter`].
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Update { pub enum Update {
/// The new signed references commit was written to the Git repository. /// The new signed references commit was written to the Git repository.
Changed { entry: Box<Commit> }, Changed {
entry: Box<Commit>,
level: FeatureLevel,
},
/// The provided [`Refs`] were equal to the current [`Refs`], so the process /// The provided [`Refs`] were equal to the current [`Refs`], so the process
/// exited early. /// exited early.
Unchanged { Unchanged { verified: VerifiedCommit },
commit: Oid,
refs: Refs,
signature: crypto::Signature,
},
} }
impl From<Commit> for Update { impl Update {
fn from(commit: Commit) -> Self { fn changed(commit: Commit, level: FeatureLevel) -> Self {
Self::Changed { Self::Changed {
entry: Box::new(commit), entry: Box::new(commit),
level,
} }
} }
}
impl From<Head> for Update { fn unchanged(verified: VerifiedCommit) -> Self {
fn from( Self::Unchanged { verified }
Head {
commit,
refs,
signature,
}: Head,
) -> Self {
Self::Unchanged {
commit,
refs,
signature,
}
} }
} }
@ -73,6 +64,7 @@ impl From<Head> for Update {
/// over the [`Refs`]. /// over the [`Refs`].
pub struct SignedRefsWriter<'a, R, S> { pub struct SignedRefsWriter<'a, R, S> {
refs: Refs, refs: Refs,
rid: RepoId,
namespace: NodeId, namespace: NodeId,
repository: &'a R, repository: &'a R,
signer: &'a S, signer: &'a S,
@ -82,20 +74,28 @@ impl<'a, R, S> SignedRefsWriter<'a, R, S>
where where
R: object::Writer + object::Reader + reference::Writer + reference::Reader, R: object::Writer + object::Reader + reference::Writer + reference::Reader,
S: Signer<crypto::Signature>, S: Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{ {
/// Construct a new [`SignedRefsWriter`]. /// Construct a new [`SignedRefsWriter`].
/// ///
/// The construction removes the ref [`SIGREFS_BRANCH`] from [`Refs`] /// The construction removes the ref [`SIGREFS_BRANCH`] from [`Refs`]
/// (if present). /// (if present).
/// ///
/// When calling [`SignedRefsWriter::write`], if the process is successful, /// When calling writing signed references, if the process is successful,
/// the given [`Refs`] will be written to the provided `namespace`. /// 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 { pub fn new(
mut refs: Refs,
rid: RepoId,
namespace: NodeId,
repository: &'a R,
signer: &'a S,
) -> Self {
debug_assert!(refs.get(&IDENTITY_ROOT).is_some()); debug_assert!(refs.get(&IDENTITY_ROOT).is_some());
debug_assert!(refs.get(&SIGREFS_PARENT).is_none()); debug_assert!(refs.get(&SIGREFS_PARENT).is_none());
refs.remove_sigrefs(); refs.remove_sigrefs();
Self { Self {
refs, refs,
rid,
namespace, namespace,
repository, repository,
signer, signer,
@ -128,26 +128,33 @@ where
let author = committer.into_inner(); let author = committer.into_inner();
let Self { let Self {
refs, refs,
rid,
namespace, namespace,
repository, repository,
signer, signer,
} = self; } = self;
let reference = SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace)); let reference = SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&namespace));
let head = HeadReader::new(&reference, repository)
let head = HeadReader::new(&reference, repository, rid, self.signer)
.read() .read()
.map_err(error::Write::Head)?; .map_err(error::Write::Head)?;
let commit_writer = match head { let commit_writer = match head {
Some(head) if head.is_unchanged(&refs) => return Ok(Update::from(head)), Some(head) if head.is_unchanged(&refs) => return Ok(Update::unchanged(head.verified)),
Some(head) => { Some(head) => CommitWriter::with_parent(
CommitWriter::with_parent(refs, head.commit, author, message, repository, signer) refs,
} *head.verified.commit().oid(),
author,
message,
repository,
signer,
),
None => CommitWriter::root(refs, author, message, repository, signer), None => CommitWriter::root(refs, author, message, repository, signer),
}; };
let commit = commit_writer.write().map_err(error::Write::Commit)?; let commit = commit_writer.write().map_err(error::Write::Commit)?;
repository repository
.write_reference(&reference, commit.oid, commit.parent, reflog) .write_reference(&reference, commit.oid, commit.parent, reflog)
.map_err(error::Write::Reference)?; .map_err(error::Write::Reference)?;
Ok(Update::from(commit)) Ok(Update::changed(commit, FeatureLevel::Parent))
} }
} }
@ -317,37 +324,43 @@ where
/// references payload. /// references payload.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct Head { struct Head {
/// The commit [`Oid`] at the head of the reference. /// The verified commit at the head of the reference.
commit: Oid, verified: VerifiedCommit,
/// The [`Refs`] found within the head commit.
refs: Refs,
/// The [`crypto::Signature`] over the [`Refs`] blob.
signature: crypto::Signature,
} }
impl Head { impl Head {
/// Returns `true` if the `proposed` [`Refs`] are equal to the [`Refs`] /// Returns `true` if the `proposed` [`Refs`] are equal to the [`Refs`]
/// of the [`Head`]. /// of the [`Head`].
fn is_unchanged(&self, proposed: &Refs) -> bool { fn is_unchanged(&self, proposed: &Refs) -> bool {
self.refs == *proposed self.verified.commit().refs() == proposed
} }
} }
struct HeadReader<'a, 'b, R> { struct HeadReader<'a, 'b, 'c, R, V> {
rid: RepoId,
reference: &'a git::fmt::Namespaced<'a>, reference: &'a git::fmt::Namespaced<'a>,
repository: &'b R, repository: &'b R,
verifier: &'c V,
} }
impl<'a, 'b, R> HeadReader<'a, 'b, R> impl<'a, 'b, 'c, R, V> HeadReader<'a, 'b, 'c, R, V>
where where
R: object::Reader + reference::Reader, R: object::Reader + reference::Reader,
V: signature::Verifier<crypto::Signature>,
{ {
/// Construct a [`HeadReader`] with the `reference` that is being read from /// Construct a [`HeadReader`] with the `reference` that is being read from
/// the `repository.` /// the `repository.`
fn new(reference: &'a git::fmt::Namespaced<'a>, repository: &'b R) -> Self { fn new(
reference: &'a git::fmt::Namespaced<'a>,
repository: &'b R,
rid: RepoId,
verifier: &'c V,
) -> Self {
Self { Self {
rid,
reference, reference,
repository, repository,
verifier,
} }
} }
@ -358,54 +371,20 @@ where
/// ///
/// The returned [`Refs`] do not contain the [`SIGREFS_PARENT`] reference. /// The returned [`Refs`] do not contain the [`SIGREFS_PARENT`] reference.
fn read(self) -> Result<Option<Head>, error::Head> { fn read(self) -> Result<Option<Head>, error::Head> {
self.repository let Some(oid) = self
.repository
.find_reference(self.reference) .find_reference(self.reference)
.map_err(error::Head::Reference)? .map_err(error::Head::Reference)?
.map(|commit| self.with_refs(commit)) else {
.transpose() return Ok(None);
} };
fn with_refs(&self, commit: Oid) -> Result<Head, error::Head> { let verified = CommitReader::new(oid, self.repository)
let refs = self.refs(commit)?; .read()
let signature = self.refs_signature(commit)?; .map_err(error::Head::Commit)?
Ok(Head { .verify(self.rid, self.verifier)
commit, .map_err(error::Head::Verify)?;
refs,
signature,
})
}
fn refs(&self, commit: Oid) -> Result<Refs, error::Head> { Ok(Some(Head { verified }))
let path = Path::new(REFS_BLOB_PATH);
let object::Blob { bytes, .. } = self
.repository
.read_blob(&commit, path)
.map_err(error::Head::Blob)?
.ok_or(error::Head::MissingPath {
commit,
path: path.to_path_buf(),
})?;
let mut refs = Refs::from_canonical(&bytes).map_err(error::Head::Refs)?;
refs.remove_parent();
Ok(refs)
}
fn refs_signature(&self, commit: Oid) -> Result<crypto::Signature, error::Head> {
let path = Path::new(SIGNATURE_BLOB_PATH);
let object::Blob {
bytes: sig_bytes, ..
} = self
.repository
.read_blob(&commit, path)
.map_err(error::Head::Blob)?
.ok_or(error::Head::MissingPath {
commit,
path: path.to_path_buf(),
})?;
crypto::Signature::try_from(sig_bytes.as_slice()).map_err(|err| error::Head::Signature {
commit,
source: err,
})
} }
} }

View File

@ -1,9 +1,5 @@
use std::path::PathBuf;
use radicle_oid::Oid;
use thiserror::Error; use thiserror::Error;
use crate::storage::refs::canonical;
use crate::storage::refs::sigrefs::git::{object, reference}; use crate::storage::refs::sigrefs::git::{object, reference};
// TODO: use commit NID (and RID?) for traceability // TODO: use commit NID (and RID?) for traceability
@ -46,13 +42,7 @@ pub enum Head {
#[error(transparent)] #[error(transparent)]
Reference(reference::error::FindReference), Reference(reference::error::FindReference),
#[error(transparent)] #[error(transparent)]
Blob(object::error::ReadBlob), Commit(super::read::error::Commit),
#[error(transparent)] #[error(transparent)]
Refs(canonical::Error), Verify(super::read::error::Verify),
#[error("failed to parse refs signature in commit {commit}")]
Signature { commit: Oid, source: crypto::Error },
#[error(
"could not find the references blob, within the commit '{commit}', under the path {path:?}"
)]
MissingPath { commit: Oid, path: PathBuf },
} }

View File

@ -1,14 +1,22 @@
use radicle_git_ref_format::Component; use radicle_git_ref_format::Component;
use super::mock::{self, MockRepository}; use super::mock::{self, MockRepository};
use crate::storage::refs::sigrefs::read;
use crate::storage::refs::sigrefs::write::{error, Head, HeadReader}; use crate::storage::refs::sigrefs::write::{error, Head, HeadReader};
use crate::storage::refs::{Refs, SIGREFS_BRANCH}; use crate::storage::refs::{Refs, IDENTITY_ROOT, SIGREFS_BRANCH};
/// Drive `HeadReader` directly via the sigrefs reference for `mock::node_id()`. /// Drive `HeadReader` directly via the sigrefs reference for `mock::node_id()`.
fn read(repo: &MockRepository) -> Result<Option<Head>, error::Head> { fn read(repo: &MockRepository) -> Result<Option<Head>, error::Head> {
let namespace = mock::node_id(); let namespace = mock::node_id();
let reference = SIGREFS_BRANCH.with_namespace(Component::from(&namespace)); let reference = SIGREFS_BRANCH.with_namespace(Component::from(&namespace));
HeadReader::new(&reference, repo).read() HeadReader::new(&reference, repo, mock::rid(), &mock::AlwaysSign).read()
}
fn refs() -> [(crate::git::fmt::RefString, radicle_oid::Oid); 2] {
[
(mock::refs_heads_main(), mock::oid(10)),
(IDENTITY_ROOT.to_ref_string(), mock::oid(99)),
]
} }
#[test] #[test]
@ -28,8 +36,14 @@ fn refs_blob_error() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_refs_error(head); .with_refs_error(head);
assert!(matches!(read(&repo), Err(error::Head::Blob(_)))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::Refs(_)
)))
));
} }
#[test] #[test]
@ -37,9 +51,15 @@ fn refs_blob_missing() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_missing_refs(head) .with_missing_refs(head)
.with_signature(head, 1); .with_signature(head, 1);
assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. }))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::MissingBlobs(read::error::MissingBlobs::Signature { .. })
)))
));
} }
#[test] #[test]
@ -47,9 +67,15 @@ fn refs_parse_error() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_invalid_refs(head) .with_invalid_refs(head)
.with_signature(head, 1); .with_signature(head, 1);
assert!(matches!(read(&repo), Err(error::Head::Refs(_)))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::ParseRefs(_)
)))
));
} }
#[test] #[test]
@ -57,9 +83,15 @@ fn signature_blob_error() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))]) .with_commit(head, mock::commit_data([]))
.with_refs(head, refs())
.with_signature_error(head); .with_signature_error(head);
assert!(matches!(read(&repo), Err(error::Head::Blob(_)))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::Signature(_)
)))
));
} }
#[test] #[test]
@ -67,9 +99,15 @@ fn signature_blob_missing() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))]) .with_commit(head, mock::commit_data([]))
.with_refs(head, refs())
.with_missing_signature(head); .with_missing_signature(head);
assert!(matches!(read(&repo), Err(error::Head::MissingPath { .. }))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::MissingBlobs(read::error::MissingBlobs::Refs { .. })
)))
));
} }
#[test] #[test]
@ -77,25 +115,35 @@ fn signature_parse_error() {
let head = mock::oid(1); let head = mock::oid(1);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_refs(head, [(mock::refs_heads_main(), mock::oid(10))]) .with_commit(head, mock::commit_data([]))
.with_refs(head, refs())
.with_invalid_signature(head); .with_invalid_signature(head);
assert!(matches!(read(&repo), Err(error::Head::Signature { .. }))); assert!(matches!(
read(&repo),
Err(error::Head::Commit(read::error::Commit::Tree(
read::error::Tree::ParseSignature(_)
)))
));
} }
#[test] #[test]
fn read_ok() { fn read_ok() {
let head = mock::oid(1); let oid = mock::oid(1);
let refs = [(mock::refs_heads_main(), mock::oid(10))];
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), oid)
.with_refs(head, refs.clone()) .with_commit(oid, mock::commit_data([]))
.with_signature(head, 1); .with_refs(oid, refs())
.with_signature(oid, 1);
let head = read(&repo).unwrap().unwrap();
assert_eq!(head.verified.commit().oid(), &oid);
assert_eq!( assert_eq!(
read(&repo).unwrap(), head.verified.commit().signature(),
Some(Head { &crypto::Signature::from([1; 64])
commit: head, );
refs: Refs::from(refs.into_iter()), assert_eq!(head.verified.commit().parent(), None);
signature: crypto::Signature::from([1; 64]), assert_eq!(
}) *head.verified.commit().refs(),
Refs::from(refs().into_iter())
); );
} }

View File

@ -2,13 +2,20 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr as _; use std::str::FromStr as _;
use radicle_core::NodeId; use radicle_core::{NodeId, RepoId};
use radicle_git_metadata::author::{Author, Time}; 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 radicle_oid::Oid;
use crate::git; use crate::git;
use crate::identity::doc;
use crate::storage::refs::sigrefs::git::{object, reference}; use crate::storage::refs::sigrefs::git::{object, reference};
use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH}; use crate::storage::refs::{Refs, REFS_BLOB_PATH, SIGNATURE_BLOB_PATH, SIGREFS_BRANCH};
use crate::storage::HasRepoId;
const MOCKED_IDENTITY: u8 = 99u8;
enum WriteTreeBehavior { enum WriteTreeBehavior {
/// [`object::Writer::write_tree`] returns `Ok(oid)`. /// [`object::Writer::write_tree`] returns `Ok(oid)`.
@ -46,6 +53,7 @@ enum RefBehavior {
} }
pub struct MockRepository { pub struct MockRepository {
commits: HashMap<Oid, CommitBehavior>,
blobs: HashMap<(Oid, PathBuf), BlobBehavior>, blobs: HashMap<(Oid, PathBuf), BlobBehavior>,
references: HashMap<String, RefBehavior>, references: HashMap<String, RefBehavior>,
write_tree: Option<WriteTreeBehavior>, write_tree: Option<WriteTreeBehavior>,
@ -53,15 +61,28 @@ pub struct MockRepository {
write_reference: Option<WriteReferenceBehavior>, write_reference: Option<WriteReferenceBehavior>,
} }
enum CommitBehavior {
/// [`object::Reader::read_commit`] returns `Ok(Some(bytes))`.
Present(Box<CommitData<Oid, Oid>>),
}
impl MockRepository { impl MockRepository {
pub fn new() -> MockRepository { pub fn new() -> MockRepository {
MockRepository { MockRepository {
commits: HashMap::new(),
blobs: HashMap::new(), blobs: HashMap::new(),
references: HashMap::new(), references: HashMap::new(),
write_tree: None, write_tree: None,
write_commit: None, write_commit: None,
write_reference: None, write_reference: None,
} }
.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_rad_sigrefs(mut self, namespace: &NodeId, commit: Oid) -> MockRepository { pub fn with_rad_sigrefs(mut self, namespace: &NodeId, commit: Oid) -> MockRepository {
@ -125,6 +146,10 @@ impl MockRepository {
self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), bytes) self.with_blob(commit, Path::new(SIGNATURE_BLOB_PATH), bytes)
} }
pub fn with_identity(self, commit: Oid) -> Self {
self.with_blob(commit, &identity_path(), vec![])
}
fn with_blob(mut self, commit: Oid, path: &Path, bytes: Vec<u8>) -> Self { fn with_blob(mut self, commit: Oid, path: &Path, bytes: Vec<u8>) -> Self {
self.blobs self.blobs
.insert((commit, path.to_path_buf()), BlobBehavior::Present(bytes)); .insert((commit, path.to_path_buf()), BlobBehavior::Present(bytes));
@ -169,10 +194,18 @@ impl MockRepository {
} }
} }
impl HasRepoId for MockRepository {
fn rid(&self) -> radicle_core::RepoId {
rid()
}
}
impl object::Reader for MockRepository { impl object::Reader for MockRepository {
fn read_commit(&self, _oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> { fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {
// HeadReader never calls read_commit; this is a no-op placeholder. match self.commits.get(oid) {
Ok(None) Some(CommitBehavior::Present(data)) => Ok(Some(data.to_string().as_bytes().to_vec())),
None => Ok(None),
}
} }
fn read_blob( fn read_blob(
@ -269,6 +302,16 @@ impl crypto::signature::Signer<crypto::Signature> for AlwaysSign {
} }
} }
impl crypto::signature::Verifier<crypto::Signature> for AlwaysSign {
fn verify(
&self,
_msg: &[u8],
_sig: &crypto::Signature,
) -> Result<(), crypto::signature::Error> {
Ok(())
}
}
/// Always fails to sign. /// Always fails to sign.
pub struct NeverSign; pub struct NeverSign;
@ -285,6 +328,11 @@ pub fn oid(n: u8) -> Oid {
Oid::from_sha1([n; 20]) Oid::from_sha1([n; 20])
} }
/// A fixed [`RepoId`] for tests.
pub fn rid() -> RepoId {
RepoId::from(oid(MOCKED_IDENTITY))
}
/// A fixed [`NodeId`] for tests. /// A fixed [`NodeId`] for tests.
pub fn node_id() -> NodeId { pub fn node_id() -> NodeId {
NodeId::from([1u8; 32]) NodeId::from([1u8; 32])
@ -303,9 +351,29 @@ pub fn author() -> Author {
} }
} }
pub fn commit_data(parents: impl IntoIterator<Item = Oid>) -> CommitData<Oid, Oid> {
let tree = oid(0);
let author = author();
let message = "test\n".to_owned();
CommitData::new::<_, _, OwnedTrailer>(
tree,
parents,
author.clone(),
author,
Headers::new(),
message,
vec![],
)
}
fn sigrefs_ref_name(namespace: &NodeId) -> String { fn sigrefs_ref_name(namespace: &NodeId) -> String {
SIGREFS_BRANCH SIGREFS_BRANCH
.with_namespace(git::fmt::Component::from(namespace)) .with_namespace(git::fmt::Component::from(namespace))
.as_str() .as_str()
.to_owned() .to_owned()
} }
fn identity_path() -> PathBuf {
Path::new("embeds").join(*doc::PATH)
}

View File

@ -2,7 +2,7 @@ use radicle_oid::Oid;
use crate::storage::refs::sigrefs::git::Committer; use crate::storage::refs::sigrefs::git::Committer;
use crate::storage::refs::sigrefs::write::{error, SignedRefsWriter, Update}; use crate::storage::refs::sigrefs::write::{error, SignedRefsWriter, Update};
use crate::storage::refs::{Refs, IDENTITY_ROOT, SIGREFS_BRANCH}; use crate::storage::refs::{FeatureLevel, Refs, IDENTITY_ROOT, SIGREFS_BRANCH};
use super::mock; use super::mock;
use super::mock::MockRepository; use super::mock::MockRepository;
@ -18,7 +18,13 @@ fn some_refs(identity_root: Oid) -> Refs {
} }
fn other_refs() -> Refs { fn other_refs() -> Refs {
Refs::from([(mock::refs_heads_main(), mock::oid(20))].into_iter()) Refs::from(
[
(mock::refs_heads_main(), mock::oid(20)),
(IDENTITY_ROOT.to_ref_string(), mock::oid(99)),
]
.into_iter(),
)
} }
fn refs_with_rad_sigrefs() -> Refs { fn refs_with_rad_sigrefs() -> Refs {
@ -33,7 +39,7 @@ fn refs_with_rad_sigrefs() -> Refs {
} }
fn write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> { fn write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> {
SignedRefsWriter::new(refs, mock::node_id(), repo, &mock::AlwaysSign).write( SignedRefsWriter::new(refs, mock::rid(), mock::node_id(), repo, &mock::AlwaysSign).write(
Committer::new(mock::author()), Committer::new(mock::author()),
"msg".into(), "msg".into(),
"reflog".into(), "reflog".into(),
@ -55,16 +61,17 @@ fn unchanged() {
let refs = some_refs(mock::oid(99)); let refs = some_refs(mock::oid(99));
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_refs(head, refs.clone()) .with_refs(head, refs.clone())
.with_signature(head, 1); .with_signature(head, 1);
assert_eq!(
write(refs.clone(), &repo).unwrap(), match write(refs.clone(), &repo).unwrap() {
Update::Unchanged { Update::Unchanged { verified } => {
commit: head, assert_eq!(verified.commit().oid(), &head);
refs, assert_eq!(verified.level(), FeatureLevel::Root);
signature: crypto::Signature::from([1; 64]),
} }
); Update::Changed { .. } => unreachable!(),
}
} }
#[test] #[test]
@ -102,7 +109,7 @@ fn write_root_ok() {
.with_write_reference_ok(); .with_write_reference_ok();
let refs = some_refs(mock::oid(99)); let refs = some_refs(mock::oid(99));
let update = write(refs.clone(), &repo).unwrap(); let update = write(refs.clone(), &repo).unwrap();
let Update::Changed { entry } = update else { let Update::Changed { entry, .. } = update else {
panic!("expected Update::Changed, got {update:?}"); panic!("expected Update::Changed, got {update:?}");
}; };
assert_eq!(entry.parent, None); assert_eq!(entry.parent, None);
@ -116,6 +123,7 @@ fn write_with_parent_ok() {
let commit_oid = mock::oid(42); let commit_oid = mock::oid(42);
let repo = MockRepository::new() let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head) .with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_refs(head, other_refs()) .with_refs(head, other_refs())
.with_signature(head, 1) .with_signature(head, 1)
.with_write_tree_ok(mock::oid(99)) .with_write_tree_ok(mock::oid(99))
@ -123,7 +131,7 @@ fn write_with_parent_ok() {
.with_write_reference_ok(); .with_write_reference_ok();
let refs = some_refs(mock::oid(99)); let refs = some_refs(mock::oid(99));
let update = write(refs.clone(), &repo).unwrap(); let update = write(refs.clone(), &repo).unwrap();
let Update::Changed { entry } = update else { let Update::Changed { entry, .. } = update else {
panic!("expected Update::Changed, got {update:?}"); panic!("expected Update::Changed, got {update:?}");
}; };
assert_eq!(entry.parent, Some(head)); assert_eq!(entry.parent, Some(head));
@ -142,7 +150,7 @@ fn write_empty_refs() {
.with_write_commit_ok(commit_oid) .with_write_commit_ok(commit_oid)
.with_write_reference_ok(); .with_write_reference_ok();
let update = write(refs.clone(), &repo).unwrap(); let update = write(refs.clone(), &repo).unwrap();
let Update::Changed { entry } = update else { let Update::Changed { entry, .. } = update else {
panic!("expected Update::Changed, got {update:?}"); panic!("expected Update::Changed, got {update:?}");
}; };
assert_eq!(entry.parent, None); assert_eq!(entry.parent, None);
@ -160,7 +168,7 @@ fn never_write_rad_sigrefs() {
.with_write_reference_ok(); .with_write_reference_ok();
let mut refs = refs_with_rad_sigrefs(); let mut refs = refs_with_rad_sigrefs();
let update = write(refs.clone(), &repo).unwrap(); let update = write(refs.clone(), &repo).unwrap();
let Update::Changed { entry } = update else { let Update::Changed { entry, .. } = update else {
panic!("expected Update::Changed, got {update:?}"); panic!("expected Update::Changed, got {update:?}");
}; };
assert_eq!(entry.parent, None); assert_eq!(entry.parent, None);