diff --git a/src/OrasProject.Oras/Extensions.cs b/src/OrasProject.Oras/Extensions.cs index 505f7ff..6b048ac 100644 --- a/src/OrasProject.Oras/Extensions.cs +++ b/src/OrasProject.Oras/Extensions.cs @@ -52,8 +52,8 @@ public static async Task CopyGraphAsync(this ITarget src, ITarget dst, Descripto { // check if node exists in target if (await dst.ExistsAsync(node, cancellationToken).ConfigureAwait(false)) - { - return; + { + return; } // retrieve successors diff --git a/src/OrasProject.Oras/Registry/IRegistry.cs b/src/OrasProject.Oras/Registry/IRegistry.cs index c4db55a..92cc915 100644 --- a/src/OrasProject.Oras/Registry/IRegistry.cs +++ b/src/OrasProject.Oras/Registry/IRegistry.cs @@ -25,7 +25,7 @@ public interface IRegistry /// /// /// - Task GetRepository(string name, CancellationToken cancellationToken = default); + Task GetRepositoryAsync(string name, CancellationToken cancellationToken = default); /// /// Repositories lists the name of repositories available in the registry. diff --git a/src/OrasProject.Oras/Registry/Reference.cs b/src/OrasProject.Oras/Registry/Reference.cs index b048b75..54eca43 100644 --- a/src/OrasProject.Oras/Registry/Reference.cs +++ b/src/OrasProject.Oras/Registry/Reference.cs @@ -13,6 +13,7 @@ using OrasProject.Oras.Exceptions; using System; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; namespace OrasProject.Oras.Registry; @@ -82,11 +83,11 @@ public string Digest { if (_reference == null) { - throw new InvalidReferenceException("null content reference"); + throw new InvalidReferenceException("Null content reference"); } if (_isTag) { - throw new InvalidReferenceException("not a digest"); + throw new InvalidReferenceException("Not a digest"); } return _reference; } @@ -101,11 +102,11 @@ public string Tag { if (_reference == null) { - throw new InvalidReferenceException("null content reference"); + throw new InvalidReferenceException("Null content reference"); } if (!_isTag) { - throw new InvalidReferenceException("not a tag"); + throw new InvalidReferenceException("Not a tag"); } return _reference; } @@ -142,7 +143,7 @@ public static Reference Parse(string reference) var parts = reference.Split('/', 2); if (parts.Length == 1) { - throw new InvalidReferenceException("missing repository"); + throw new InvalidReferenceException("Missing repository"); } var registry = parts[0]; var path = parts[1]; @@ -186,6 +187,20 @@ public static Reference Parse(string reference) return new Reference(registry, path); } + public static bool TryParse(string reference, [NotNullWhen(true)] out Reference? parsedReference) + { + try + { + parsedReference = Parse(reference); + return true; + } + catch (InvalidReferenceException) + { + parsedReference = null; + return false; + } + } + public Reference(string registry) => _registry = ValidateRegistry(registry); public Reference(string registry, string? repository) : this(registry) @@ -199,7 +214,7 @@ private static string ValidateRegistry(string registry) var url = "dummy://" + registry; if (!Uri.IsWellFormedUriString(url, UriKind.Absolute) || new Uri(url).Authority != registry) { - throw new InvalidReferenceException("invalid registry"); + throw new InvalidReferenceException("Invalid registry"); } return registry; } @@ -208,7 +223,7 @@ private static string ValidateRepository(string? repository) { if (repository == null || !_repositoryRegex.IsMatch(repository)) { - throw new InvalidReferenceException("invalid respository"); + throw new InvalidReferenceException("Invalid respository"); } return repository; } @@ -219,7 +234,7 @@ private static string ValidateReferenceAsTag(string? reference) { if (reference == null || !_tagRegex.IsMatch(reference)) { - throw new InvalidReferenceException("invalid tag"); + throw new InvalidReferenceException("Invalid tag"); } return reference; } diff --git a/src/OrasProject.Oras/Registry/Remote/Auth/HttpClientWithBasicAuth.cs b/src/OrasProject.Oras/Registry/Remote/Auth/HttpClientWithBasicAuth.cs index 40e7633..ef451d7 100644 --- a/src/OrasProject.Oras/Registry/Remote/Auth/HttpClientWithBasicAuth.cs +++ b/src/OrasProject.Oras/Registry/Remote/Auth/HttpClientWithBasicAuth.cs @@ -16,26 +16,22 @@ using System.Net.Http.Headers; using System.Text; -namespace OrasProject.Oras.Remote.Auth +namespace OrasProject.Oras.Registry.Remote.Auth; + +/// +/// HttpClientWithBasicAuth adds the Basic Auth Scheme to the Authorization Header +/// +public class HttpClientWithBasicAuth : HttpClient { - /// - /// HttpClientWithBasicAuth adds the Basic Auth Scheme to the Authorization Header - /// - public class HttpClientWithBasicAuth : HttpClient - { - public HttpClientWithBasicAuth(string username, string password) - { - this.AddUserAgent(); - DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); - } + public HttpClientWithBasicAuth(string username, string password) => Initialize(username, password); - public HttpClientWithBasicAuth(string username, string password, HttpMessageHandler handler) : base(handler) - { - this.AddUserAgent(); - DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); - } + public HttpClientWithBasicAuth(string username, string password, HttpMessageHandler handler) : base(handler) + => Initialize(username, password); + private void Initialize(string username, string password) + { + this.AddUserAgent(); + DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); } } diff --git a/src/OrasProject.Oras/Registry/Remote/BlobStore.cs b/src/OrasProject.Oras/Registry/Remote/BlobStore.cs index 3824445..96bf805 100644 --- a/src/OrasProject.Oras/Registry/Remote/BlobStore.cs +++ b/src/OrasProject.Oras/Registry/Remote/BlobStore.cs @@ -11,52 +11,92 @@ // See the License for the specific language governing permissions and // limitations under the License. -using OrasProject.Oras.Content; using OrasProject.Oras.Exceptions; using OrasProject.Oras.Oci; -using OrasProject.Oras.Remote; using System; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using System.Web; namespace OrasProject.Oras.Registry.Remote; -internal class BlobStore : IBlobStore +public class BlobStore(Repository repository) : IBlobStore { + public Repository Repository { get; init; } = repository; - public Repository Repository { get; set; } - - public BlobStore(Repository repository) + public async Task FetchAsync(Descriptor target, CancellationToken cancellationToken = default) { - Repository = repository; - + var remoteReference = Repository.ParseReferenceFromDigest(target.Digest); + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryBlob(); + var response = await Repository.Options.HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + try + { + switch (response.StatusCode) + { + case HttpStatusCode.OK: + // server does not support seek as `Range` was ignored. + if (response.Content.Headers.ContentLength is var size && size != -1 && size != target.Size) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: mismatch Content-Length"); + } + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + case HttpStatusCode.NotFound: + throw new NotFoundException($"{target.Digest}: not found"); + default: + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + response.Dispose(); + throw; + } } - - public async Task FetchAsync(Descriptor target, CancellationToken cancellationToken = default) + /// + /// FetchReferenceAsync fetches the blob identified by the reference. + /// The reference must be a digest. + /// + /// + /// + /// + public async Task<(Descriptor Descriptor, Stream Stream)> FetchAsync(string reference, CancellationToken cancellationToken = default) { - var remoteReference = Repository.RemoteReference; - Digest.Validate(target.Digest); - remoteReference.ContentReference = target.Digest; - var url = URLUtiliity.BuildRepositoryBlobURL(Repository.PlainHTTP, remoteReference); - var resp = await Repository.HttpClient.GetAsync(url, cancellationToken); - switch (resp.StatusCode) + var remoteReference = Repository.ParseReference(reference); + var refDigest = remoteReference.Digest; + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryBlob(); + var response = await Repository.Options.HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + try { - case HttpStatusCode.OK: - // server does not support seek as `Range` was ignored. - if (resp.Content.Headers.ContentLength is var size && size != -1 && size != target.Size) - { - throw new Exception($"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: mismatch Content-Length"); - } - return await resp.Content.ReadAsStreamAsync(); - case HttpStatusCode.NotFound: - throw new NotFoundException($"{target.Digest}: not found"); - default: - throw await ErrorUtility.ParseErrorResponse(resp); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + // server does not support seek as `Range` was ignored. + Descriptor desc; + if (response.Content.Headers.ContentLength == -1) + { + desc = await ResolveAsync(refDigest, cancellationToken).ConfigureAwait(false); + } + else + { + desc = response.GenerateBlobDescriptor(refDigest); + } + return (desc, await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)); + case HttpStatusCode.NotFound: + throw new NotFoundException(); + default: + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + response.Dispose(); + throw; } } @@ -70,14 +110,13 @@ public async Task ExistsAsync(Descriptor target, CancellationToken cancell { try { - await ResolveAsync(target.Digest, cancellationToken); + await ResolveAsync(target.Digest, cancellationToken).ConfigureAwait(false); return true; } catch (NotFoundException) { return false; } - } /// @@ -98,64 +137,37 @@ public async Task ExistsAsync(Descriptor target, CancellationToken cancell /// public async Task PushAsync(Descriptor expected, Stream content, CancellationToken cancellationToken = default) { - var url = URLUtiliity.BuildRepositoryBlobUploadURL(Repository.PlainHTTP, Repository.RemoteReference); - using var resp = await Repository.HttpClient.PostAsync(url, null, cancellationToken); - var reqHostname = resp.RequestMessage.RequestUri.Host; - var reqPort = resp.RequestMessage.RequestUri.Port; - if (resp.StatusCode != HttpStatusCode.Accepted) + var url = new UriFactory(Repository.Options).BuildRepositoryBlobUpload(); + using (var response = await Repository.Options.HttpClient.PostAsync(url, null, cancellationToken).ConfigureAwait(false)) { - throw await ErrorUtility.ParseErrorResponse(resp); + if (response.StatusCode != HttpStatusCode.Accepted) + { + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + + var location = response.Headers.Location ?? throw new HttpRequestException("missing location header"); + url = location.IsAbsoluteUri ? location : new Uri(url, location); } - string location; // monolithic upload - if (!resp.Headers.Location.IsAbsoluteUri) - { - location = resp.RequestMessage.RequestUri.Scheme + "://" + resp.RequestMessage.RequestUri.Authority + resp.Headers.Location; - } - else - { - location = resp.Headers.Location.ToString(); - } - // work-around solution for https://github.com/oras-project/oras-go/issues/177 - // For some registries, if the port 443 is explicitly set to the hostname plicitly set to the hostname - // like registry.wabbit-networks.io:443/myrepo, blob push will fail since - // the hostname of the Location header in the response is set to - // registry.wabbit-networks.io instead of registry.wabbit-networks.io:443. - var uri = new UriBuilder(location); - var locationHostname = uri.Host; - var locationPort = uri.Port; - // if location port 443 is missing, add it back - if (reqPort == 443 && locationHostname == reqHostname && locationPort != reqPort) + // add digest key to query string with expected digest value + var req = new HttpRequestMessage(HttpMethod.Put, new UriBuilder(url) { - location = new UriBuilder($"{locationHostname}:{reqPort}").ToString(); - } - - url = location; - - var req = new HttpRequestMessage(HttpMethod.Put, url); + Query = $"digest={HttpUtility.UrlEncode(expected.Digest)}" + }.Uri); req.Content = new StreamContent(content); req.Content.Headers.ContentLength = expected.Size; // the expected media type is ignored as in the API doc. - req.Content.Headers.Add("Content-Type", "application/octet-stream"); - - // add digest key to query string with expected digest value - req.RequestUri = new UriBuilder($"{req.RequestUri}?digest={expected.Digest}").Uri; + req.Content.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); - //reuse credential from previous POST request - resp.Headers.TryGetValues("Authorization", out var auth); - if (auth != null) + using (var response = await Repository.Options.HttpClient.SendAsync(req, cancellationToken).ConfigureAwait(false)) { - req.Headers.Add("Authorization", auth.FirstOrDefault()); + if (response.StatusCode != HttpStatusCode.Created) + { + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } } - using var resp2 = await Repository.HttpClient.SendAsync(req, cancellationToken); - if (resp2.StatusCode != HttpStatusCode.Created) - { - throw await ErrorUtility.ParseErrorResponse(resp2); - } - - return; } /// @@ -168,14 +180,14 @@ public async Task ResolveAsync(string reference, CancellationToken c { var remoteReference = Repository.ParseReference(reference); var refDigest = remoteReference.Digest; - var url = URLUtiliity.BuildRepositoryBlobURL(Repository.PlainHTTP, remoteReference); + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryBlob(); var requestMessage = new HttpRequestMessage(HttpMethod.Head, url); - using var resp = await Repository.HttpClient.SendAsync(requestMessage, cancellationToken); + using var resp = await Repository.Options.HttpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); return resp.StatusCode switch { - HttpStatusCode.OK => Repository.GenerateBlobDescriptor(resp, refDigest), + HttpStatusCode.OK => resp.GenerateBlobDescriptor(refDigest), HttpStatusCode.NotFound => throw new NotFoundException($"{remoteReference.ContentReference}: not found"), - _ => throw await ErrorUtility.ParseErrorResponse(resp) + _ => throw await resp.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false) }; } @@ -186,42 +198,5 @@ public async Task ResolveAsync(string reference, CancellationToken c /// /// public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default) - { - await Repository.DeleteAsync(target, false, cancellationToken); - } - - /// - /// FetchReferenceAsync fetches the blob identified by the reference. - /// The reference must be a digest. - /// - /// - /// - /// - public async Task<(Descriptor Descriptor, Stream Stream)> FetchAsync(string reference, CancellationToken cancellationToken = default) - { - var remoteReference = Repository.ParseReference(reference); - var refDigest = remoteReference.Digest; - var url = URLUtiliity.BuildRepositoryBlobURL(Repository.PlainHTTP, remoteReference); - var resp = await Repository.HttpClient.GetAsync(url, cancellationToken); - switch (resp.StatusCode) - { - case HttpStatusCode.OK: - // server does not support seek as `Range` was ignored. - Descriptor desc; - if (resp.Content.Headers.ContentLength == -1) - { - desc = await ResolveAsync(refDigest, cancellationToken); - } - else - { - desc = Repository.GenerateBlobDescriptor(resp, refDigest); - } - - return (desc, await resp.Content.ReadAsStreamAsync()); - case HttpStatusCode.NotFound: - throw new NotFoundException(); - default: - throw await ErrorUtility.ParseErrorResponse(resp); - } - } + => await Repository.DeleteAsync(target, false, cancellationToken).ConfigureAwait(false); } diff --git a/src/OrasProject.Oras/Registry/Remote/ErrorUtility.cs b/src/OrasProject.Oras/Registry/Remote/ErrorUtility.cs deleted file mode 100644 index b2c3de5..0000000 --- a/src/OrasProject.Oras/Registry/Remote/ErrorUtility.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright The ORAS Authors. -// 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. - -using System; -using System.Net.Http; -using System.Threading.Tasks; - -namespace OrasProject.Oras.Remote -{ - internal class ErrorUtility - { - /// - /// ParseErrorResponse parses the error returned by the remote registry. - /// - /// - /// - internal static async Task ParseErrorResponse(HttpResponseMessage response) - { - var body = await response.Content.ReadAsStringAsync(); - return new Exception(new - { - response.RequestMessage.Method, - URL = response.RequestMessage.RequestUri, - response.StatusCode, - Errors = body - }.ToString()); - } - } -} diff --git a/src/OrasProject.Oras/Registry/Remote/HttpClientExtensions.cs b/src/OrasProject.Oras/Registry/Remote/HttpClientExtensions.cs index 6ba4295..8a92617 100644 --- a/src/OrasProject.Oras/Registry/Remote/HttpClientExtensions.cs +++ b/src/OrasProject.Oras/Registry/Remote/HttpClientExtensions.cs @@ -13,13 +13,15 @@ using System.Net.Http; -namespace OrasProject.Oras.Remote +namespace OrasProject.Oras.Registry.Remote; + +internal static class HttpClientExtensions { - internal static class HttpClientExtensions + private const string _userAgent = "oras-dotnet"; + + public static HttpClient AddUserAgent(this HttpClient client) { - public static void AddUserAgent(this HttpClient client) - { - client.DefaultRequestHeaders.Add("User-Agent", new string[] { "oras-dotnet" }); - } + client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent); + return client; } } diff --git a/src/OrasProject.Oras/Registry/Remote/HttpResponseMessageExtensions.cs b/src/OrasProject.Oras/Registry/Remote/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..a7271c9 --- /dev/null +++ b/src/OrasProject.Oras/Registry/Remote/HttpResponseMessageExtensions.cs @@ -0,0 +1,244 @@ +// Copyright The ORAS Authors. +// 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. + +using OrasProject.Oras.Content; +using OrasProject.Oras.Oci; +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; + +namespace OrasProject.Oras.Registry.Remote; + +internal static class HttpResponseMessageExtensions +{ + /// + /// Parses the error returned by the remote registry. + /// + /// + /// + public static async Task ParseErrorResponseAsync(this HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return new Exception(new + { + response.RequestMessage!.Method, + URL = response.RequestMessage.RequestUri, + response.StatusCode, + Errors = body + }.ToString()); + } + + /// + /// Returns the URL of the response's "Link" header, if present. + /// + /// next link or null if not present + public static Uri? ParseLink(this HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Link", out var values)) + { + return null; + } + + var link = values.FirstOrDefault(); + if (string.IsNullOrEmpty(link) || link[0] != '<') + { + throw new Exception($"invalid next link {link}: missing '<"); + } + if (link.IndexOf('>') is var index && index == -1) + { + throw new Exception($"invalid next link {link}: missing '>'"); + } + link = link[1..index]; + if (!Uri.IsWellFormedUriString(link, UriKind.RelativeOrAbsolute)) + { + throw new Exception($"invalid next link {link}"); + } + + return new Uri(response.RequestMessage!.RequestUri!, link); + } + + /// + /// VerifyContentDigest verifies "Docker-Content-Digest" header if present. + /// OCI distribution-spec states the Docker-Content-Digest header is optional. + /// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#legacy-docker-support-http-headers + /// + /// + /// + /// + public static void VerifyContentDigest(this HttpResponseMessage response, string expected) + { + if (!response.Content.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)) + { + return; + } + var digestStr = digestValues.FirstOrDefault(); + if (string.IsNullOrEmpty(digestStr)) + { + return; + } + + string contentDigest; + try + { + contentDigest = Digest.Validate(digestStr); + } + catch (Exception) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response header: `Docker-Content-Digest: {digestStr}`"); + } + if (contentDigest != expected) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response; digest mismatch in Docker-Content-Digest: received {contentDigest} when expecting {digestStr}"); + } + } + + /// + /// Returns a descriptor generated from the response. + /// + /// + /// + /// + /// + public static Descriptor GenerateBlobDescriptor(this HttpResponseMessage response, string expectedDigest) + { + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (string.IsNullOrEmpty(mediaType)) + { + mediaType = MediaTypeNames.Application.Octet; + } + var size = response.Content.Headers.ContentLength ?? -1; + if (size == -1) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: unknown response Content-Length"); + } + response.VerifyContentDigest(expectedDigest); + return new Descriptor + { + MediaType = mediaType, + Digest = expectedDigest, + Size = size + }; + } + + /// + /// Returns a descriptor generated from the response. + /// + /// + /// + /// + /// + /// + public static async Task GenerateDescriptorAsync(this HttpResponseMessage response, Reference reference, CancellationToken cancellationToken) + { + // 1. Validate Content-Type + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (!MediaTypeHeaderValue.TryParse(mediaType, out _)) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response `Content-Type` header"); + } + + // 2. Validate Size + var size = response.Content.Headers.ContentLength ?? -1; + if (size == -1) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: unknown response Content-Length"); + } + + // 3. Validate Client Reference + string? refDigest = null; + try + { + refDigest = reference.Digest; + } + catch { } + + // 4. Validate Server Digest (if present) + string? serverDigest = null; + if (response.Content.Headers.TryGetValues("Docker-Content-Digest", out var serverHeaderDigest)) + { + serverDigest = serverHeaderDigest.FirstOrDefault(); + if (!string.IsNullOrEmpty(serverDigest)) + { + try + { + response.VerifyContentDigest(serverDigest); + } + catch + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response header value: `Docker-Content-Digest: {serverHeaderDigest}`"); + } + } + } + + // 5. Now, look for specific error conditions; + string contentDigest; + if (string.IsNullOrEmpty(serverDigest)) + { + if (response.RequestMessage!.Method == HttpMethod.Head) + { + if (string.IsNullOrEmpty(refDigest)) + { + // HEAD without server `Docker-Content-Digest` + // immediate fail + throw new Exception($"{response.RequestMessage.Method} {response.RequestMessage.RequestUri}: missing required header {serverHeaderDigest}"); + } + // Otherwise, just trust the client-supplied digest + contentDigest = refDigest; + } + else + { + // GET without server `Docker-Content-Digest header forces the + // expensive calculation + try + { + contentDigest = await response.CalculateDigestFromResponse(cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + throw new Exception($"failed to calculate digest on response body; {e.Message}"); + } + } + } + else + { + contentDigest = serverDigest; + } + if (!string.IsNullOrEmpty(refDigest) && refDigest != contentDigest) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response; digest mismatch in {serverHeaderDigest}: received {contentDigest} when expecting {refDigest}"); + } + + // 6. Finally, if we made it this far, then all is good; return the descriptor + return new Descriptor + { + MediaType = mediaType, + Digest = contentDigest, + Size = size + }; + } + + /// + /// CalculateDigestFromResponse calculates the actual digest of the response body + /// taking care not to destroy it in the process + /// + /// + private static async Task CalculateDigestFromResponse(this HttpResponseMessage response, CancellationToken cancellationToken) + { + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + return Digest.ComputeSHA256(bytes); + } +} diff --git a/src/OrasProject.Oras/Registry/Remote/LinkUtility.cs b/src/OrasProject.Oras/Registry/Remote/LinkUtility.cs deleted file mode 100644 index a9bc555..0000000 --- a/src/OrasProject.Oras/Registry/Remote/LinkUtility.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright The ORAS Authors. -// 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. - -using System; -using System.Linq; -using System.Net.Http; - -namespace OrasProject.Oras.Remote -{ - internal class LinkUtility - { - /// - /// ParseLink returns the URL of the response's "Link" header, if present. - /// - /// - /// - internal static string ParseLink(HttpResponseMessage resp) - { - string link; - if (resp.Headers.TryGetValues("Link", out var values)) - { - link = values.FirstOrDefault(); - } - else - { - throw new NoLinkHeaderException(); - } - - if (link[0] != '<') - { - throw new Exception($"invalid next link {link}: missing '<"); - } - if (link.IndexOf('>') is var index && index == -1) - { - throw new Exception($"invalid next link {link}: missing '>'"); - } - else - { - link = link[1..index]; - } - - if (!Uri.IsWellFormedUriString(link, UriKind.RelativeOrAbsolute)) - { - throw new Exception($"invalid next link {link}"); - } - - var scheme = resp.RequestMessage.RequestUri.Scheme; - var authority = resp.RequestMessage.RequestUri.Authority; - Uri baseUri = new Uri(scheme + "://" + authority); - Uri resolvedUri = new Uri(baseUri, link); - - return resolvedUri.AbsoluteUri; - } - - - /// - /// NoLinkHeaderException is thrown when a link header is missing. - /// - internal class NoLinkHeaderException : Exception - { - public NoLinkHeaderException() - { - } - - public NoLinkHeaderException(string message) - : base(message) - { - } - - public NoLinkHeaderException(string message, Exception inner) - : base(message, inner) - { - } - } - } -} diff --git a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs index 7170d50..a47e331 100644 --- a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs +++ b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs @@ -11,33 +11,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -using OrasProject.Oras.Content; using OrasProject.Oras.Exceptions; using OrasProject.Oras.Oci; -using OrasProject.Oras.Remote; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; namespace OrasProject.Oras.Registry.Remote; -public class ManifestStore : IManifestStore +public class ManifestStore(Repository repository) : IManifestStore { - public Repository Repository { get; set; } - - public ManifestStore(Repository repository) - { - Repository = repository; - } + public Repository Repository { get; init; } = repository; /// - /// FetchASync fetches the content identified by the descriptor. + /// Fetches the content identified by the descriptor. /// /// /// @@ -46,39 +36,84 @@ public ManifestStore(Repository repository) /// public async Task FetchAsync(Descriptor target, CancellationToken cancellationToken = default) { - var remoteReference = Repository.RemoteReference; - remoteReference.ContentReference = target.Digest; - var url = URLUtiliity.BuildRepositoryManifestURL(Repository.PlainHTTP, remoteReference); - var req = new HttpRequestMessage(HttpMethod.Get, url); - req.Headers.Add("Accept", target.MediaType); - var resp = await Repository.HttpClient.SendAsync(req, cancellationToken); - - switch (resp.StatusCode) + var remoteReference = Repository.ParseReferenceFromDigest(target.Digest); + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryManifest(); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.ParseAdd(target.MediaType); + var response = await Repository.Options.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + try { - case HttpStatusCode.OK: - break; - case HttpStatusCode.NotFound: - throw new NotFoundException($"digest {target.Digest} not found"); - default: - throw await ErrorUtility.ParseErrorResponse(resp); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + break; + case HttpStatusCode.NotFound: + throw new NotFoundException($"digest {target.Digest} not found"); + default: + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (mediaType != target.MediaType) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: mismatch response Content-Type {mediaType}: expect {target.MediaType}"); + } + if (response.Content.Headers.ContentLength is var size && size != -1 && size != target.Size) + { + throw new Exception($"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: mismatch Content-Length"); + } + response.VerifyContentDigest(target.Digest); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } - var mediaType = resp.Content.Headers?.ContentType.MediaType; - if (mediaType != target.MediaType) + catch { - throw new Exception( - $"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: mismatch response Content-Type {mediaType}: expect {target.MediaType}"); + response.Dispose(); + throw; } - if (resp.Content.Headers.ContentLength is var size && size != -1 && size != target.Size) + } + + /// + /// Fetches the manifest identified by the reference. + /// + /// + /// + /// + public async Task<(Descriptor Descriptor, Stream Stream)> FetchAsync(string reference, CancellationToken cancellationToken = default) + { + var remoteReference = Repository.ParseReference(reference); + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryManifest(); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.ParseAdd(Repository.ManifestAcceptHeader()); + var response = await Repository.Options.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + try { - throw new Exception( - $"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: mismatch Content-Length"); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + Descriptor desc; + if (response.Content.Headers.ContentLength == -1) + { + desc = await ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + } + else + { + desc = await response.GenerateDescriptorAsync(remoteReference, cancellationToken).ConfigureAwait(false); + } + return (desc, await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)); + case HttpStatusCode.NotFound: + throw new NotFoundException($"{request.Method} {request.RequestUri}: manifest unknown"); + default: + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + response.Dispose(); + throw; } - Repository.VerifyContentDigest(resp, target.Digest); - return await resp.Content.ReadAsStreamAsync(); } /// - /// ExistsAsync returns true if the described content exists. + /// Returns true if the described content exists. /// /// /// @@ -87,258 +122,98 @@ public async Task ExistsAsync(Descriptor target, CancellationToken cancell { try { - await ResolveAsync(target.Digest, cancellationToken); + await ResolveAsync(target.Digest, cancellationToken).ConfigureAwait(false); return true; } catch (NotFoundException) { return false; } - } /// - /// PushAsync pushes the content, matching the expected descriptor. + /// Pushes the content, matching the expected descriptor. /// /// /// /// /// public async Task PushAsync(Descriptor expected, Stream content, CancellationToken cancellationToken = default) - { - await InternalPushAsync(expected, content, expected.Digest, cancellationToken); - } - + => await InternalPushAsync(expected, content, expected.Digest, cancellationToken).ConfigureAwait(false); /// - /// PushAsync pushes the manifest content, matching the expected descriptor. + /// PushReferenceASync pushes the manifest with a reference tag. /// /// - /// + /// /// /// - private async Task InternalPushAsync(Descriptor expected, Stream stream, string reference, CancellationToken cancellationToken) - { - var remoteReference = Repository.RemoteReference; - remoteReference.ContentReference = reference; - var url = URLUtiliity.BuildRepositoryManifestURL(Repository.PlainHTTP, remoteReference); - var req = new HttpRequestMessage(HttpMethod.Put, url); - req.Content = new StreamContent(stream); - req.Content.Headers.ContentLength = expected.Size; - req.Content.Headers.Add("Content-Type", expected.MediaType); - var client = Repository.HttpClient; - using var resp = await client.SendAsync(req, cancellationToken); - if (resp.StatusCode != HttpStatusCode.Created) - { - throw await ErrorUtility.ParseErrorResponse(resp); - } - Repository.VerifyContentDigest(resp, expected.Digest); - } - - public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) - { - var remoteReference = Repository.ParseReference(reference); - var url = URLUtiliity.BuildRepositoryManifestURL(Repository.PlainHTTP, remoteReference); - var req = new HttpRequestMessage(HttpMethod.Head, url); - req.Headers.Add("Accept", ManifestUtility.ManifestAcceptHeader(Repository.ManifestMediaTypes)); - using var res = await Repository.HttpClient.SendAsync(req, cancellationToken); - - return res.StatusCode switch - { - HttpStatusCode.OK => await GenerateDescriptorAsync(res, remoteReference, req.Method), - HttpStatusCode.NotFound => throw new NotFoundException($"reference {reference} not found"), - _ => throw await ErrorUtility.ParseErrorResponse(res) - }; - } - - /// - /// GenerateDescriptor returns a descriptor generated from the response. - /// - /// - /// - /// /// - /// - public async Task GenerateDescriptorAsync(HttpResponseMessage res, Reference reference, HttpMethod httpMethod) - { - string mediaType; - try - { - // 1. Validate Content-Type - mediaType = res.Content.Headers.ContentType.MediaType; - MediaTypeHeaderValue.Parse(mediaType); - } - catch (Exception e) - { - throw new Exception($"{res.RequestMessage.Method} {res.RequestMessage.RequestUri}: invalid response `Content-Type` header; {e.Message}"); - } - - // 2. Validate Size - if (!res.Content.Headers.ContentLength.HasValue || res.Content.Headers.ContentLength == -1) - { - throw new Exception($"{res.RequestMessage.Method} {res.RequestMessage.RequestUri}: unknown response Content-Length"); - } - - // 3. Validate Client Reference - string refDigest = string.Empty; - try - { - refDigest = reference.Digest; - } - catch (Exception) - { - } - - - // 4. Validate Server Digest (if present) - res.Content.Headers.TryGetValues("Docker-Content-Digest", out IEnumerable serverHeaderDigest); - var serverDigest = serverHeaderDigest?.First(); - if (!string.IsNullOrEmpty(serverDigest)) - { - try - { - Repository.VerifyContentDigest(res, serverDigest); - } - catch (Exception) - { - throw new Exception($"{res.RequestMessage.Method} {res.RequestMessage.RequestUri}: invalid response header value: `Docker-Content-Digest: {serverHeaderDigest}`"); - } - } - - // 5. Now, look for specific error conditions; - string contentDigest; - - if (string.IsNullOrEmpty(serverDigest)) - { - if (httpMethod == HttpMethod.Head) - { - if (string.IsNullOrEmpty(refDigest)) - { - // HEAD without server `Docker-Content-Digest` - // immediate fail - throw new Exception($"{res.RequestMessage.Method} {res.RequestMessage.RequestUri}: HTTP {httpMethod} request missing required header {serverHeaderDigest}"); - } - // Otherwise, just trust the client-supplied digest - contentDigest = refDigest; - } - else - { - // GET without server `Docker-Content-Digest header forces the - // expensive calculation - string calculatedDigest; - try - { - calculatedDigest = await CalculateDigestFromResponse(res); - } - catch (Exception e) - { - throw new Exception($"failed to calculate digest on response body; {e.Message}"); - } - contentDigest = calculatedDigest; - } - } - else - { - contentDigest = serverDigest; - } - if (!string.IsNullOrEmpty(refDigest) && refDigest != contentDigest) - { - throw new Exception($"{res.RequestMessage.Method} {res.RequestMessage.RequestUri}: invalid response; digest mismatch in {serverHeaderDigest}: received {contentDigest} when expecting {refDigest}"); - } - - // 6. Finally, if we made it this far, then all is good; return the descriptor - return new Descriptor - { - MediaType = mediaType, - Digest = contentDigest, - Size = res.Content.Headers.ContentLength.Value - }; - } - - /// - /// CalculateDigestFromResponse calculates the actual digest of the response body - /// taking care not to destroy it in the process - /// - /// - static async Task CalculateDigestFromResponse(HttpResponseMessage res) + public async Task PushAsync(Descriptor expected, Stream content, string reference, CancellationToken cancellationToken = default) { - var bytes = await res.Content.ReadAsByteArrayAsync(); - return Digest.ComputeSHA256(bytes); + var contentReference = Repository.ParseReference(reference).ContentReference!; + await InternalPushAsync(expected, content, contentReference, cancellationToken).ConfigureAwait(false); } /// - /// DeleteAsync removes the manifest content identified by the descriptor. + /// Pushes the manifest content, matching the expected descriptor. /// - /// + /// + /// + /// /// - /// - public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default) + private async Task InternalPushAsync(Descriptor expected, Stream stream, string contentReference, CancellationToken cancellationToken) { - await Repository.DeleteAsync(target, true, cancellationToken); + var remoteReference = Repository.ParseReference(contentReference); + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryManifest(); + var request = new HttpRequestMessage(HttpMethod.Put, url); + request.Content = new StreamContent(stream); + request.Content.Headers.ContentLength = expected.Size; + request.Content.Headers.Add("Content-Type", expected.MediaType); + var client = Repository.Options.HttpClient; + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Created) + { + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); + } + response.VerifyContentDigest(expected.Digest); } - - /// - /// FetchReferenceAsync fetches the manifest identified by the reference. - /// - /// - /// - /// - public async Task<(Descriptor Descriptor, Stream Stream)> FetchAsync(string reference, CancellationToken cancellationToken = default) + public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) { var remoteReference = Repository.ParseReference(reference); - var url = URLUtiliity.BuildRepositoryManifestURL(Repository.PlainHTTP, remoteReference); - var req = new HttpRequestMessage(HttpMethod.Get, url); - req.Headers.Add("Accept", ManifestUtility.ManifestAcceptHeader(Repository.ManifestMediaTypes)); - var resp = await Repository.HttpClient.SendAsync(req, cancellationToken); - switch (resp.StatusCode) + var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryManifest(); + var request = new HttpRequestMessage(HttpMethod.Head, url); + request.Headers.Accept.ParseAdd(Repository.ManifestAcceptHeader()); + using var response = await Repository.Options.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.StatusCode switch { - case HttpStatusCode.OK: - Descriptor desc; - if (resp.Content.Headers.ContentLength == -1) - { - desc = await ResolveAsync(reference, cancellationToken); - } - else - { - desc = await GenerateDescriptorAsync(resp, remoteReference, HttpMethod.Get); - } - - return (desc, await resp.Content.ReadAsStreamAsync()); - case HttpStatusCode.NotFound: - throw new NotFoundException($"{req.Method} {req.RequestUri}: manifest unknown"); - default: - throw await ErrorUtility.ParseErrorResponse(resp); - - } + HttpStatusCode.OK => await response.GenerateDescriptorAsync(remoteReference, cancellationToken).ConfigureAwait(false), + HttpStatusCode.NotFound => throw new NotFoundException($"reference {reference} not found"), + _ => throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false) + }; } /// - /// PushReferenceASync pushes the manifest with a reference tag. + /// Tags a manifest descriptor with a reference string. /// - /// - /// + /// /// /// /// - public async Task PushAsync(Descriptor expected, Stream content, string reference, - CancellationToken cancellationToken = default) + public async Task TagAsync(Descriptor descriptor, string reference, CancellationToken cancellationToken = default) { var remoteReference = Repository.ParseReference(reference); - await InternalPushAsync(expected, content, remoteReference.ContentReference, cancellationToken); + using var contentStream = await FetchAsync(descriptor, cancellationToken).ConfigureAwait(false); + await InternalPushAsync(descriptor, contentStream, remoteReference.ContentReference!, cancellationToken).ConfigureAwait(false); } /// - /// TagAsync tags a manifest descriptor with a reference string. + /// Removes the manifest content identified by the descriptor. /// - /// - /// + /// /// /// - public async Task TagAsync(Descriptor descriptor, string reference, CancellationToken cancellationToken = default) - { - var remoteReference = Repository.ParseReference(reference); - var rc = await FetchAsync(descriptor, cancellationToken); - await InternalPushAsync(descriptor, rc, remoteReference.ContentReference, cancellationToken); - } + public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default) + => await Repository.DeleteAsync(target, true, cancellationToken).ConfigureAwait(false); } diff --git a/src/OrasProject.Oras/Registry/Remote/ManifestUtility.cs b/src/OrasProject.Oras/Registry/Remote/ManifestUtility.cs deleted file mode 100644 index 154faa2..0000000 --- a/src/OrasProject.Oras/Registry/Remote/ManifestUtility.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright The ORAS Authors. -// 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. - -using OrasProject.Oras.Oci; -using System.Linq; - -namespace OrasProject.Oras.Remote -{ - internal static class ManifestUtility - { - internal static string[] DefaultManifestMediaTypes = new[] - { - Docker.MediaType.Manifest, - Docker.MediaType.ManifestList, - Oci.MediaType.ImageIndex, - Oci.MediaType.ImageManifest - }; - - /// - /// isManifest determines if the given descriptor is a manifest. - /// - /// - /// - /// - internal static bool IsManifest(string[] manifestMediaTypes, Descriptor desc) - { - if (manifestMediaTypes == null || manifestMediaTypes.Length == 0) - { - manifestMediaTypes = DefaultManifestMediaTypes; - } - - if (manifestMediaTypes.Any((mediaType) => mediaType == desc.MediaType)) - { - return true; - } - - return false; - } - - /// - /// ManifestAcceptHeader returns the accept header for the given manifest media types. - /// - /// - /// - internal static string ManifestAcceptHeader(string[] manifestMediaTypes) - { - if (manifestMediaTypes == null || manifestMediaTypes.Length == 0) - { - manifestMediaTypes = DefaultManifestMediaTypes; - } - - return string.Join(',', manifestMediaTypes); - } - } -} diff --git a/src/OrasProject.Oras/Registry/Remote/Registry.cs b/src/OrasProject.Oras/Registry/Remote/Registry.cs index 297795c..4a2a76a 100644 --- a/src/OrasProject.Oras/Registry/Remote/Registry.cs +++ b/src/OrasProject.Oras/Registry/Remote/Registry.cs @@ -20,139 +20,125 @@ using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using static System.Web.HttpUtility; +using System.Web; -namespace OrasProject.Oras.Remote +namespace OrasProject.Oras.Remote; + +public class Registry : IRegistry { - public class Registry : IRegistry, IRepositoryOption - { + public RepositoryOptions RepositoryOptions => _opts; - public HttpClient HttpClient { get; set; } - public Reference RemoteReference { get; set; } - public bool PlainHTTP { get; set; } - public string[] ManifestMediaTypes { get; set; } - public int TagListPageSize { get; set; } + private RepositoryOptions _opts; - public Registry(string name) - { - RemoteReference = new Reference(name); - HttpClient = new HttpClient(); - HttpClient.AddUserAgent(); - } + public Registry(string registry) : this(registry, new HttpClient().AddUserAgent()) { } - public Registry(string name, HttpClient httpClient) - { - RemoteReference = new Reference(name); - HttpClient = httpClient; - } + public Registry(string registry, HttpClient httpClient) => _opts = new() + { + Reference = new Reference(registry), + HttpClient = httpClient, + }; - /// - /// PingAsync checks whether or not the registry implement Docker Registry API V2 or - /// OCI Distribution Specification. - /// Ping can be used to check authentication when an auth client is configured. - /// References: - /// - https://docs.docker.com/registry/spec/api/#base - /// - https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#api - /// - /// - /// - public async Task PingAsync(CancellationToken cancellationToken) - { - var url = URLUtiliity.BuildRegistryBaseURL(PlainHTTP, RemoteReference); - using var resp = await HttpClient.GetAsync(url, cancellationToken); - switch (resp.StatusCode) - { - case HttpStatusCode.OK: - return; - case HttpStatusCode.NotFound: - throw new NotFoundException($"Repository {RemoteReference} not found"); - default: - throw await ErrorUtility.ParseErrorResponse(resp); - } - } + public Registry(RepositoryOptions options) => _opts = options; - /// - /// Repository returns a repository object for the given repository name. - /// - /// - /// - /// - public Task GetRepository(string name, CancellationToken cancellationToken) + /// + /// PingAsync checks whether or not the registry implement Docker Registry API V2 or + /// OCI Distribution Specification. + /// Ping can be used to check authentication when an auth client is configured. + /// References: + /// - https://docs.docker.com/registry/spec/api/#base + /// - https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#api + /// + /// + /// + public async Task PingAsync(CancellationToken cancellationToken = default) + { + var url = new UriFactory(_opts).BuildRegistryBase(); + using var resp = await _opts.HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + switch (resp.StatusCode) { - var reference = new Reference(RemoteReference.Registry, name); - - return Task.FromResult(new Repository(reference, this)); + case HttpStatusCode.OK: + return; + case HttpStatusCode.NotFound: + throw new NotFoundException($"Repository {_opts.Reference} not found"); + default: + throw await resp.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); } + } + /// + /// Repository returns a repository object for the given repository name. + /// + /// + /// + /// + public Task GetRepositoryAsync(string name, CancellationToken cancellationToken) + { + var reference = new Reference(_opts.Reference.Registry, name); + var options = _opts; // shallow copy + options.Reference = reference; + return Task.FromResult(new Repository(options)); + } - /// - /// Repositories returns a list of repositories from the remote registry. - /// - /// - /// - /// - public async IAsyncEnumerable ListRepositoriesAsync(string? last = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + /// + /// Repositories returns a list of repositories from the remote registry. + /// + /// + /// + /// + public async IAsyncEnumerable ListRepositoriesAsync(string? last = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var url = new UriFactory(_opts).BuildRegistryCatalog(); + do { - var url = URLUtiliity.BuildRegistryCatalogURL(PlainHTTP, RemoteReference); - var done = false; - while (!done) + (var repositories, url) = await FetchRepositoryPageAsync(last, url!, cancellationToken).ConfigureAwait(false); + last = null; + foreach (var repository in repositories) { - IEnumerable repositories = Array.Empty(); - try - { - url = await RepositoryPageAsync(last, values => repositories = values, url, cancellationToken); - last = ""; - } - catch (LinkUtility.NoLinkHeaderException) - { - done = true; - } - foreach (var repository in repositories) - { - yield return repository; - } + yield return repository; } - } + } while (url != null); + } - /// - /// RepositoryPageAsync returns a returns a single page of repositories list with the next link - /// - /// - /// - /// - /// - /// - private async Task RepositoryPageAsync(string? last, Action fn, string url, CancellationToken cancellationToken) + /// + /// Returns a returns a single page of repositories list with the next link + /// + /// + /// + /// + /// + private async Task<(string[], Uri?)> FetchRepositoryPageAsync(string? last, Uri url, CancellationToken cancellationToken) + { + var uriBuilder = new UriBuilder(url); + if (_opts.TagListPageSize > 0 || !string.IsNullOrEmpty(last)) { - var uriBuilder = new UriBuilder(url); - var query = ParseQueryString(uriBuilder.Query); - if (TagListPageSize > 0 || !string.IsNullOrEmpty(last)) + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + if (_opts.TagListPageSize > 0) { - if (TagListPageSize > 0) - { - query["n"] = TagListPageSize.ToString(); - - - } - if (!string.IsNullOrEmpty(last)) - { - query["last"] = last; - } + query["n"] = _opts.TagListPageSize.ToString(); } - - uriBuilder.Query = query.ToString(); - using var response = await HttpClient.GetAsync(uriBuilder.ToString(), cancellationToken); - if (response.StatusCode != HttpStatusCode.OK) + if (!string.IsNullOrEmpty(last)) { - throw await ErrorUtility.ParseErrorResponse(response); - + query["last"] = last; } - var data = await response.Content.ReadAsStringAsync(); - var repositories = JsonSerializer.Deserialize(data); - fn(repositories.Repositories); - return LinkUtility.ParseLink(response); + uriBuilder.Query = query.ToString(); + } + + using var response = await _opts.HttpClient.GetAsync(uriBuilder.ToString(), cancellationToken).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + { + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); } + var data = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var repositories = JsonSerializer.Deserialize(data); + return (repositories.Repositories, response.ParseLink()); + } + + internal struct RepositoryList + { + [JsonPropertyName("repositories")] + public string[] Repositories { get; set; } } } diff --git a/src/OrasProject.Oras/Registry/Remote/Repository.cs b/src/OrasProject.Oras/Registry/Remote/Repository.cs index adab7dc..fd2a4f3 100644 --- a/src/OrasProject.Oras/Registry/Remote/Repository.cs +++ b/src/OrasProject.Oras/Registry/Remote/Repository.cs @@ -11,10 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -using OrasProject.Oras.Content; using OrasProject.Oras.Exceptions; using OrasProject.Oras.Oci; -using OrasProject.Oras.Remote; using System; using System.Collections.Generic; using System.IO; @@ -23,104 +21,70 @@ using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using static System.Web.HttpUtility; +using System.Web; namespace OrasProject.Oras.Registry.Remote; /// /// Repository is an HTTP client to a remote repository /// -public class Repository : IRepository, IRepositoryOption +public class Repository : IRepository { /// - /// HttpClient is the underlying HTTP client used to access the remote registry. + /// Blobs provides access to the blob CAS only, which contains + /// layers, and other generic blobs. /// - public HttpClient HttpClient { get; set; } + public IBlobStore Blobs => new BlobStore(this); /// - /// ReferenceObj references the remote repository. + /// Manifests provides access to the manifest CAS only. /// - public Reference RemoteReference { get; set; } + /// + public IManifestStore Manifests => new ManifestStore(this); - /// - /// PlainHTTP signals the transport to access the remote repository via HTTP - /// instead of HTTPS. - /// - public bool PlainHTTP { get; set; } + public RepositoryOptions Options => _opts; + internal static readonly string[] DefaultManifestMediaTypes = + [ + Docker.MediaType.Manifest, + Docker.MediaType.ManifestList, + MediaType.ImageIndex, + MediaType.ImageManifest + ]; - /// - /// ManifestMediaTypes is used in `Accept` header for resolving manifests - /// from references. It is also used in identifying manifests and blobs from - /// descriptors. If an empty list is present, default manifest media types - /// are used. - /// - public string[] ManifestMediaTypes { get; set; } - - /// - /// TagListPageSize specifies the page size when invoking the tag list API. - /// If zero, the page size is determined by the remote registry. - /// Reference: https://docs.docker.com/registry/spec/api/#tags - /// - public int TagListPageSize { get; set; } + private RepositoryOptions _opts; /// /// Creates a client to the remote repository identified by a reference /// Example: localhost:5000/hello-world /// /// - public Repository(string reference) - { - RemoteReference = Reference.Parse(reference); - HttpClient = new HttpClient(); - HttpClient.DefaultRequestHeaders.Add("User-Agent", new string[] { "oras-dotnet" }); - } + public Repository(string reference) : this(reference, new HttpClient().AddUserAgent()) { } /// /// Creates a client to the remote repository using a reference and a HttpClient /// /// /// - public Repository(string reference, HttpClient httpClient) + public Repository(string reference, HttpClient httpClient) : this(new RepositoryOptions() { - RemoteReference = Reference.Parse(reference); - HttpClient = httpClient; - } + Reference = Reference.Parse(reference), + HttpClient = httpClient, + }) + { } - /// - /// This constructor customizes the HttpClient and sets the properties - /// using values from the parameter. - /// - /// - /// - internal Repository(Reference reference, IRepositoryOption option) - { - HttpClient = option.HttpClient; - RemoteReference = reference; - ManifestMediaTypes = option.ManifestMediaTypes; - PlainHTTP = option.PlainHTTP; - TagListPageSize = option.TagListPageSize; - } - - /// - /// BlobStore detects the blob store for the given descriptor. - /// - /// - /// - private IBlobStore BlobStore(Descriptor desc) + public Repository(RepositoryOptions options) { - if (ManifestUtility.IsManifest(ManifestMediaTypes, desc)) + if (string.IsNullOrEmpty(options.Reference.Repository)) { - return Manifests; + throw new InvalidReferenceException("Missing repository"); } - - return Blobs; + _opts = options; } - - /// /// FetchAsync fetches the content identified by the descriptor. /// @@ -128,9 +92,7 @@ private IBlobStore BlobStore(Descriptor desc) /// /// public async Task FetchAsync(Descriptor target, CancellationToken cancellationToken = default) - { - return await BlobStore(target).FetchAsync(target, cancellationToken); - } + => await BlobStore(target).FetchAsync(target, cancellationToken).ConfigureAwait(false); /// /// ExistsAsync returns true if the described content exists. @@ -139,9 +101,7 @@ public async Task FetchAsync(Descriptor target, CancellationToken cancel /// /// public async Task ExistsAsync(Descriptor target, CancellationToken cancellationToken = default) - { - return await BlobStore(target).ExistsAsync(target, cancellationToken); - } + => await BlobStore(target).ExistsAsync(target, cancellationToken).ConfigureAwait(false); /// /// PushAsync pushes the content, matching the expected descriptor. @@ -151,9 +111,7 @@ public async Task ExistsAsync(Descriptor target, CancellationToken cancell /// /// public async Task PushAsync(Descriptor expected, Stream content, CancellationToken cancellationToken = default) - { - await BlobStore(expected).PushAsync(expected, content, cancellationToken); - } + => await BlobStore(expected).PushAsync(expected, content, cancellationToken).ConfigureAwait(false); /// /// ResolveAsync resolves a reference to a manifest descriptor @@ -163,9 +121,7 @@ public async Task PushAsync(Descriptor expected, Stream content, CancellationTok /// /// public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) - { - return await Manifests.ResolveAsync(reference, cancellationToken); - } + => await Manifests.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); /// /// TagAsync tags a manifest descriptor with a reference string. @@ -175,9 +131,7 @@ public async Task ResolveAsync(string reference, CancellationToken c /// /// public async Task TagAsync(Descriptor descriptor, string reference, CancellationToken cancellationToken = default) - { - await Manifests.TagAsync(descriptor, reference, cancellationToken); - } + => await Manifests.TagAsync(descriptor, reference, cancellationToken).ConfigureAwait(false); /// /// FetchReference fetches the manifest identified by the reference. @@ -187,9 +141,7 @@ public async Task TagAsync(Descriptor descriptor, string reference, Cancellation /// /// public async Task<(Descriptor Descriptor, Stream Stream)> FetchAsync(string reference, CancellationToken cancellationToken = default) - { - return await Manifests.FetchAsync(reference, cancellationToken); - } + => await Manifests.FetchAsync(reference, cancellationToken).ConfigureAwait(false); /// /// PushReference pushes the manifest with a reference tag. @@ -199,11 +151,8 @@ public async Task TagAsync(Descriptor descriptor, string reference, Cancellation /// /// /// - public async Task PushAsync(Descriptor descriptor, Stream content, string reference, - CancellationToken cancellationToken = default) - { - await Manifests.PushAsync(descriptor, content, reference, cancellationToken); - } + public async Task PushAsync(Descriptor descriptor, Stream content, string reference, CancellationToken cancellationToken = default) + => await Manifests.PushAsync(descriptor, content, reference, cancellationToken).ConfigureAwait(false); /// /// DeleteAsync removes the content identified by the descriptor. @@ -212,25 +161,7 @@ public async Task PushAsync(Descriptor descriptor, Stream content, string refere /// /// public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default) - { - await BlobStore(target).DeleteAsync(target, cancellationToken); - } - - /// - /// TagsAsync returns a list of tags in a repository - /// - /// - /// - /// - public async Task> TagsAsync(ITagListable repo, CancellationToken cancellationToken) - { - var tags = new List(); - await foreach (var tag in repo.ListTagsAsync().WithCancellation(cancellationToken)) - { - tags.Add(tag); - } - return tags; - } + => await BlobStore(target).DeleteAsync(target, cancellationToken).ConfigureAwait(false); /// /// TagsAsync lists the tags available in the repository. @@ -243,66 +174,60 @@ public async Task> TagsAsync(ITagListable repo, CancellationToken c /// - https://docs.docker.com/registry/spec/api/#tags /// /// - /// /// /// public async IAsyncEnumerable ListTagsAsync(string? last = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var url = URLUtiliity.BuildRepositoryTagListURL(PlainHTTP, RemoteReference); - var done = false; - while (!done) + var url = new UriFactory(_opts).BuildRepositoryTagList(); + do { - IEnumerable tags = Array.Empty(); - try - { - url = await TagsPageAsync(last, values => tags = values, url, cancellationToken); - last = ""; - } - catch (LinkUtility.NoLinkHeaderException) - { - done = true; - } + (var tags, url) = await FetchTagsPageAsync(last, url!, cancellationToken).ConfigureAwait(false); + last = null; foreach (var tag in tags) { yield return tag; } - } + } while (url != null); } /// - /// TagsPageAsync returns a single page of tag list with the next link. + /// Returns a single page of tag list with the next link. /// /// /// /// /// - private async Task TagsPageAsync(string? last, Action fn, string url, CancellationToken cancellationToken) + private async Task<(string[], Uri?)> FetchTagsPageAsync(string? last, Uri url, CancellationToken cancellationToken) { var uriBuilder = new UriBuilder(url); - var query = ParseQueryString(uriBuilder.Query); - if (TagListPageSize > 0 || !string.IsNullOrEmpty(last)) + if (_opts.TagListPageSize > 0 || !string.IsNullOrEmpty(last)) { - if (TagListPageSize > 0) + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + if (_opts.TagListPageSize > 0) { - query["n"] = TagListPageSize.ToString(); + query["n"] = _opts.TagListPageSize.ToString(); } if (!string.IsNullOrEmpty(last)) { query["last"] = last; } + uriBuilder.Query = query.ToString(); } - uriBuilder.Query = query.ToString(); - using var resp = await HttpClient.GetAsync(uriBuilder.ToString(), cancellationToken); - if (resp.StatusCode != HttpStatusCode.OK) + using var response = await _opts.HttpClient.GetAsync(uriBuilder.ToString(), cancellationToken).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) { - throw await ErrorUtility.ParseErrorResponse(resp); - + throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); } - var data = await resp.Content.ReadAsStringAsync(); - var tagList = JsonSerializer.Deserialize(data); - fn(tagList.Tags); - return LinkUtility.ParseLink(resp); + var data = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var tagList = JsonSerializer.Deserialize(data); + return (tagList.Tags, response.ParseLink()); + } + + internal struct TagList + { + [JsonPropertyName("tags")] + public string[] Tags { get; set; } } /// @@ -314,82 +239,25 @@ private async Task TagsPageAsync(string? last, Action fn, stri /// /// /// - /// internal async Task DeleteAsync(Descriptor target, bool isManifest, CancellationToken cancellationToken) { - var remoteReference = RemoteReference; - remoteReference.ContentReference = target.Digest; - string url; - if (isManifest) - { - url = URLUtiliity.BuildRepositoryManifestURL(PlainHTTP, remoteReference); - } - else - { - url = URLUtiliity.BuildRepositoryBlobURL(PlainHTTP, remoteReference); - } - - using var resp = await HttpClient.DeleteAsync(url, cancellationToken); + var remoteReference = ParseReferenceFromDigest(target.Digest); + var uriFactory = new UriFactory(remoteReference, _opts.PlainHttp); + var url = isManifest ? uriFactory.BuildRepositoryManifest() : uriFactory.BuildRepositoryBlob(); + using var resp = await _opts.HttpClient.DeleteAsync(url, cancellationToken).ConfigureAwait(false); switch (resp.StatusCode) { case HttpStatusCode.Accepted: - VerifyContentDigest(resp, target.Digest); + resp.VerifyContentDigest(target.Digest); break; case HttpStatusCode.NotFound: - throw new NotFoundException($"digest {target.Digest} not found"); + throw new NotFoundException($"Digest {target.Digest} not found"); default: - throw await ErrorUtility.ParseErrorResponse(resp); + throw await resp.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false); } } - - /// - /// VerifyContentDigest verifies "Docker-Content-Digest" header if present. - /// OCI distribution-spec states the Docker-Content-Digest header is optional. - /// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#legacy-docker-support-http-headers - /// - /// - /// - /// - internal static void VerifyContentDigest(HttpResponseMessage resp, string expected) - { - if (!resp.Content.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)) return; - var digestStr = digestValues.FirstOrDefault(); - if (string.IsNullOrEmpty(digestStr)) - { - return; - } - - string contentDigest; - try - { - contentDigest = Digest.Validate(digestStr); - } - catch (Exception) - { - throw new Exception($"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: invalid response header: `Docker-Content-Digest: {digestStr}`"); - } - if (contentDigest != expected) - { - throw new Exception($"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: invalid response; digest mismatch in Docker-Content-Digest: received {contentDigest} when expecting {digestStr}"); - } - } - - - /// - /// Blobs provides access to the blob CAS only, which contains - /// layers, and other generic blobs. - /// - public IBlobStore Blobs => new BlobStore(this); - - - /// - /// Manifests provides access to the manifest CAS only. - /// - /// - public IManifestStore Manifests => new ManifestStore(this); - /// /// ParseReference resolves a tag or a digest reference to a fully qualified /// reference from a base reference Reference. @@ -400,77 +268,68 @@ internal static void VerifyContentDigest(HttpResponseMessage resp, string expect /// error, InvalidReferenceException. /// /// - /// - public Reference ParseReference(string reference) + internal Reference ParseReference(string reference) { - Reference remoteReference; - var hasError = false; - try - { - remoteReference = Reference.Parse(reference); - } - catch (Exception) + if (Reference.TryParse(reference, out var remoteReference)) { - hasError = true; - //reference is not a FQDN - var index = reference.IndexOf("@"); - if (index != -1) - { - // `@` implies *digest*, so drop the *tag* (irrespective of what it is). - reference = reference[(index + 1)..]; - } - remoteReference = new Reference(RemoteReference.Registry, RemoteReference.Repository, reference); - if (index != -1) - { - _ = remoteReference.Digest; + if (remoteReference.Registry != _opts.Reference.Registry || remoteReference.Repository != _opts.Reference.Repository) + { + throw new InvalidReferenceException( + $"mismatch between received {JsonSerializer.Serialize(remoteReference)} and expected {JsonSerializer.Serialize(_opts.Reference)}"); } } - - if (!hasError) + else { - if (remoteReference.Registry != RemoteReference.Registry || - remoteReference.Repository != RemoteReference.Repository) + var index = reference.IndexOf("@"); + if (index != -1) { - throw new InvalidReferenceException( - $"mismatch between received {JsonSerializer.Serialize(remoteReference)} and expected {JsonSerializer.Serialize(RemoteReference)}"); + // `@` implies *digest*, so drop the *tag* (irrespective of what it is). + reference = reference[(index + 1)..]; + } + remoteReference = new Reference(_opts.Reference.Registry, _opts.Reference.Repository, reference); + if (index != -1) + { + _ = remoteReference.Digest; } } if (string.IsNullOrEmpty(remoteReference.ContentReference)) { - throw new InvalidReferenceException(); + throw new InvalidReferenceException("Empty content reference"); } return remoteReference; - } + internal Reference ParseReferenceFromDigest(string digest) + { + var reference = new Reference(_opts.Reference.Registry, _opts.Reference.Repository, digest); + _ = reference.Digest; + return reference; + } - /// - /// GenerateBlobDescriptor returns a descriptor generated from the response. - /// - /// - /// - /// - /// - public static Descriptor GenerateBlobDescriptor(HttpResponseMessage resp, string refDigest) + internal Reference ParseReferenceFromContentReference(string reference) { - var mediaType = resp.Content.Headers.ContentType.MediaType; - if (string.IsNullOrEmpty(mediaType)) - { - mediaType = "application/octet-stream"; - } - var size = resp.Content.Headers.ContentLength.Value; - if (size == -1) + if (string.IsNullOrEmpty(reference)) { - throw new Exception($"{resp.RequestMessage.Method} {resp.RequestMessage.RequestUri}: unknown response Content-Length"); + throw new InvalidReferenceException("Empty content reference"); } + return new Reference(_opts.Reference.Registry, _opts.Reference.Repository, reference); + } - VerifyContentDigest(resp, refDigest); + /// + /// Returns the accept header for manifest media types. + /// + internal string ManifestAcceptHeader() => string.Join(',', _opts.ManifestMediaTypes ?? DefaultManifestMediaTypes); - return new Descriptor - { - MediaType = mediaType, - Digest = refDigest, - Size = size - }; - } + /// + /// Determines if the given descriptor is a manifest. + /// + /// + private bool IsManifest(Descriptor desc) => (_opts.ManifestMediaTypes ?? DefaultManifestMediaTypes).Any(mediaType => mediaType == desc.MediaType); + + /// + /// Detects the blob store for the given descriptor. + /// + /// + /// + private IBlobStore BlobStore(Descriptor desc) => IsManifest(desc) ? Manifests : Blobs; } diff --git a/src/OrasProject.Oras/Registry/Remote/IRepositoryOption.cs b/src/OrasProject.Oras/Registry/Remote/RepositoryOptions.cs similarity index 75% rename from src/OrasProject.Oras/Registry/Remote/IRepositoryOption.cs rename to src/OrasProject.Oras/Registry/Remote/RepositoryOptions.cs index 29d72b0..0302ea6 100644 --- a/src/OrasProject.Oras/Registry/Remote/IRepositoryOption.cs +++ b/src/OrasProject.Oras/Registry/Remote/RepositoryOptions.cs @@ -11,39 +11,38 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Collections.Generic; using System.Net.Http; namespace OrasProject.Oras.Registry.Remote; /// -/// IRepositoryOption is used to configure a remote repository. +/// RepositoryOption is used to configure a remote repository. /// -public interface IRepositoryOption +public struct RepositoryOptions { /// /// Client is the underlying HTTP client used to access the remote registry. /// - public HttpClient HttpClient { get; set; } + public required HttpClient HttpClient { get; set; } /// /// Reference references the remote repository. /// - public Reference RemoteReference { get; set; } + public required Reference Reference { get; set; } /// - /// PlainHTTP signals the transport to access the remote repository via HTTP + /// PlainHttp signals the transport to access the remote repository via HTTP /// instead of HTTPS. /// - public bool PlainHTTP { get; set; } - + public bool PlainHttp { get; set; } /// /// ManifestMediaTypes is used in `Accept` header for resolving manifests /// from references. It is also used in identifying manifests and blobs from - /// descriptors. If an empty list is present, default manifest media types - /// are used. + /// descriptors. If null, default manifest media types are used. /// - public string[] ManifestMediaTypes { get; set; } + public IEnumerable? ManifestMediaTypes { get; set; } /// /// TagListPageSize specifies the page size when invoking the tag list API. @@ -51,5 +50,4 @@ public interface IRepositoryOption /// Reference: https://docs.docker.com/registry/spec/api/#tags /// public int TagListPageSize { get; set; } - } diff --git a/src/OrasProject.Oras/Registry/Remote/ResponseTypes.cs b/src/OrasProject.Oras/Registry/Remote/ResponseTypes.cs deleted file mode 100644 index 402751d..0000000 --- a/src/OrasProject.Oras/Registry/Remote/ResponseTypes.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright The ORAS Authors. -// 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. - -using System.Text.Json.Serialization; - -namespace OrasProject.Oras.Remote -{ - internal static class ResponseTypes - { - internal struct RepositoryList - { - [JsonPropertyName("repositories")] - public string[] Repositories { get; set; } - } - - internal struct TagList - { - [JsonPropertyName("tags")] - public string[] Tags { get; set; } - } - } -} diff --git a/src/OrasProject.Oras/Registry/Remote/URLUtiliity.cs b/src/OrasProject.Oras/Registry/Remote/URLUtiliity.cs deleted file mode 100644 index 25882fb..0000000 --- a/src/OrasProject.Oras/Registry/Remote/URLUtiliity.cs +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright The ORAS Authors. -// 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. - -using OrasProject.Oras.Registry; - -namespace OrasProject.Oras.Remote -{ - - internal static class URLUtiliity - { - /// - /// BuildScheme returns HTTP scheme used to access the remote registry. - /// - /// - /// - internal static string BuildScheme(bool plainHTTP) - { - if (plainHTTP) - { - return "http"; - } - - return "https"; - } - - /// - /// BuildRegistryBaseURL builds the URL for accessing the base API. - /// Format: :///v2/ - /// Reference: https://docs.docker.com/registry/spec/api/#base - /// - /// - /// - /// - internal static string BuildRegistryBaseURL(bool plainHTTP, Reference reference) - { - return $"{BuildScheme(plainHTTP)}://{reference.Host}/v2/"; - } - - /// - /// BuildManifestURL builds the URL for accessing the catalog API. - /// Format: :///v2/_catalog - /// Reference: https://docs.docker.com/registry/spec/api/#catalog - /// - /// - /// - /// - internal static string BuildRegistryCatalogURL(bool plainHTTP, Reference reference) - { - return $"{BuildScheme(plainHTTP)}://{reference.Host}/v2/_catalog"; - } - - /// - /// BuildRepositoryBaseURL builds the base endpoint of the remote repository. - /// Format: :///v2/ - /// - /// - /// - /// - internal static string BuildRepositoryBaseURL(bool plainHTTP, Reference reference) - { - return $"{BuildScheme(plainHTTP)}://{reference.Host}/v2/{reference.Repository}"; - } - - /// - /// BuildRepositoryTagListURL builds the URL for accessing the tag list API. - /// Format: :///v2//tags/list - /// Reference: https://docs.docker.com/registry/spec/api/#tags - /// - /// - /// - /// - internal static string BuildRepositoryTagListURL(bool plainHTTP, Reference reference) - { - return $"{BuildRepositoryBaseURL(plainHTTP, reference)}/tags/list"; - } - - /// - /// BuildRepositoryManifestURL builds the URL for accessing the manifest API. - /// Format: :///v2//manifests/ - /// Reference: https://docs.docker.com/registry/spec/api/#manifest - /// - /// - /// - /// - internal static string BuildRepositoryManifestURL(bool plainHTTP, Reference reference) - { - return $"{BuildRepositoryBaseURL(plainHTTP, reference)}/manifests/{reference.ContentReference}"; - } - - /// - /// BuildRepositoryBlobURL builds the URL for accessing the blob API. - /// Format: :///v2//blobs/ - /// Reference: https://docs.docker.com/registry/spec/api/#blob - /// - /// - /// - /// - internal static string BuildRepositoryBlobURL(bool plainHTTP, Reference reference) - { - return $"{BuildRepositoryBaseURL(plainHTTP, reference)}/blobs/{reference.ContentReference}"; - } - - /// - /// BuildRepositoryBlobUploadURL builds the URL for accessing the blob upload API. - /// Format: :///v2//blobs/uploads/ - /// Reference: https://docs.docker.com/registry/spec/api/#initiate-blob-upload - - /// - /// - /// - /// - internal static string BuildRepositoryBlobUploadURL(bool plainHTTP, Reference reference) - { - return $"{BuildRepositoryBaseURL(plainHTTP, reference)}/blobs/uploads/"; - } - - } -} diff --git a/src/OrasProject.Oras/Registry/Remote/UriFactory.cs b/src/OrasProject.Oras/Registry/Remote/UriFactory.cs new file mode 100644 index 0000000..149be0b --- /dev/null +++ b/src/OrasProject.Oras/Registry/Remote/UriFactory.cs @@ -0,0 +1,122 @@ +// Copyright The ORAS Authors. +// 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. + +using OrasProject.Oras.Exceptions; +using System; + +namespace OrasProject.Oras.Registry.Remote; + +internal class UriFactory : UriBuilder +{ + private readonly Reference _reference; + private readonly Uri _base; + + public UriFactory(Reference reference, bool plainHttp = false) + { + _reference = reference; + var scheme = plainHttp ? "http" : "https"; + _base = new Uri($"{scheme}://{_reference.Host}"); + } + + public UriFactory(RepositoryOptions options) : this(options.Reference, options.PlainHttp) { } + + /// + /// Builds the URL for accessing the base API. + /// Format: :///v2/ + /// Reference: https://docs.docker.com/registry/spec/api/#base + /// + public Uri BuildRegistryBase() => new UriBuilder(_base) + { + Path = "/v2/" + }.Uri; + + /// + /// Builds the URL for accessing the catalog API. + /// Format: :///v2/_catalog + /// Reference: https://docs.docker.com/registry/spec/api/#catalog + /// + public Uri BuildRegistryCatalog() => new UriBuilder(_base) + { + Path = "/v2/_catalog" + }.Uri; + + /// + /// Builds the URL for accessing the tag list API. + /// Format: :///v2//tags/list + /// Reference: https://docs.docker.com/registry/spec/api/#tags + /// + public Uri BuildRepositoryTagList() + { + var builder = NewRepositoryBaseBuilder(); + builder.Path += "/tags/list"; + return builder.Uri; + } + + /// + /// Builds the URL for accessing the manifest API. + /// Format: :///v2//manifests/ + /// Reference: https://docs.docker.com/registry/spec/api/#manifest + /// + public Uri BuildRepositoryManifest() + { + if (string.IsNullOrEmpty(_reference.Repository)) + { + throw new InvalidReferenceException("missing manifest reference"); + } + var builder = NewRepositoryBaseBuilder(); + builder.Path += $"/manifests/{_reference.ContentReference}"; + return builder.Uri; + } + + /// + /// Builds the URL for accessing the blob API. + /// Format: :///v2//blobs/ + /// Reference: https://docs.docker.com/registry/spec/api/#blob + /// + public Uri BuildRepositoryBlob() + { + var builder = NewRepositoryBaseBuilder(); + builder.Path += $"/blobs/{_reference.Digest}"; + return builder.Uri; + } + + /// + /// Builds the URL for accessing the blob upload API. + /// Format: :///v2//blobs/uploads/ + /// Reference: https://docs.docker.com/registry/spec/api/#initiate-blob-upload + /// + public Uri BuildRepositoryBlobUpload() + { + var builder = NewRepositoryBaseBuilder(); + builder.Path += "/blobs/uploads/"; + return builder.Uri; + } + + /// + /// Generates a UriBuilder with the base endpoint of the remote repository. + /// Format: :///v2/ + /// + /// Repository-scoped base UriBuilder + protected UriBuilder NewRepositoryBaseBuilder() + { + if (string.IsNullOrEmpty(_reference.Repository)) + { + throw new InvalidReferenceException("missing repository"); + } + var builder = new UriBuilder(_base) + { + Path = $"/v2/{_reference.Repository}" + }; + return builder; + } +} diff --git a/tests/OrasProject.Oras.Tests/RemoteTest/AuthTest.cs b/tests/OrasProject.Oras.Tests/RemoteTest/AuthTest.cs index 861a938..86365dc 100644 --- a/tests/OrasProject.Oras.Tests/RemoteTest/AuthTest.cs +++ b/tests/OrasProject.Oras.Tests/RemoteTest/AuthTest.cs @@ -13,7 +13,7 @@ using Moq; using Moq.Protected; -using OrasProject.Oras.Remote.Auth; +using OrasProject.Oras.Registry.Remote.Auth; using System.Net; using System.Text; using Xunit; diff --git a/tests/OrasProject.Oras.Tests/RemoteTest/RegistryTest.cs b/tests/OrasProject.Oras.Tests/RemoteTest/RegistryTest.cs index 75a3c05..b51586c 100644 --- a/tests/OrasProject.Oras.Tests/RemoteTest/RegistryTest.cs +++ b/tests/OrasProject.Oras.Tests/RemoteTest/RegistryTest.cs @@ -13,7 +13,8 @@ using Moq; using Moq.Protected; -using OrasProject.Oras.Remote; +using OrasProject.Oras.Registry; +using OrasProject.Oras.Registry.Remote; using System.Net; using System.Text.Json; using System.Text.RegularExpressions; @@ -23,7 +24,6 @@ namespace OrasProject.Oras.Tests.RemoteTest { public class RegistryTest { - public static HttpClient CustomClient(Func func) { var moqHandler = new Mock(); @@ -35,6 +35,18 @@ public static HttpClient CustomClient(Func + /// Test registry constructor + /// + [Fact] + public void Registry() + { + var registryName = "foobar"; + var registry = new Remote.Registry(registryName); + var options = registry.RepositoryOptions; + Assert.Equal(registryName, options.Reference.Registry); + } + /// /// PingAsync tests the PingAsync method of the Registry class. /// @@ -65,9 +77,12 @@ public async Task PingAsync() return res; } }; - var registry = new OrasProject.Oras.Remote.Registry("localhost:5000"); - registry.PlainHTTP = true; - registry.HttpClient = CustomClient(func); + var registry = new Remote.Registry(new RepositoryOptions() + { + Reference = new Reference("localhost:5000"), + PlainHttp = true, + HttpClient = CustomClient(func), + }); var cancellationToken = new CancellationToken(); await registry.PingAsync(cancellationToken); V2Implemented = false; @@ -129,7 +144,7 @@ public async Task Repositories() break; } - var repositoryList = new ResponseTypes.RepositoryList + var repositoryList = new Remote.Registry.RepositoryList { Repositories = repos.ToArray() }; @@ -138,21 +153,24 @@ public async Task Repositories() }; - var registry = new OrasProject.Oras.Remote.Registry("localhost:5000"); - registry.PlainHTTP = true; - registry.HttpClient = CustomClient(func); + var registry = new Remote.Registry(new RepositoryOptions() + { + Reference = new Reference("localhost:5000"), + PlainHttp = true, + HttpClient = CustomClient(func), + TagListPageSize = 4, + }); var cancellationToken = new CancellationToken(); - registry.TagListPageSize = 4; var wantRepositories = new List(); - foreach (var set in repoSet) - { - wantRepositories.AddRange(set); + foreach (var set in repoSet) + { + wantRepositories.AddRange(set); } var gotRepositories = new List(); - await foreach (var repo in registry.ListRepositoriesAsync().WithCancellation(cancellationToken)) - { - gotRepositories.Add(repo); + await foreach (var repo in registry.ListRepositoriesAsync().WithCancellation(cancellationToken)) + { + gotRepositories.Add(repo); } Assert.Equal(wantRepositories, gotRepositories); } diff --git a/tests/OrasProject.Oras.Tests/RemoteTest/RepositoryTest.cs b/tests/OrasProject.Oras.Tests/RemoteTest/RepositoryTest.cs index e8e8715..5881f8f 100644 --- a/tests/OrasProject.Oras.Tests/RemoteTest/RepositoryTest.cs +++ b/tests/OrasProject.Oras.Tests/RemoteTest/RepositoryTest.cs @@ -18,7 +18,6 @@ using OrasProject.Oras.Oci; using OrasProject.Oras.Registry; using OrasProject.Oras.Registry.Remote; -using OrasProject.Oras.Remote; using System.Collections.Immutable; using System.Diagnostics; using System.Net; @@ -217,9 +216,12 @@ public async Task Repository_FetchAsync() resp.StatusCode = HttpStatusCode.NotFound; return resp; }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var stream = await repo.FetchAsync(blobDesc, cancellationToken); var buf = new byte[stream.Length]; @@ -278,7 +280,8 @@ public async Task Repository_PushAsync() } - if (!req.RequestUri.Query.Contains("digest=" + blobDesc.Digest)) + var queries = HttpUtility.ParseQueryString(req.RequestUri.Query); + if (queries["digest"] != blobDesc.Digest) { resp.StatusCode = HttpStatusCode.BadRequest; return resp; @@ -314,9 +317,12 @@ public async Task Repository_PushAsync() }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); await repo.PushAsync(blobDesc, new MemoryStream(blob), cancellationToken); Assert.Equal(blob, gotBlob); @@ -378,9 +384,12 @@ public async Task Repository_ExistsAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var exists = await repo.ExistsAsync(blobDesc, cancellationToken); Assert.True(exists); @@ -438,9 +447,12 @@ public async Task Repository_DeleteAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); await repo.DeleteAsync(blobDesc, cancellationToken); Assert.True(blobDeleted); @@ -503,9 +515,12 @@ public async Task Repository_ResolveAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); await Assert.ThrowsAsync(async () => await repo.ResolveAsync(blobDesc.Digest, cancellationToken)); @@ -593,9 +608,12 @@ public async Task Repository_TagAsync() return new HttpResponseMessage(HttpStatusCode.Forbidden); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); await Assert.ThrowsAnyAsync( async () => await repo.TagAsync(blobDesc, reference, cancellationToken)); @@ -645,9 +663,12 @@ public async Task Repository_PushReferenceAsync() return new HttpResponseMessage(HttpStatusCode.Forbidden); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var streamContent = new MemoryStream(index); await repo.PushAsync(indexDesc, streamContent, reference, cancellationToken); @@ -708,9 +729,12 @@ public async Task Repository_FetchReferenceAsyc() return new HttpResponseMessage(HttpStatusCode.Found); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); // test with blob digest @@ -804,7 +828,7 @@ public async Task Repository_TagsAsync() break; } - var listOfTags = new ResponseTypes.TagList + var listOfTags = new Repository.TagList { Tags = tags.ToArray() }; @@ -813,10 +837,13 @@ public async Task Repository_TagsAsync() }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; - repo.TagListPageSize = 4; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + TagListPageSize = 4, + }); var cancellationToken = new CancellationToken(); @@ -867,9 +894,12 @@ public async Task BlobStore_FetchAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); var stream = await store.FetchAsync(blobDesc, cancellationToken); @@ -957,9 +987,12 @@ public async Task BlobStore_FetchAsync_CanSeek() return res; }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); var stream = await store.FetchAsync(blobDesc, cancellationToken); @@ -1018,9 +1051,12 @@ public async Task BlobStore_FetchAsync_ZeroSizedBlob() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); var stream = await store.FetchAsync(blobDesc, cancellationToken); @@ -1079,9 +1115,12 @@ public async Task BlobStore_PushAsync() return new HttpResponseMessage(HttpStatusCode.Forbidden); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); await store.PushAsync(blobDesc, new MemoryStream(blob), cancellationToken); @@ -1129,9 +1168,12 @@ public async Task BlobStore_ExistsAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); var exists = await store.ExistsAsync(blobDesc, cancellationToken); @@ -1175,9 +1217,12 @@ public async Task BlobStore_DeleteAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); await store.DeleteAsync(blobDesc, cancellationToken); @@ -1227,9 +1272,12 @@ public async Task BlobStore_ResolveAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); var got = await store.ResolveAsync(blobDesc.Digest, cancellationToken); @@ -1286,9 +1334,12 @@ public async Task BlobStore_FetchReferenceAsync() return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); @@ -1385,9 +1436,12 @@ public async Task BlobStore_FetchReferenceAsync_Seek() return res; }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new BlobStore(repo); @@ -1483,8 +1537,7 @@ public void GenerateBlobDescriptor_WithVariousDockerContentDigestHeaders() var err = false; try { - Repository.GenerateBlobDescriptor(resp, d); - + resp.GenerateBlobDescriptor(d); } catch (Exception e) { @@ -1542,9 +1595,12 @@ public async Task ManifestStore_FetchAsync() } return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); var data = await store.FetchAsync(manifestDesc, cancellationToken); @@ -1604,9 +1660,12 @@ public async Task ManifestStore_PushAsync() return new HttpResponseMessage(HttpStatusCode.Forbidden); } }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); await store.PushAsync(manifestDesc, new MemoryStream(manifest), cancellationToken); @@ -1648,9 +1707,12 @@ public async Task ManifestStore_ExistAsync() } return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); var exist = await store.ExistsAsync(manifestDesc, cancellationToken); @@ -1709,9 +1771,12 @@ public async Task ManifestStore_DeleteAsync() } return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); await store.DeleteAsync(manifestDesc, cancellationToken); @@ -1763,9 +1828,12 @@ public async Task ManifestStore_ResolveAsync() } return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); var got = await store.ResolveAsync(manifestDesc.Digest, cancellationToken); @@ -1829,9 +1897,12 @@ public async Task ManifestStore_FetchReferenceAsync() } return new HttpResponseMessage(HttpStatusCode.NotFound); }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); @@ -1938,9 +2009,12 @@ public async Task ManifestStore_TagAsync() return res; }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); @@ -1998,9 +2072,12 @@ public async Task ManifestStore_PushReferenceAsync() res.StatusCode = HttpStatusCode.Forbidden; return res; }; - var repo = new Repository("localhost:5000/test"); - repo.HttpClient = CustomClient(func); - repo.PlainHTTP = true; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(func), + PlainHttp = true, + }); var cancellationToken = new CancellationToken(); var store = new ManifestStore(repo); await store.PushAsync(indexDesc, new MemoryStream(index), reference, cancellationToken); @@ -2093,9 +2170,12 @@ public async Task CopyFromRepositoryToMemory() return res; }; - var reg = new Remote.Registry("localhost:5000"); - reg.HttpClient = CustomClient(func); - var src = await reg.GetRepository("source", CancellationToken.None); + var reg = new Remote.Registry(new RepositoryOptions() + { + Reference = new Reference("localhost:5000"), + HttpClient = CustomClient(func), + }); + var src = await reg.GetRepositoryAsync("source", CancellationToken.None); var dst = new MemoryStore(); var tagName = "latest"; @@ -2137,7 +2217,7 @@ public async Task ManifestStore_generateDescriptorWithVariousDockerContentDigest var err = false; try { - await s.GenerateDescriptorAsync(resp, reference, method); + await resp.GenerateDescriptorAsync(reference, CancellationToken.None); } catch (Exception e) {