diff --git a/Cargo.lock b/Cargo.lock index 9ea7d67e..9908fdbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -868,6 +868,7 @@ dependencies = [ "serde", "serde_json", "serde_test", + "serde_with", "sha2", "similar-asserts", "strum", @@ -990,7 +991,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1165,6 +1166,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.60", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.60", +] + [[package]] name = "der" version = "0.6.1" @@ -1193,6 +1229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -2347,6 +2384,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -2387,6 +2430,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -2397,6 +2441,7 @@ checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown 0.14.3", + "serde", ] [[package]] @@ -3560,6 +3605,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee80b0e361bbf88fd2f6e242ccd19cfda072cb0faa6ae694ecee08199938569a" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.2.6", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6561dc161a9224638a31d876ccdfefbc1df91d3f3a8342eddb35f055d48c7655" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3945,6 +4020,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 0baf34c9..5543df22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ reqwest = { version = "0.11", features = ["rustls-tls-native-roots"], default-fe semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_with = "3.7" tar = "0.4" thiserror = "1.0.49" tokio = { version = "^1.26", features = ["fs", "rt", "macros", "process", "io-std", "tracing"] } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..ae53aa4c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,95 @@ +// 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 miette::{miette, Context, IntoDiagnostic}; +use serde::{Deserialize, Serialize}; +use std::{io::ErrorKind, path::PathBuf}; +use tokio::fs; + +use crate::{ + errors::{DeserializationError, FileExistsError, ReadError, SerializationError, WriteError}, + registry::RegistryTable, + ManagedFile, +}; + +/// Filename of the config file +pub const CONFIG_FILE: &str = "config.toml"; + +/// Configuration file for the buffrs cli +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Config { + /// A mapping from registry name to their corresponding uri + pub registry: Option, +} + +impl Config { + fn location() -> PathBuf { + PathBuf::from("./.buffrs/").join(CONFIG_FILE) + } + + /// Checks if the credentials exists + pub async fn exists() -> miette::Result { + fs::try_exists(Self::location()) + .await + .into_diagnostic() + .wrap_err(FileExistsError(CONFIG_FILE)) + } + + /// Reads the credentials from the file system + pub async fn read() -> miette::Result> { + // if the file does not exist, we don't need to treat it as an error. + match fs::read_to_string(Self::location()).await { + Ok(contents) => { + let raw = toml::from_str(&contents) + .into_diagnostic() + .wrap_err(DeserializationError(ManagedFile::Configuration))?; + + Ok(Some(raw)) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error) + .into_diagnostic() + .wrap_err(ReadError(CONFIG_FILE)), + } + } + + /// Writes the credentials to the file system + pub async fn write(&self) -> miette::Result<()> { + let location = Self::location(); + + if let Some(parent) = location.parent() { + // if directory already exists, error is returned but that is fine + fs::create_dir(parent).await.ok(); + } + + fs::write( + location, + toml::to_string(&self) + .into_diagnostic() + .wrap_err(SerializationError(ManagedFile::Configuration))? + .into_bytes(), + ) + .await + .into_diagnostic() + .wrap_err(WriteError(CONFIG_FILE)) + } + + /// Loads the config from the file system, returning an error if it doesnt exist + pub async fn load() -> miette::Result { + Self::read() + .await + .transpose() + .ok_or(miette!("missing configuration file"))? + } +} diff --git a/src/credentials.rs b/src/credentials.rs index a0f79a9c..bd402b15 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -19,7 +19,7 @@ use tokio::fs; use crate::{ errors::{DeserializationError, FileExistsError, ReadError, SerializationError, WriteError}, - registry::RegistryUri, + registry::Registry, ManagedFile, }; @@ -31,8 +31,8 @@ pub const CREDENTIALS_FILE: &str = "credentials.toml"; /// This type represents a snapshot of the read credential store. #[derive(Debug, Default, Clone)] pub struct Credentials { - /// A mapping from registry URIs to their corresponding tokens - pub registry_tokens: HashMap, + /// A mapping from registry to their corresponding tokens + pub tokens: HashMap, } impl Credentials { @@ -108,7 +108,7 @@ struct RawCredentialCollection { /// Credentials for a single registry. Serialization type. #[derive(Serialize, Deserialize)] struct RawRegistryCredentials { - uri: RegistryUri, + registry: Registry, token: String, } @@ -117,11 +117,11 @@ impl From for Credentials { let credentials = value .credentials .into_iter() - .map(|it| (it.uri, it.token)) + .map(|it| (it.registry, it.token)) .collect(); Self { - registry_tokens: credentials, + tokens: credentials, } } } @@ -129,9 +129,9 @@ impl From for Credentials { impl From for RawCredentialCollection { fn from(value: Credentials) -> Self { let credentials = value - .registry_tokens + .tokens .into_iter() - .map(|(uri, token)| RawRegistryCredentials { uri, token }) + .map(|(registry, token)| RawRegistryCredentials { registry, token }) .collect(); Self { credentials } diff --git a/src/lib.rs b/src/lib.rs index 9db2fa12..31983f2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,8 @@ use thiserror::Error; pub mod cache; /// CLI command implementations pub mod command; +/// Configuration for the CLI +pub mod config; /// Credential management pub mod credentials; /// Common error types @@ -63,6 +65,7 @@ fn home() -> Result { #[derive(Debug)] pub(crate) enum ManagedFile { + Configuration, Credentials, Manifest, Lock, @@ -70,11 +73,13 @@ pub(crate) enum ManagedFile { impl ManagedFile { fn name(&self) -> &str { + use config::CONFIG_FILE; use credentials::CREDENTIALS_FILE; use lock::LOCKFILE; use manifest::MANIFEST_FILE; match self { + ManagedFile::Configuration => CONFIG_FILE, ManagedFile::Manifest => MANIFEST_FILE, ManagedFile::Lock => LOCKFILE, ManagedFile::Credentials => CREDENTIALS_FILE, diff --git a/src/manifest.rs b/src/manifest.rs index 49128dda..82c2ec65 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -26,7 +26,7 @@ use tokio::fs; use crate::{ errors::{DeserializationError, FileExistsError, SerializationError, WriteError}, package::{PackageName, PackageType}, - registry::RegistryUri, + registry::Registry, ManagedFile, }; @@ -46,8 +46,6 @@ pub enum Edition { /// at any time. Users are responsible for consulting documentation and /// help channels if errors occur. Canary, - /// The canary edition used by buffrs 0.7.x - Canary07, /// Unknown edition of manifests /// /// This is unrecommended as breaking changes could be introduced due to being @@ -66,7 +64,6 @@ impl From<&str> for Edition { fn from(value: &str) -> Self { match value { self::CANARY_EDITION => Self::Canary, - "0.7" => Self::Canary07, _ => Self::Unknown, } } @@ -76,7 +73,6 @@ impl From for &'static str { fn from(value: Edition) -> Self { match value { Edition::Canary => CANARY_EDITION, - Edition::Canary07 => "0.7", Edition::Unknown => "unknown", } } @@ -206,7 +202,7 @@ mod deserializer { }; match Edition::from(edition.as_str()) { - Edition::Canary | Edition::Canary07 => Ok(RawManifest::Canary { + Edition::Canary => Ok(RawManifest::Canary { package, dependencies, }), @@ -231,7 +227,7 @@ impl From for RawManifest { .collect(); match manifest.edition { - Edition::Canary | Edition::Canary07 => RawManifest::Canary { + Edition::Canary => RawManifest::Canary { package: manifest.package, dependencies, }, @@ -394,19 +390,10 @@ pub struct Dependency { impl Dependency { /// Creates a new dependency - pub fn new( - registry: RegistryUri, - repository: String, - package: PackageName, - version: VersionReq, - ) -> Self { + pub fn new(registry: Registry, package: PackageName, version: VersionReq) -> Self { Self { package, - manifest: DependencyManifest { - repository, - version, - registry, - }, + manifest: DependencyManifest { version, registry }, } } @@ -428,11 +415,7 @@ impl Dependency { impl Display for Dependency { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}/{}@{}", - self.manifest.repository, self.package, self.manifest.version - ) + write!(f, "{}@{}", self.package, self.manifest.version) } } @@ -441,8 +424,8 @@ impl Display for Dependency { pub struct DependencyManifest { /// Version requirement in the buffrs format, currently only supports pinning pub version: VersionReq, - /// Artifactory repository to pull dependency from - pub repository: String, - /// Artifactory registry to pull from - pub registry: RegistryUri, + /// Registry ID to pull dependency from, this must be configured + #[serde(default)] + #[serde(skip_serializing_if = "Registry::is_default")] + pub registry: Registry, } diff --git a/src/registry/artifactory.rs b/src/registry/artifactory.rs index 71856e8b..cf1e9f5f 100644 --- a/src/registry/artifactory.rs +++ b/src/registry/artifactory.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::RegistryUri; +use super::{Registry, RegistryTable, RegistryUri}; use crate::{credentials::Credentials, manifest::Dependency, package::Package}; use miette::{ensure, miette, Context, IntoDiagnostic}; use reqwest::{Body, Method, Response}; @@ -21,17 +21,17 @@ use url::Url; /// The registry implementation for artifactory #[derive(Debug, Clone)] pub struct Artifactory { - registry: RegistryUri, - token: Option, + registries: RegistryTable, + credentials: Credentials, client: reqwest::Client, } impl Artifactory { /// Creates a new instance of an Artifactory registry client - pub fn new(registry: RegistryUri, credentials: &Credentials) -> miette::Result { + pub fn new(registries: RegistryTable, credentials: Credentials) -> miette::Result { Ok(Self { - registry: registry.clone(), - token: credentials.registry_tokens.get(®istry).cloned(), + registries, + credentials, client: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() @@ -39,26 +39,41 @@ impl Artifactory { }) } - fn new_request(&self, method: Method, url: Url) -> RequestBuilder { + fn new_request( + &self, + method: Method, + registry: &Registry, + url: Url, + ) -> miette::Result { let mut request_builder = RequestBuilder::new(self.client.clone(), method, url); - if let Some(token) = &self.token { - request_builder = request_builder.auth(token.clone()); + let uri = self + .registries + .get(®istry) + .ok_or(miette!("unknown registry: {registry}"))?; + + if let Some(token) = &self.credentials.tokens.get(®istry) { + request_builder = request_builder.auth(token.to_string()); } - request_builder + Ok(request_builder) } /// Pings artifactory to ensure registry access is working - pub async fn ping(&self) -> miette::Result<()> { - let repositories_url: Url = { - let mut uri = self.registry.to_owned(); + pub async fn ping(&self, registry: &Registry) -> miette::Result<()> { + let url: Url = { + let mut uri = self + .registries + .get(®istry) + .ok_or(miette!("unknown registry: {registry}"))? + .base(); + let path = &format!("{}/api/repositories", uri.path()); uri.set_path(path); uri.into() }; - self.new_request(Method::GET, repositories_url) + self.new_request(Method::GET, registry, url)? .send() .await .map(|_| ()) @@ -67,16 +82,22 @@ impl Artifactory { /// Downloads a package from artifactory pub async fn download(&self, dependency: Dependency) -> miette::Result { + let registry = &dependency.manifest.registry; + let artifact_url = { let version = super::dependency_version_string(&dependency)?; - let path = dependency.manifest.registry.path().to_owned(); + let uri = self.registries.get(registry).ok_or(miette!( + "cannot download {} because there is no uri for the registry {}", + dependency.package, + registry + ))?; + + let mut url = uri.raw.clone(); - let mut url = dependency.manifest.registry.clone(); url.set_path(&format!( - "{}/{}/{}/{}-{}.tgz", - path, - dependency.manifest.repository, + "{}/{}/{}-{}.tgz", + url.path(), dependency.package, dependency.package, version @@ -85,9 +106,12 @@ impl Artifactory { url.into() }; - tracing::debug!("Hitting download URL: {artifact_url}"); + tracing::debug!("hitting download URL: {artifact_url}"); - let response = self.new_request(Method::GET, artifact_url).send().await?; + let response = self + .new_request(Method::GET, registry, artifact_url)? + .send() + .await?; let response: reqwest::Response = response.into(); @@ -110,11 +134,14 @@ impl Artifactory { } /// Publishes a package to artifactory - pub async fn publish(&self, package: Package, repository: String) -> miette::Result<()> { + pub async fn publish(&self, package: Package, registry: &Registry) -> miette::Result<()> { + let uri = self.registries.get(registry).ok_or(miette!( + "cannot publish because there is no uri configured for the registry {registry}", + ))?; + let artifact_uri: Url = format!( - "{}/{}/{}/{}-{}.tgz", - self.registry, - repository, + "{}/{}/{}-{}.tgz", + uri.raw.path(), package.name(), package.name(), package.version(), @@ -126,17 +153,12 @@ impl Artifactory { ))?; let _ = self - .new_request(Method::PUT, artifact_uri) + .new_request(Method::PUT, registry, artifact_uri)? .body(package.tgz.clone()) .send() .await?; - tracing::info!( - ":: published {}/{}@{}", - repository, - package.name(), - package.version() - ); + tracing::info!(":: published {}@{}", package.name(), package.version()); Ok(()) } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 65967352..672cfe13 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -13,8 +13,8 @@ // limitations under the License. use std::{ + collections::HashMap, fmt::{self, Display}, - ops::{Deref, DerefMut}, str::FromStr, }; @@ -25,39 +25,131 @@ mod cache; pub use artifactory::Artifactory; use miette::{ensure, miette, Context, IntoDiagnostic}; use semver::VersionReq; -use serde::{Deserialize, Serialize}; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; use thiserror::Error; use url::Url; use crate::manifest::Dependency; -/// A representation of a registry URI -#[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct RegistryUri(Url); +/// A registry lookup table to retrieve actual registry uris from +/// +/// Must contain at least the default registry +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RegistryTable { + default: RegistryUri, + named: HashMap, +} -impl From for Url { - fn from(value: RegistryUri) -> Self { - value.0 +impl RegistryTable { + pub fn get(&self, reg: &Registry) -> Option<&RegistryUri> { + match reg { + &Registry::Default => Some(&self.default), + &Registry::Named(name) => self.named.get(&name), + } + } +} + +impl<'de> Deserialize<'de> for RegistryTable { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut named = HashMap::deserialize(deserializer)?; + + let default = named + .remove("default") + .ok_or_else(|| de::Error::missing_field("default"))?; + + Ok(Self { default, named }) + } +} + +/// A pointer to a registry in the registry table +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(into = "&str", from = "&str")] +pub enum Registry { + /// Use the registry configured as default registry + Default, + /// Use a named registry that is configured in the configuration + Named(String), +} + +impl Registry { + const DEFAULT: &'static str = "default"; + + pub fn is_default(&self) -> bool { + matches!(self, &Self::Default) } } -impl Deref for RegistryUri { - type Target = Url; +impl Default for Registry { + fn default() -> Self { + Self::Default + } +} - fn deref(&self) -> &Self::Target { - &self.0 +impl From for &str { + fn from(value: Registry) -> Self { + match value { + Registry::Default => &Registry::DEFAULT, + Registry::Named(v) => &v, + } } } -impl DerefMut for RegistryUri { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 +impl From<&str> for Registry { + fn from(value: &str) -> Self { + if value == Self::DEFAULT { + return Self::Default; + } + + Self::Named(value.to_owned()) + } +} + +impl Display for Registry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fmt = match self { + &Registry::Default => Registry::DEFAULT, + &Registry::Named(ref v) => v, + }; + + write!(f, "{fmt}") + } +} + +/// A representation of a artifactory registry URI +/// +/// This is compatible with everything in the format `.jfrog.io/artifactory/` +#[derive( + Debug, Clone, Hash, SerializeDisplay, DeserializeFromStr, PartialEq, Eq, PartialOrd, Ord, +)] +pub struct RegistryUri { + raw: Url, + repository: String, +} + +impl RegistryUri { + /// Get the base path of the artifactory instance + pub fn base(&self) -> Url { + let mut url = self.raw.clone(); + + url.set_path("/artifactory"); + + url + } +} + +impl From for Url { + fn from(value: RegistryUri) -> Self { + value.raw } } impl Display for RegistryUri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) + write!(f, "{}", self.raw) } } @@ -69,13 +161,13 @@ impl FromStr for RegistryUri { .into_diagnostic() .wrap_err(miette!("not a valid URL: {value}"))?; - sanity_check_url(&url)?; + let (raw, repository) = inspect_url(url)?; - Ok(Self(url)) + Ok(Self { raw, repository }) } } -fn sanity_check_url(url: &Url) -> miette::Result<()> { +fn inspect_url(url: Url) -> miette::Result<(Url, String)> { let scheme = url.scheme(); ensure!( @@ -83,15 +175,25 @@ fn sanity_check_url(url: &Url) -> miette::Result<()> { "invalid URI scheme {scheme} - must be http or https" ); - if let Some(host) = url.host_str() { - ensure!( - !host.ends_with(".jfrog.io") || url.path().ends_with("/artifactory"), - "the url must end with '/artifactory' when using a *.jfrog.io host" - ); - Ok(()) - } else { - Err(miette!("the URI must contain a host component: {url}")) - } + ensure!( + url.has_host(), + "the URI must contain a host component: {url}" + ); + + let mut path = url + .path_segments() + .ok_or(miette!("the URI must contain a path"))?; + + ensure!( + path.next() == Some("artifactory"), + "expecting URI in the format of `/artifactory/`" + ); + + let repository = path.next().ok_or(miette!( + "registry URI is missing the repository component: {url}" + ))?; + + Ok((url, repository.to_string())) } #[derive(Error, Debug)] @@ -149,14 +251,12 @@ mod tests { registry::{dependency_version_string, VersionNotPinned}, }; - use super::RegistryUri; + use super::{Registry, RegistryUri}; fn get_dependency(version: &str) -> Dependency { - let registry = RegistryUri::from_str("https://my-registry.com").unwrap(); - let repository = String::from("my-repo"); let package = PackageName::from_str("package").unwrap(); let version = VersionReq::from_str(version).unwrap(); - Dependency::new(registry, repository, package, version) + Dependency::new(Registry::Default, package, version) } #[test]