diff --git a/node/src/identity.rs b/node/src/identity.rs index a1befe34..ff77c961 100644 --- a/node/src/identity.rs +++ b/node/src/identity.rs @@ -1,22 +1,19 @@ -use std::marker::PhantomData; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use std::{ffi::OsString, fmt, io, str::FromStr}; +pub mod doc; + +use std::ops::Deref; +use std::path::PathBuf; +use std::{ffi::OsString, fmt, str::FromStr}; -use nonempty::NonEmpty; -use once_cell::sync::Lazy; -use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::crypto::{self, Unverified, Verified}; +use crate::crypto::{self, Verified}; use crate::hash; use crate::serde_ext; -use crate::storage::{BranchName, Remotes}; +use crate::storage::Remotes; pub use crypto::PublicKey; - -pub static IDENTITY_PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json")); +pub use doc::{Delegate, Doc}; #[derive(Error, Debug)] pub enum IdError { @@ -170,193 +167,6 @@ pub struct Project { pub path: PathBuf, } -#[derive(Error, Debug)] -pub enum DocError { - #[error("json: {0}")] - Json(#[from] serde_json::Error), - #[error("i/o: {0}")] - Io(#[from] io::Error), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Delegate { - pub name: String, - pub id: Did, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct Doc { - pub name: String, - pub description: String, - pub default_branch: String, - pub version: u32, - pub parent: Option, - pub delegates: NonEmpty, - pub threshold: usize, - - verified: PhantomData, -} - -impl Doc { - pub fn write(&self, mut writer: W) -> Result { - let mut buf = Vec::new(); - let mut ser = - serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new()); - - self.serialize(&mut ser)?; - - let digest = hash::Digest::new(&buf); - let id = Id::from(digest); - - // TODO: Currently, we serialize the document in canonical JSON. This isn't strictly - // necessary, as we could use CJSON just to get the hash, but then write a prettified - // version to disk to make it easier for users to edit. - writer.write_all(&buf)?; - - Ok(id) - } -} - -pub const MAX_STRING_LENGTH: usize = 255; -pub const MAX_DELEGATES: usize = 255; - -#[derive(Error, Debug)] -pub enum DocVerificationError { - #[error("invalid name: {0}")] - Name(&'static str), - #[error("invalid description: {0}")] - Description(&'static str), - #[error("invalid default branch: {0}")] - DefaultBranch(&'static str), - #[error("invalid delegates: {0}")] - Delegates(&'static str), - #[error("invalid version `{0}`")] - Version(u32), - #[error("invalid parent: {0}")] - Parent(&'static str), - #[error("invalid threshold `{0}`: {1}")] - Threshold(usize, &'static str), -} - -impl Doc { - pub fn initial( - name: String, - description: String, - default_branch: BranchName, - delegate: Delegate, - ) -> Self { - Self { - name, - description, - default_branch, - version: 1, - parent: None, - delegates: NonEmpty::new(delegate), - threshold: 1, - verified: PhantomData, - } - } - - pub fn new( - name: String, - description: String, - default_branch: BranchName, - parent: Option, - delegates: NonEmpty, - threshold: usize, - ) -> Self { - Self { - name, - description, - default_branch, - version: 1, - parent, - delegates, - threshold, - verified: PhantomData, - } - } - - pub fn from_json(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes) - } - - pub fn verified(self) -> Result, DocVerificationError> { - if self.name.is_empty() { - return Err(DocVerificationError::Name("name cannot be empty")); - } - if self.name.len() > MAX_STRING_LENGTH { - return Err(DocVerificationError::Name("name cannot exceed 255 bytes")); - } - if self.description.len() > MAX_STRING_LENGTH { - return Err(DocVerificationError::Description( - "description cannot exceed 255 bytes", - )); - } - if self.delegates.len() > MAX_DELEGATES { - return Err(DocVerificationError::Delegates( - "number of delegates cannot exceed 255", - )); - } - if self - .delegates - .iter() - .any(|d| d.name.is_empty() || d.name.len() > MAX_STRING_LENGTH) - { - return Err(DocVerificationError::Delegates( - "delegate name must not be empty and must not exceed 255 bytes", - )); - } - if self.delegates.is_empty() { - return Err(DocVerificationError::Delegates( - "delegate list cannot be empty", - )); - } - if self.default_branch.is_empty() { - return Err(DocVerificationError::DefaultBranch( - "default branch cannot be empty", - )); - } - if self.default_branch.len() > MAX_STRING_LENGTH { - return Err(DocVerificationError::DefaultBranch( - "default branch cannot exceed 255 bytes", - )); - } - if let Some(parent) = self.parent { - if parent.is_zero() { - return Err(DocVerificationError::Parent("parent cannot be zero")); - } - } - if self.version != 1 { - return Err(DocVerificationError::Version(self.version)); - } - if self.threshold > self.delegates.len() { - return Err(DocVerificationError::Threshold( - self.threshold, - "threshold cannot exceed number of delegates", - )); - } - if self.threshold == 0 { - return Err(DocVerificationError::Threshold( - self.threshold, - "threshold cannot be zero", - )); - } - - Ok(Doc { - name: self.name, - description: self.description, - delegates: self.delegates, - default_branch: self.default_branch, - parent: self.parent, - version: self.version, - threshold: self.threshold, - verified: PhantomData, - }) - } -} - #[cfg(test)] mod test { use super::*; @@ -364,14 +174,6 @@ mod test { use quickcheck_macros::quickcheck; use std::collections::HashSet; - #[quickcheck] - fn prop_encode_decode(doc: Doc) { - let mut bytes = Vec::new(); - - doc.write(&mut bytes).unwrap(); - assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); - } - #[quickcheck] fn prop_key_equality(a: PublicKey, b: PublicKey) { assert_ne!(a, b); diff --git a/node/src/identity/doc.rs b/node/src/identity/doc.rs new file mode 100644 index 00000000..2330d8e4 --- /dev/null +++ b/node/src/identity/doc.rs @@ -0,0 +1,218 @@ +use std::io; +use std::marker::PhantomData; +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::{self, Unverified, Verified}; +use crate::hash; +use crate::identity::{Did, Id}; +use crate::storage::BranchName; + +pub use crypto::PublicKey; + +pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json")); +pub const MAX_STRING_LENGTH: usize = 255; +pub const MAX_DELEGATES: usize = 255; + +#[derive(Error, Debug)] +pub enum Error { + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("i/o: {0}")] + Io(#[from] io::Error), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Delegate { + pub name: String, + pub id: Did, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Doc { + pub name: String, + pub description: String, + pub default_branch: String, + pub version: u32, + pub parent: Option, + pub delegates: NonEmpty, + pub threshold: usize, + + verified: PhantomData, +} + +impl Doc { + pub fn write(&self, mut writer: W) -> Result { + let mut buf = Vec::new(); + let mut ser = + serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new()); + + self.serialize(&mut ser)?; + + let digest = hash::Digest::new(&buf); + let id = Id::from(digest); + + // TODO: Currently, we serialize the document in canonical JSON. This isn't strictly + // necessary, as we could use CJSON just to get the hash, but then write a prettified + // version to disk to make it easier for users to edit. + writer.write_all(&buf)?; + + Ok(id) + } +} + +#[derive(Error, Debug)] +pub enum VerificationError { + #[error("invalid name: {0}")] + Name(&'static str), + #[error("invalid description: {0}")] + Description(&'static str), + #[error("invalid default branch: {0}")] + DefaultBranch(&'static str), + #[error("invalid delegates: {0}")] + Delegates(&'static str), + #[error("invalid version `{0}`")] + Version(u32), + #[error("invalid parent: {0}")] + Parent(&'static str), + #[error("invalid threshold `{0}`: {1}")] + Threshold(usize, &'static str), +} + +impl Doc { + pub fn initial( + name: String, + description: String, + default_branch: BranchName, + delegate: Delegate, + ) -> Self { + Self { + name, + description, + default_branch, + version: 1, + parent: None, + delegates: NonEmpty::new(delegate), + threshold: 1, + verified: PhantomData, + } + } + + pub fn new( + name: String, + description: String, + default_branch: BranchName, + parent: Option, + delegates: NonEmpty, + threshold: usize, + ) -> Self { + Self { + name, + description, + default_branch, + version: 1, + parent, + delegates, + threshold, + verified: PhantomData, + } + } + + pub fn from_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + pub fn verified(self) -> Result, VerificationError> { + if self.name.is_empty() { + return Err(VerificationError::Name("name cannot be empty")); + } + if self.name.len() > MAX_STRING_LENGTH { + return Err(VerificationError::Name("name cannot exceed 255 bytes")); + } + if self.description.len() > MAX_STRING_LENGTH { + return Err(VerificationError::Description( + "description cannot exceed 255 bytes", + )); + } + if self.delegates.len() > MAX_DELEGATES { + return Err(VerificationError::Delegates( + "number of delegates cannot exceed 255", + )); + } + if self + .delegates + .iter() + .any(|d| d.name.is_empty() || d.name.len() > MAX_STRING_LENGTH) + { + return Err(VerificationError::Delegates( + "delegate name must not be empty and must not exceed 255 bytes", + )); + } + if self.delegates.is_empty() { + return Err(VerificationError::Delegates( + "delegate list cannot be empty", + )); + } + if self.default_branch.is_empty() { + return Err(VerificationError::DefaultBranch( + "default branch cannot be empty", + )); + } + if self.default_branch.len() > MAX_STRING_LENGTH { + return Err(VerificationError::DefaultBranch( + "default branch cannot exceed 255 bytes", + )); + } + if let Some(parent) = self.parent { + if parent.is_zero() { + return Err(VerificationError::Parent("parent cannot be zero")); + } + } + if self.version != 1 { + return Err(VerificationError::Version(self.version)); + } + if self.threshold > self.delegates.len() { + return Err(VerificationError::Threshold( + self.threshold, + "threshold cannot exceed number of delegates", + )); + } + if self.threshold == 0 { + return Err(VerificationError::Threshold( + self.threshold, + "threshold cannot be zero", + )); + } + + Ok(Doc { + name: self.name, + description: self.description, + delegates: self.delegates, + default_branch: self.default_branch, + parent: self.parent, + version: self.version, + threshold: self.threshold, + verified: PhantomData, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use quickcheck_macros::quickcheck; + + #[quickcheck] + fn prop_encode_decode(doc: Doc) { + let mut bytes = Vec::new(); + + doc.write(&mut bytes).unwrap(); + assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); + } +} diff --git a/node/src/rad.rs b/node/src/rad.rs index e615f590..34e28a12 100644 --- a/node/src/rad.rs +++ b/node/src/rad.rs @@ -16,9 +16,9 @@ pub const REMOTE_NAME: &str = "rad"; #[derive(Error, Debug)] pub enum InitError { #[error("doc: {0}")] - Doc(#[from] identity::DocError), + Doc(#[from] identity::doc::Error), #[error("doc: {0}")] - DocVerification(#[from] identity::DocVerificationError), + DocVerification(#[from] identity::doc::VerificationError), #[error("git: {0}")] Git(#[from] git2::Error), #[error("i/o: {0}")] @@ -56,7 +56,7 @@ pub fn init<'r, G: Signer, S: storage::WriteStorage<'r>>( ) .verified()?; - let filename = *identity::IDENTITY_PATH; + let filename = *identity::doc::PATH; let mut doc_bytes = Vec::new(); let id = doc.write(&mut doc_bytes)?; let project = storage.repository(&id)?; diff --git a/node/src/storage.rs b/node/src/storage.rs index edd13772..a6ad471e 100644 --- a/node/src/storage.rs +++ b/node/src/storage.rs @@ -42,7 +42,7 @@ pub enum Error { #[error("i/o: {0}")] Io(#[from] io::Error), #[error("doc: {0}")] - Doc(#[from] identity::DocError), + Doc(#[from] identity::doc::Error), #[error("invalid repository head")] InvalidHead, } diff --git a/node/src/storage/git.rs b/node/src/storage/git.rs index 76817a5b..a928e61e 100644 --- a/node/src/storage/git.rs +++ b/node/src/storage/git.rs @@ -9,7 +9,7 @@ pub use radicle_git_ext::Oid; use crate::crypto::{Signer, Verified}; use crate::git; -use crate::identity::{self, IDENTITY_PATH}; +use crate::identity; use crate::identity::{Id, Project}; use crate::storage::refs; use crate::storage::refs::{Refs, SignedRefs}; @@ -263,7 +263,7 @@ impl Repository { return Ok(None); }; - let doc = match self.blob_at(oid, Path::new(&*IDENTITY_PATH)) { + let doc = match self.blob_at(oid, Path::new(&*identity::doc::PATH)) { Err(git::ext::Error::NotFound(_)) => return Ok(None), Err(e) => return Err(e.into()), Ok(doc) => doc, diff --git a/node/src/test/arbitrary.rs b/node/src/test/arbitrary.rs index ae6f8995..e1fcca03 100644 --- a/node/src/test/arbitrary.rs +++ b/node/src/test/arbitrary.rs @@ -14,7 +14,7 @@ use crate::crypto; use crate::crypto::{PublicKey, SecretKey, Signer, Unverified, Verified}; use crate::git; use crate::hash; -use crate::identity::{Delegate, Did, Doc, Id, Project}; +use crate::identity::{doc::Delegate, doc::Doc, Did, Id, Project}; use crate::service::filter::{Filter, FILTER_SIZE}; use crate::service::message::{ Address, Envelope, InventoryAnnouncement, Message, NodeAnnouncement, RefsAnnouncement,