diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 273f2bdf..bb3271b2 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -86,7 +86,7 @@ pub trait CommandT { /// Show information for a registered project. pub struct ShowProject { /// The name of the project - project_name: String32, + project_name: ProjectName, /// The org in which the project is registered. org_id: OrgId, } @@ -192,7 +192,7 @@ impl CommandT for UnregisterOrg { /// Register a project with the given name under the given org. pub struct RegisterProject { /// Name of the project to register. - project_name: String32, + project_name: ProjectName, /// Org under which to register the project. org_id: OrgId, diff --git a/client/examples/project_registration.rs b/client/examples/project_registration.rs index 93a1b3a6..cc68f694 100644 --- a/client/examples/project_registration.rs +++ b/client/examples/project_registration.rs @@ -19,7 +19,7 @@ async fn go() -> Result<(), Error> { let node_host = url::Host::parse("127.0.0.1").unwrap(); let client = Client::create(node_host).await?; - let project_name = ProjectName::from_string("radicle-registry".to_string()).unwrap(); + let project_name = ProjectName::try_from("radicle-registry").unwrap(); let org_id = OrgId::try_from("monadic").unwrap(); // Choose some random project hash and create a checkpoint diff --git a/core/src/lib.rs b/core/src/lib.rs index 8d504aa4..b29fe9c8 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -39,6 +39,9 @@ pub use string32::String32; mod org_id; pub use org_id::OrgId; +mod project_name; +pub use project_name::ProjectName; + mod error; pub use error::RegistryError; @@ -58,9 +61,6 @@ pub type Balance = u128; /// The id of a project. Used as storage key. pub type ProjectId = (ProjectName, OrgId); -/// The name a project is registered with. -pub type ProjectName = String32; - /// Org /// /// Different from [state::Org] in which this type gathers diff --git a/core/src/project_name.rs b/core/src/project_name.rs new file mode 100644 index 00000000..3d6bee96 --- /dev/null +++ b/core/src/project_name.rs @@ -0,0 +1,208 @@ +// Radicle Registry +// Copyright (C) 2019 Monadic GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +/// The name associated to a [`Project`]. +/// +/// https://github.com/radicle-dev/registry-spec/blob/master/body.tex#L306 +use alloc::prelude::v1::*; +use core::convert::{From, Into, TryFrom}; +use parity_scale_codec as codec; + +#[derive(codec::Encode, Clone, Debug, Eq, PartialEq)] +pub struct ProjectName(String); + +impl ProjectName { + fn from_string(input: String) -> Result { + // Must be at least 1 character. + if input.is_empty() { + return Err(InvalidProjectNameError("must be at least 1 character")); + } + // Must be no longer than 32. + if input.len() > 32 { + return Err(InvalidProjectNameError("must not exceed 32 characters")); + } + + // Must only contain a-z, 0-9, '-', '_' and '.' characters. + { + let check_charset = |c: char| { + c.is_ascii_digit() || c.is_ascii_lowercase() || c == '-' || c == '_' || c == '.' + }; + + if !input.chars().all(check_charset) { + return Err(InvalidProjectNameError( + "must only include a-z, 0-9, '-', '_' and '.'", + )); + } + } + + // Must not equal '.' or '..'. + if input == "." || input == ".." { + return Err(InvalidProjectNameError("must not be equal to '.' or '..'")); + } + + let id = Self(input); + + Ok(id) + } +} + +impl codec::Decode for ProjectName { + fn decode(input: &mut I) -> Result { + let decoded: String = String::decode(input)?; + + match Self::try_from(decoded) { + Ok(id) => Ok(id), + Err(err) => Err(codec::Error::from(err.what())), + } + } +} + +impl Into for ProjectName { + fn into(self) -> String { + self.0 + } +} + +impl TryFrom for ProjectName { + type Error = InvalidProjectNameError; + + fn try_from(input: String) -> Result { + Self::from_string(input) + } +} + +impl TryFrom<&str> for ProjectName { + type Error = InvalidProjectNameError; + + fn try_from(input: &str) -> Result { + Self::from_string(input.to_string()) + } +} + +impl core::str::FromStr for ProjectName { + type Err = InvalidProjectNameError; + + fn from_str(s: &str) -> Result { + Self::from_string(s.to_string()) + } +} + +#[cfg(feature = "std")] +impl core::fmt::Display for ProjectName { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Error type when conversion from an inordinate input failed. +#[derive(codec::Encode, Clone, Debug, Eq, PartialEq)] +pub struct InvalidProjectNameError(&'static str); + +impl InvalidProjectNameError { + /// Error description + /// + /// This function returns an actual error str when running in `std` + /// environment, but `""` on `no_std`. + #[cfg(feature = "std")] + pub fn what(&self) -> &'static str { + self.0 + } + + /// Error description + /// + /// This function returns an actual error str when running in `std` + /// environment, but `""` on `no_std`. + #[cfg(not(feature = "std"))] + pub fn what(&self) -> &'static str { + "" + } +} + +#[cfg(feature = "std")] +impl std::fmt::Display for InvalidProjectNameError { + fn fmt(&self, f: &mut core::fmt::Formatter) -> std::fmt::Result { + write!(f, "InvalidProjectNameError({})", self.0) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for InvalidProjectNameError { + fn description(&self) -> &str { + self.0 + } +} + +impl From<&'static str> for InvalidProjectNameError { + #[cfg(feature = "std")] + fn from(s: &'static str) -> Self { + Self(s) + } + + #[cfg(not(feature = "std"))] + fn from(s: &'static str) -> Self { + InvalidProjectNameError(s) + } +} + +#[cfg(test)] +mod test { + use super::ProjectName; + use parity_scale_codec::{Decode, Encode}; + + #[test] + fn name_too_short() { + assert!(ProjectName::from_string("".into()).is_err()); + } + + #[test] + fn name_too_long() { + let input = std::iter::repeat("X").take(33).collect::(); + let too_long = ProjectName::from_string(input); + assert!(too_long.is_err()); + } + + #[test] + fn name_invalid_characters() { + let invalid_characters = ProjectName::from_string("AZ+*".into()); + assert!(invalid_characters.is_err()); + } + + #[test] + fn name_is_dot() { + let dot = ProjectName::from_string(".".into()); + assert!(dot.is_err()); + } + + #[test] + fn name_is_double_dot() { + let dot = ProjectName::from_string("..".into()); + assert!(dot.is_err()); + } + + #[test] + fn name_valid() { + let valid = ProjectName::from_string("--radicle_registry001".into()); + assert!(valid.is_ok()); + } + + #[test] + fn encode_then_decode() { + let id = ProjectName::from_string("monadic".into()).unwrap(); + let encoded = id.encode(); + let decoded = ::decode(&mut &encoded[..]).unwrap(); + + assert_eq!(id, decoded) + } +} diff --git a/runtime/src/registry.rs b/runtime/src/registry.rs index 5fd012ac..4678f20b 100644 --- a/runtime/src/registry.rs +++ b/runtime/src/registry.rs @@ -361,7 +361,7 @@ mod test { /// is identify as to the original input id. fn projects_decode_key_identity() { let org_id = OrgId::try_from("monadic").unwrap(); - let project_name = ProjectName::from_string("Radicle".into()).unwrap(); + let project_name = ProjectName::try_from("radicle".to_string()).unwrap(); let project_id: ProjectId = (project_name, org_id); let hashed_key = store::Projects::storage_map_final_key(project_id.clone()); let decoded_key = store::Projects::decode_key(&hashed_key).unwrap(); diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 2f35526a..f7469309 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -89,13 +89,18 @@ pub fn random_org_id() -> OrgId { OrgId::try_from(random_string(size).to_lowercase()).unwrap() } +pub fn random_project_name() -> ProjectName { + let size = rand::thread_rng().gen_range(1, 33); + ProjectName::try_from(random_string(size).to_lowercase()).unwrap() +} + /// Create a [core::message::RegisterProject] with random parameters to register a project with. pub fn random_register_project_message( org_id: OrgId, checkpoint_id: CheckpointId, ) -> message::RegisterProject { message::RegisterProject { - project_name: random_string32(), + project_name: random_project_name(), org_id, checkpoint_id, metadata: Bytes128::random(),