diff --git a/Cargo.toml b/Cargo.toml index 7ae9713..c99d4be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,22 +6,22 @@ edition = "2021" [dependencies] alphanumeric-sort = "1.5.3" getset = "0.1.2" -lazy_static = "1.4.0" pretty_assertions = "1.4.0" -regex = "1.6.0" serde = { version = "1.0.140", features = ["derive"] } strum = { version = "0.24.1", features = ["derive"] } thiserror = "1.0.31" utoipa = "4.2.3" serde_json = "1.0.95" -documented = "0.4.1" +documented = "0.7.1" semver = "1.0.23" bon = "2.3.0" duplicate = "2.0.0" +lazy-regex = { version = "3.3.0", features = ["regex"] } [dev-dependencies] assert_matches = "1.5.0" impls = "1.0.3" itertools = "0.10.5" proptest = "1.0.0" +simple_test_case = "1.2.0" static_assertions = "1.1.0" diff --git a/src/lib.rs b/src/lib.rs index 2c4881a..22b24a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,11 +3,10 @@ #![deny(missing_docs)] #![warn(rust_2018_idioms)] -use std::{borrow::Cow, str::FromStr}; +use std::{borrow::Cow, num::ParseIntError, str::FromStr}; +use documented::Documented; use duplicate::duplicate; -use lazy_static::lazy_static; -use regex::Regex; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, Display, EnumIter, EnumString}; use utoipa::ToSchema; @@ -23,13 +22,7 @@ pub use locator::*; pub use locator_package::*; pub use locator_strict::*; -/// [`Locator`](crate::Locator) is closely tied with the concept of Core's "fetchers", -/// which are asynchronous jobs tasked with downloading the code -/// referred to by a [`Locator`](crate::Locator) so that Core or some other service -/// may analyze it. -/// -/// For more information on the background of `Locator` and fetchers generally, -/// refer to [Fetchers and Locators](https://go/fetchers-doc). +/// `Fetcher` identifies a supported code host protocol. #[derive( Copy, Clone, @@ -45,10 +38,12 @@ pub use locator_strict::*; AsRefStr, Serialize, Deserialize, + Documented, ToSchema, )] #[non_exhaustive] #[serde(rename_all = "snake_case")] +#[schema(example = json!("git"))] pub enum Fetcher { /// Archive locators are FOSSA specific. #[strum(serialize = "archive")] @@ -176,7 +171,13 @@ pub enum Fetcher { } /// Identifies the organization to which this locator is namespaced. -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +/// +/// Organization IDs are canonically created by FOSSA instances +/// and have no meaning outside of FOSSA instances. +#[derive( + Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Hash, Documented, ToSchema, +)] +#[schema(example = json!(1))] pub struct OrgId(usize); impl From for usize { @@ -191,6 +192,15 @@ impl From for OrgId { } } +impl FromStr for OrgId { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let id = s.parse()?; + Ok(Self(id)) + } +} + duplicate! { [ number; @@ -237,7 +247,16 @@ impl std::fmt::Debug for OrgId { } /// The package section of the locator. -#[derive(Clone, Eq, PartialEq, Hash)] +/// +/// A "package" is generally the name of a project or dependency in a code host. +/// However some fetcher protocols (such as `git`) embed additional information +/// inside the `Package` of a locator, such as the URL of the `git` instance +/// from which the project can be fetched. +/// +/// Additionally, some fetcher protocols (such as `apk`, `rpm-generic`, and `deb`) +/// further encode additional standardized information in the `Package` of the locator. +#[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Documented, ToSchema)] +#[schema(example = json!("github.com/fossas/locator-rs"))] pub struct Package(String); impl Package { @@ -290,9 +309,15 @@ impl std::cmp::PartialOrd for Package { } /// The revision section of the locator. -#[derive(Clone, Eq, PartialEq, Hash)] +/// +/// A "revision" is the version of the project in the code host. +/// Some fetcher protocols (such as `apk`, `rpm-generic`, and `deb`) +/// encode additional standardized information in the `Revision` of the locator. +#[derive(Clone, Eq, PartialEq, Hash, Documented, ToSchema)] +#[schema(example = json!("v1.0.0"))] pub enum Revision { /// The revision is valid semver. + #[schema(value_type = String)] Semver(semver::Version), /// The revision is an opaque string. @@ -345,6 +370,24 @@ impl std::fmt::Debug for Revision { } } +impl Serialize for Revision { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Revision { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self::from) + } +} + impl std::cmp::Ord for Revision { fn cmp(&self, other: &Self) -> std::cmp::Ordering { let cmp = alphanumeric_sort::compare_str; @@ -364,46 +407,39 @@ impl std::cmp::PartialOrd for Revision { } /// Optionally parse an org ID and trimmed package out of a package string. -fn parse_org_package(package: &str) -> Result<(Option, Package), PackageParseError> { - lazy_static! { - static ref RE: Regex = Regex::new(r"^(?:(?P\d+)/)?(?P.+)") - .expect("Package parsing expression must compile"); - } - - let mut captures = RE.captures_iter(package); - let capture = captures.next().ok_or_else(|| PackageParseError::Package { - package: package.to_string(), - })?; - - let trimmed_package = - capture - .name("package") - .map(|m| m.as_str()) - .ok_or_else(|| PackageParseError::Field { - package: package.to_string(), - field: String::from("package"), - })?; - - // If we fail to parse the org_id as a valid number, don't fail the overall parse; - // just don't namespace to org ID and return the input unmodified. - match capture - .name("org_id") - .map(|m| m.as_str()) - .map(OrgId::try_from) - { - // An org ID was provided and validly parsed, use it. - Some(Ok(org_id)) => Ok((Some(org_id), Package::from(trimmed_package))), - - // Otherwise, if we either didn't get an org ID section, - // or it wasn't a valid org ID, - // just use the package as-is. - _ => Ok((None, Package::from(package))), +fn parse_org_package(input: &str) -> (Option, Package) { + macro_rules! construct { + ($org_id:expr, $package:expr) => { + return (Some($org_id), Package::from($package)) + }; + ($package:expr) => { + return (None, Package::from($package)) + }; } + + // No `/`? This must not be namespaced. + let Some((org_id, package)) = input.split_once('/') else { + construct!(input); + }; + + // Nothing before or after the `/`? Still not namespaced. + if org_id.is_empty() || package.is_empty() { + construct!(input); + }; + + // If the part before the `/` isn't a number, it can't be a namespaced org id. + let Ok(org_id) = org_id.parse() else { + construct!(input) + }; + + // Ok, there was text before and after the `/`, and the content before was a number. + // Finally, we've got a namespaced package. + construct!(org_id, package) } #[cfg(test)] mod tests { - use itertools::izip; + use simple_test_case::test_case; use super::*; @@ -413,52 +449,91 @@ mod tests { } } + macro_rules! revision { + (semver => $input:expr) => { + Revision::Semver(semver::Version::parse($input).expect("parse semver")) + }; + (opaque => $input:expr) => { + Revision::Opaque(String::from($input)) + }; + } + + #[test_case("0/name", Some(OrgId(0)), Package::new("name"); "0/name")] + #[test_case("1/name", Some(OrgId(1)), Package::new("name"); "1/name")] + #[test_case("1/name/foo", Some(OrgId(1)), Package::new("name/foo"); "1/name/foo")] + #[test_case("1//name/foo", Some(OrgId(1)), Package::new("/name/foo"); "doubleslash_1/name/foo")] + #[test_case("9809572/name/foo", Some(OrgId(9809572)), Package::new("name/foo"); "9809572/name/foo")] + #[test_case("name/foo", None, Package::new("name/foo"); "name/foo")] + #[test_case("name", None, Package::new("name"); "name")] + #[test_case("/name/foo", None, Package::new("/name/foo"); "/name/foo")] + #[test_case("/123/name/foo", None, Package::new("/123/name/foo"); "/123/name/foo")] + #[test_case("/name", None, Package::new("/name"); "/name")] + #[test_case("abcd/1234/name", None, Package::new("abcd/1234/name"); "abcd/1234/name")] + #[test_case("1abc2/name", None, Package::new("1abc2/name"); "1abc2/name")] + #[test_case("name/1234", None, Package::new("name/1234"); "name/1234")] #[test] - fn parses_org_package() { - let orgs = [OrgId(0usize), OrgId(1), OrgId(9809572)]; - let names = [Package::new("name"), Package::new("name/foo")]; - - for (org, name) in izip!(orgs, names) { - let test = format!("{org}/{name}"); - let Ok((Some(org_id), package)) = parse_org_package(&test) else { - panic!("must parse '{test}'") - }; - assert_eq!(org_id, org, "'org_id' must match in '{test}'"); - assert_eq!(package, name, "'package' must match in '{test}"); - } + fn parse_org_package(input: &str, org: Option, package: Package) { + let (org_id, name) = parse_org_package(input); + assert_eq!(org_id, org, "'org_id' must match in '{input}'"); + assert_eq!(package, name, "'package' must match in '{input}"); } + #[test_case(r#""rpm-generic""#, Fetcher::LinuxRpm; "rpm-generic")] + #[test_case(r#""deb""#, Fetcher::LinuxDebian; "deb")] + #[test_case(r#""apk""#, Fetcher::LinuxAlpine; "apk")] #[test] - fn parses_org_package_no_org() { - let names = [ - Package::new("/name/foo"), - Package::new("/name"), - Package::new("abcd/1234/name"), - Package::new("1abc2/name"), - ]; - for test in names { - let input = &format!("{test}"); - let Ok((org_id, package)) = parse_org_package(input) else { - panic!("must parse '{test}'") - }; - assert_eq!(org_id, None, "'org_id' must be None in '{test}'"); - assert_eq!(package, test, "'package' must match in '{test}"); - } + fn serializes_linux_properly(expected: &str, value: Fetcher) { + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } + + #[test_case(Package::new("name"); "name")] + #[test_case(Package::new("name/foo"); "name/foo")] + #[test_case(Package::new("/name/foo"); "/name/foo")] + #[test_case(Package::new("/name"); "/name")] + #[test_case(Package::new("abcd/1234/name"); "abcd/1234/name")] + #[test_case(Package::new("1abc2/name"); "1abc2/name")] + #[test] + fn package_roundtrip(package: Package) { + let serialized = serde_json::to_string(&package).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(package, deserialized); + } + + #[test_case("1.0.0", revision!(semver => "1.0.0"); "1.0.0")] + #[test_case("1.2.0", revision!(semver => "1.2.0"); "1.2.0")] + #[test_case("1.0.0-alpha.1", revision!(semver => "1.0.0-alpha.1"); "1.0.0-alpha.1")] + #[test_case("1.0.0-alpha1", revision!(semver => "1.0.0-alpha1"); "1.0.0-alpha1")] + #[test_case("1.0.0-rc.10+r1234", revision!(semver => "1.0.0-rc.10+r1234"); "1.0.0-rc.10+r1234")] + #[test_case("abcd1234", revision!(opaque => "abcd1234"); "abcd1234")] + #[test_case("v1.0.0", revision!(opaque => "v1.0.0"); "v1.0.0")] + #[test] + fn revision(revision: &str, expected: Revision) { + let serialized = serde_json::to_string(&revision).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(expected, deserialized); + } + + #[test_case(Revision::from("1.0.0"); "1.0.0")] + #[test_case(Revision::from("1.2.0"); "1.2.0")] + #[test_case(Revision::from("1.0.0-alpha.1"); "1.0.0-alpha.1")] + #[test_case(Revision::from("1.0.0-alpha1"); "1.0.0-alpha1")] + #[test_case(Revision::from("1.0.0-rc.10"); "1.0.0-rc.10")] + #[test_case(Revision::from("abcd1234"); "abcd1234")] + #[test_case(Revision::from("v1.0.0"); "v1.0.0")] + #[test] + fn revision_roundtrip(revision: Revision) { + let serialized = serde_json::to_string(&revision).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(revision, deserialized); } + #[test_case(OrgId(1); "1")] + #[test_case(OrgId(0); "0")] + #[test_case(OrgId(1210931039); "1210931039")] #[test] - fn serializes_linux_properly() { - assert_eq!( - r#""rpm-generic""#, - serde_json::to_string(&Fetcher::LinuxRpm).unwrap() - ); - assert_eq!( - r#""deb""#, - serde_json::to_string(&Fetcher::LinuxDebian).unwrap() - ); - assert_eq!( - r#""apk""#, - serde_json::to_string(&Fetcher::LinuxAlpine).unwrap() - ); + fn org_roundtrip(org: OrgId) { + let serialized = serde_json::to_string(&org).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(org, deserialized); } } diff --git a/src/locator.rs b/src/locator.rs index de60089..77e0e6b 100644 --- a/src/locator.rs +++ b/src/locator.rs @@ -3,8 +3,6 @@ use std::{fmt::Display, str::FromStr}; use bon::Builder; use documented::Documented; use getset::{CopyGetters, Getters}; -use lazy_static::lazy_static; -use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::{ @@ -65,55 +63,84 @@ macro_rules! locator { }; } -/// Core, and most services that interact with Core, -/// refer to open source packages via the `Locator` type. +/// The regular expression used to parse the locator. /// -/// This type is nearly universally rendered to a string -/// before being serialized to the database or sent over the network. +/// ``` +/// # use locator::locator_regex; /// -/// This type represents a _validly-constructed_ `Locator`, but does not -/// validate whether a `Locator` is actually valid. This means that a -/// given `Locator` is guaranteed to be correctly formatted data, -/// but that the actual repository or revision to which the `Locator` -/// refers is _not_ guaranteed to exist or be accessible. -/// Currently the canonical method for validating whether a given `Locator` is -/// accessible is to run it through the Core fetcher system. +/// // Get the raw string used for the expression. +/// let expression = locator_regex!(); +/// +/// // Parse the regular expression. +/// // The expression is compiled once per callsite. +/// let parsed = locator_regex!(parse => "git+github.com/fossas/locator-rs$v2.2.0"); +/// ``` +#[macro_export] +#[doc(hidden)] +macro_rules! locator_regex { + () => { + r"^(?:([a-z-]+)\+|)([^$]+)(?:\$|)(.+|)$" + }; + (parse => $input:expr) => { + lazy_regex::regex_captures!(r"^(?:([a-z-]+)\+|)([^$]+)(?:\$|)(.+|)$", $input) + }; +} + +/// `Locator` identifies a package, optionally at a specific revision, in a code host. /// -/// For more information on the background of `Locator` and fetchers generally, -/// FOSSA employees may refer to -/// [Fetchers and Locators](https://go/fetchers-doc). +/// If the `revision` component is not specified, FOSSA services interpret this to mean +/// that the "latest" version of the package should be used if the requested operation +/// requires a concrete version of the package. +/// +/// ## Guarantees +/// +/// This type represents a _validly-constructed_ `Locator`, but does not +/// guarantee whether a package or revision actually exists or is accessible +/// in the code host. /// /// ## Ordering /// -/// Locators order by: +/// `Locator` orders by: /// 1. Fetcher, alphanumerically. /// 2. Organization ID, alphanumerically; missing organizations are sorted higher. /// 3. The package field, alphanumerically. /// 4. The revision field: -/// If both comparing locators use semver, these are compared using semver rules; -/// otherwise these are compared alphanumerically. -/// Missing revisions are sorted higher. +/// - If both comparing locators use semver, these are compared using semver rules. +/// - Otherwise these are compared alphanumerically. +/// - Missing revisions are sorted higher. /// -/// Importantly, there may be other metrics for ordering using the actual code host -/// which contains the package (for example, ordering by release date). -/// This library does not perform such ordering. +/// **Important:** there may be other metrics for ordering using the actual code host +/// which contains the package- for example ordering by release date, or code hosts +/// such as `git` which have non-linear history (making flat ordering a lossy operation). +/// `Locator` does not take such edge cases into account in any way. /// /// ## Parsing /// -/// The input string must be in one of the following forms: -/// - `{fetcher}+{package}` -/// - `{fetcher}+{package}$` -/// - `{fetcher}+{package}${revision}` +/// This type is canonically rendered to a string before being serialized +/// to the database or sent over the network according to the rules in this section. +/// +/// The input string must be in one of the following formats: +/// ```ignore +/// {fetcher}+{package}${revision} +/// {fetcher}+{package} +/// ``` /// /// Packages may also be namespaced to a specific organization; /// in such cases the organization ID is at the start of the `{package}` field /// separated by a slash. The ID can be any non-negative integer. -/// This yields the following formats: -/// - `{fetcher}+{org_id}/{package}` -/// - `{fetcher}+{org_id}/{package}$` -/// - `{fetcher}+{org_id}/{package}${revision}` +/// This yields the following optional formats: +/// ```ignore +/// {fetcher}+{org_id}/{package}${revision} +/// {fetcher}+{org_id}/{package} +/// ``` /// -/// This parse function is based on the function used in FOSSA Core for maximal compatibility. +/// Note that locators do not feature escaping: instead the _first_ instance +/// of each delimiter (`+`, `/`, `$`) is used to split the fields. However, +/// as a special case organization IDs are only extracted if the field content +/// fully consists of a non-negative integer. +// +// For more information on the background of `Locator` and fetchers generally, +// FOSSA employees may refer to the "fetchers and locators" doc: https://go/fetchers-doc. #[derive( Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Builder, Getters, CopyGetters, Documented, )] @@ -163,71 +190,61 @@ pub struct Locator { } impl Locator { + /// The regular expression used to parse locators. + pub const REGEX: &'static str = locator_regex!(); + /// Parse a `Locator`. /// For details, see the parsing section on [`Locator`]. - pub fn parse(locator: &str) -> Result { - lazy_static! { - static ref RE: Regex = Regex::new( - r"^(?:(?P[a-z-]+)\+|)(?P[^$]+)(?:\$|)(?P.+|)$" - ) - .expect("Locator parsing expression must compile"); + pub fn parse(input: &str) -> Result { + /// Convenience macro for fatal errors without needing to type out all the `.into()`s. + macro_rules! fatal { + ($type:ident => $input:expr) => { + ParseError::$type { + input: $input.into(), + } + }; + ($type:ident => $input:expr, $($key:ident: $value:expr),+) => { + ParseError::$type { + input: $input.into(), + $($key: $value.into()),*, + } + }; } - let mut captures = RE.captures_iter(locator); - let capture = captures.next().ok_or_else(|| ParseError::Syntax { - input: locator.to_string(), - })?; - - let fetcher = - capture - .name("fetcher") - .map(|m| m.as_str()) - .ok_or_else(|| ParseError::Field { - input: locator.to_owned(), - field: "fetcher".to_string(), - })?; - - let fetcher = Fetcher::try_from(fetcher).map_err(|error| ParseError::Fetcher { - input: locator.to_owned(), - fetcher: fetcher.to_string(), - error, - })?; - - let package = capture - .name("package") - .map(|m| m.as_str().to_owned()) - .ok_or_else(|| ParseError::Field { - input: locator.to_owned(), - field: "package".to_string(), - })?; - - let revision = capture.name("revision").map(|m| m.as_str()).and_then(|s| { - if s.is_empty() { - None - } else { - Some(Revision::from(s)) - } - }); + /// Convenience macro for early returns. + macro_rules! bail { + ($($tt:tt)*) => { + return Err(Error::from(fatal!($($tt)*))) + }; + } - match parse_org_package(&package) { - Ok((org_id @ Some(_), package)) => Ok(Locator { - fetcher, - org_id, - package, - revision, - }), - Ok((org_id @ None, _)) => Ok(Locator { - fetcher, - org_id, - package: Package::from(package.as_str()), - revision, - }), - Err(error) => Err(Error::Parse(ParseError::Package { - input: locator.to_owned(), - package, - error, - })), + let Some((_, fetcher, package, revision)) = locator_regex!(parse => input) else { + bail!(Syntax => input); + }; + + if fetcher.is_empty() { + bail!(Field => input, field: "fetcher"); } + if package.is_empty() { + bail!(Field => input, field: "package"); + } + + let fetcher = Fetcher::try_from(fetcher) + .map_err(|err| fatal!(Fetcher => input, fetcher: fetcher, error: err))?; + + let revision = if revision.is_empty() { + None + } else { + Some(Revision::from(revision)) + }; + + let (org_id, package) = parse_org_package(package); + Ok(Locator { + fetcher, + org_id, + package, + revision, + }) } /// Promote a `Locator` to a [`StrictLocator`] by providing the default value to use @@ -316,24 +333,6 @@ impl Serialize for Locator { } } -impl<'a> ToSchema<'a> for Locator { - fn schema() -> ( - &'a str, - utoipa::openapi::RefOr, - ) { - ( - "Locator", - ObjectBuilder::new() - .description(Some(Self::DOCS)) - .example(Some(json!("git+github.com/fossas/example$1234"))) - .min_length(Some(3)) - .schema_type(SchemaType::String) - .build() - .into(), - ) - } -} - impl From for Locator { fn from(package: PackageLocator) -> Self { let (fetcher, org_id, package) = package.explode(); @@ -394,6 +393,24 @@ impl FromStr for Locator { } } +impl<'a> ToSchema<'a> for Locator { + fn schema() -> ( + &'a str, + utoipa::openapi::RefOr, + ) { + ( + "Locator", + ObjectBuilder::new() + .description(Some(Self::DOCS)) + .example(Some(json!("git+github.com/fossas/locator-rs$v1.0.0"))) + .min_length(Some(3)) + .schema_type(SchemaType::String) + .build() + .into(), + ) + } +} + #[cfg(test)] mod tests { use std::borrow::Cow; diff --git a/src/locator_package.rs b/src/locator_package.rs index 19ab4c6..5997251 100644 --- a/src/locator_package.rs +++ b/src/locator_package.rs @@ -39,38 +39,55 @@ macro_rules! package { }; } -/// A [`Locator`] specialized to not include the `revision` component. +/// `PackageLocator` identifies a package in a code host. /// -/// Any [`Locator`] may be converted to a `PackageLocator` by simply discarding the `revision` component. -/// To create a [`Locator`] from a `PackageLocator`, the value for `revision` must be provided; see [`Locator`] for details. +/// "Package" locators are similar to standard locators, except that they +/// _never specify_ the `revision` field. If the `revision` field +/// is provided in the input string, `PackageLocator` ignores it. +/// +/// ## Guarantees +/// +/// This type represents a _validly-constructed_ `PackageLocator`, but does not +/// guarantee whether a package actually exists or is accessible in the code host. /// /// ## Ordering /// -/// Locators order by: +/// `PackageLocator` orders by: /// 1. Fetcher, alphanumerically. /// 2. Organization ID, alphanumerically; missing organizations are sorted higher. /// 3. The package field, alphanumerically. /// -/// Importantly, there may be other metrics for ordering using the actual code host -/// which contains the package (for example, ordering by release date). -/// This library does not perform such ordering. +/// **Important:** there may be other metrics for ordering using the actual code host +/// which contains the package- for example ordering by release date. +/// `PackageLocator` does not take such edge cases into account in any way. /// /// ## Parsing /// -/// The input string must be in one of the following forms: -/// - `{fetcher}+{package}` -/// - `{fetcher}+{package}$` -/// - `{fetcher}+{package}${revision}` +/// This type is canonically rendered to a string before being serialized +/// to the database or sent over the network according to the rules in this section. +/// +/// The input string must be in one of the following formats: +/// ```ignore +/// {fetcher}+{package}${revision} +/// {fetcher}+{package} +/// ``` /// /// Packages may also be namespaced to a specific organization; /// in such cases the organization ID is at the start of the `{package}` field /// separated by a slash. The ID can be any non-negative integer. -/// This yields the following formats: -/// - `{fetcher}+{org_id}/{package}` -/// - `{fetcher}+{org_id}/{package}$` -/// - `{fetcher}+{org_id}/{package}${revision}` +/// This yields the following optional formats: +/// ```ignore +/// {fetcher}+{org_id}/{package}${revision} +/// {fetcher}+{org_id}/{package} +/// ``` /// -/// This implementation ignores the `revision` segment if it exists. If this is not preferred, use [`Locator`] instead. +/// Note that locators do not feature escaping: instead the _first_ instance +/// of each delimiter (`+`, `/`, `$`) is used to split the fields. However, +/// as a special case organization IDs are only extracted if the field content +/// fully consists of a non-negative integer. +// +// For more information on the background of `Locator` and fetchers generally, +// FOSSA employees may refer to the "fetchers and locators" doc: https://go/fetchers-doc. #[derive( Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Builder, Getters, CopyGetters, Documented, )] @@ -169,24 +186,6 @@ impl<'de> Deserialize<'de> for PackageLocator { } } -impl<'a> ToSchema<'a> for PackageLocator { - fn schema() -> ( - &'a str, - utoipa::openapi::RefOr, - ) { - ( - "PackageLocator", - ObjectBuilder::new() - .description(Some(Self::DOCS)) - .example(Some(json!("git+github.com/fossas/example"))) - .min_length(Some(3)) - .schema_type(SchemaType::String) - .build() - .into(), - ) - } -} - impl Serialize for PackageLocator { fn serialize(&self, serializer: S) -> Result where @@ -252,6 +251,24 @@ impl FromStr for PackageLocator { } } +impl<'a> ToSchema<'a> for StrictLocator { + fn schema() -> ( + &'a str, + utoipa::openapi::RefOr, + ) { + ( + "StrictLocator", + ObjectBuilder::new() + .description(Some(Self::DOCS)) + .example(Some(json!("git+github.com/fossas/locator-rs$v1.0.0"))) + .min_length(Some(3)) + .schema_type(SchemaType::String) + .build() + .into(), + ) + } +} + #[cfg(test)] mod tests { use assert_matches::assert_matches; diff --git a/src/locator_strict.rs b/src/locator_strict.rs index d1c5f7c..817d086 100644 --- a/src/locator_strict.rs +++ b/src/locator_strict.rs @@ -41,24 +41,38 @@ macro_rules! strict { }; } -/// A [`Locator`] specialized to **require** the `revision` component. +/// `StrictLocator` identifies a package at a specific revision in a code host. +/// +/// "Strict" locators are similar to standard locators, except that they +/// _require_ the `revision` field to be specified. If the `revision` field +/// is not specified, `StrictLocator` fails to parse. +/// +/// ## Guarantees +/// +/// This type represents a _validly-constructed_ `StrictLocator`, but does not +/// guarantee whether a package or revision actually exists or is accessible +/// in the code host. /// /// ## Ordering /// -/// Locators order by: +/// `StrictLocator` orders by: /// 1. Fetcher, alphanumerically. /// 2. Organization ID, alphanumerically; missing organizations are sorted higher. /// 3. The package field, alphanumerically. /// 4. The revision field: -/// If both comparing locators use semver, these are compared using semver rules; -/// otherwise these are compared alphanumerically. +/// - If both comparing locators use semver, these are compared using semver rules. +/// - Otherwise these are compared alphanumerically. /// -/// Importantly, there may be other metrics for ordering using the actual code host -/// which contains the package (for example, ordering by release date). -/// This library does not perform such ordering. +/// **Important:** there may be other metrics for ordering using the actual code host +/// which contains the package- for example ordering by release date, or code hosts +/// such as `git` which have non-linear history (making flat ordering a lossy operation). +/// `StrictLocator` does not take such edge cases into account in any way. /// /// ## Parsing /// +/// This type is canonically rendered to a string before being serialized +/// to the database or sent over the network according to the rules in this section. +/// /// The input string must be in the following format: /// ```ignore /// {fetcher}+{package}${revision} @@ -67,10 +81,18 @@ macro_rules! strict { /// Packages may also be namespaced to a specific organization; /// in such cases the organization ID is at the start of the `{package}` field /// separated by a slash. The ID can be any non-negative integer. -/// This yields the following format: +/// This yields the following optional format: /// ```ignore /// {fetcher}+{org_id}/{package}${revision} /// ``` +/// +/// Note that locators do not feature escaping: instead the _first_ instance +/// of each delimiter (`+`, `/`, `$`) is used to split the fields. However, +/// as a special case organization IDs are only extracted if the field content +/// fully consists of a non-negative integer. +// +// For more information on the background of `Locator` and fetchers generally, +// FOSSA employees may refer to the "fetchers and locators" doc: https://go/fetchers-doc. #[derive( Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Builder, Getters, CopyGetters, Documented, )] @@ -195,16 +217,30 @@ impl Serialize for StrictLocator { } } -impl<'a> ToSchema<'a> for StrictLocator { +impl AsRef for StrictLocator { + fn as_ref(&self) -> &StrictLocator { + self + } +} + +impl FromStr for StrictLocator { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl<'a> ToSchema<'a> for PackageLocator { fn schema() -> ( &'a str, utoipa::openapi::RefOr, ) { ( - "StrictLocator", + "PackageLocator", ObjectBuilder::new() .description(Some(Self::DOCS)) - .example(Some(json!("git+github.com/fossas/example$1234"))) + .example(Some(json!("git+github.com/fossas/locator-rs"))) .min_length(Some(3)) .schema_type(SchemaType::String) .build() @@ -213,20 +249,6 @@ impl<'a> ToSchema<'a> for StrictLocator { } } -impl AsRef for StrictLocator { - fn as_ref(&self) -> &StrictLocator { - self - } -} - -impl FromStr for StrictLocator { - type Err = Error; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - #[cfg(test)] mod tests { use assert_matches::assert_matches;