diff --git a/src/command.rs b/src/command.rs index 3c6398eb..cf4cd6f4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -60,6 +60,7 @@ pub async fn init(kind: Option, name: Option) -> miett Ok(PackageManifest { kind, name, + directory: None, version: INITIAL_VERSION, description: None, }) diff --git a/src/lock.rs b/src/lock.rs index 90ad9153..aadeb4f2 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -23,7 +23,7 @@ use url::Url; use crate::{ errors::{DeserializationError, FileExistsError, FileNotFound, SerializationError, WriteError}, - package::{Package, PackageName}, + package::{Package, PackageDirectory, PackageName}, registry::RegistryUri, ManagedFile, }; @@ -41,6 +41,8 @@ pub const LOCKFILE: &str = "Proto.lock"; pub struct LockedPackage { /// The name of the package pub name: PackageName, + /// Directory where the package's contents are stored + pub directory: Option, /// The cryptographic digest of the package contents pub digest: Digest, /// The URI of the registry that contains the package @@ -67,6 +69,7 @@ impl LockedPackage { ) -> Self { Self { name: package.name().to_owned(), + directory: package.directory().cloned(), registry, repository, digest: DigestAlgorithm::SHA256.digest(&package.tgz), diff --git a/src/manifest.rs b/src/manifest.rs index 49128dda..02f6f50a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -25,7 +25,7 @@ use tokio::fs; use crate::{ errors::{DeserializationError, FileExistsError, SerializationError, WriteError}, - package::{PackageName, PackageType}, + package::{PackageDirectory, PackageName, PackageType}, registry::RegistryUri, ManagedFile, }; @@ -377,12 +377,23 @@ pub struct PackageManifest { pub kind: PackageType, /// Name of the package pub name: PackageName, + /// Directory in which to put the cache. If unset, defaults to the package name + pub directory: Option, /// Version of the package pub version: Version, /// Description of the api package pub description: Option, } +impl PackageManifest { + /// Get the directory where the package contents will be stored. + /// + /// This fallbacks to `name` if `directory` is unset. + pub fn directory(&self) -> &str { + self.directory.as_deref().unwrap_or(self.name.as_ref()) + } +} + /// Represents a single project dependency #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)] pub struct Dependency { diff --git a/src/package/compressed.rs b/src/package/compressed.rs index e7a23b3e..fd8064b6 100644 --- a/src/package/compressed.rs +++ b/src/package/compressed.rs @@ -32,6 +32,8 @@ use crate::{ ManagedFile, }; +use super::PackageDirectory; + /// An in memory representation of a `buffrs` package #[derive(Clone, Debug, PartialEq, Eq)] pub struct Package { @@ -208,6 +210,31 @@ impl Package { .name } + /// The directory of this package + #[inline] + pub fn directory(&self) -> Option<&PackageDirectory> { + assert!(self.manifest.package.is_some()); + + self.manifest + .package + .as_ref() + .expect("compressed package contains invalid manifest (package section missing)") + .directory + .as_ref() + } + + /// Directory for this oackage + #[inline] + pub fn directory_str(&self) -> &str { + assert!(self.manifest.package.is_some()); + + self.manifest + .package + .as_ref() + .expect("compressed package contains invalid manifest (package section missing)") + .directory() + } + /// The version of this package #[inline] pub fn version(&self) -> &Version { diff --git a/src/package/directory.rs b/src/package/directory.rs new file mode 100644 index 00000000..5635f516 --- /dev/null +++ b/src/package/directory.rs @@ -0,0 +1,172 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{fmt, ops::Deref, str::FromStr}; + +use miette::IntoDiagnostic; +use serde::{Deserialize, Serialize}; + +/// A `buffrs` package directory for parsing and type safety +#[derive(Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug)] +#[serde(try_from = "String", into = "String")] +pub struct PackageDirectory(String); + +/// Errors that can be generated parsing [`PackageDirectory`], see [`PackageDirectory::new()`]. +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum PackageDirectoryError { + /// Empty package directory. + #[error("package directory must be at least one character long, but was empty")] + Empty, + /// Too long. + #[error("package directories must be at most 128 characters long, but was {0:}")] + TooLong(usize), + /// Invalid start character. + #[error("package directory must start with alphabetic character, but was {0:}")] + InvalidStart(char), + /// Invalid character. + #[error("package directory must consist of only ASCII lowercase and dashes (-, _), but contains {0:} at position {1:}")] + InvalidCharacter(char, usize), +} + +impl super::ParseError for PackageDirectoryError { + #[inline] + fn empty() -> Self { + Self::Empty + } + + #[inline] + fn too_long(current_length: usize) -> Self { + Self::TooLong(current_length) + } + + #[inline] + fn invalid_start(first: char) -> Self { + Self::InvalidStart(first) + } + + #[inline] + fn invalid_character(found: char, pos: usize) -> Self { + Self::InvalidCharacter(found, pos) + } +} + +impl PackageDirectory { + const MAX_LENGTH: usize = 128; + + /// New package directory from string. + pub fn new>(value: S) -> Result { + let value = value.into(); + Self::validate(&value)?; + Ok(Self(value)) + } + + /// Validate a package directory. + pub fn validate(directory: impl AsRef) -> Result<(), PackageDirectoryError> { + super::validate(directory.as_ref(), &[b'-', b'_'], Self::MAX_LENGTH) + } +} + +impl TryFrom for PackageDirectory { + type Error = PackageDirectoryError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl FromStr for PackageDirectory { + type Err = miette::Report; + + fn from_str(input: &str) -> Result { + Self::new(input).into_diagnostic() + } +} + +impl From for String { + fn from(s: PackageDirectory) -> Self { + s.to_string() + } +} + +impl Deref for PackageDirectory { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for PackageDirectory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn ascii_lowercase() { + assert_eq!( + PackageDirectory::new("abc"), + Ok(PackageDirectory("abc".into())) + ); + } + + #[test] + fn short() { + assert_eq!(PackageDirectory::new("a"), Ok(PackageDirectory("a".into()))); + assert_eq!( + PackageDirectory::new("ab"), + Ok(PackageDirectory("ab".into())) + ); + } + + #[test] + fn long() { + assert_eq!( + PackageDirectory::new("a".repeat(PackageDirectory::MAX_LENGTH)), + Ok(PackageDirectory("a".repeat(PackageDirectory::MAX_LENGTH))) + ); + + assert_eq!( + PackageDirectory::new("a".repeat(PackageDirectory::MAX_LENGTH + 1)), + Err(PackageDirectoryError::TooLong( + PackageDirectory::MAX_LENGTH + 1 + )) + ); + } + + #[test] + fn empty() { + assert_eq!(PackageDirectory::new(""), Err(PackageDirectoryError::Empty)); + } + + #[test] + fn numeric_start() { + assert_eq!( + PackageDirectory::new("4abc"), + Err(PackageDirectoryError::InvalidStart('4')) + ); + } + + #[test] + fn underscore_and_dash() { + assert_eq!( + PackageDirectory::new("with_underscore-and-dash"), + Ok(PackageDirectory("with_underscore-and-dash".into())), + ); + } +} diff --git a/src/package/mod.rs b/src/package/mod.rs index e4c1136f..5a1e2954 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -13,8 +13,152 @@ // limitations under the License. mod compressed; +mod directory; mod name; mod store; mod r#type; -pub use self::{compressed::Package, name::PackageName, r#type::PackageType, store::PackageStore}; +pub use self::{ + compressed::Package, directory::PackageDirectory, name::PackageName, r#type::PackageType, + store::PackageStore, +}; + +trait ParseError { + fn empty() -> Self; + fn too_long(current_length: usize) -> Self; + fn invalid_start(first: char) -> Self; + fn invalid_character(found: char, pos: usize) -> Self; +} + +/// Validation function for both package name and directories. They have very similar rules with +/// just extra allowed characters being different at the moment. +/// +/// Shared allowed characters are `a-z` for the first and `a-z0-9` + extras for the rest. +fn validate(raw: &str, extra_allowed_chars: &[u8], max_len: usize) -> Result<(), E> +where + E: ParseError, +{ + let (first, rest) = match raw.as_bytes() { + [] => return Err(E::empty()), + x if x.len() > max_len => return Err(E::too_long(x.len())), + [first, rest @ ..] => (first, rest), + }; + + if !first.is_ascii_lowercase() { + // Handle UTF-8 chars correctly + return Err(E::invalid_start(raw.chars().next().unwrap())); + } + + let is_disallowed = |&(_, c): &(usize, &u8)| { + !(c.is_ascii_lowercase() || c.is_ascii_digit() || extra_allowed_chars.contains(c)) + }; + + match rest.iter().enumerate().find(is_disallowed) { + // We need the +1 since the first character has been checked separately + Some((pos, _)) => Err(E::invalid_character( + // Handle UTF-8 chars correctly + raw.chars().nth(pos + 1).unwrap(), + pos + 1, + )), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ParserError { + Empty, + TooLong(usize), + InvalidStart(char), + InvalidCharacter(char, usize), + } + + impl ParseError for ParserError { + fn empty() -> Self { + Self::Empty + } + + fn too_long(current_length: usize) -> Self { + Self::TooLong(current_length) + } + + fn invalid_start(first: char) -> Self { + Self::InvalidStart(first) + } + + fn invalid_character(found: char, pos: usize) -> Self { + Self::InvalidCharacter(found, pos) + } + } + + #[track_caller] + fn validate(raw: &str, extra_allowed_chars: &[u8], max_len: usize) -> Result<(), ParserError> { + super::validate(raw, extra_allowed_chars, max_len) + } + + #[test] + fn empty_fails() { + let res = validate("", &[], 10); + assert_eq!(res, Err(ParserError::Empty)); + } + + #[test] + fn length_check() { + let res = validate("abcdefghijklm", &[], 5); + assert_eq!(res, Err(ParserError::TooLong(13))); + + let res = validate("abcdefghijklm", &[], 10); + assert_eq!(res, Err(ParserError::TooLong(13))); + + let res = validate("abcdefghijklm", &[], 15); + assert_eq!(res, Ok(())); + } + + #[test] + fn invalid_start() { + let res = validate("Ab", &[b'A'], 5); + assert_eq!(res, Err(ParserError::InvalidStart('A'))); + + let res = validate("5b", &[b'5'], 5); + assert_eq!(res, Err(ParserError::InvalidStart('5'))); + + let res = validate("-b", &[b'_'], 5); + assert_eq!(res, Err(ParserError::InvalidStart('-'))); + + let res = validate("_b", &[b'_'], 5); + assert_eq!(res, Err(ParserError::InvalidStart('_'))); + + let res = validate("🦀b", &('🦀' as u32).to_ne_bytes(), 10); + assert_eq!(res, Err(ParserError::InvalidStart('🦀'))); + } + + #[test] + fn invalid_character() { + let res = validate("bAc", &[], 5); + assert_eq!(res, Err(ParserError::InvalidCharacter('A', 1))); + + let res = validate("bowl-", &[], 5); + assert_eq!(res, Err(ParserError::InvalidCharacter('-', 4))); + + let res = validate("bob_", &[], 5); + assert_eq!(res, Err(ParserError::InvalidCharacter('_', 3))); + + let res = validate("bo🦀", &[], 10); + assert_eq!(res, Err(ParserError::InvalidCharacter('🦀', 2))); + } + + #[test] + fn basic_format() { + let res = validate("abcdefghijklmnopqrstuvwxyz0123456789", &[], 36); + assert_eq!(res, Ok(())); + } + + #[test] + fn extra_allowed_chars() { + let res = validate("b0A_2d-c", &[b'A', b'_', b'-'], 10); + assert_eq!(res, Ok(())); + } +} diff --git a/src/package/name.rs b/src/package/name.rs index c6528023..86e1bbf4 100644 --- a/src/package/name.rs +++ b/src/package/name.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; #[serde(try_from = "String", into = "String")] pub struct PackageName(String); -/// Errors that can be generated parsing [`PackageName`][], see [`PackageName::new()`][]. +/// Errors that can be generated parsing [`PackageName`], see [`PackageName::new()`]. #[derive(thiserror::Error, Debug, PartialEq)] pub enum PackageNameError { /// Empty package name. @@ -39,8 +39,29 @@ pub enum PackageNameError { InvalidCharacter(char, usize), } +impl super::ParseError for PackageNameError { + #[inline] + fn empty() -> Self { + Self::Empty + } + + #[inline] + fn too_long(current_length: usize) -> Self { + Self::TooLong(current_length) + } + + #[inline] + fn invalid_start(first: char) -> Self { + Self::InvalidStart(first) + } + + #[inline] + fn invalid_character(found: char, pos: usize) -> Self { + Self::InvalidCharacter(found, pos) + } +} + impl PackageName { - const MIN_LENGTH: usize = 1; const MAX_LENGTH: usize = 128; /// New package name from string. @@ -55,53 +76,9 @@ impl PackageName { Self(value.into()) } - /// Determine if this character is allowed at the start of a package name. - fn is_allowed_start(c: char) -> bool { - c.is_alphabetic() - } - - /// Determine if this character is allowed anywhere in a package name. - fn is_allowed(c: char) -> bool { - let is_ascii_lowercase_alphanumeric = - |c: char| c.is_ascii_alphanumeric() && !c.is_ascii_uppercase(); - match c { - '-' => true, - c if is_ascii_lowercase_alphanumeric(c) => true, - _ => false, - } - } - /// Validate a package name. pub fn validate(name: impl AsRef) -> Result<(), PackageNameError> { - let name = name.as_ref(); - - // validate length - if name.len() < Self::MIN_LENGTH { - return Err(PackageNameError::Empty); - } - - if name.len() > Self::MAX_LENGTH { - return Err(PackageNameError::TooLong(name.len())); - } - - // validate first character - match name.chars().next() { - Some(c) if Self::is_allowed_start(c) => {} - Some(c) => return Err(PackageNameError::InvalidStart(c)), - None => unreachable!(), - } - - // validate all characters - let illegal = name - .chars() - .enumerate() - .find(|(_, c)| !Self::is_allowed(*c)); - - if let Some((index, c)) = illegal { - return Err(PackageNameError::InvalidCharacter(c, index)); - } - - Ok(()) + super::validate(name.as_ref(), &[b'-'], Self::MAX_LENGTH) } } @@ -148,7 +125,6 @@ mod test { #[test] fn ascii_lowercase() { assert_eq!(PackageName::new("abc"), Ok(PackageName("abc".into()))); - assert_eq!(PackageName::new("abc"), Ok(PackageName("abc".into()))); } #[test] @@ -160,13 +136,13 @@ mod test { #[test] fn long() { assert_eq!( - PackageName::new("a".repeat(128)), - Ok(PackageName("a".repeat(128))) + PackageName::new("a".repeat(PackageName::MAX_LENGTH)), + Ok(PackageName("a".repeat(PackageName::MAX_LENGTH))) ); assert_eq!( - PackageName::new("a".repeat(129)), - Err(PackageNameError::TooLong(129)) + PackageName::new("a".repeat(PackageName::MAX_LENGTH + 1)), + Err(PackageNameError::TooLong(PackageName::MAX_LENGTH + 1)) ); } diff --git a/src/package/store.rs b/src/package/store.rs index fa6cf0b1..931a88e8 100644 --- a/src/package/store.rs +++ b/src/package/store.rs @@ -60,7 +60,7 @@ impl PackageStore { /// Path to where the package contents are populated. fn populated_path(&self, manifest: &PackageManifest) -> PathBuf { - self.proto_vendor_path().join(manifest.name.to_string()) + self.proto_vendor_path().join(manifest.directory()) } /// Creates the expected directory structure for `buffrs` @@ -96,7 +96,7 @@ impl PackageStore { /// Unpacks a package into a local directory pub async fn unpack(&self, package: &Package) -> miette::Result<()> { - let pkg_dir = self.locate(package.name()); + let pkg_dir = self.locate(package.directory_str()); package.unpack(&pkg_dir).await?; @@ -181,8 +181,8 @@ impl PackageStore { } /// Directory for the vendored installation of a package - pub fn locate(&self, package: &PackageName) -> PathBuf { - self.proto_vendor_path().join(&**package) + pub fn locate(&self, directory: &str) -> PathBuf { + self.proto_vendor_path().join(directory) } /// Collect .proto files in a given path @@ -214,7 +214,7 @@ impl PackageStore { /// Sync this stores proto files to the vendor directory pub async fn populate(&self, manifest: &PackageManifest) -> miette::Result<()> { let source_path = self.proto_path(); - let target_dir = self.proto_vendor_path().join(manifest.name.to_string()); + let target_dir = self.populated_path(manifest); if tokio::fs::try_exists(&target_dir) .await diff --git a/src/registry/cache.rs b/src/registry/cache.rs index e546a510..8535ed7e 100644 --- a/src/registry/cache.rs +++ b/src/registry/cache.rs @@ -68,7 +68,7 @@ impl LocalRegistry { let path = self.base_dir.join(PathBuf::from(format!( "{}/{}/{}-{}.tgz", repository, - package.name(), + package.directory_str(), package.name(), package.version(), ))); @@ -115,6 +115,7 @@ mod tests { Some(PackageManifest { kind: PackageType::Api, name: "test-api".parse().unwrap(), + directory: None, version: "0.1.0".parse().unwrap(), description: None, }),