diff --git a/.gitignore b/.gitignore index e9868bd2..83abdf7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +/content /target *.swp diff --git a/Cargo.lock b/Cargo.lock index 474cd218..ec747c43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4105,6 +4105,7 @@ dependencies = [ name = "warg-api" version = "0.1.0" dependencies = [ + "itertools 0.11.0", "serde", "serde_with", "thiserror", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 786fe1f9..72c235e3 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -11,3 +11,4 @@ warg-crypto = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } thiserror = { workspace = true } +itertools = { workspace = true } diff --git a/crates/api/src/v1/package.rs b/crates/api/src/v1/package.rs index eae61a2a..0701c7e9 100644 --- a/crates/api/src/v1/package.rs +++ b/crates/api/src/v1/package.rs @@ -21,6 +21,26 @@ pub enum ContentSource { }, } +/// Represents the supported kinds of content upload endpoints. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum UploadEndpoint { + /// Content may be uploaded via HTTP POST to the given URL. + /// If the endpoint responds with "201 Created" and a Location header, that + /// header's value will be the content source. + HttpPost { + /// The URL to POST content to. + url: String, + }, +} + +/// Information about missing content. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MissingContent { + /// Upload endpoint(s) that may be used to provide missing content. + pub upload: Vec, +} + /// Represents a request to publish a record to a package log. #[derive(Serialize, Deserialize)] #[serde(rename = "camelCase")] @@ -45,13 +65,13 @@ pub struct PackageRecord { } impl PackageRecord { - /// Gets the missing content digests of the record. - pub fn missing_content(&self) -> &[AnyHash] { + /// Gets the missing content of the record. + pub fn missing_content(&self) -> impl Iterator { match &self.state { PackageRecordState::Sourcing { missing_content, .. - } => missing_content, - _ => &[], + } => itertools::Either::Left(missing_content.iter()), + _ => itertools::Either::Right(std::iter::empty()), } } } @@ -66,18 +86,22 @@ impl PackageRecord { #[allow(clippy::large_enum_variant)] pub enum PackageRecordState { /// The package record needs content sources. + #[serde(rename_all = "camelCase")] Sourcing { /// The digests of the missing content. - missing_content: Vec, + missing_content: HashMap, }, /// The package record is processing. + #[serde(rename_all = "camelCase")] Processing, /// The package record is rejected. + #[serde(rename_all = "camelCase")] Rejected { /// The reason the record was rejected. reason: String, }, /// The package record was successfully published to the log. + #[serde(rename_all = "camelCase")] Published { /// The envelope of the package record. record: ProtoEnvelopeBody, diff --git a/crates/api/src/v1/paths.rs b/crates/api/src/v1/paths.rs index 71665750..479a14f3 100644 --- a/crates/api/src/v1/paths.rs +++ b/crates/api/src/v1/paths.rs @@ -1,6 +1,5 @@ //! The paths of the Warg REST API. -use warg_crypto::hash::AnyHash; use warg_protocol::registry::{LogId, RecordId}; /// The path of the "fetch logs" API. @@ -23,11 +22,6 @@ pub fn package_record(log_id: &LogId, record_id: &RecordId) -> String { format!("v1/package/{log_id}/record/{record_id}") } -/// The path for a package record's content. -pub fn package_record_content(log_id: &LogId, record_id: &RecordId, digest: &AnyHash) -> String { - format!("v1/package/{log_id}/record/{record_id}/content/{digest}") -} - /// The path for proving checkpoint consistency. pub fn prove_consistency() -> &'static str { "v1/proof/consistency" diff --git a/crates/client/src/api.rs b/crates/client/src/api.rs index 467007ad..581fc60f 100644 --- a/crates/client/src/api.rs +++ b/crates/client/src/api.rs @@ -1,12 +1,11 @@ //! A module for Warg registry API clients. -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{anyhow, Result}; use bytes::Bytes; use futures_util::{Stream, TryStreamExt}; -use reqwest::{Body, IntoUrl, Response, StatusCode, Url}; +use reqwest::{Body, IntoUrl, Response, StatusCode}; use serde::de::DeserializeOwned; use thiserror::Error; -use url::Host; use warg_api::v1::{ fetch::{FetchError, FetchLogsRequest, FetchLogsResponse}, package::{ @@ -27,6 +26,8 @@ use warg_transparency::{ map::MapProofBundle, }; +use crate::registry_url::RegistryUrl; + /// Represents an error that occurred while communicating with the registry. #[derive(Debug, Error)] pub enum ClientError { @@ -122,14 +123,14 @@ async fn into_result Result { - let url = Self::validate_url(url)?; + let url = RegistryUrl::new(url)?; Ok(Self { url, client: reqwest::Client::new(), @@ -137,56 +138,13 @@ impl Client { } /// Gets the URL of the API client. - pub fn url(&self) -> &str { - self.url.as_str() - } - - /// Parses and validates the given URL. - /// - /// Returns the validated URL on success. - pub fn validate_url(url: impl IntoUrl) -> Result { - // Default to a HTTPS scheme if none is provided - let url: Url = if !url.as_str().contains("://") { - Url::parse(&format!("https://{url}", url = url.as_str())) - .context("failed to parse registry server URL")? - } else { - url.into_url() - .context("failed to parse registry server URL")? - }; - - match url.scheme() { - "https" => {} - "http" => { - // Only allow HTTP connections to loopback - match url - .host() - .ok_or_else(|| anyhow!("expected a host for URL `{url}`"))? - { - Host::Domain(d) => { - if d != "localhost" { - bail!("an unsecured connection is not permitted to `{d}`"); - } - } - Host::Ipv4(ip) => { - if !ip.is_loopback() { - bail!("an unsecured connection is not permitted to address `{ip}`"); - } - } - Host::Ipv6(ip) => { - if !ip.is_loopback() { - bail!("an unsecured connection is not permitted to address `{ip}`"); - } - } - } - } - _ => bail!("expected a HTTPS scheme for URL `{url}`"), - } - Ok(url) + pub fn url(&self) -> &RegistryUrl { + &self.url } /// Gets the latest checkpoint from the registry. pub async fn latest_checkpoint(&self) -> Result, ClientError> { - let url = self.url.join(paths::fetch_checkpoint()).unwrap(); + let url = self.url.join(paths::fetch_checkpoint()); tracing::debug!("getting latest checkpoint at `{url}`"); into_result::<_, FetchError>(reqwest::get(url).await?).await } @@ -196,7 +154,7 @@ impl Client { &self, request: FetchLogsRequest<'_>, ) -> Result { - let url = self.url.join(paths::fetch_logs()).unwrap(); + let url = self.url.join(paths::fetch_logs()); tracing::debug!("fetching logs at `{url}`"); let response = self.client.post(url).json(&request).send().await?; @@ -209,10 +167,7 @@ impl Client { log_id: &LogId, request: PublishRecordRequest<'_>, ) -> Result { - let url = self - .url - .join(&paths::publish_package_record(log_id)) - .unwrap(); + let url = self.url.join(&paths::publish_package_record(log_id)); tracing::debug!( "appending record to package `{id}` at `{url}`", id = request.id @@ -228,10 +183,7 @@ impl Client { log_id: &LogId, record_id: &RecordId, ) -> Result { - let url = self - .url - .join(&paths::package_record(log_id, record_id)) - .unwrap(); + let url = self.url.join(&paths::package_record(log_id, record_id)); tracing::debug!("getting record `{record_id}` for package `{log_id}` at `{url}`"); let response = reqwest::get(url).await?; @@ -283,7 +235,7 @@ impl Client { /// Proves the inclusion of the given package log heads in the registry. pub async fn prove_inclusion(&self, request: InclusionRequest<'_>) -> Result<(), ClientError> { - let url = self.url.join(paths::prove_inclusion()).unwrap(); + let url = self.url.join(paths::prove_inclusion()); tracing::debug!("proving checkpoint inclusion at `{url}`"); let response = into_result::( @@ -303,7 +255,7 @@ impl Client { &self, request: ConsistencyRequest<'_>, ) -> Result<(), ClientError> { - let url = self.url.join(paths::prove_consistency()).unwrap(); + let url = self.url.join(paths::prove_consistency()); let response = into_result::( self.client.post(url).json(&request).send().await?, ) @@ -349,15 +301,12 @@ impl Client { /// Uploads package content to the registry. pub async fn upload_content( &self, - log_id: &LogId, - record_id: &RecordId, - digest: &AnyHash, + url: &str, content: impl Into, ) -> Result { - let url = self - .url - .join(&paths::package_record_content(log_id, record_id, digest)) - .unwrap(); + // Upload URLs may be relative to the registry URL. + let url = self.url.join(url); + tracing::debug!("uploading content to `{url}`"); let response = self.client.post(url).body(content).send().await?; diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 94ee3776..10ae0b49 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -1,10 +1,9 @@ //! Module for client configuration. -use crate::{api, ClientError}; +use crate::{ClientError, RegistryUrl}; use anyhow::{anyhow, Context, Result}; use normpath::PathExt; use once_cell::sync::Lazy; -use reqwest::Url; use serde::{Deserialize, Serialize}; use std::{ env::current_dir, @@ -63,7 +62,7 @@ fn normalize_path(path: &Path) -> PathBuf { /// Paths used for storage pub struct StoragePaths { /// The registry URL relating to the storage paths. - pub url: Url, + pub registry_url: RegistryUrl, /// The path to the registry storage directory. pub registries_dir: PathBuf, /// The path to the content storage directory. @@ -242,16 +241,16 @@ impl Config { &self, url: Option<&str>, ) -> Result { - let url = api::Client::validate_url( + let registry_url = RegistryUrl::new( url.or(self.default_url.as_deref()) .ok_or(ClientError::NoDefaultUrl)?, )?; - let host = url.host().unwrap().to_string().to_ascii_lowercase(); - let registries_dir = self.registries_dir()?.join(host); + let label = registry_url.safe_label(); + let registries_dir = self.registries_dir()?.join(label); let content_dir = self.content_dir()?; Ok(StoragePaths { - url, + registry_url, registries_dir, content_dir, }) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 1149c8a8..33095599 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -13,7 +13,10 @@ use storage::{ use thiserror::Error; use warg_api::v1::{ fetch::{FetchError, FetchLogsRequest, FetchLogsResponse}, - package::{PackageError, PackageRecord, PackageRecordState, PublishRecordRequest}, + package::{ + MissingContent, PackageError, PackageRecord, PackageRecordState, PublishRecordRequest, + UploadEndpoint, + }, proof::{ConsistencyRequest, InclusionRequest}, }; use warg_crypto::{ @@ -29,8 +32,10 @@ use warg_protocol::{ pub mod api; mod config; pub mod lock; +mod registry_url; pub mod storage; pub use self::config::*; +pub use self::registry_url::RegistryUrl; /// A client for a Warg registry. pub struct Client { @@ -51,7 +56,7 @@ impl Client { } /// Gets the URL of the client. - pub fn url(&self) -> &str { + pub fn url(&self) -> &RegistryUrl { self.api.url() } @@ -154,34 +159,33 @@ impl Client { }) })?; - let missing = record.missing_content(); - if !missing.is_empty() { - // Upload the missing content - // TODO: parallelize this - for digest in record.missing_content() { - self.api - .upload_content( - &log_id, - &record.id, - digest, - Body::wrap_stream(self.content.load_content(digest).await?.ok_or_else( - || ClientError::ContentNotFound { - digest: digest.clone(), - }, - )?), - ) - .await - .map_err(|e| match e { - api::ClientError::Package(PackageError::Rejection(reason)) => { - ClientError::PublishRejected { - id: package.id.clone(), - record_id: record.id.clone(), - reason, - } + // TODO: parallelize this + for (digest, MissingContent { upload }) in record.missing_content() { + // Upload the missing content, if the registry supports it + let Some(UploadEndpoint::HttpPost {url}) = upload.first() else { + continue; + }; + + self.api + .upload_content( + url, + Body::wrap_stream(self.content.load_content(digest).await?.ok_or_else( + || ClientError::ContentNotFound { + digest: digest.clone(), + }, + )?), + ) + .await + .map_err(|e| match e { + api::ClientError::Package(PackageError::Rejection(reason)) => { + ClientError::PublishRejected { + id: package.id.clone(), + record_id: record.id.clone(), + reason, } - _ => e.into(), - })?; - } + } + _ => e.into(), + })?; } Ok(record.id) @@ -580,7 +584,7 @@ impl FileSystemClient { config: &Config, ) -> Result, ClientError> { let StoragePaths { - url, + registry_url: url, registries_dir, content_dir, } = config.storage_paths_for_url(url)?; @@ -595,7 +599,9 @@ impl FileSystemClient { }; Ok(StorageLockResult::Acquired(Self::new( - url, packages, content, + url.into_url(), + packages, + content, )?)) } @@ -607,12 +613,12 @@ impl FileSystemClient { /// This method blocks if storage locks cannot be acquired. pub fn new_with_config(url: Option<&str>, config: &Config) -> Result { let StoragePaths { - url, + registry_url, registries_dir, content_dir, } = config.storage_paths_for_url(url)?; Self::new( - url, + registry_url.into_url(), FileSystemRegistryStorage::lock(registries_dir)?, FileSystemContentStorage::lock(content_dir)?, ) diff --git a/crates/client/src/registry_url.rs b/crates/client/src/registry_url.rs new file mode 100644 index 00000000..e45988c4 --- /dev/null +++ b/crates/client/src/registry_url.rs @@ -0,0 +1,194 @@ +use anyhow::{anyhow, bail, Context, Result}; +use reqwest::IntoUrl; +use url::{Host, Url}; + +/// The base URL of a registry server. +// Note: The inner Url always has a scheme and host. +#[derive(Clone)] +pub struct RegistryUrl(Url); + +impl RegistryUrl { + /// Parses and validates the given URL into a [`RegistryUrl`]. + pub fn new(url: impl IntoUrl) -> Result { + // Default to a HTTPS scheme if none is provided + let mut url: Url = if !url.as_str().contains("://") { + Url::parse(&format!("https://{url}", url = url.as_str())) + .context("failed to parse registry server URL")? + } else { + url.into_url() + .context("failed to parse registry server URL")? + }; + + match url.scheme() { + "https" => {} + "http" => { + // Only allow HTTP connections to loopback + match url + .host() + .ok_or_else(|| anyhow!("expected a host for URL `{url}`"))? + { + Host::Domain(d) => { + if d != "localhost" { + bail!("an unsecured connection is not permitted to `{d}`"); + } + } + Host::Ipv4(ip) => { + if !ip.is_loopback() { + bail!("an unsecured connection is not permitted to address `{ip}`"); + } + } + Host::Ipv6(ip) => { + if !ip.is_loopback() { + bail!("an unsecured connection is not permitted to address `{ip}`"); + } + } + } + } + _ => bail!("expected a HTTPS scheme for URL `{url}`"), + } + + // Normalize by appending a '/' if missing + if !url.path().ends_with('/') { + url.set_path(&(url.path().to_string() + "/")); + } + + Ok(Self(url)) + } + + /// Returns a mostly-human-readable string that identifies the registry and + /// contains only the characters `[0-9a-zA-Z-._]`. This string is + /// appropriate to use with external systems that can't accept arbitrary + /// URLs such as file system paths. + pub fn safe_label(&self) -> String { + // Host + let mut label = match self.0.host().unwrap() { + Host::Domain(domain) => domain.to_string(), + Host::Ipv4(ip) => ip.to_string(), + Host::Ipv6(ip) => format!("ipv6_{ip}").replace(':', "."), + }; + // Port (if not the scheme default) + if let Some(port) = self.0.port() { + label += &format!("-{port}"); + } + // Path (if not empty) + let path = self.0.path().trim_matches('/'); + if !path.is_empty() { + label += "_"; + // The path is already urlencoded; we just need to replace a few chars. + for ch in path.chars() { + match ch { + '/' => label += "_", + '%' => label += ".", + '*' => label += ".2A", + '.' => label += ".2E", + '_' => label += ".5F", + oth => label.push(oth), + } + } + } + label + } + + pub(crate) fn into_url(self) -> Url { + self.0 + } + + pub(crate) fn join(&self, path: &str) -> String { + // Url::join can only fail if the base is relative or if the result is + // very large (>4GB), neither of which should be possible in this lib. + self.0.join(path).unwrap().to_string() + } +} + +impl std::str::FromStr for RegistryUrl { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + Self::new(s) + } +} + +impl std::fmt::Display for RegistryUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Debug for RegistryUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("RegistryUrl") + .field(&self.0.as_str()) + .finish() + } +} + +impl From for Url { + fn from(value: RegistryUrl) -> Self { + value.into_url() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn must_parse(input: &str) -> RegistryUrl { + RegistryUrl::new(input) + .unwrap_or_else(|err| panic!("failed to parse valid input {input:?}: {err:?}")) + } + + #[test] + fn new_valid() { + for (input, expected) in [ + ("bare-host", "https://bare-host/"), + ("https://warg.io", "https://warg.io/"), + ("https://warg.io/with/path", "https://warg.io/with/path/"), + ("http://localhost", "http://localhost/"), + ("http://127.0.0.1", "http://127.0.0.1/"), + ("http://[::1]", "http://[::1]/"), + ("http://localhost:8080", "http://localhost:8080/"), + ("https://unchanged/", "https://unchanged/"), + ] { + assert_eq!( + must_parse(input).to_string(), + expected, + "incorrect output for input {input:?}" + ) + } + } + + #[test] + fn new_invalid() { + for input in [ + "invalid:url", + "bad://scheme", + "http://insecure-domain", + "http://6.6.6.6/insecure/ipv4", + "http://[abcd::1234]/insecure/ipv6", + ] { + let res = RegistryUrl::new(input); + assert!( + res.is_err(), + "input {input:?} should have failed; got {res:?}" + ); + } + } + + #[test] + fn safe_label_works() { + for (input, expected) in [ + ("warg.io", "warg.io"), + ("http://localhost:80", "localhost"), + ("example.com/with/path", "example.com_with_path"), + ("port:1234", "port-1234"), + ("port:1234/with/path", "port-1234_with_path"), + ("https://1.2.3.4:1234/1234", "1.2.3.4-1234_1234"), + ("https://[abcd::1234]:5678", "ipv6_abcd..1234-5678"), + ("syms/splat*dot.lowdash_", "syms_splat.2Adot.2Elowdash.5F"), + ("☃︎/☃︎", "xn--n3h_.E2.98.83.EF.B8.8E"), // punycode host + percent-encoded path + ] { + let url = must_parse(input); + assert_eq!(url.safe_label(), expected); + } + } +} diff --git a/crates/server/openapi.yaml b/crates/server/openapi.yaml index 4da61026..d807379a 100644 --- a/crates/server/openapi.yaml +++ b/crates/server/openapi.yaml @@ -700,12 +700,9 @@ components: enum: [sourcing] example: sourcing missingContent: - type: array - description: The array of content digests that are missing for the package record. - minItems: 1 - maxItems: 128 - items: - "$ref": "#/components/schemas/AnyHash" + "$ref": "#/components/schemas/MissingContentMap" + description: The missing content for the package record. + minProperties: 1 ProcessingRecord: type: object description: A record that is being processed. @@ -825,6 +822,44 @@ components: description: The algorithm-prefixed bytes of the signature (base64 encoded). pattern: ^[a-z0-9-]+:(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{4}|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{2}={2})$ example: "ecdsa-p256:MEUCIQCzWZBW6ux9LecP66Y+hjmLZTP/hZVz7puzlPTXcRT2wwIgQZO7nxP0nugtw18MwHZ26ROFWcJmgCtKOguK031Y1D0=" + MissingContentMap: + type: object + description: The map of content digest to missing content info. + patternProperties: + "^[a-z0-9-]+:[a-f0-9]+$": + "$ref": "#/components/schemas/MissingContent" + example: + ? "sha256:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded9773" + : upload: + - type: httpPost + url: https://example.com/7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded9773 + MissingContent: + description: Information about missing content. + properties: + upload: + description: Upload endpoint(s) for the missing content. + type: array + items: + oneOf: + - "$ref": "#/components/schemas/HttpPostUpload" + discriminator: + propertyName: type + mapping: + httpPost: "#/components/schemas/HttpPostUpload" + HttpPostUpload: + type: object + description: A HTTP POST upload endpoint. + properties: + type: + type: string + description: The type of upload endpoint. + enum: [httpPost] + example: httpPost + url: + type: string + description: The URL of the upload endpoint, which may be relative to the API base URL. + example: https://example.com/contents.wasm + format: uri ContentSourceMap: type: object description: The map of content digest to sources. diff --git a/crates/server/src/api/v1/package.rs b/crates/server/src/api/v1/package.rs index 0744affc..8e0f2a5c 100644 --- a/crates/server/src/api/v1/package.rs +++ b/crates/server/src/api/v1/package.rs @@ -16,13 +16,14 @@ use axum::{ Router, }; use futures::StreamExt; -use std::path::PathBuf; use std::sync::Arc; +use std::{collections::HashMap, path::PathBuf}; use tempfile::NamedTempFile; use tokio::io::AsyncWriteExt; use url::Url; use warg_api::v1::package::{ - ContentSource, PackageError, PackageRecord, PackageRecordState, PublishRecordRequest, + ContentSource, MissingContent, PackageError, PackageRecord, PackageRecordState, + PublishRecordRequest, UploadEndpoint, }; use warg_crypto::hash::{AnyHash, Sha256}; use warg_protocol::{ @@ -91,6 +92,26 @@ impl Config { .unwrap() .to_string() } + + fn build_missing_content<'a>( + &self, + log_id: &LogId, + record_id: &RecordId, + missing_digests: impl IntoIterator, + ) -> HashMap { + missing_digests + .into_iter() + .map(|digest| { + let url = format!("v1/package/{log_id}/record/{record_id}/content/{digest}"); + ( + digest.clone(), + MissingContent { + upload: vec![UploadEndpoint::HttpPost { url }], + }, + ) + }) + .collect() + } } struct PackageApiError(PackageError); @@ -231,13 +252,12 @@ async fn publish_record( )); } + let missing_content = config.build_missing_content(&log_id, &record_id, missing); Ok(( StatusCode::ACCEPTED, Json(PackageRecord { id: record_id, - state: PackageRecordState::Sourcing { - missing_content: missing.into_iter().cloned().collect(), - }, + state: PackageRecordState::Sourcing { missing_content }, }), )) } @@ -254,12 +274,13 @@ async fn get_record( .await?; match record.status { - RecordStatus::MissingContent(missing) => Ok(Json(PackageRecord { - id: record_id, - state: PackageRecordState::Sourcing { - missing_content: missing, - }, - })), + RecordStatus::MissingContent(missing) => { + let missing_content = config.build_missing_content(&log_id, &record_id, &missing); + Ok(Json(PackageRecord { + id: record_id, + state: PackageRecordState::Sourcing { missing_content }, + })) + } // Validated is considered still processing until included in a checkpoint RecordStatus::Pending | RecordStatus::Validated => Ok(Json(PackageRecord { id: record_id, @@ -275,11 +296,11 @@ async fn get_record( .as_ref() .contents() .into_iter() - .map(|d| { + .map(|digest| { ( - d.clone(), + digest.clone(), vec![ContentSource::Http { - url: config.content_url(d), + url: config.content_url(digest), }], ) }) @@ -363,7 +384,7 @@ async fn upload_content( { config .core_service - .submit_package_record(log_id, record_id.clone()) + .submit_package_record(log_id, record_id) .await; } diff --git a/src/commands.rs b/src/commands.rs index 7edd25fb..9f597301 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -4,7 +4,7 @@ use anyhow::Context; use anyhow::Result; use clap::Args; use std::path::PathBuf; -use url::Url; +use warg_client::RegistryUrl; use warg_client::{ClientError, Config, FileSystemClient, StorageLockResult}; use warg_crypto::signing::PrivateKey; @@ -77,7 +77,7 @@ impl CommonOptions { } /// Gets the signing key for the given registry URL. - pub fn signing_key(&self, registry_url: &str) -> Result { + pub fn signing_key(&self, registry_url: &RegistryUrl) -> Result { if let Some(file) = &self.key_file { let key_str = std::fs::read_to_string(file) .with_context(|| format!("failed to read key from {file:?}"))? @@ -86,15 +86,7 @@ impl CommonOptions { PrivateKey::decode(key_str) .with_context(|| format!("failed to parse key from {file:?}")) } else { - let url: Url = registry_url - .parse() - .with_context(|| format!("failed to parse registry URL `{registry_url}`"))?; - - let host = url - .host_str() - .with_context(|| format!("registry URL `{url}` has no host"))?; - - get_signing_key(host, &self.key_name) + get_signing_key(registry_url, &self.key_name) } } } diff --git a/src/commands/config.rs b/src/commands/config.rs index d3000f73..eb926c0b 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,7 +1,7 @@ use anyhow::{bail, Context, Result}; use clap::Args; use std::path::PathBuf; -use warg_client::{api, Config}; +use warg_client::{Config, RegistryUrl}; /// Creates a new warg configuration file. #[derive(Args)] @@ -46,7 +46,7 @@ impl ConfigCommand { let default_url = self .registry - .map(api::Client::validate_url) + .map(RegistryUrl::new) .transpose()? .map(|u| u.to_string()); diff --git a/src/commands/key.rs b/src/commands/key.rs index 02aa7a36..68ba1386 100644 --- a/src/commands/key.rs +++ b/src/commands/key.rs @@ -5,6 +5,7 @@ use dialoguer::{theme::ColorfulTheme, Confirm}; use keyring::{Entry, Error as KeyringError}; use p256::ecdsa::SigningKey; use rand_core::OsRng; +use warg_client::RegistryUrl; use warg_crypto::signing::PrivateKey; /// Manage signing keys for interacting with a registry. @@ -45,26 +46,26 @@ struct KeyringEntryArgs { /// The name to use for the signing key. #[clap(long, short, value_name = "KEY_NAME", default_value = "default")] pub name: String, - /// The host name of the registry to create a signing key for. - #[clap(value_name = "HOST")] - pub host: String, + /// The URL of the registry to create a signing key for. + #[clap(value_name = "URL")] + pub url: RegistryUrl, } impl KeyringEntryArgs { fn get_entry(&self) -> Result { - get_signing_key_entry(&self.host, &self.name) + get_signing_key_entry(&self.url, &self.name) } fn get_key(&self) -> Result { - get_signing_key(&self.host, &self.name) + get_signing_key(&self.url, &self.name) } fn set_entry(&self, key: &PrivateKey) -> Result<()> { - set_signing_key(&self.host, &self.name, key) + set_signing_key(&self.url, &self.name, key) } fn delete_entry(&self) -> Result<()> { - delete_signing_key(&self.host, &self.name) + delete_signing_key(&self.url, &self.name) } } @@ -72,9 +73,9 @@ impl std::fmt::Display for KeyringEntryArgs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "`{name}` for registry `{host}`", + "`{name}` for registry `{url}`", name = self.name, - host = self.host + url = self.url ) } } @@ -97,9 +98,9 @@ impl KeyNewCommand { } Ok(_) | Err(KeyringError::Ambiguous(_)) => { bail!( - "a signing key `{name}` already exists for registry `{host}`", + "a signing key `{name}` already exists for registry `{url}`", name = self.keyring_entry.name, - host = self.keyring_entry.host + url = self.keyring_entry.url ); } Err(e) => { @@ -186,8 +187,8 @@ impl KeyDeleteCommand { ); } else { println!( - "skipping deletion of signing key for registry `{host}`", - host = self.keyring_entry.host, + "skipping deletion of signing key for registry `{url}`", + url = self.keyring_entry.url, ); } diff --git a/src/keyring.rs b/src/keyring.rs index 16794c5b..2e2e2f09 100644 --- a/src/keyring.rs +++ b/src/keyring.rs @@ -2,65 +2,63 @@ use anyhow::{bail, Context, Result}; use keyring::Entry; +use warg_client::RegistryUrl; use warg_crypto::signing::PrivateKey; /// Gets the signing key entry for the given registry and key name. -pub fn get_signing_key_entry(host: &str, name: &str) -> Result { - Entry::new( - &format!("warg-signing-key:{host}", host = host.to_lowercase()), - name, - ) - .context("failed to get keyring entry") +pub fn get_signing_key_entry(registry_url: &RegistryUrl, key_name: &str) -> Result { + let label = format!("warg-signing-key:{}", registry_url.safe_label()); + Entry::new(&label, key_name).context("failed to get keyring entry") } -/// Gets the signing key for the given registry host and key name. -pub fn get_signing_key(host: &str, name: &str) -> Result { - let entry = get_signing_key_entry(host, name)?; +/// Gets the signing key for the given registry registry_label and key name. +pub fn get_signing_key(registry_url: &RegistryUrl, key_name: &str) -> Result { + let entry = get_signing_key_entry(registry_url, key_name)?; match entry.get_password() { Ok(secret) => PrivateKey::decode(secret).context("failed to parse signing key"), Err(keyring::Error::NoEntry) => { - bail!("no signing key found with name `{name}` of registry `{host}`"); + bail!("no signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(keyring::Error::Ambiguous(_)) => { - bail!("more than one signing key found with name `{name}` of registry `{host}`"); + bail!("more than one signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(e) => { - bail!("failed to get signing key with name `{name}` of registry `{host}`: {e}"); + bail!("failed to get signing key with name `{key_name}` of registry `{registry_url}`: {e}"); } } } /// Sets the signing key for the given registry host and key name. -pub fn set_signing_key(host: &str, name: &str, key: &PrivateKey) -> Result<()> { - let entry = get_signing_key_entry(host, name)?; +pub fn set_signing_key(registry_url: &RegistryUrl, key_name: &str, key: &PrivateKey) -> Result<()> { + let entry = get_signing_key_entry(registry_url, key_name)?; match entry.set_password(&key.encode()) { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => { - bail!("no signing key found with name `{name}` of registry `{host}`"); + bail!("no signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(keyring::Error::Ambiguous(_)) => { - bail!("more than one signing key found with name `{name}` of registry `{host}`"); + bail!("more than one signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(e) => { - bail!("failed to set signing key with name `{name}` of registry `{host}`: {e}"); + bail!("failed to set signing key with name `{key_name}` of registry `{registry_url}`: {e}"); } } } /// Deletes the signing key for the given registry host and key name. -pub fn delete_signing_key(host: &str, name: &str) -> Result<()> { - let entry = get_signing_key_entry(host, name)?; +pub fn delete_signing_key(registry_url: &RegistryUrl, key_name: &str) -> Result<()> { + let entry = get_signing_key_entry(registry_url, key_name)?; match entry.delete_password() { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => { - bail!("no signing key found with name `{name}` of registry `{host}`"); + bail!("no signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(keyring::Error::Ambiguous(_)) => { - bail!("more than one signing key found with name `{name}` of registry `{host}`"); + bail!("more than one signing key found with name `{key_name}` of registry `{registry_url}`"); } Err(e) => { - bail!("failed to set signing key with name `{name}` of registry `{host}`: {e}"); + bail!("failed to set signing key with name `{key_name}` of registry `{registry_url}`: {e}"); } } }