From 5031866876f25147f12db42a956ba199b0eed03a Mon Sep 17 00:00:00 2001 From: Anthony Oteri <4360016+anthonyoteri@users.noreply.github.com> Date: Wed, 13 May 2026 14:01:18 -0400 Subject: [PATCH] refactor: simplify codebase and fix correctness issues - api: extract check_api_version_header() helper, eliminating duplicated header-checking logic in parse_response_status() - api: simplify parse_rfc5988() using split_once and let-else - api: propagate JSON decode errors in fetch_paginated() instead of silently swallowing them - api: add connect/request timeouts via a shared build_client() helper; all handlers now use a configured client instead of reqwest::get() - api: fix stale log trace name get_manifest -> get_digest - commands: promote inline response structs to module-level for clarity - commands: fix etag stripping logic (was using wrong quote/apostrophe pattern; now correctly strips RFC 7232 double-quotes) - commands: simplify iterator chains in catalog/tags handlers - error: simplify ResponseHeaderParseError from Box to String - main: fix stale log trace name make_registry_url -> parse_registry_arg - main: use as_deref().unwrap_or() instead of allocating via to_owned() - cli: remove unused imports and #![allow(unused_imports)] attribute --- src/api.rs | 497 ++++++++++++++++++++++++++++++++++++++---------- src/cli.rs | 120 ++++++++++-- src/commands.rs | 320 +++++++++++++++++++++---------- src/error.rs | 122 +++++++++++- src/main.rs | 66 +++++-- 5 files changed, 882 insertions(+), 243 deletions(-) diff --git a/src/api.rs b/src/api.rs index a79cefe..2a16d52 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,6 +7,8 @@ * copied, modified, or distributed except according to those terms. */ +use std::time::Duration; + use reqwest::header; use reqwest::header::HeaderValue; use reqwest::StatusCode; @@ -15,26 +17,66 @@ use url::Url; use crate::error::ApiError; +/// The MIME type for Docker Image Manifest V2, Schema 2. +/// +/// This value is sent in `Accept` headers when fetching manifests so that +/// the registry returns the canonical V2 manifest rather than a legacy V1 +/// manifest. const MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json"; -/// Iterate over a paginated result set, collecting and returning the response -/// set. +/// Connect timeout applied when establishing a TCP connection. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Overall request timeout from first byte sent to last byte received. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Build a shared [`reqwest::Client`] with sensible default timeouts. /// -/// The Docker Registry API specifies that when making a GET request, the -/// response will be paginated using a Link response header for the Next URI. -/// The URL will be encoded using [RFC5988](https://tools.ietf.org/html/rfc5988) +/// All outbound HTTP requests should use this client to prevent hung +/// connections from blocking the process indefinitely. /// -/// This function will continuously request the "Next" link as long as it is -/// returned, collecting and returning the deserialized response bodies as a -/// Vec. +/// # Errors /// -/// # Errors: +/// Returns [`ApiError::HttpError`] if the underlying TLS backend fails to +/// initialise and the client cannot be constructed. +pub fn build_client() -> Result { + reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(ApiError::HttpError) +} + +/// Fetch all pages of a paginated Docker Registry API endpoint and return the +/// collected, deserialized response bodies. /// -/// Returns an `ApiError` if there is a problem constructing the URL from the -/// configured `registry_url` base and the given `path`, or if there is an -/// error deserializing the HTTP response body as JSON, or if there is an -/// error parsing the `Link` header value as an RFC5988 URL. +/// The Docker Registry HTTP API V2 paginates list responses using a `Link` +/// response header whose value is an [RFC 5988](https://tools.ietf.org/html/rfc5988) +/// URL pointing to the next page. This function follows every `Link` header +/// until no further pages remain, accumulating each page's deserialized JSON +/// body into the returned `Vec`. +/// +/// # Arguments +/// +/// * `client` — A configured [`reqwest::Client`] used to send requests. +/// * `origin` — The base URL of the Docker Registry (e.g. +/// `https://registry.example.com`). +/// * `path` — The API path to request (e.g. `v2/_catalog`). +/// +/// # Errors +/// +/// Returns an [`ApiError`] in any of the following situations: +/// +/// * [`ApiError::UrlParseError`] — `origin` and `path` cannot be joined into a +/// valid URL. +/// * [`ApiError::HttpError`] — an HTTP request fails at the transport layer, or +/// a response body cannot be deserialized as JSON into `T`. +/// * [`ApiError::ResponseHeaderParseError`] — a `Link` header value contains +/// non-UTF-8 bytes. +/// * Any variant returned by [`parse_response_status`] — see that function for +/// the full list of status-code error conditions. pub async fn fetch_paginated Deserialize<'de>>( + client: &reqwest::Client, origin: &Url, path: &str, ) -> Result, ApiError> { @@ -45,14 +87,12 @@ pub async fn fetch_paginated Deserialize<'de>>( loop { let url = origin.join(&next_path)?; - let resp = reqwest::get(url).await?; + let resp = client.get(url).send().await?; parse_response_status(&resp)?; let headers = resp.headers().clone(); - if let Ok(json) = resp.json().await { - responses.push(json); - } + responses.push(resp.json().await?); if let Some(p) = parse_rfc5988(headers.get(header::LINK))? { next_path = p; @@ -63,117 +103,119 @@ pub async fn fetch_paginated Deserialize<'de>>( Ok(responses) } -/// Given an optional header value possibly containing an RFC5988 formatted -/// URL, parse said URL into a `String`. +/// Extract the URL from an optional RFC 5988 `Link` header value. /// -/// If the `header_value` does not contain a correctly formatted RFC5988 URL, -/// or if the `header_value` is not properly formatted containing a URL -/// surrounded by angle brackets, separated from the link relation by a ';' -/// character, the `None` variant will be returned. +/// The Docker Registry API uses `Link` headers of the form +/// `; rel="next"` to signal the next page of a paginated result. +/// This function extracts the URL between the angle brackets from the +/// portion before the first `;`. /// -/// # Errors: +/// Returns `Ok(Some(url))` when a valid bracketed URL is found, +/// `Ok(None)` when the header is absent or does not contain a +/// bracketed URL (e.g. it is malformed or uses a different format). /// -/// Returns and `ApiError` if there is a problem parsing contents of the -/// supplied header value. +/// # Errors +/// +/// Returns an [`ApiError`] if the header value contains non-UTF-8 bytes. fn parse_rfc5988(header_value: Option<&HeaderValue>) -> Result, ApiError> { log::trace!("parse_rfc5988(header_value: {header_value:?})"); - if let Some(link_value) = header_value { - let link_str = link_value.to_str()?; - let parts: Vec<&str> = link_str.split(';').collect(); - if let Some(url_part) = parts.first() { - if let Some(path) = url_part - .trim() - .strip_prefix('<') - .and_then(|s| s.strip_suffix('>')) - { - return Ok(Some(String::from(path))); - } - } - } + let Some(link_value) = header_value else { + return Ok(None); + }; - Ok(None) + let link_str = link_value.to_str()?; + // RFC 5988 link header format: `; rel="next"` — take everything + // before the first ';', strip the surrounding angle brackets. + let url_part = link_str.split_once(';').map_or(link_str, |(url, _)| url); + let path = url_part + .trim() + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')); + + Ok(path.map(String::from)) } -/// Parse the response according to the API Documentation. +/// Check that the `Docker-Distribution-API-Version` response header is present +/// and equals `"registry/2.0"`. /// -/// If a 200 OK response is returned, the registry implements the V2(.1) -/// registry API and the client may proceed safely with other V2 operations. -/// Optionally, the response may contain information about the supported -/// paths in the response body. The client should be prepared to ignore this data. +/// Returns `Ok(())` when the header is correct. /// -/// If a 401 Unauthorized response is returned, the client should take action -/// based on the contents of the "WWW-Authenticate" header and try the endpoint -/// again. Depending on access control setup, the client may still have to -/// authenticate against different resources, even if this check succeeds. +/// # Errors /// -/// If 404 Not Found response status, or other unexpected status, is returned, -/// the client should proceed with the assumption that the registry does not -/// implement V2 of the API. +/// * [`ApiError::ResponseHeaderParseError`] — the header value contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnsupportedVersion`] — the header is present but its value +/// is not `"registry/2.0"`. +/// * [`ApiError::UnexpectedResponse`] — the header is entirely absent. +fn check_api_version_header(response: &reqwest::Response) -> Result<(), ApiError> { + match response.headers().get("Docker-Distribution-API-Version") { + Some(v) if v.to_str()? == "registry/2.0" => Ok(()), + Some(v) => Err(ApiError::UnsupportedVersion(v.to_str()?.into())), + None => Err(ApiError::UnexpectedResponse( + "Missing version header".into(), + )), + } +} + +/// Validate the HTTP status code of a Docker Registry API response. /// -/// When a 200 OK or 401 Unauthorized response is returned, the -/// "Docker-Distribution-API-Version" header should be set to "registry/2.0". -/// Clients may require this header value to determine if the endpoint serves -/// this API. When this header is omitted, clients may fallback to an older -/// API version. +/// The Docker Registry API contract requires that `2xx` responses include a +/// `Docker-Distribution-API-Version: registry/2.0` header. `401 Unauthorized` +/// responses must also carry this header; when they do the caller should +/// authenticate and retry. All other non-success codes are treated as errors. /// -/// # Errors: +/// # Errors /// -/// Returns an `ApiError` on the following conditions: -/// -/// * There is an error parsing the "Docker-Distribution-API-Version" header. -/// * The value of the above header is not the expected result. -/// * The above header is missing from the response. -/// * A non 200 HTTP response status code is returned. +/// * [`ApiError::ResponseHeaderParseError`] — the `Docker-Distribution-API-Version` +/// header value contains non-UTF-8 bytes (only checked on `2xx` and `401`). +/// * [`ApiError::UnsupportedVersion`] — a `2xx` or `401` response contains the +/// version header with a value other than `"registry/2.0"`. +/// * [`ApiError::UnexpectedResponse`] — a `2xx` or `401` response is missing the +/// version header entirely. +/// * [`ApiError::AuthorizationFailed`] — the status code is `401 Unauthorized` +/// and the version header is valid. +/// * [`ApiError::NotFound`] — the status code is `404 Not Found`. +/// * [`ApiError::MethodNotAllowed`] — the status code is `405 Method Not Allowed`. +/// * [`ApiError::UnexpectedResponse`] — any other undocumented status code is +/// received. pub fn parse_response_status(response: &reqwest::Response) -> Result<(), ApiError> { log::trace!("parse_response_status(response: {response:?})"); match response.status() { - StatusCode::OK | StatusCode::ACCEPTED => { - let headers = response.headers(); - if let Some(header_value) = headers.get("Docker-Distribution-API-Version") { - if header_value.to_str()? == "registry/2.0" { - Ok(()) - } else { - Err(ApiError::UnsupportedVersion(header_value.to_str()?.into())) - } - } else { - Err(ApiError::UnexpectedResponse( - "Missing version header".into(), - )) - } - } - StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed), + StatusCode::OK | StatusCode::ACCEPTED => check_api_version_header(response), StatusCode::UNAUTHORIZED => { - let headers = response.headers(); - if let Some(header_value) = headers.get("Docker-Distribution-API-Version") { - if header_value.to_str()? == "registry/2.0" { - Err(ApiError::AuthorizationFailed) - } else { - Err(ApiError::UnsupportedVersion(header_value.to_str()?.into())) - } - } else { - Err(ApiError::UnexpectedResponse( - "Missing version header".into(), - )) - } + check_api_version_header(response)?; + Err(ApiError::AuthorizationFailed) } StatusCode::NOT_FOUND => Err(ApiError::NotFound), + StatusCode::METHOD_NOT_ALLOWED => Err(ApiError::MethodNotAllowed), e => Err(ApiError::UnexpectedResponse(format!( "Undocumented status code: {e:?}" ))), } } -/// Fetch the V2 Registry Digest for the specific manifest referenced in the -/// provided `url`. +/// Fetch the content digest for the manifest at `url`. /// -/// # Errors: +/// Sends a `HEAD` request with an `Accept: application/vnd.docker.distribution.manifest.v2+json` +/// header and returns the value of the `docker-content-digest` response header. +/// This digest is required to delete a manifest, since the Docker Registry API +/// only accepts deletions by digest, not by tag name. /// -/// This will return an `ApiError` if there is a problem fetching the manifest -/// headers. +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP request fails at the transport layer. +/// * [`ApiError::ResponseHeaderParseError`] — a response header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is +/// absent, or a `2xx` response is missing the version header. +/// * [`ApiError::UnsupportedVersion`] — the version header has an unexpected value. +/// * [`ApiError::AuthorizationFailed`] — the registry returns `401 Unauthorized`. +/// * [`ApiError::NotFound`] — the registry returns `404 Not Found`. +/// * [`ApiError::MethodNotAllowed`] — the registry returns `405 Method Not Allowed`. pub async fn get_digest(client: &reqwest::Client, url: &Url) -> Result { - log::trace!("get_manifest(client: {client:?}, url: {url}"); + log::trace!("get_digest(client: {client:?}, url: {url}"); let resp = client .head(url.as_ref()) .header(header::ACCEPT, MANIFEST_V2) @@ -220,20 +262,29 @@ mod tests { /// variant is returned. #[tokio::test] async fn test_parse_rfc5988_invalid() { - // Mock a valid RFC5988 header value - let invalid_header_value = HeaderValue::from_str(r#"invalid header value"#) + let invalid_header_value = HeaderValue::from_str(r"invalid header value") .expect("Failed to create valid header value"); - // Call the parse_rfc5988 function with the valid header value + // Call the parse_rfc5988 function with the invalid header value let result = parse_rfc5988(Some(&invalid_header_value)).unwrap(); - // Assert that the function returned the expected URL as Some(String) + // Assert that the function returned None assert_eq!(result, None); } - /// Validates the happy path for the get_digest function + /// Test that `parse_rfc5988` with `None` input returns `Ok(None)`. /// - /// This tests starts up a mock server, and the client makes a request for + /// When no `Link` header is present in the response, the function should + /// return `Ok(None)` to signal that there is no next page. + #[test] + fn test_parse_rfc5988_none_input() { + let result = parse_rfc5988(None).unwrap(); + assert_eq!(result, None); + } + + /// Validates the happy path for the `get_digest` function. + /// + /// This test starts up a mock server, and the client makes a request for /// the digest with the proper headers set. The test then validates that /// the correct digest is returned and that the mock server had the expected /// interactions. @@ -274,4 +325,244 @@ mod tests { Ok(()) } + + /// Test `get_digest` when the `docker-content-digest` header is missing. + /// + /// The function must return `ApiError::UnexpectedResponse` when the registry + /// omits the `docker-content-digest` header from an otherwise successful + /// `HEAD` response. + #[tokio::test] + async fn test_get_digest_missing_digest_header() -> Result<(), Box> { + let mut server = mockito::Server::new_async().await; + let path = "/v2/foo/manifests/latest"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("HEAD", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header("Docker-Distribution-API-Version", "registry/2.0") + // No docker-content-digest header + .create(); + + let url = registry_url.join(path)?; + let client = reqwest::Client::new(); + let result = get_digest(&client, &url).await; + + assert!(result.is_err()); + assert!( + matches!(result.unwrap_err(), ApiError::UnexpectedResponse(_)), + "Expected ApiError::UnexpectedResponse" + ); + + mock_response.assert(); + Ok(()) + } + + /// Test `fetch_paginated` happy path — single page with no `Link` header. + /// + /// When the registry returns a single page (no pagination link), the + /// function should return a `Vec` containing exactly one parsed response. + #[tokio::test] + async fn test_fetch_paginated_single_page() -> Result<(), Box> { + use serde::Deserialize; + + #[derive(Deserialize)] + struct Resp { + items: Vec, + } + + let mut server = mockito::Server::new_async().await; + let path = "/v2/test/list"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/2.0") + .with_body(r#"{"items": ["a", "b", "c"]}"#) + .create(); + + let client = build_client().expect("Failed to build client"); + let result: Vec = fetch_paginated(&client, ®istry_url, path).await?; + assert_eq!(result.len(), 1); + assert_eq!(result[0].items, vec!["a", "b", "c"]); + + mock_response.assert(); + Ok(()) + } + + /// Test that `fetch_paginated` propagates a JSON decode error on an empty body. + /// + /// When the registry returns a success status but no body, the JSON + /// deserializer will fail. The error must be surfaced to the caller rather + /// than silently swallowed. + #[tokio::test] + async fn test_fetch_paginated_empty_body_returns_error() { + use serde::Deserialize; + + #[derive(Deserialize)] + struct Resp { + #[allow(dead_code)] + items: Vec, + } + + let mut server = mockito::Server::new_async().await; + let path = "/v2/test/empty"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header("Docker-Distribution-API-Version", "registry/2.0") + // No body — JSON deserialisation must fail and be propagated. + .create(); + + let client = build_client().expect("Failed to build client"); + let result: Result, _> = fetch_paginated(&client, ®istry_url, path).await; + assert!( + result.is_err(), + "Expected an error on empty body but got Ok" + ); + } + + /// Test `parse_response_status` with `UNAUTHORIZED` and valid version header + /// returns `ApiError::AuthorizationFailed`. + #[tokio::test] + async fn test_parse_response_status_unauthorized_valid_version() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into()) + .with_header("Docker-Distribution-API-Version", "registry/2.0") + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::AuthorizationFailed)), + "Expected AuthorizationFailed, got {result:?}" + ); + } + + /// Test `parse_response_status` with `UNAUTHORIZED` and wrong version header + /// returns `ApiError::UnsupportedVersion`. + #[tokio::test] + async fn test_parse_response_status_unauthorized_wrong_version() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into()) + .with_header("Docker-Distribution-API-Version", "registry/1.0") + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::UnsupportedVersion(_))), + "Expected UnsupportedVersion, got {result:?}" + ); + } + + /// Test `parse_response_status` with `UNAUTHORIZED` and missing version header + /// returns `ApiError::UnexpectedResponse`. + #[tokio::test] + async fn test_parse_response_status_unauthorized_missing_version() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::UNAUTHORIZED.as_u16().into()) + // No Docker-Distribution-API-Version header + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::UnexpectedResponse(_))), + "Expected UnexpectedResponse, got {result:?}" + ); + } + + /// Test `parse_response_status` with `NOT_FOUND` returns `ApiError::NotFound`. + #[tokio::test] + async fn test_parse_response_status_not_found() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::NOT_FOUND.as_u16().into()) + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::NotFound)), + "Expected NotFound, got {result:?}" + ); + } + + /// Test `parse_response_status` with `METHOD_NOT_ALLOWED` returns + /// `ApiError::MethodNotAllowed`. + #[tokio::test] + async fn test_parse_response_status_method_not_allowed() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::METHOD_NOT_ALLOWED.as_u16().into()) + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::MethodNotAllowed)), + "Expected MethodNotAllowed, got {result:?}" + ); + } + + /// Test `parse_response_status` with an unexpected status code returns + /// `ApiError::UnexpectedResponse`. + #[tokio::test] + async fn test_parse_response_status_unexpected_status() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + server + .mock("GET", path) + .with_status(http::status::StatusCode::INTERNAL_SERVER_ERROR.as_u16().into()) + .create(); + + let url = registry_url.join(path).expect("Failed to join URL"); + let resp = reqwest::get(url).await.expect("Request failed"); + let result = parse_response_status(&resp); + + assert!( + matches!(result, Err(ApiError::UnexpectedResponse(_))), + "Expected UnexpectedResponse, got {result:?}" + ); + } } diff --git a/src/cli.rs b/src/cli.rs index 8c206cf..571f870 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,25 +7,34 @@ * copied, modified, or distributed except according to those terms. */ -#![allow(unused_imports)] - -use std::ffi::OsString; -use std::path::PathBuf; - -use clap::Args; use clap::Parser; use clap::Subcommand; use clap::ValueEnum; -/// Dredge is a command line tool for working with the Docker Registry -/// V2 API. +/// Command-line interface for `dredge`. +/// +/// `dredge` is a tool for interacting with Docker Registry HTTP API V2 +/// endpoints. It supports listing repositories and tags, inspecting image +/// manifests, deleting tagged images, and verifying registry API +/// compatibility. +/// +/// The `` positional argument accepts a bare hostname +/// (`registry.example.com`), a host-and-port pair +/// (`registry.example.com:5000`), or a full URL with an explicit scheme +/// (`https://registry.example.com` or `http://registry.example.com`). +/// When no scheme is given, `https://` is assumed. #[derive(Debug, Parser, PartialEq, Eq)] #[command(name = "dredge", version, author)] #[command(about, long_about)] pub(crate) struct Cli { + /// The subcommand to execute. #[command(subcommand)] pub command: Commands, + /// Minimum log level for messages written to stderr. + /// + /// Possible values: `trace`, `debug`, `info`, `warn`, `error`, `off`. + /// Defaults to `info`. #[arg( long = "log-level", require_equals = true, @@ -37,17 +46,32 @@ pub(crate) struct Cli { )] pub log_level: LogLevel, - /// The host or host:port or full base URL of the Docker Registry + /// The Docker Registry endpoint. + /// + /// Accepts a hostname (`registry.example.com`), host and port + /// (`registry.example.com:5000`), or a full URL with an explicit scheme + /// (`https://registry.example.com` or `http://registry.example.com`). + /// The `https://` scheme is assumed when no scheme is provided. pub registry: String, } +/// Log verbosity level for the `--log-level` CLI option. +/// +/// Maps directly to the corresponding [`log::LevelFilter`] variants. Use +/// `Off` to suppress all log output. #[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] pub enum LogLevel { + /// Extremely verbose output, including internal trace points. Trace, + /// Verbose output useful for debugging. Debug, + /// Informational messages (default). Info, + /// Warnings about potentially unexpected conditions. Warn, + /// Only error messages. Error, + /// Suppress all log output. Off, } @@ -64,28 +88,94 @@ impl From for log::LevelFilter { } } +/// Available `dredge` subcommands. #[derive(Debug, Subcommand, PartialEq, Eq)] pub enum Commands { - /// Fetch the list of available repositories from the catalog. + /// List all repositories available in the registry catalog. + /// + /// Queries the `/v2/_catalog` endpoint and prints one repository name per + /// line. Paginated responses are followed automatically. + /// + /// **Example:** + /// ```text + /// dredge registry.example.com catalog + /// ``` Catalog, - /// Fetch the list of tags for a given image. + /// List all tags published for an image. + /// + /// Queries the `/v2//tags/list` endpoint and prints one tag per + /// line. Paginated responses are followed automatically. + /// + /// **Example:** + /// ```text + /// dredge registry.example.com tags myorg/backend + /// ``` #[command(arg_required_else_help = true)] - Tags { name: String }, + Tags { + /// The repository name whose tags should be listed + /// (e.g. `myorg/backend`). + name: String, + }, - /// Show detailed information about a particular image. + /// Show detailed manifest information for a tagged image. + /// + /// Queries the `/v2//manifests/` endpoint and prints the + /// parsed manifest as YAML, including the image name, tag, architecture, + /// filesystem layers, content digest, and `ETag`. + /// + /// When `[TAG]` is omitted, `latest` is used. + /// + /// **Examples:** + /// ```text + /// dredge registry.example.com show myorg/backend + /// dredge registry.example.com show myorg/backend v2.0.0 + /// ``` #[command(arg_required_else_help = true)] Show { + /// The repository name of the image to inspect (e.g. `myorg/backend`). image: String, + /// The tag to inspect. Defaults to `latest` when omitted. #[arg(default_missing_value = "latest")] tag: Option, }, /// Delete a tagged image from the registry. + /// + /// Resolves the tag to its content digest via a `HEAD` request, then + /// sends a `DELETE` request for that digest to the + /// `/v2//manifests/` endpoint. + /// + /// Requires the registry to have storage deletion enabled (set + /// `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container). + /// If deletion is not enabled the registry returns a + /// `405 Method Not Allowed` response. + /// + /// Only the manifest is removed; unreferenced layer blobs remain on disk + /// until the registry garbage collector is run. + /// + /// **Example:** + /// ```text + /// dredge registry.example.com delete myorg/backend v1.0.0 + /// ``` #[command(arg_required_else_help = true)] - Delete { image: String, tag: String }, + Delete { + /// The repository name of the image to delete (e.g. `myorg/backend`). + image: String, + /// The tag to delete (e.g. `v1.0.0`). + tag: String, + }, - /// Perform a simple version check towards the Docker Registry API + /// Verify that the registry endpoint implements Docker Distribution API v2. + /// + /// Sends a `GET` request to `/v2` and checks that the response contains a + /// `Docker-Distribution-API-Version: registry/2.0` header. Prints `Ok` + /// on success. + /// + /// **Example:** + /// ```text + /// dredge registry.example.com check + /// ``` Check, } diff --git a/src/commands.rs b/src/commands.rs index c112520..925fe6e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,78 +16,164 @@ use url::Url; use crate::api; use crate::error::ApiError; -/// Handler for the `Catalog` endpoint +/// Deserialized body of a `/v2/_catalog` response page. +#[derive(Deserialize)] +struct CatalogResponse { + repositories: Vec, +} + +/// Deserialized body of a `/v2//tags/list` response page. +#[derive(Deserialize)] +struct TagsResponse { + tags: Vec, +} + +/// A single filesystem layer entry within a V1 image manifest. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FsLayer { + blob_sum: String, +} + +/// Deserialized body of a `/v2//manifests/` response, augmented +/// with the `digest` and `etag` values extracted from response headers. +#[derive(Debug, Serialize, Deserialize)] +struct ManifestResponse { + name: String, + tag: String, + architecture: String, + + #[serde(rename = "fsLayers")] + fslayers: Vec, + + /// Content digest from the `docker-content-digest` response header. + #[serde(skip_deserializing)] + digest: String, + + /// `ETag` value from the response header (quotes stripped), or the digest + /// when the `ETag` header is absent. + #[serde(skip_deserializing)] + etag: String, +} + +/// Fetch all repository names from the registry catalog and write them to `buf`. /// -/// Fetch the list of repository names from the Docker Registry API, and -/// simply print the resulting names to stdout. +/// Queries `/v2/_catalog` via [`api::fetch_paginated`], collects all pages, +/// and writes one repository name per line to the provided writer. /// -/// # Errors: +/// # Arguments /// -/// Returns an `ApiError` if there is a problem fetching or parsing the -/// responses from the Docker Registry API. +/// * `buf` — Output sink (typically stdout or a test buffer). +/// * `registry_url` — Base URL of the Docker Registry. +/// +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a +/// request to the registry failed at the transport layer, or a response body +/// could not be decoded as JSON. +/// * [`ApiError::UrlParseError`] — the catalog URL could not be constructed. +/// * [`ApiError::ResponseHeaderParseError`] — a response header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnsupportedVersion`] — the registry version header has an +/// unexpected value. +/// * [`ApiError::UnexpectedResponse`] — a required response header is absent. +/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication. +/// * [`ApiError::NotFound`] — the catalog endpoint does not exist. +/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method. +/// * [`ApiError::IOError`] — writing a repository name to `buf` failed. pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> { - #[derive(Deserialize)] - struct Response { - repositories: Vec, - } - log::trace!("catalog_handler(registry_url: {registry_url:?})"); - let path = "v2/_catalog"; - let responses: Vec = api::fetch_paginated(registry_url, path).await?; - let repository_list: Vec<&str> = responses - .iter() - .flat_map(|r| r.repositories.iter().map(String::as_str)) - .collect(); + let client = api::build_client()?; + let responses: Vec = + api::fetch_paginated(&client, registry_url, "v2/_catalog").await?; - for repository in repository_list { - writeln!(buf, "{repository}")?; + for repo in responses.iter().flat_map(|r| r.repositories.iter()) { + writeln!(buf, "{repo}")?; } Ok(()) } -/// Handler for the `Tags` endpoint +/// Fetch all tags for an image from the registry and write them to `buf`. /// -/// Fetch the list of tags names for a given image from the Docker Registry API, and -/// simply print the resulting names to stdout. +/// Queries `/v2//tags/list` via [`api::fetch_paginated`], collects all +/// pages, and writes one tag name per line to the provided writer. /// -/// # Errors: +/// # Arguments /// -/// Returns an `ApiError` if there is a problem fetching or parsing the -/// responses from the Docker Registry API. +/// * `buf` — Output sink (typically stdout or a test buffer). +/// * `registry_url` — Base URL of the Docker Registry. +/// * `name` — The repository name whose tags should be listed +/// (e.g. `"myorg/backend"`). +/// +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a +/// request to the registry failed at the transport layer, or a response body +/// could not be decoded as JSON. +/// * [`ApiError::UrlParseError`] — the tags URL could not be constructed. +/// * [`ApiError::ResponseHeaderParseError`] — a response header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnsupportedVersion`] — the registry version header has an +/// unexpected value. +/// * [`ApiError::UnexpectedResponse`] — a required response header is absent. +/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication. +/// * [`ApiError::NotFound`] — the image does not exist in the registry. +/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method. +/// * [`ApiError::IOError`] — writing a tag name to `buf` failed. pub async fn tags_handler( buf: &mut dyn Write, registry_url: &Url, name: &str, ) -> Result<(), ApiError> { - #[derive(Deserialize)] - struct Response { - tags: Vec, - } - log::trace!("tags_handler(registry_url: {registry_url:?}, name: {name})"); - let path = format!("/v2/{name}/tags/list"); - let responses: Vec = api::fetch_paginated(registry_url, &path).await?; - let tag_list: Vec<&str> = responses - .iter() - .flat_map(|r| r.tags.iter().map(String::as_str)) - .collect(); + let client = api::build_client()?; + let responses: Vec = + api::fetch_paginated(&client, registry_url, &format!("/v2/{name}/tags/list")).await?; - for tag in tag_list { + for tag in responses.iter().flat_map(|r| r.tags.iter()) { writeln!(buf, "{tag}")?; } Ok(()) } -/// Handler function for showing manifest details +/// Fetch and display the manifest for a tagged image. /// -/// # Errors: +/// Queries `/v2//manifests/`, extracts the +/// `docker-content-digest` and `etag` response headers, deserializes the +/// manifest JSON body, and serializes the result as YAML to `buf`. /// -/// Returns an `ApiError` if there is a problem fetching the manifest or if there -/// is a problem parsing the response from the Docker Registry API. +/// The output includes the image name, tag, target architecture, filesystem +/// layer digests, content digest, and `ETag`. +/// +/// # Arguments +/// +/// * `buf` — Output sink (typically stdout or a test buffer). +/// * `registry_url` — Base URL of the Docker Registry. +/// * `image` — The repository name (e.g. `"myorg/backend"`). +/// * `tag` — The tag to inspect (e.g. `"v2.0.0"`). Pass `"latest"` when +/// no explicit tag was provided by the caller. +/// +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the +/// request failed at the transport layer, or the response body could not be +/// decoded as JSON. +/// * [`ApiError::UrlParseError`] — the manifest URL could not be constructed. +/// * [`ApiError::ResponseHeaderParseError`] — a response header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is +/// absent, or a required version header is missing. +/// * [`ApiError::UnsupportedVersion`] — the registry version header has an +/// unexpected value. +/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication. +/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry. +/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method. +/// * [`ApiError::SerializerError`] — the manifest could not be serialized to YAML. +/// * [`ApiError::IOError`] — writing the YAML output to `buf` failed. #[allow(clippy::similar_names)] pub async fn show_handler( buf: &mut dyn Write, @@ -95,71 +181,81 @@ pub async fn show_handler( image: &str, tag: &str, ) -> Result<(), ApiError> { - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct FsLayer { - blob_sum: String, - } - - #[derive(Debug, Serialize, Deserialize)] - struct Response { - name: String, - tag: String, - architecture: String, - - #[serde(rename = "fsLayers")] - fslayers: Vec, - - #[serde(skip_deserializing)] - digest: String, - - #[serde(skip_deserializing)] - etag: String, - } log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); let path = format!("/v2/{image}/manifests/{tag}"); let url = registry_url.join(&path)?; - let resp = reqwest::get(url).await?; + let client = api::build_client()?; + let resp = client.get(url).send().await?; api::parse_response_status(&resp)?; let headers = resp.headers(); - let digest: String = String::from( - headers - .get("docker-content-digest") - .ok_or(ApiError::UnexpectedResponse(String::from( - "Missing docker-content-digest header", - )))? - .to_str()?, - ); + let digest = headers + .get("docker-content-digest") + .ok_or_else(|| { + ApiError::UnexpectedResponse("Missing docker-content-digest header".into()) + })? + .to_str()? + .to_owned(); - let etag: String = String::from( - headers - .get("etag") - .ok_or(ApiError::UnexpectedResponse(String::from( - "Missing etag header", - )))? - .to_str()? - .strip_prefix("'\"") - .and_then(|s| s.strip_suffix("\"'")) - .unwrap_or(&digest), - ); + // Docker Registry API ETags are quoted strings per RFC 7232, e.g. + // `"sha256:abc123"`. Strip surrounding double-quotes when present; fall + // back to the digest when the header is absent. + let etag = match headers.get("etag") { + Some(v) => { + let raw = v.to_str()?; + raw.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(raw) + .to_owned() + } + None => digest.clone(), + }; - let mut body: Response = resp.json().await?; + let mut body: ManifestResponse = resp.json().await?; body.digest = digest; body.etag = etag; - serde_yaml::to_writer(buf, &body)?; + serde_yml::to_writer(buf, &body)?; Ok(()) } -/// Handler function for deleting a manifest for a given tagged image. +/// Delete the manifest for a tagged image from the registry. /// -/// # Errors: +/// Resolves `tag` to its content digest by sending a `HEAD` request to +/// `/v2//manifests/`, then deletes the manifest by digest via +/// `DELETE /v2//manifests/`. /// -/// Returns and `ApiError` if there is a problem converting the given tag to a -/// manifest digest, or if there is a problem deleting the manifest from the -/// Docker Registry API. +/// The registry must have storage deletion enabled. Set the environment +/// variable `REGISTRY_STORAGE_DELETE_ENABLED=true` on the registry container. +/// If deletion is not enabled the registry returns `405 Method Not Allowed` +/// and this function returns [`ApiError::MethodNotAllowed`]. +/// +/// Only the manifest is removed. Unreferenced layer blobs remain on disk +/// until the registry garbage collector is run separately. +/// +/// # Arguments +/// +/// * `_buf` — Unused output sink (reserved for future use). +/// * `registry_url` — Base URL of the Docker Registry. +/// * `image` — The repository name (e.g. `"myorg/backend"`). +/// * `tag` — The tag to delete (e.g. `"v1.0.0"`). +/// +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or a +/// request failed at the transport layer. +/// * [`ApiError::UrlParseError`] — a manifest URL could not be constructed. +/// * [`ApiError::ResponseHeaderParseError`] — a response header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnexpectedResponse`] — the `docker-content-digest` header is +/// absent from the `HEAD` response, or a required version header is missing. +/// * [`ApiError::UnsupportedVersion`] — the registry version header has an +/// unexpected value. +/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication. +/// * [`ApiError::NotFound`] — the image or tag does not exist in the registry. +/// * [`ApiError::MethodNotAllowed`] — the registry does not permit deletion; +/// ensure `REGISTRY_STORAGE_DELETE_ENABLED=true` is set on the registry. #[allow(clippy::unused_async)] pub async fn delete_handler( _buf: &mut dyn Write, @@ -169,7 +265,7 @@ pub async fn delete_handler( ) -> Result<(), ApiError> { log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); - let client = reqwest::Client::new(); + let client = api::build_client()?; let url = registry_url.join(&format!("/v2/{image}/manifests/{tag}"))?; let digest = api::get_digest(&client, &url).await?; @@ -181,21 +277,39 @@ pub async fn delete_handler( Ok(()) } -// Path to the Docker Registry APIs "api version check" endpoint. - -/// Handler for the API Version Check. +/// Verify that the registry endpoint implements Docker Distribution API v2. /// -/// # Errors: +/// Sends a `GET` request to `/v2` and validates the response with +/// [`api::parse_response_status`]. On success writes `"Ok\n"` to `buf`. /// -/// Returns an `ApiError` if there is a problem communicating with the -/// endpoint or if the required version is not supported. +/// # Arguments +/// +/// * `buf` — Output sink (typically stdout or a test buffer). +/// * `registry_url` — Base URL of the Docker Registry. +/// +/// # Errors +/// +/// * [`ApiError::HttpError`] — the HTTP client could not be constructed, or the +/// request failed at the transport layer. +/// * [`ApiError::UrlParseError`] — the `/v2` URL could not be constructed. +/// * [`ApiError::ResponseHeaderParseError`] — the version header contains +/// non-UTF-8 bytes. +/// * [`ApiError::UnexpectedResponse`] — the `Docker-Distribution-API-Version` +/// header is absent from the response. +/// * [`ApiError::UnsupportedVersion`] — the version header has a value other +/// than `"registry/2.0"`. +/// * [`ApiError::AuthorizationFailed`] — the registry requires authentication. +/// * [`ApiError::NotFound`] — the `/v2` endpoint does not exist. +/// * [`ApiError::MethodNotAllowed`] — the registry rejected the request method. +/// * [`ApiError::IOError`] — writing `"Ok\n"` to `buf` failed. pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> { log::trace!("check_handler(registry_url: {registry_url:?})"); let path = "/v2"; let url = registry_url.join(path)?; - let resp = reqwest::get(url).await?; + let client = api::build_client()?; + let resp = client.get(url).send().await?; api::parse_response_status(&resp)?; writeln!(buf, "Ok")?; Ok(()) @@ -260,7 +374,7 @@ mod tests { .with_header("Docker-Distribution-API-Version", "registry/2.0") .with_header( http::header::LINK.as_str(), - &format!(r#"<{path2}>; rel=next"#), + &format!(r"<{path2}>; rel=next"), ) .with_body(r#"{"repositories": ["image1", "image2"]}"#) .create(); @@ -310,9 +424,9 @@ mod tests { mock_response.assert(); } - /// Validate the pagination of the catalog handler. + /// Validate the pagination of the tags handler. /// - /// This test spins up a mock server, and makes a request to the catalog + /// This test spins up a mock server, and makes a request to the tags /// endpoint. The response includes a pagination link, which the handler /// should follow, resulting in the combined list. It checks that the /// handler both called the request the expected number of times, and did @@ -332,7 +446,7 @@ mod tests { .with_header("Docker-Distribution-API-Version", "registry/2.0") .with_header( http::header::LINK.as_str(), - &format!(r#"<{path2}>; rel=next"#), + &format!(r"<{path2}>; rel=next"), ) .with_body(r#"{"tags": ["tag1", "tag2"]}"#) .create(); @@ -381,7 +495,7 @@ mod tests { mock_response.assert(); } - /// Validate the the check handler on invalid API version + /// Validate the check handler when the API version header is missing. /// /// This validates that if the "Docker-Distribution-API-Version" header /// is missing in the response, the appropriate error is returned. @@ -413,7 +527,7 @@ mod tests { Ok(()) } - /// Validate the the check handler on invalid API version + /// Validate the check handler when the API version header has an unexpected value. /// /// This validates that if the "Docker-Distribution-API-Version" header /// is present in the response but contains an unexpected value, the diff --git a/src/error.rs b/src/error.rs index 5f3be0a..14f6d70 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,62 +12,164 @@ use thiserror::Error; -/// The common error type for this Application. +/// The top-level error type for the `dredge` application. +/// +/// Wraps lower-level errors from the registry API, URL parsing, I/O, and +/// the logging subsystem so they can all be returned from `main`. #[derive(Error, Debug)] pub enum DredgeError { - /// An error communicating with the Registry API + /// An error returned by the Docker Registry API layer. #[error(transparent)] ApiError(#[from] ApiError), - /// An error building the registry URL + /// The `` argument could not be parsed as a valid URL. #[error("Error determining registry URL from {0}")] RegistryUrlError(String), + /// An I/O error writing output to stdout. #[error(transparent)] IOError(#[from] std::io::Error), + /// The logging subsystem could not be initialised. #[error(transparent)] LoggerError(#[from] log::SetLoggerError), } -/// An error related to the communication with the registry API. +/// Errors that can occur while communicating with the Docker Registry API. #[derive(Error, Debug)] pub enum ApiError { - /// Error parsing a URL + /// A URL could not be constructed or parsed. #[error(transparent)] UrlParseError(#[from] url::ParseError), - /// Error in HTTP Request + /// An HTTP transport-level error (connection refused, TLS failure, timeout, + /// etc.) or a response body that could not be decoded. #[error(transparent)] HttpError(#[from] reqwest::Error), - #[error("Failed to parse response headers")] - ResponseHeaderParseError(Box), + /// A response header contained bytes that could not be decoded as UTF-8. + #[error("Failed to parse response header: {0}")] + ResponseHeaderParseError(String), + /// The registry returned a `Docker-Distribution-API-Version` header value + /// other than `"registry/2.0"`. The inner `String` holds the actual value. #[error("Version Mismatch {0}")] UnsupportedVersion(String), + /// The registry returned a response that did not match the expected API + /// contract (e.g. a required header was absent). The inner `String` + /// describes the specific problem. #[error("Unexpected response from API: {0}")] UnexpectedResponse(String), + /// The registry returned `401 Unauthorized`. Authentication is not + /// currently supported; the request cannot be retried automatically. #[error("HTTP Authorization failed")] AuthorizationFailed, + /// The requested resource does not exist in the registry (`404 Not Found`). #[error("Resource not found")] NotFound, + /// An I/O error writing serialized output to the output buffer. #[error(transparent)] IOError(#[from] std::io::Error), + /// The manifest response body could not be serialized to YAML for output. #[error(transparent)] - SerializerError(#[from] serde_yaml::Error), + SerializerError(#[from] serde_yml::Error), + /// The registry returned `405 Method Not Allowed`, typically because + /// storage deletion has not been enabled on the registry. #[error("Method not allowed")] MethodNotAllowed, } impl From for ApiError { fn from(other: reqwest::header::ToStrError) -> Self { - Self::ResponseHeaderParseError(Box::from(other)) + Self::ResponseHeaderParseError(other.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that `DredgeError::from(ApiError::NotFound)` works via the `From` impl. + #[test] + fn test_dredge_error_from_api_error_not_found() { + let api_err = ApiError::NotFound; + let dredge_err = DredgeError::from(api_err); + assert!(matches!(dredge_err, DredgeError::ApiError(ApiError::NotFound))); + } + + /// Test that `DredgeError::from(ApiError::AuthorizationFailed)` works. + #[test] + fn test_dredge_error_from_api_error_authorization_failed() { + let api_err = ApiError::AuthorizationFailed; + let dredge_err = DredgeError::from(api_err); + assert!(matches!( + dredge_err, + DredgeError::ApiError(ApiError::AuthorizationFailed) + )); + } + + /// Test that `DredgeError::from(ApiError::MethodNotAllowed)` works. + #[test] + fn test_dredge_error_from_api_error_method_not_allowed() { + let api_err = ApiError::MethodNotAllowed; + let dredge_err = DredgeError::from(api_err); + assert!(matches!( + dredge_err, + DredgeError::ApiError(ApiError::MethodNotAllowed) + )); + } + + /// Test Display output for `ApiError::NotFound`. + #[test] + fn test_api_error_not_found_display() { + let err = ApiError::NotFound; + assert_eq!(err.to_string(), "Resource not found"); + } + + /// Test Display output for `ApiError::AuthorizationFailed`. + #[test] + fn test_api_error_authorization_failed_display() { + let err = ApiError::AuthorizationFailed; + assert_eq!(err.to_string(), "HTTP Authorization failed"); + } + + /// Test Display output for `ApiError::MethodNotAllowed`. + #[test] + fn test_api_error_method_not_allowed_display() { + let err = ApiError::MethodNotAllowed; + assert_eq!(err.to_string(), "Method not allowed"); + } + + /// Test Display output for `ApiError::UnsupportedVersion`. + #[test] + fn test_api_error_unsupported_version_display() { + let err = ApiError::UnsupportedVersion(String::from("registry/1.0")); + assert_eq!(err.to_string(), "Version Mismatch registry/1.0"); + } + + /// Test Display output for `ApiError::UnexpectedResponse`. + #[test] + fn test_api_error_unexpected_response_display() { + let err = ApiError::UnexpectedResponse(String::from("Missing header")); + assert_eq!( + err.to_string(), + "Unexpected response from API: Missing header" + ); + } + + /// Test Display output for `DredgeError::RegistryUrlError`. + #[test] + fn test_dredge_error_registry_url_error_display() { + let err = DredgeError::RegistryUrlError(String::from("bad-url")); + assert_eq!( + err.to_string(), + "Error determining registry URL from bad-url" + ); } } diff --git a/src/main.rs b/src/main.rs index 8a74020..af53815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,30 +24,45 @@ pub(crate) mod cli; mod commands; mod error; -/// Name of "latest" tag +/// The default image tag used when no tag is specified by the caller. const LATEST: &str = "latest"; -/// Parse the "" argument into a complete Docker Registry URL. +/// Parse the `` CLI argument into a complete Docker Registry [`Url`]. /// -/// This prepends the HTTPS scheme and converts the given string to a `Url` -/// instance. +/// Accepts a bare hostname (`registry.example.com`), a host-and-port pair +/// (`registry.example.com:5000`), or a full URL +/// (`https://registry.example.com:5000`). When no URL scheme is present, +/// `https://` is prepended automatically before parsing. /// -/// If the given `host` value is already a valid URL, then it will be returned -/// as-is. +/// # Errors /// -/// # Errors: +/// Returns [`DredgeError::RegistryUrlError`] containing the attempted URL +/// string if it cannot be parsed as a valid URL after the scheme is prepended. /// -/// If there is a problem parsing the resulting string as a valid URL, a -/// `DredgeError::RegistryUrlError` will be returned. +/// # Examples +/// +/// ```rust,ignore +/// // Bare hostname — HTTPS is assumed +/// let url = parse_registry_arg("registry.example.com").unwrap(); +/// assert_eq!(url.scheme(), "https"); +/// +/// // Host with port +/// let url = parse_registry_arg("registry.example.com:5000").unwrap(); +/// assert_eq!(url.port(), Some(5000)); +/// +/// // Full URL returned as-is +/// let url = parse_registry_arg("https://registry.example.com").unwrap(); +/// assert_eq!(url.as_str(), "https://registry.example.com/"); +/// ``` fn parse_registry_arg(host: &str) -> Result { - log::trace!("make_registry_url(host: {host})"); + log::trace!("parse_registry_arg(host: {host})"); let mut host = String::from(host); if !host.starts_with("http://") && !host.starts_with("https://") { host = format!("https://{host}"); } - Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.to_string()))) + Url::parse(&host).or(Err(DredgeError::RegistryUrlError(host.clone()))) } #[tokio::main(flavor = "current_thread")] @@ -76,7 +91,7 @@ async fn main() -> Result<(), DredgeError> { &mut buf, ®istry_url, &image, - &tag.unwrap_or(LATEST.to_string()), + tag.as_deref().unwrap_or(LATEST), ) .await?; } @@ -156,4 +171,31 @@ mod tests { _ => panic!("Expected RegistryUrlError, got a different error"), } } + + /// Test that an HTTP (non-HTTPS) URL is returned as-is without prepending + /// the HTTPS scheme. + #[test] + fn test_parse_registry_arg_http_url() { + let host = "http://example.com/registry"; + let result = parse_registry_arg(host); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host_str(), Some("example.com")); + assert_eq!(url.path(), "/registry"); + } + + /// Test that a trailing slash in the registry argument is preserved. + #[test] + fn test_parse_registry_arg_trailing_slash() { + let host = "example.com/registry/"; + let result = parse_registry_arg(host); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("example.com")); + assert_eq!(url.path(), "/registry/"); + } }