Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src storage refs.rs
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::Oid;
use crate::git::raw::ErrorExt as _;
use crate::storage;
use crate::storage::RemoteId;
use crate::storage::refs::sigrefs::read::Tip;

pub use crate::git::refs::storage::*;

use super::HasRepoId;

/// File in which the signed references are stored, in the `refs/rad/sigrefs` branch.
pub const REFS_BLOB_PATH: &str = "refs";
/// File in which the signature over the references is stored in the `refs/rad/sigrefs` branch.
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 {
    /// Whether this error is caused by a reference not being found.
    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,
        }
    }
}

// TODO(finto): we should turn `git::fmt::RefString` to `git::fmt::Qualified`,
// since all these refs SHOULD be `Qualified`.
/// The published state of a local repository.
#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Refs(BTreeMap<git::fmt::RefString, Oid>);

impl Refs {
    pub fn new() -> Self {
        Self(BTreeMap::new())
    }

    /// Save the signed refs to disk.
    /// This creates a new commit on the signed refs branch, and updates the branch pointer.
    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)
    }

    /// Save the signed refs to disk, even if the refs are unchanged.
    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))
            }
        }
    }

    /// Get a particular ref.
    pub fn get(&self, name: &git::fmt::Qualified) -> Option<Oid> {
        self.0.get(name.to_ref_string().as_refstr()).copied()
    }

    /// Get a particular head ref.
    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()
    }

    /// Create refs from a canonical representation.
    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!(!oid.is_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())
    }

    /// 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 {
    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
    }
}

/// The Signed References feature has evolved over time.
/// This enum captures the corresponding "feature level".
///
/// Feature levels are monotonic, in the sense that a greater feature level
/// encompasses all the features of smaller ones.
#[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 {
    /// The lowest feature level, with least security. It is vulnerable to
    /// graft attacks and replay attacks.
    #[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`.\
    ")
    )]
    /// Requires [`IDENTITY_ROOT`].
    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`.\
    ")
    )]
    /// Requires [`SIGREFS_PARENT`].
    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)
    }
}

/// The content-addressable information required to load a remote's
/// `rad/sigrefs`.
///
/// Use [`RefsAt::remote`] and [`RefsAt::at`] with [`SignedRefs::load_at`] to
/// attempt loading the expected signed references.
///
/// `RefsAt` can also be used for communicating announcements of updates
/// references to other nodes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RefsAt {
    /// The remote namespace of the `rad/sigrefs`.
    pub remote: RemoteId,
    /// The commit SHA that `rad/sigrefs` points to.
    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)
    }
}

/// Verified [`SignedRefs`] that keeps track of their content address
/// [`Oid`].
///
/// The signature is a cryptographic signature over the refs.
/// This allows us to easily verify if a set of refs
/// came from a particular key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SignedRefs {
    /// The signed refs.
    refs: Refs,
    /// The signature of the signer over the refs.
    #[serde(skip)]
    signature: Signature,
    /// This is the remote under which these refs exist, and the public key of the signer.
    id: PublicKey,

    #[serde(skip)]
    level: FeatureLevel,

    /// The [`Oid`] of the parent commit of the commit in which.
    #[serde(skip)]
    parent: Option<Oid>,

    pub at: Oid,
}

impl SignedRefs {
    /// Returns the [`NodeId`] of the [`SignedRefs`].
    pub fn id(&self) -> NodeId {
        self.id
    }

    /// Returns the [`Refs`] of the [`SignedRefs`].
    pub fn refs(&self) -> &Refs {
        &self.refs
    }

    /// Returns the [`FeatureLevel`] computed for the signed references.
    pub fn feature_level(&self) -> FeatureLevel {
        self.level
    }

    /// The [`Oid`] of the parent commit, or [`None`] if these signed references
    /// were found at a root commit.
    pub fn parent(&self) -> Option<&Oid> {
        self.parent.as_ref()
    }

    /// Load the [`SignedRefs`] found under `remote`'s [`SIGREFS_BRANCH`].
    ///
    /// This will return `None` if the branch was not found, all other
    /// errors are returned.
    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::{RemoteRepository, SignRepository, WriteStorage, git::transport};

    use super::*;
    use crate::assert_matches;

    use crate::node::device::Device;
    use crate::storage::WriteRepository as _;
    use crate::{Storage, cob::Title, cob::identity::Identity, rad, test::fixtures};

    #[quickcheck]
    fn prop_canonical_roundtrip(refs: Refs) {
        let encoded = refs.canonical();
        let decoded = Refs::from_canonical(&encoded).unwrap();

        assert_eq!(refs, decoded);
    }

    #[test]
    // Test that a user's signed refs are tied to a specific RID, and they can't simply be
    // used in a different repository.
    //
    // We create two repos, `paris` and `london`, and we copy over Bob's signed refs from `paris`
    // to `london`. We expect that this does not cause the canonical head of the `london` repo
    // to change, despite Bob being a delegate of both repos, because the refs were signed for the
    // `paris` repo. We also don't expected the signed refs to validate without error.
    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());

        // Alice creates "paris" repo.
        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();

        // Alice creates "london" repo.
        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();

        // Bob is added to both repos as a delegate, by Alice.
        {
            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, &alice).unwrap();
            let mut london_ident = Identity::load_mut(&london, &alice).unwrap();

            paris_ident
                .update(Title::new("Add Bob").unwrap(), "", &paris_doc)
                .unwrap();
            london_ident
                .update(Title::new("Add Bob").unwrap(), "", &london_doc)
                .unwrap();
        }

        // Now Bob checks out a copy of the `paris` repository and pushes a commit to the
        // default branch (master). We store the OID of that commit in `bob_head`, as this
        // is the commit we will try to get the `london` repo to point to.
        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())
        };

        {
            // Sanity check: make sure the default branches don't already match between Alice and Bob.
            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()
            );
        }

        {
            // For the graft to work, we also have to copy over the objects that Bob created in
            // `paris`, so that the grafted signed refs point to valid objects.
            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();
        }
        // Now we're going to "graft" Bob's signed refs from `paris` to `london`.
        // We save Bob's `paris` signed refs in the `london` repo, performing the graft, and update
        // Bob's `master` branch reference to point to his commit, created in the `paris` repo. This
        // only modifies his own namespace. Note that anyone (eg. Eve) could create a reference
        // under her copy of Bob's namespace, and this would only be rejected during signed ref
        // validation.
        {
            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();

        // Due to the verification, we get a validation error when trying to load Bob's remote.
        // The graft is not allowed.
        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
        );
    }
}