radicle-heartwood-lfs/radicle/src/identity/doc.rs

358 lines
10 KiB
Rust

mod id;
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use std::marker::PhantomData;
use std::ops::Deref;
use std::path::Path;
use nonempty::NonEmpty;
use once_cell::sync::Lazy;
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::crypto;
use crate::crypto::{Signature, Unverified, Verified};
use crate::git;
use crate::identity::{project::Project, Did};
use crate::storage::git::trailers;
use crate::storage::{ReadRepository, RemoteId};
pub use crypto::PublicKey;
pub use id::*;
/// Path to the identity document in the identity branch.
pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json"));
/// Maximum length of a string in the identity document.
pub const MAX_STRING_LENGTH: usize = 255;
/// Maximum number of a delegates in the identity document.
pub const MAX_DELEGATES: usize = 255;
#[derive(Error, Debug)]
pub enum DocError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid delegates: {0}")]
Delegates(&'static str),
#[error("invalid version `{0}`")]
Version(u32),
#[error("invalid threshold `{0}`: {1}")]
Threshold(usize, &'static str),
#[error("git: {0}")]
GitExt(#[from] git::Error),
#[error("git: {0}")]
Git(#[from] git2::Error),
}
impl DocError {
/// Whether this error is caused by the document not being found.
pub fn is_not_found(&self) -> bool {
match self {
Self::GitExt(git::Error::NotFound(_)) => true,
Self::GitExt(git::Error::Git(e)) if git::is_not_found_err(e) => true,
Self::Git(err) if git::is_not_found_err(err) => true,
_ => false,
}
}
}
/// Identifies an identity document payload type.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
// TODO: Restrict values.
pub struct PayloadId(String);
impl fmt::Display for PayloadId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl PayloadId {
/// Project payload type.
pub fn project() -> Self {
Self(String::from("xyz.radicle.project"))
}
}
#[derive(Debug, Error)]
pub enum PayloadError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("payload '{0}' not found in identity document")]
NotFound(PayloadId),
}
/// Payload value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Payload {
value: serde_json::Value,
}
impl From<serde_json::Value> for Payload {
fn from(value: serde_json::Value) -> Self {
Self { value }
}
}
impl Deref for Payload {
type Target = serde_json::Value;
fn deref(&self) -> &Self::Target {
&self.value
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Doc<V> {
pub payload: BTreeMap<PayloadId, Payload>,
pub delegates: NonEmpty<Did>,
pub threshold: usize,
#[serde(skip)]
verified: PhantomData<V>,
}
impl<V> Doc<V> {
pub fn head<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<Oid, DocError> {
repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
.map_err(DocError::from)
}
}
impl Doc<Verified> {
pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), DocError> {
let mut buf = Vec::new();
let mut serializer =
serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new());
self.serialize(&mut serializer)?;
let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?;
Ok((oid.into(), buf))
}
/// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before.
pub fn delegate(&mut self, key: crypto::PublicKey) -> bool {
let delegate = Did::from(key);
if self.delegates.iter().all(|id| id != &delegate) {
self.delegates.push(delegate);
return true;
}
false
}
/// Get the project payload, if it exists and is valid, out of this document.
pub fn project(&self) -> Result<Project, PayloadError> {
let value = self
.payload
.get(&PayloadId::project())
.ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
let proj: Project = serde_json::from_value((**value).clone())?;
Ok(proj)
}
pub fn sign<G: crypto::Signer>(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> {
let (oid, bytes) = self.encode()?;
let sig = signer.sign(&bytes);
Ok((oid, sig))
}
pub fn init(
doc: &[u8],
remote: &RemoteId,
signatures: &[(&PublicKey, Signature)],
repo: &git2::Repository,
) -> Result<git::Oid, DocError> {
let tree = git::write_tree(*PATH, doc, repo)?;
let oid = Doc::commit(remote, &tree, "Initialize Radicle\n", &[], signatures, repo)?;
Ok(oid)
}
pub fn update(
&self,
remote: &RemoteId,
msg: &str,
signatures: &[(&PublicKey, Signature)],
repo: &git2::Repository,
) -> Result<git::Oid, DocError> {
let (_, doc) = self.encode()?;
let tree = git::write_tree(*PATH, doc.as_slice(), repo)?;
let id_ref = git::refs::storage::id(remote);
let head = repo.find_reference(&id_ref)?.peel_to_commit()?;
let oid = Doc::commit(remote, &tree, msg, &[&head], signatures, repo)?;
Ok(oid)
}
fn commit(
remote: &RemoteId,
tree: &git2::Tree,
msg: &str,
parents: &[&git2::Commit],
signatures: &[(&PublicKey, Signature)],
repo: &git2::Repository,
) -> Result<git::Oid, DocError> {
let sig = repo
.signature()
.or_else(|_| git2::Signature::now("radicle", remote.to_string().as_str()))?;
let mut msg = format!("{}\n\n", msg.trim());
for (key, sig) in signatures {
writeln!(&mut msg, "{}: {key} {sig}", trailers::SIGNATURE_TRAILER)
.expect("in-memory writes don't fail");
}
let id_ref = git::refs::storage::id(remote);
let oid = repo.commit(Some(&id_ref), &sig, &sig, &msg, tree, parents)?;
Ok(oid.into())
}
}
impl Doc<Unverified> {
pub fn initial(project: Project, delegate: Did) -> Self {
Self::new(project, NonEmpty::new(delegate), 1)
}
pub fn new(project: Project, delegates: NonEmpty<Did>, threshold: usize) -> Self {
let project =
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
Self {
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
delegates,
threshold,
verified: PhantomData,
}
}
pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
serde_json::from_slice(bytes).map_err(DocError::from)
}
pub fn verified(self) -> Result<Doc<Verified>, DocError> {
if self.delegates.len() > MAX_DELEGATES {
return Err(DocError::Delegates("number of delegates cannot exceed 255"));
}
if self.delegates.is_empty() {
return Err(DocError::Delegates("delegate list cannot be empty"));
}
if self.threshold > self.delegates.len() {
return Err(DocError::Threshold(
self.threshold,
"threshold cannot exceed number of delegates",
));
}
if self.threshold == 0 {
return Err(DocError::Threshold(
self.threshold,
"threshold cannot be zero",
));
}
Ok(Doc {
payload: self.payload,
delegates: self.delegates,
threshold: self.threshold,
verified: PhantomData,
})
}
pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
repo.blob_at(commit, Path::new(&*PATH))
.map_err(DocError::from)
}
pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> {
let blob = Self::blob_at(commit, repo)?;
let doc = Doc::from_json(blob.content())?;
Ok((doc, blob.id().into()))
}
pub fn load<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> {
let oid = Self::head(remote, repo)?;
Self::load_at(oid, repo)
}
}
#[cfg(test)]
mod test {
use radicle_crypto::test::signer::MockSigner;
use radicle_crypto::Signer as _;
use crate::rad;
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::WriteStorage;
use crate::test::arbitrary;
use crate::test::fixtures;
use super::*;
use qcheck_macros::quickcheck;
#[test]
fn test_canonical_example() {
let tempdir = tempfile::tempdir().unwrap();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
transport::local::register(storage.clone());
let delegate = MockSigner::from_seed([0xff; 32]);
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (id, _, _) = rad::init(
&repo,
"heartwood",
"Radicle Heartwood Protocol & Stack",
git::refname!("master"),
&delegate,
&storage,
)
.unwrap();
assert_eq!(
delegate.public_key().to_human(),
String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi")
);
assert_eq!(
(*id).to_string(),
"d96f425412c9f8ad5d9a9a05c9831d0728e2338d"
);
assert_eq!(
id.to_human(),
String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji")
);
}
#[test]
fn test_not_found() {
let tempdir = tempfile::tempdir().unwrap();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let remote = arbitrary::gen::<RemoteId>(1);
let proj = arbitrary::gen::<Id>(1);
let repo = storage.repository(proj).unwrap();
let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
let err = Doc::<Unverified>::head(&remote, &repo).unwrap_err();
assert!(err.is_not_found());
let err = Doc::load_at(oid.into(), &repo).unwrap_err();
assert!(err.is_not_found());
}
#[quickcheck]
fn prop_encode_decode(doc: Doc<Verified>) {
let (_, bytes) = doc.encode().unwrap();
assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
}
}