Validate project names properly in all places

We were only validating names when passed as a flag to `rad init
--name`.
This commit is contained in:
cloudhead 2024-07-31 12:55:53 +02:00
parent ee1c867800
commit 84e3ba1482
No known key found for this signature in database
10 changed files with 111 additions and 42 deletions

View File

@ -13,6 +13,7 @@ use serde_json as json;
use radicle::crypto::{ssh, Verified};
use radicle::explorer::ExplorerUrl;
use radicle::git::RefString;
use radicle::identity::project::ProjectName;
use radicle::identity::{RepoId, Visibility};
use radicle::node::events::UploadPack;
use radicle::node::policy::Scope;
@ -57,7 +58,7 @@ Options
#[derive(Default)]
pub struct Options {
pub path: Option<PathBuf>,
pub name: Option<String>,
pub name: Option<ProjectName>,
pub description: Option<String>,
pub branch: Option<String>,
pub interactive: Interactive,
@ -94,19 +95,8 @@ impl Args for Options {
Long("name") if name.is_none() => {
let value = parser.value()?;
let value = term::args::string(&value);
let allowed = ['-', '_', '.'];
let value = ProjectName::try_from(value)?;
// Nb. We avoid characters that need to be quoted by shells, such as `$`,
// `!` etc., since repository names are used for naming folders during clone.
if !value
.chars()
.all(|c| c.is_alphanumeric() || allowed.contains(&c))
{
anyhow::bail!(
"invalid repository name specified with `--name`, \
only alphanumeric characters, '-', '_' and '.' are allowed"
);
}
name = Some(value);
}
Long("description") if description.is_none() => {
@ -243,7 +233,7 @@ pub fn init(
term::format::dim(path.display())
));
let name = match options.name {
let name: ProjectName = match options.name {
Some(name) => name,
None => {
let default = path.file_name().map(|f| f.to_string_lossy().to_string());
@ -252,6 +242,7 @@ pub fn init(
default,
Some("The name of your repository, eg. 'acme'"),
)?
.try_into()?
}
};
let description = match options.description {
@ -287,7 +278,7 @@ pub fn init(
match radicle::rad::init(
&repo,
&name,
name,
&description,
branch.clone(),
visibility,

View File

@ -548,7 +548,7 @@ impl<G: cyphernet::Ecdh<Pk = NodeId> + Signer + Clone> Node<G> {
let branch = refname!("master");
let id = rad::init(
repo,
name,
name.try_into().unwrap(),
description,
branch.clone(),
Visibility::default(),

View File

@ -132,7 +132,7 @@ impl<G: Signer> Peer<Storage, G> {
let (repo, _) = fixtures::repository(self.tempdir.path().join(name));
let (rid, _, _) = rad::init(
&repo,
name,
name.try_into().unwrap(),
description,
radicle::git::refname!("master"),
Visibility::default(),

View File

@ -1648,7 +1648,7 @@ fn test_init_and_seed() {
// Alice creates a new project.
let (proj_id, _, _) = rad::init(
&repo,
"alice",
"alice".try_into().unwrap(),
"alice's repo",
git::refname!("master"),
Visibility::default(),

View File

@ -10,7 +10,7 @@ fn main() -> anyhow::Result<()> {
let signer = profile.signer()?;
let (id, _, _) = radicle::rad::init(
&repo,
&name,
name.try_into()?,
"",
git::refname!("master"),
Visibility::default(),

View File

@ -462,7 +462,7 @@ mod test {
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (id, _, _) = rad::init(
&repo,
"heartwood",
"heartwood".try_into().unwrap(),
"Radicle Heartwood Protocol & Stack",
git::refname!("master"),
Visibility::default(),
@ -509,7 +509,7 @@ mod test {
let delegate = MockSigner::from_seed([0xff; 32]);
let (rid, doc, _) = rad::init(
&working,
"heartwood",
"heartwood".try_into().unwrap(),
"Radicle Heartwood Protocol & Stack",
git::refname!("master"),
Visibility::default(),

View File

@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, str::FromStr};
use serde::{
de::{self, MapAccess, Visitor},
@ -24,12 +24,72 @@ pub enum ProjectError {
DefaultBranch(&'static str),
}
/// A valid project name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ProjectName(String);
impl From<ProjectName> for String {
fn from(value: ProjectName) -> Self {
value.0
}
}
impl ProjectName {
/// List of allowed special characters.
pub const ALLOWED_CHARS: &'static [char] = &['-', '_', '.'];
/// Return a string reference to the name.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<&str> for ProjectName {
type Error = ProjectError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
ProjectName::from_str(s)
}
}
impl TryFrom<String> for ProjectName {
type Error = ProjectError;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.is_empty() {
return Err(ProjectError::Name("name cannot be empty"));
} else if s.len() > doc::MAX_STRING_LENGTH {
return Err(ProjectError::Name("name cannot exceed 255 bytes"));
}
// Nb. We avoid characters that need to be quoted by shells, such as `$`,
// `!` etc., since repository names are used for naming folders during clone.
if !s
.chars()
.all(|c| c.is_alphanumeric() || Self::ALLOWED_CHARS.contains(&c))
{
return Err(ProjectError::Name(
"invalid repository name, only alphanumeric characters, '-', '_' and '.' are allowed",
));
}
Ok(Self(s))
}
}
impl FromStr for ProjectName {
type Err = ProjectError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s.to_owned())
}
}
/// A "project" payload in an identity document.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Project {
/// Project name.
name: String,
name: ProjectName,
/// Project description.
description: String,
/// Project default branch.
@ -48,8 +108,7 @@ impl<'de> Deserialize<'de> for Project {
Description,
DefaultBranch,
/// A catch-all variant to allow for unknown fields
#[allow(dead_code)]
Unknown(String),
Unknown(#[allow(dead_code)] String),
}
struct ProjectVisitor;
@ -123,18 +182,12 @@ impl Project {
/// * `description`'s length must not exceed 255.
/// * `default_branch`'s length must not be empty and must not exceed 255.
pub fn new(
name: String,
name: ProjectName,
description: String,
default_branch: BranchName,
) -> Result<Self, Vec<ProjectError>> {
let mut errs = Vec::new();
if name.is_empty() {
errs.push(ProjectError::Name("name cannot be empty"));
} else if name.len() > doc::MAX_STRING_LENGTH {
errs.push(ProjectError::Name("name cannot exceed 255 bytes"));
}
if description.len() > doc::MAX_STRING_LENGTH {
errs.push(ProjectError::Description(
"description cannot exceed 255 bytes",
@ -171,7 +224,7 @@ impl Project {
/// original validation rules (see [`Project::new`]).
pub fn update(
self,
name: impl Into<Option<String>>,
name: impl Into<Option<ProjectName>>,
description: impl Into<Option<String>>,
default_branch: impl Into<Option<BranchName>>,
) -> Result<Self, Vec<ProjectError>> {
@ -183,7 +236,7 @@ impl Project {
#[inline]
pub fn name(&self) -> &str {
&self.name
self.name.as_str()
}
#[inline]
@ -205,3 +258,26 @@ impl From<Project> for Payload {
Self::from(value)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::assert_matches;
#[test]
fn test_project_name() {
assert_matches!(serde_json::from_str::<ProjectName>("\"\""), Err(_));
assert_matches!(
serde_json::from_str::<ProjectName>("\"invalid name\""),
Err(_)
);
assert_matches!(
serde_json::from_str::<ProjectName>("\"invalid%name\""),
Err(_)
);
assert_eq!(
serde_json::from_str::<ProjectName>("\"valid-name\"").unwrap(),
ProjectName("valid-name".to_owned())
);
}
}

View File

@ -11,7 +11,7 @@ use crate::crypto::{Signer, Verified};
use crate::git;
use crate::identity::doc;
use crate::identity::doc::{DocError, RepoId, Visibility};
use crate::identity::project::Project;
use crate::identity::project::{Project, ProjectName};
use crate::storage::git::transport;
use crate::storage::git::Repository;
use crate::storage::refs::SignedRefs;
@ -46,7 +46,7 @@ pub enum InitError {
/// Initialize a new radicle project from a git repository.
pub fn init<G: Signer, S: WriteStorage>(
repo: &git2::Repository,
name: &str,
name: ProjectName,
description: &str,
default_branch: BranchName,
visibility: Visibility,
@ -416,7 +416,7 @@ mod tests {
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (proj, _, refs) = init(
&repo,
"acme",
"acme".try_into().unwrap(),
"Acme's repo",
git::refname!("master"),
Visibility::default(),
@ -471,7 +471,7 @@ mod tests {
let (original, _) = fixtures::repository(tempdir.path().join("original"));
let (id, _, alice_refs) = init(
&original,
"acme",
"acme".try_into().unwrap(),
"Acme's repo",
git::refname!("master"),
Visibility::default(),
@ -507,7 +507,7 @@ mod tests {
let (original, _) = fixtures::repository(tempdir.path().join("original"));
let (id, _, _) = init(
&original,
"acme",
"acme".try_into().unwrap(),
"Acme's repo",
git::refname!("master"),
Visibility::default(),

View File

@ -13,6 +13,7 @@ use qcheck::Arbitrary;
use crate::collections::RandomMap;
use crate::identity::doc::Visibility;
use crate::identity::project::ProjectName;
use crate::identity::{
doc::{Doc, DocAt, RepoId},
project::Project,
@ -108,9 +109,10 @@ impl Arbitrary for Project {
fn arbitrary(g: &mut qcheck::Gen) -> Self {
let mut rng = fastrand::Rng::with_seed(u64::arbitrary(g));
let length = rng.usize(1..16);
let name = iter::repeat_with(|| rng.alphanumeric())
let name: String = iter::repeat_with(|| rng.alphanumeric())
.take(length)
.collect();
let name = ProjectName::from_str(&name).unwrap();
let description = iter::repeat_with(|| rng.alphanumeric())
.take(length * 2)
.collect();

View File

@ -44,7 +44,7 @@ pub fn storage<P: AsRef<Path>, G: Signer>(path: P, signer: &G) -> Result<Storage
let (repo, _) = repository(path.join("workdir").join(name));
rad::init(
&repo,
name,
name.try_into().unwrap(),
desc,
git::refname!("master"),
Visibility::default(),
@ -67,7 +67,7 @@ pub fn project<P: AsRef<Path>, G: Signer>(
let (working, head) = repository(path);
let (id, _, refs) = rad::init(
&working,
"acme",
"acme".try_into().unwrap(),
"Acme's repository",
git::refname!("master"),
Visibility::default(),