pub mod sigrefs;
#[cfg(any(test, feature = "test"))]
pub mod arbitrary;
use std::collections::BTreeMap;
use std::fmt::Debug;
use std::io;
use std::io::{BufRead, BufReader};
use std::ops::Deref;
use std::str::FromStr;
use crypto::signature;
use crypto::{PublicKey, Signature};
use radicle_core::NodeId;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::git;
use crate::git::raw::ErrorExt as _;
use crate::git::Oid;
use crate::storage;
use crate::storage::refs::sigrefs::read::Tip;
use crate::storage::RemoteId;
pub use crate::git::refs::storage::*;
use super::HasRepoId;
pub const REFS_BLOB_PATH: &str = "refs";
pub const SIGNATURE_BLOB_PATH: &str = "signature";
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid reference")]
InvalidRef,
#[error("invalid reference: {0}")]
Ref(#[from] git::RefError),
#[error(transparent)]
Git(#[from] git::raw::Error),
#[error(transparent)]
Read(#[from] sigrefs::read::error::Read),
#[error(transparent)]
Write(#[from] sigrefs::write::error::Write),
}
impl Error {
pub fn is_not_found(&self) -> bool {
match self {
Self::Git(e) => e.is_not_found(),
Self::Read(sigrefs::read::error::Read::MissingSigrefs { .. }) => true,
_ => false,
}
}
}
#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Refs(BTreeMap<git::fmt::RefString, Oid>);
impl Refs {
pub fn new() -> Self {
Self(BTreeMap::new())
}
pub fn save<R, S>(
self,
namespace: NodeId,
committer: sigrefs::git::Committer,
repo: &R,
signer: &S,
) -> Result<SignedRefs, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{
self.save_with(namespace, committer, repo, signer, false)
}
pub fn force_save<R, S>(
self,
namespace: NodeId,
committer: sigrefs::git::Committer,
repo: &R,
signer: &S,
) -> Result<SignedRefs, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{
self.save_with(namespace, committer, repo, signer, true)
}
fn save_with<R, S>(
self,
namespace: NodeId,
committer: sigrefs::git::Committer,
repo: &R,
signer: &S,
force: bool,
) -> Result<SignedRefs, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{
let msg = "Update signed refs\n";
let reflog = format!("Save {} signed references", self.len());
let writer =
sigrefs::write::SignedRefsWriter::new(self, repo.rid(), namespace, repo, signer);
let update = if force {
writer.force_write(committer, msg.to_string(), reflog)?
} else {
writer.write(committer, msg.to_string(), reflog)?
};
match update {
sigrefs::write::Update::Changed { entry, level } => {
Ok(entry.into_sigrefs_at(namespace, level))
}
sigrefs::write::Update::Unchanged { verified } => {
Ok(verified.into_sigrefs_at(namespace))
}
}
}
pub fn get(&self, name: &git::fmt::Qualified) -> Option<Oid> {
self.0.get(name.to_ref_string().as_refstr()).copied()
}
pub fn head(&self, name: impl AsRef<git::fmt::RefStr>) -> Option<Oid> {
let branch = git::fmt::refname!("refs/heads").join(name);
self.0.get(&branch).copied()
}
fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
let reader = BufReader::new(bytes);
let mut refs = BTreeMap::new();
for line in reader.lines() {
let line = line?;
let (oid, name) = line
.split_once(' ')
.ok_or(canonical::Error::InvalidFormat)?;
let name = git::fmt::RefString::try_from(name)?;
let oid = Oid::from_str(oid).map_err(|_| canonical::Error::InvalidFormat)?;
if oid.is_zero() || name.as_refstr() == SIGREFS_BRANCH.as_ref() {
continue;
}
refs.insert(name, oid);
}
Ok(Self(refs))
}
fn canonical(&self) -> Vec<u8> {
let mut buf = String::new();
for (name, oid) in self.0.iter() {
debug_assert_ne!(oid, &Oid::sha1_zero());
debug_assert_ne!(name, &SIGREFS_BRANCH.to_ref_string());
buf.push_str(&oid.to_string());
buf.push(' ');
buf.push_str(name);
buf.push('\n');
}
buf.into_bytes()
}
pub fn insert(&mut self, refname: git::fmt::RefString, target: Oid) -> Option<Oid> {
if target.is_zero() {
self.0.remove(&refname)
} else {
self.0.insert(refname, target)
}
}
pub(crate) fn keys<'a>(
&'a self,
) -> std::collections::btree_map::Keys<'a, git::fmt::RefString, Oid> {
self.0.keys()
}
#[cfg(any(test, feature = "test"))]
pub(crate) fn values<'a>(
&'a self,
) -> std::collections::btree_map::Values<'a, git::fmt::RefString, Oid> {
self.0.values()
}
pub fn iter<'a>(&'a self) -> std::collections::btree_map::Iter<'a, git::fmt::RefString, Oid> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub(super) fn remove_sigrefs(&mut self) -> Option<Oid> {
self.0.remove(&SIGREFS_BRANCH.to_ref_string())
}
#[inline]
fn add_parent(&mut self, commit: Oid) -> Option<Oid> {
self.0.insert(SIGREFS_PARENT.to_ref_string(), commit)
}
#[inline]
fn remove_parent(&mut self) -> Option<Oid> {
self.0.remove(&SIGREFS_PARENT.to_ref_string())
}
}
impl IntoIterator for Refs {
type Item = (git::fmt::RefString, Oid);
type IntoIter = std::collections::btree_map::IntoIter<git::fmt::RefString, Oid>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl From<Refs> for BTreeMap<git::fmt::RefString, Oid> {
fn from(refs: Refs) -> Self {
refs.0
}
}
impl<I> From<I> for Refs
where
I: Iterator<Item = (git::fmt::RefString, Oid)>,
{
fn from(value: I) -> Self {
let mut refs = Self::new();
for (refname, target) in value {
refs.insert(refname, target);
}
refs
}
}
#[derive(
Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum FeatureLevel {
#[default]
None,
#[cfg_attr(
feature = "schemars",
schemars(description = "\
An intermediate feature level, which protects against graft attacks \
but is vulnerable to replay attacks. \
Introduced in Radicle 1.1.0, in commit \
`989edacd564fa658358f5ccfd08c243c5ebd8cda`.\
")
)]
Root,
#[cfg_attr(
feature = "schemars",
schemars(description = "\
The highest feature level known, which protects against graft attacks \
and replay attacks. \
Introduced in Radicle 1.7.0, in commit \
`d3bc868e84c334f113806df1737f52cc57c5453d`.\
")
)]
Parent,
}
impl FeatureLevel {
pub const LATEST: Self = FeatureLevel::Parent;
}
impl std::fmt::Display for FeatureLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match &self {
Self::None => "none",
Self::Root => "root",
Self::Parent => "parent",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RefsAt {
pub remote: RemoteId,
pub at: Oid,
}
impl RefsAt {
pub fn new<R>(repo: &R, remote: RemoteId) -> Result<Self, sigrefs::read::error::Read>
where
R: sigrefs::git::reference::Reader,
{
let at = repo
.find_reference(
&storage::refs::SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&remote)),
)
.map_err(sigrefs::read::error::Read::FindReference)?
.ok_or_else(|| sigrefs::read::error::Read::MissingSigrefs { namespace: remote })?;
Ok(RefsAt { remote, at })
}
pub fn path(&self) -> &git::fmt::Qualified<'_> {
&SIGREFS_BRANCH
}
}
impl std::fmt::Display for RefsAt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} @ {}", self.remote, self.at)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SignedRefs {
refs: Refs,
#[serde(skip)]
signature: Signature,
id: PublicKey,
#[serde(skip)]
level: FeatureLevel,
#[serde(skip)]
parent: Option<Oid>,
pub at: Oid,
}
impl SignedRefs {
pub fn id(&self) -> NodeId {
self.id
}
pub fn refs(&self) -> &Refs {
&self.refs
}
pub fn feature_level(&self) -> FeatureLevel {
self.level
}
pub fn parent(&self) -> Option<&Oid> {
self.parent.as_ref()
}
pub fn load<R>(remote: RemoteId, repo: &R) -> Result<Option<Self>, sigrefs::read::error::Read>
where
R: HasRepoId,
R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader,
{
Self::load_internal(remote, repo, sigrefs::read::Tip::Reference(remote))
}
pub fn load_at<R>(
oid: Oid,
remote: RemoteId,
repo: &R,
) -> Result<Option<Self>, sigrefs::read::error::Read>
where
R: HasRepoId,
R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader,
{
Self::load_internal(remote, repo, sigrefs::read::Tip::Commit(oid))
}
fn load_internal<R>(
remote: RemoteId,
repo: &R,
tip: Tip,
) -> Result<Option<Self>, sigrefs::read::error::Read>
where
R: HasRepoId,
R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader,
{
let root = repo.rid();
match sigrefs::SignedRefsReader::new(root, tip, repo, &remote).read() {
Ok(latest) => Ok(Some(latest.into_sigrefs_at(remote))),
Err(sigrefs::read::error::Read::MissingSigrefs { namespace }) => {
debug_assert_eq!(namespace, remote);
Ok(None)
}
Err(err) => Err(err),
}
}
pub fn iter(&self) -> impl Iterator<Item = (&git::fmt::RefString, &Oid)> {
self.refs.iter()
}
}
impl Deref for SignedRefs {
type Target = Refs;
fn deref(&self) -> &Self::Target {
&self.refs
}
}
pub mod canonical {
use super::*;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
InvalidRef(#[from] git::fmt::Error),
#[error("invalid canonical format")]
InvalidFormat,
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Git(#[from] git::raw::Error),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use qcheck_macros::quickcheck;
use storage::{git::transport, RemoteRepository, SignRepository, WriteStorage};
use super::*;
use crate::assert_matches;
use crate::node::device::Device;
use crate::storage::WriteRepository as _;
use crate::{cob::identity::Identity, cob::Title, rad, test::fixtures, Storage};
#[quickcheck]
fn prop_canonical_roundtrip(refs: Refs) {
let encoded = refs.canonical();
let decoded = Refs::from_canonical(&encoded).unwrap();
assert_eq!(refs, decoded);
}
#[test]
fn test_rid_verification() {
let tmp = tempfile::tempdir().unwrap();
let alice = Device::mock();
let bob = Device::mock();
let storage = &Storage::open(tmp.path().join("storage"), fixtures::user()).unwrap();
transport::local::register(storage.clone());
let (paris_repo, paris_head) = fixtures::repository(tmp.path().join("paris"));
let (paris_rid, paris_doc, _) = rad::init(
&paris_repo,
"paris".try_into().unwrap(),
"Paris repository",
git::fmt::refname!("master"),
Default::default(),
&alice,
storage,
)
.unwrap();
let (london_repo, _london_head) = fixtures::repository(tmp.path().join("london"));
let (london_rid, london_doc, _) = rad::init(
&london_repo,
"london".try_into().unwrap(),
"London repository",
git::fmt::refname!("master"),
Default::default(),
&alice,
storage,
)
.unwrap();
assert_ne!(london_rid, paris_rid);
log::debug!(target: "test", "London RID: {london_rid}");
log::debug!(target: "test", "Paris RID: {paris_rid}");
let paris = storage.repository_mut(paris_rid).unwrap();
let london = storage.repository_mut(london_rid).unwrap();
{
let paris_doc = paris_doc
.with_edits(|doc| {
doc.delegates.push(bob.public_key().into());
})
.unwrap();
let london_doc = london_doc
.with_edits(|doc| {
doc.delegates.push(bob.public_key().into());
})
.unwrap();
let mut paris_ident = Identity::load_mut(&paris).unwrap();
let mut london_ident = Identity::load_mut(&london).unwrap();
paris_ident
.update(Title::new("Add Bob").unwrap(), "", &paris_doc, &alice)
.unwrap();
london_ident
.update(Title::new("Add Bob").unwrap(), "", &london_doc, &alice)
.unwrap();
}
let (bob_paris_sigrefs, bob_head) = {
let bob_working = rad::checkout(
paris.id,
bob.public_key(),
tmp.path().join("working"),
&storage,
false,
)
.unwrap();
let paris_head = bob_working.find_commit(paris_head).unwrap();
let bob_sig = git::raw::Signature::now("bob", "bob@example.com").unwrap();
let bob_head = git::empty_commit(
&bob_working,
&paris_head,
git::fmt::refname!("refs/heads/master").as_refstr(),
"Bob's commit",
&bob_sig,
)
.unwrap();
let mut bob_master_ref = bob_working.find_reference("refs/heads/master").unwrap();
bob_master_ref.set_target(bob_head.id(), "").unwrap();
bob_working
.find_remote("rad")
.unwrap()
.push(&["refs/heads/master"], None)
.unwrap();
let sigrefs = paris.sign_refs(&bob).unwrap();
assert_eq!(
sigrefs
.get(&crate::git::fmt::qualified!("refs/heads/master"))
.unwrap(),
bob_head.id()
);
(sigrefs, bob_head.id())
};
{
let alice_paris_sigrefs = SignedRefs::load(*alice.public_key(), &paris)
.unwrap()
.unwrap();
assert_ne!(
alice_paris_sigrefs
.get(&crate::git::fmt::qualified!("refs/heads/master"))
.unwrap(),
bob_paris_sigrefs
.get(&crate::git::fmt::qualified!("refs/heads/master"))
.unwrap()
);
}
{
let paris_odb = paris.raw().odb().unwrap();
let london_odb = london.raw().odb().unwrap();
paris_odb
.foreach(|oid| {
let obj = paris_odb.read(*oid).unwrap();
london_odb.write(obj.kind(), obj.data()).unwrap();
true
})
.unwrap();
}
{
let name = &SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(bob.node_id()));
let id = paris.backend.refname_to_id(name.as_str()).unwrap();
london
.backend
.reference(name.as_str(), id, true, "Graft attack")
.unwrap();
}
london
.raw()
.reference(
git::refs::storage::branch_of(bob.public_key(), &git::fmt::refname!("master"))
.as_str(),
bob_head,
false,
"",
)
.unwrap();
assert_matches!(
london.remote(bob.public_key()),
Err(Error::Read(sigrefs::read::error::Read::Verify(sigrefs::read::error::Verify::MismatchedIdentity {
expected,
found,
sigrefs_commit: _,
identity_commit: _,
})))
if expected == london_rid && found == paris_rid
);
}
}