quorate/packages/fsharp-runner/oci-tests/Program.cs

764 lines
29 KiB
C#
Raw Permalink Normal View History

using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using Quorate.Oci;
namespace Quorate.Oci.Tests;
[TestClass]
public sealed class RegistryTests
{
[TestMethod]
public async Task PublishesLayoutWithConcurrentAuthentication()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
using var handler = new RegistryHandler(layout.ManifestDigest);
using var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler);
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
.ConfigureAwait(false);
Assert.AreEqual(layout.ManifestDigest, published);
Assert.AreEqual(2, handler.TokenRequests);
Assert.HasCount(2, handler.Uploaded);
Assert.HasCount(2, handler.Manifests);
Assert.AreEqual(layout.ManifestDigest, handler.Manifests.Last());
Assert.AreEqual(nameof(RegistryAuth), new RegistryAuth("writer", "password").ToString());
}
[TestMethod]
public async Task RejectsCancelledPublication()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
using var handler = new RegistryHandler(layout.ManifestDigest);
using var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler);
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync().ConfigureAwait(false);
await Assert.ThrowsAsync<OperationCanceledException>(() =>
registry.PushAsync("quorate/test", layout.Path, "test", "test", cancelled.Token))
.ConfigureAwait(false);
}
[TestMethod]
public void KeepsInjectedHandler()
{
using var handler = new RegistryHandler("sha256:" + new string('0', 64));
using (var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler))
{
}
Assert.IsFalse(handler.Disposed);
}
[TestMethod]
public async Task CancelsActiveHash()
{
var stream = new BlockingStream();
var cancelled = new CancellationTokenSource();
try
{
Task<OciDigest> hashing = OciDigest.ComputeAsync(stream, cancelled.Token);
await stream.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await cancelled.CancelAsync().ConfigureAwait(false);
try
{
await hashing.ConfigureAwait(false);
Assert.Fail("hashing ignored cancellation");
}
catch (OperationCanceledException)
{
}
}
finally
{
cancelled.Dispose();
await stream.DisposeAsync().ConfigureAwait(false);
}
}
[TestMethod]
public async Task CancelsActiveUpload()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
using var handler = new RegistryHandler(layout.ManifestDigest) { BlockUpload = true };
using var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler);
using var cancelled = new CancellationTokenSource();
Task<string> publication = registry.PushAsync(
"quorate/test",
layout.Path,
"test",
"test",
cancelled.Token);
await handler.UploadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await cancelled.CancelAsync().ConfigureAwait(false);
await Assert.ThrowsAsync<OperationCanceledException>(() => publication).ConfigureAwait(false);
Assert.IsTrue(handler.Cancelled > 0);
}
[TestMethod]
public async Task AcceptsUploadUris()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
foreach (Uri location in new[]
{
new Uri("/upload/fixed", UriKind.Relative),
new Uri("https://registry.example/upload/fixed")
})
{
using var handler = new RegistryHandler(layout.ManifestDigest) { UploadLocation = location };
using var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler);
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
.ConfigureAwait(false);
Assert.AreEqual(layout.ManifestDigest, published);
}
}
[TestMethod]
public async Task RejectsForeignUpload()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
using var handler = new RegistryHandler(layout.ManifestDigest)
{
UploadLocation = new Uri("https://other.example/upload/fixed")
};
using var registry = new Registry(
new Uri("https://registry.example"),
new RegistryAuth("writer", "password"),
handler);
await Assert.ThrowsAsync<InvalidDataException>(() =>
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
}
[TestMethod]
public void RejectsBadChallenges()
{
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
new AuthenticationHeaderValue("Bearer", "realm=\"http://auth.example/token\"")));
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
new AuthenticationHeaderValue("Bearer", "realm=https://auth.example/token")));
}
[TestMethod]
public async Task NegotiatesHttp2WithPrivateRoot()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
.ConfigureAwait(false);
await using (server.ConfigureAwait(false))
{
using var registry = new Registry(
server.Endpoint,
new RegistryAuth("writer", "password"),
server.Root);
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
.ConfigureAwait(false);
Assert.AreEqual(layout.ManifestDigest, published);
Assert.IsFalse(server.Protocols.IsEmpty);
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/2"));
Assert.AreEqual(2, server.TokenRequests);
}
}
[TestMethod]
public async Task FallsBackToHttp1()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
layout.ManifestDigest,
HttpProtocols.Http1).ConfigureAwait(false);
await using (server.ConfigureAwait(false))
{
using var registry = new Registry(
server.Endpoint,
new RegistryAuth("writer", "password"),
server.Root);
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
.ConfigureAwait(false);
Assert.AreEqual(layout.ManifestDigest, published);
Assert.IsFalse(server.Protocols.IsEmpty);
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/1.1"));
}
}
[TestMethod]
public async Task RejectsWrongHost()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
.ConfigureAwait(false);
await using (server.ConfigureAwait(false))
{
var endpoint = new UriBuilder(server.Endpoint) { Host = "localhost" };
using var registry = new Registry(
endpoint.Uri,
new RegistryAuth("writer", "password"),
server.Root);
await Assert.ThrowsAsync<HttpRequestException>(() =>
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
}
}
[TestMethod]
public async Task RejectsRedirects()
{
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
layout.ManifestDigest,
HttpProtocols.Http2,
true).ConfigureAwait(false);
await using (server.ConfigureAwait(false))
{
using var registry = new Registry(
server.Endpoint,
new RegistryAuth("writer", "password"),
server.Root);
await Assert.ThrowsAsync<HttpRequestException>(() =>
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
Assert.AreEqual(0, server.RedirectHits);
}
}
}
internal sealed class LoopbackRegistry : IAsyncDisposable
{
private readonly WebApplication application;
private readonly bool redirectUploads;
private readonly X509Certificate2 serverCertificate;
private readonly string manifestDigest;
private int tokenRequests;
private LoopbackRegistry(
WebApplication application,
X509Certificate2 root,
X509Certificate2 serverCertificate,
string manifestDigest,
bool redirectUploads)
{
this.application = application;
Root = root;
this.serverCertificate = serverCertificate;
this.manifestDigest = manifestDigest;
this.redirectUploads = redirectUploads;
}
public Uri Endpoint { get; private set; } = null!;
public X509Certificate2 Root { get; }
public ConcurrentQueue<string> Protocols { get; } = [];
public int RedirectHits { get; private set; }
public int TokenRequests => Volatile.Read(ref tokenRequests);
public static async Task<LoopbackRegistry> StartAsync(
string manifestDigest,
HttpProtocols protocols = HttpProtocols.Http2,
bool redirectUploads = false)
{
(X509Certificate2 root, X509Certificate2 serverCertificate) = CreateCertificates();
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(options => options.Listen(
IPAddress.Loopback,
0,
endpoint =>
{
endpoint.Protocols = protocols;
endpoint.UseHttps(serverCertificate);
}));
WebApplication application = builder.Build();
var registry = new LoopbackRegistry(
application,
root,
serverCertificate,
manifestDigest,
redirectUploads);
application.Run(registry.HandleAsync);
await application.StartAsync().ConfigureAwait(false);
IServer server = application.Services.GetRequiredService<IServer>();
string address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
registry.Endpoint = new Uri(address);
return registry;
}
public async ValueTask DisposeAsync()
{
await application.StopAsync().ConfigureAwait(false);
await application.DisposeAsync().ConfigureAwait(false);
serverCertificate.Dispose();
Root.Dispose();
}
private async Task HandleAsync(HttpContext context)
{
Protocols.Enqueue(context.Request.Protocol);
string path = context.Request.Path.Value ?? "";
string authorization = context.Request.Headers.Authorization.ToString();
if (path == "/redirected")
{
RedirectHits++;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
return;
}
if (path == "/token")
{
Interlocked.Increment(ref tokenRequests);
if (!authorization.StartsWith("Basic ", StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
string token = context.Request.Query["scope"].ToString().EndsWith(
":pull,push",
StringComparison.Ordinal) ? "push-token" : "pull-token";
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($"{{\"token\":\"{token}\"}}", context.RequestAborted)
.ConfigureAwait(false);
return;
}
if (context.Request.Method == HttpMethods.Head &&
path.Contains("/blobs/", StringComparison.Ordinal))
{
if (!authorization.StartsWith("Bearer ", StringComparison.Ordinal))
{
Deny(context, "pull");
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
if (context.Request.Method == HttpMethods.Post &&
path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
{
if (authorization != "Bearer push-token")
{
Deny(context, "pull,push");
return;
}
context.Response.StatusCode = StatusCodes.Status202Accepted;
context.Response.Headers.Append(HeaderNames.Location, "/upload/" + Guid.NewGuid().ToString("N"));
return;
}
if (context.Request.Method == HttpMethods.Put &&
path.StartsWith("/upload/", StringComparison.Ordinal))
{
if (redirectUploads)
{
context.Response.StatusCode = StatusCodes.Status307TemporaryRedirect;
context.Response.Headers.Location = "/redirected";
return;
}
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
if (digest != context.Request.Query["digest"])
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
Created(context, digest);
return;
}
if (context.Request.Method == HttpMethods.Delete &&
path.StartsWith("/upload/", StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status202Accepted;
return;
}
if (context.Request.Method == HttpMethods.Put &&
path.Contains("/manifests/", StringComparison.Ordinal))
{
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
Created(context, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
return;
}
if (context.Request.Method == HttpMethods.Head &&
path.EndsWith("/manifests/test", StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.Headers.Append("Docker-Content-Digest", manifestDigest);
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
private static async Task<byte[]> ReadBodyAsync(HttpContext context)
{
using var content = new MemoryStream();
await context.Request.Body.CopyToAsync(content, context.RequestAborted).ConfigureAwait(false);
return content.ToArray();
}
private void Deny(HttpContext context, string actions)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.Append(
HeaderNames.WWWAuthenticate,
$"Bearer realm=\"{Endpoint}token\",service=\"loopback\",scope=\"repository:quorate/test:{actions}\"");
}
private static void Created(HttpContext context, string digest)
{
context.Response.StatusCode = StatusCodes.Status201Created;
context.Response.Headers.Append("Docker-Content-Digest", digest);
}
private static (X509Certificate2 Root, X509Certificate2 Server) CreateCertificates()
{
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-1);
DateTimeOffset notAfter = notBefore.AddHours(1);
using RSA rootKey = RSA.Create(2048);
var rootRequest = new CertificateRequest(
"CN=Quorate OCI Test Root",
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
true));
rootRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(rootRequest.PublicKey, false));
using X509Certificate2 rootWithKey = rootRequest.CreateSelfSigned(notBefore, notAfter);
X509Certificate2 root = X509CertificateLoader.LoadCertificate(
rootWithKey.Export(X509ContentType.Cert));
using RSA serverKey = RSA.Create(2048);
var serverRequest = new CertificateRequest(
"CN=127.0.0.1",
serverKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
serverRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
serverRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
true));
var usages = new OidCollection { new("1.3.6.1.5.5.7.3.1") };
serverRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(usages, true));
var names = new SubjectAlternativeNameBuilder();
names.AddIpAddress(IPAddress.Loopback);
serverRequest.CertificateExtensions.Add(names.Build());
byte[] serial = RandomNumberGenerator.GetBytes(16);
using X509Certificate2 publicServer = serverRequest.Create(
rootWithKey,
notBefore,
notAfter,
serial);
X509Certificate2 server = publicServer.CopyWithPrivateKey(serverKey);
return (root, server);
}
}
internal sealed class LayoutFixture : IDisposable
{
private LayoutFixture(string path, string manifestDigest)
{
Path = path;
ManifestDigest = manifestDigest;
}
public string Path { get; }
public string ManifestDigest { get; }
public static async Task<LayoutFixture> CreateAsync()
{
string root = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"oci-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(System.IO.Path.Combine(root, "blobs", "sha256"));
byte[] config = Encoding.UTF8.GetBytes("{}");
byte[] layer = Encoding.UTF8.GetBytes("layer");
string configDigest = AddBlob(config);
string layerDigest = AddBlob(layer);
byte[] manifest = JsonSerializer.SerializeToUtf8Bytes(new
{
schemaVersion = 2,
mediaType = "application/vnd.oci.image.manifest.v1+json",
config = new
{
mediaType = "application/vnd.oci.image.config.v1+json",
digest = configDigest,
size = config.Length
},
layers = new[]
{
new
{
mediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
digest = layerDigest,
size = layer.Length
}
}
});
string manifestDigest = AddBlob(manifest);
byte[] index = JsonSerializer.SerializeToUtf8Bytes(new
{
schemaVersion = 2,
manifests = new[]
{
new
{
mediaType = "application/vnd.oci.image.manifest.v1+json",
digest = manifestDigest,
size = manifest.Length,
annotations = new Dictionary<string, string>
{
["org.opencontainers.image.ref.name"] = "test"
}
}
}
});
await File.WriteAllBytesAsync(System.IO.Path.Combine(root, "index.json"), index)
.ConfigureAwait(false);
return new LayoutFixture(root, manifestDigest);
string AddBlob(byte[] content)
{
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
File.WriteAllBytes(System.IO.Path.Combine(root, "blobs", "sha256", digest[7..]), content);
return digest;
}
}
public void Dispose() => Directory.Delete(Path, true);
}
internal sealed class RegistryHandler(string manifestDigest) : HttpMessageHandler
{
private int cancelled;
private bool disposed;
private int tokenRequests;
public bool BlockUpload { get; init; }
public int Cancelled => Volatile.Read(ref cancelled);
public bool Disposed => Volatile.Read(ref disposed);
public int TokenRequests => Volatile.Read(ref tokenRequests);
public TaskCompletionSource<bool> UploadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Uri? UploadLocation { get; init; }
public ConcurrentBag<string> Uploaded { get; } = [];
public ConcurrentQueue<string> Manifests { get; } = [];
protected override void Dispose(bool disposing)
{
Volatile.Write(ref disposed, disposing);
base.Dispose(disposing);
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Assert.AreEqual(HttpVersion.Version20, request.Version);
Assert.AreEqual(HttpVersionPolicy.RequestVersionOrLower, request.VersionPolicy);
Uri uri = request.RequestUri!;
if (uri.Host == "auth.example")
{
Interlocked.Increment(ref tokenRequests);
Assert.AreEqual("Basic", request.Headers.Authorization!.Scheme);
string token = Query(uri, "scope").EndsWith(":pull,push", StringComparison.Ordinal)
? "push-token"
: "pull-token";
return Reply(HttpStatusCode.OK, request, $"{{\"token\":\"{token}\"}}");
}
string path = uri.AbsolutePath;
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal) &&
request.Headers.Authorization?.Scheme != "Bearer")
{
return Denied(request, "pull");
}
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal) &&
request.Headers.Authorization?.Parameter != "push-token")
{
return Denied(request, "pull,push");
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return Reply(HttpStatusCode.NotFound, request);
}
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
{
HttpResponseMessage started = Reply(HttpStatusCode.Accepted, request);
started.Headers.Location = UploadLocation
?? new Uri("/upload/" + Guid.NewGuid().ToString("N"), UriKind.Relative);
return started;
}
if (request.Method == HttpMethod.Put && path.StartsWith("/upload/", StringComparison.Ordinal))
{
UploadStarted.TrySetResult(true);
if (BlockUpload)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
string digest = Query(uri, "digest");
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
Assert.AreEqual(digest, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
Uploaded.Add(digest);
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
completed.Headers.Add("Docker-Content-Digest", digest);
return completed;
}
if (request.Method == HttpMethod.Delete && path.StartsWith("/upload/", StringComparison.Ordinal))
{
Interlocked.Increment(ref cancelled);
return Reply(HttpStatusCode.Accepted, request);
}
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
Manifests.Enqueue(digest);
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
completed.Headers.Add("Docker-Content-Digest", digest);
return completed;
}
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/test", StringComparison.Ordinal))
{
HttpResponseMessage completed = Reply(HttpStatusCode.OK, request);
completed.Headers.Add("Docker-Content-Digest", manifestDigest);
return completed;
}
throw new InvalidOperationException($"Unexpected request {request.Method} {uri}");
}
private static HttpResponseMessage Reply(
HttpStatusCode status,
HttpRequestMessage request,
string? content = null) => new(status)
{
RequestMessage = request,
Content = new StringContent(content ?? "")
};
private static HttpResponseMessage Denied(HttpRequestMessage request, string actions)
{
HttpResponseMessage denied = Reply(HttpStatusCode.Unauthorized, request);
denied.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
"Bearer",
$"realm=\"https://auth.example/token\",service=\"registry.example\",scope=\"repository:quorate/test:{actions}\""));
return denied;
}
private static string Query(Uri uri, string name)
{
foreach (string pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
string[] fields = pair.Split('=', 2);
if (Uri.UnescapeDataString(fields[0]) == name)
{
return Uri.UnescapeDataString(fields[1]);
}
}
throw new InvalidOperationException($"Missing query parameter {name}");
}
}
internal sealed class BlockingStream : Stream
{
public TaskCompletionSource<bool> Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override async Task<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken)
{
Started.TrySetResult(true);
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
return 0;
}
public override async ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
{
Started.TrySetResult(true);
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
return 0;
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}