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 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 { pub payload: BTreeMap, pub delegates: NonEmpty, pub threshold: usize, #[serde(skip)] verified: PhantomData, } impl Doc { pub fn head(remote: &RemoteId, repo: &R) -> Result { repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH) .map_err(DocError::from) } } impl Doc { pub fn encode(&self) -> Result<(git::Oid, Vec), 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 { 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(&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 { 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 { 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 { 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 { pub fn initial(project: Project, delegate: Did) -> Self { Self::new(project, NonEmpty::new(delegate), 1) } pub fn new(project: Project, delegates: NonEmpty, 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 { serde_json::from_slice(bytes).map_err(DocError::from) } pub fn verified(self) -> Result, 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(commit: Oid, repo: &R) -> Result { repo.blob_at(commit, Path::new(&*PATH)) .map_err(DocError::from) } pub fn load_at(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(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::(1); let proj = arbitrary::gen::(1); let repo = storage.repository(proj).unwrap(); let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap(); let err = Doc::::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) { let (_, bytes) = doc.encode().unwrap(); assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); } }