ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
85 changed files with 145336 additions and 0 deletions

1160
packages/clair/noop.patch Normal file

File diff suppressed because it is too large Load diff

2
packages/fsharp-runner/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
**/bin/
**/obj/

View file

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<AnalysisLevel>latest-all</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1.7
# Bootstrap only when no F# Runner image is available; Project Delivery owns the canonical build.
ARG SDK=10.0.403-noble@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
ARG RUNTIME=10.0.14@sha256:a3f9f81954b9c810af2ed18849716c8dc74a4707bd0907fdbd69be81554c0f30
ARG APKO=cgr.dev/chainguard/apko@sha256:c53d15d53090012cdc9f138b667a73a283b0b5bba1b61963ae84be6e28cfe5a0
FROM ${APKO} AS apko
FROM mcr.microsoft.com/dotnet/sdk:${SDK} AS build-root
ARG TARGETARCH
COPY --from=apko /usr/bin/apko /usr/local/bin/apko
COPY services/project-delivery/k8s/workflows/fsharp-runner/fsharp-runner.apko.yaml services/project-delivery/k8s/workflows/fsharp-runner/fsharp-runner.apko.lock.json /configuration/
RUN set -eu; \
case "$TARGETARCH" in \
amd64) arch=x86_64 ;; \
arm64) arch=arm64 ;; \
*) exit 1 ;; \
esac; \
mkdir -p /image /rootfs; \
apko build --sbom=false --arch "$arch" --lockfile /configuration/fsharp-runner.apko.lock.json /configuration/fsharp-runner.apko.yaml fsharp-runner:root /image/root.tar; \
mkdir /image/export; \
tar -xf /image/root.tar -C /image/export; \
tar --exclude=dev --exclude=./dev -xf /image/export/*.tar.gz -C /rootfs; \
rm -rf /rootfs/dev /rootfs/usr/share/bash-completion /rootfs/usr/share/doc /rootfs/usr/share/man
FROM mcr.microsoft.com/dotnet/sdk:${SDK} AS sdk
COPY packages/fsharp-runner/global.json /source/global.json
COPY packages/fsharp-runner/Directory.Build.props /Directory.Build.props
COPY packages/fsharp-runner/runner.csproj packages/fsharp-runner/packages.lock.json /restore/
COPY packages/fsharp-runner/oci/ /oci/
COPY packages/fsharp-runner/hostexec/ /hostexec/
RUN mkdir /packages && \
dotnet restore --locked-mode --packages /packages /restore/runner.csproj && \
dotnet build --configuration Release --no-restore --output /managed-out /restore/runner.csproj && \
dotnet publish --configuration Release --output /oci-out /oci/Oci.csproj && \
dotnet publish --configuration Release --output /host-out /hostexec/HostExec.csproj
FROM mcr.microsoft.com/dotnet/runtime:${RUNTIME} AS runtime
FROM scratch
COPY --from=build-root /rootfs/ /
COPY --from=runtime /usr/share/dotnet/ /usr/share/dotnet/
COPY --from=sdk /usr/share/dotnet/sdk/10.0.403/FSharp/ /opt/fsi/
COPY --from=sdk /managed-out/AWSSDK.Core.dll /managed-out/AWSSDK.S3.dll /managed-out/SharpCompress.dll /managed-out/ZstdSharp.dll /opt/fsi/packages/
COPY --from=sdk /oci-out/Oci.dll /opt/fsi/packages/Oci.dll
COPY --from=sdk /host-out/HostExec.dll /host-out/HostExec.deps.json /host-out/HostExec.runtimeconfig.json /opt/fsi/packages/
ENV DOTNET_MULTILEVEL_LOOKUP=0
ENV DOTNET_ROOT=/usr/share/dotnet
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV HOME=/tmp
USER 65532:65532
WORKDIR /work
ENTRYPOINT ["/usr/share/dotnet/dotnet", "/opt/fsi/fsi.dll", "--exec"]

View file

@ -0,0 +1,2 @@
**/bin
**/obj

View file

@ -0,0 +1,12 @@
{
"sdk": {
"version": "10.0.403",
"rollForward": "latestPatch"
},
"test": {
"runner": "Microsoft.Testing.Platform"
},
"msbuild-sdks": {
"MSTest.Sdk": "4.3.3"
}
}

View file

@ -0,0 +1,3 @@
using System.Runtime.Versioning;
[assembly: SupportedOSPlatform("linux")]

View file

@ -0,0 +1,15 @@
<Project Sdk="MSTest.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CA1515;NETSDK1188</NoWarn>
<RunnerSourceRoot Condition="'$(RunnerSourceRoot)' == ''">..</RunnerSourceRoot>
<TestingExtensionsProfile>None</TestingExtensionsProfile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="probe/**/*.cs" />
<ProjectReference Include="$(RunnerSourceRoot)/hostexec/HostExec.csproj" />
<ProjectReference Include="probe/HostExec.Probe.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,110 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Quorate.HostExec;
namespace Quorate.HostExec.Tests;
[TestClass]
[SupportedOSPlatform("linux")]
public sealed class HostExecTests
{
[TestMethod]
public async Task InstallsAndRunsGnuProxy()
{
string root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
try
{
Directory.CreateDirectory(Path.Combine(root, "usr/bin"));
string probe = Path.Combine(AppContext.BaseDirectory, "HostExec.Probe");
File.CreateSymbolicLink(Path.Combine(root, "usr/bin/gcc"), probe);
ToolRoot toolRoot = ToolRoot.Open(root);
string proxy = GnuProxy.Install(toolRoot, GnuTool.Gcc);
Assert.AreEqual(
"#!/usr/share/dotnet/dotnet /opt/fsi/packages/HostExec.dll\n",
await File.ReadAllTextAsync(proxy).ConfigureAwait(false));
Assert.IsTrue(File.GetUnixFileMode(proxy).HasFlag(UnixFileMode.UserExecute));
Assert.IsTrue(File.Exists($"{proxy}.json"));
IReadOnlyList<string> supplied = Proxy.WithSysroot(root, ["-c", "source.c"]);
CollectionAssert.AreEqual(new[] { $"--sysroot={root}", "-c", "source.c" }, supplied.ToArray());
IReadOnlyList<string> retained = Proxy.WithSysroot(root, [$"--sysroot={root}", "source.c"]);
Assert.HasCount(2, retained);
Assert.ThrowsExactly<ArgumentException>(() => Proxy.WithSysroot(root, ["--sysroot=/different", "source.c"]));
Assert.ThrowsExactly<ArgumentException>(() => Proxy.WithSysroot(root, ["--sysroot"]));
Assert.ThrowsExactly<ArgumentException>(() => ToolRoot.Open("relative"));
string assembly = typeof(ToolRoot).Assembly.Location;
string runtime = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
string dotnet = Path.Combine(Path.GetFullPath(Path.Combine(runtime, "../../..")), "dotnet");
await File.WriteAllTextAsync(proxy, $"#!{dotnet} {assembly}\n").ConfigureAwait(false);
var start = new ProcessStartInfo(proxy)
{
RedirectStandardOutput = true,
UseShellExecute = false
};
start.ArgumentList.Add("probe.c");
using Process child = Process.Start(start)
?? throw new InvalidOperationException("proxy did not start");
string output = await child.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
await child.WaitForExitAsync().ConfigureAwait(false);
Assert.AreEqual(0, child.ExitCode);
Assert.AreEqual($"--sysroot={root} probe.c\n", output);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, true);
}
}
}
[TestMethod]
public async Task ForwardsTermination()
{
string root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
try
{
Directory.CreateDirectory(Path.Combine(root, "usr/bin"));
string probe = Path.Combine(AppContext.BaseDirectory, "HostExec.Probe");
File.CreateSymbolicLink(Path.Combine(root, "usr/bin/gcc"), probe);
string proxy = GnuProxy.Install(ToolRoot.Open(root), GnuTool.Gcc);
string assembly = typeof(ToolRoot).Assembly.Location;
string runtime = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
string dotnet = Path.Combine(Path.GetFullPath(Path.Combine(runtime, "../../..")), "dotnet");
await File.WriteAllTextAsync(proxy, $"#!{dotnet} {assembly}\n").ConfigureAwait(false);
var start = new ProcessStartInfo(proxy)
{
RedirectStandardOutput = true,
UseShellExecute = false
};
start.ArgumentList.Add("--wait");
using Process child = Process.Start(start)
?? throw new InvalidOperationException("proxy did not start");
string childLine = await child.StandardOutput.ReadLineAsync()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false)
?? throw new InvalidDataException("probe did not report its process identity");
int childId = int.Parse(childLine, CultureInfo.InvariantCulture);
Assert.AreEqual(0, Kill(child.Id, 15));
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.AreEqual(143, child.ExitCode);
Assert.AreEqual(-1, Kill(childId, 0));
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, true);
}
}
}
[DllImport("libc", EntryPoint = "kill", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
private static extern int Kill(int processId, int signal);
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,9 @@
if (Array.IndexOf(args, "--wait") >= 0)
{
Console.WriteLine(Environment.ProcessId);
await Task.Delay(Timeout.InfiniteTimeSpan).ConfigureAwait(false);
}
else
{
Console.WriteLine(string.Join(" ", args));
}

View file

@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
[assembly: InternalsVisibleTo("HostExec.Tests")]
[assembly: SupportedOSPlatform("linux")]

View file

@ -0,0 +1,114 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Quorate.HostExec;
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
public enum GnuTool
{
C,
Cpp,
Gcc,
Gxx
}
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
public sealed class ToolRoot
{
private ToolRoot(string path)
{
Path = path;
}
public string Path { get; }
public static ToolRoot Open(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!System.IO.Path.IsPathFullyQualified(path))
{
throw new ArgumentException("tool root must be absolute", nameof(path));
}
var fullPath = System.IO.Path.GetFullPath(path);
if (!Directory.Exists(fullPath))
{
throw new DirectoryNotFoundException(fullPath);
}
return new ToolRoot(fullPath);
}
internal string Resolve(string relative)
{
if (System.IO.Path.IsPathFullyQualified(relative))
{
throw new ArgumentException("tool path must be relative", nameof(relative));
}
var path = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relative));
var prefix = Path + System.IO.Path.DirectorySeparatorChar;
if (!path.StartsWith(prefix, StringComparison.Ordinal))
{
throw new ArgumentException("tool path escapes its root", nameof(relative));
}
return path;
}
}
[SuppressMessage("Maintainability", "CA1515", Justification = "Loaded by F# actions.")]
public static class GnuProxy
{
private const string Launcher = "#!/usr/share/dotnet/dotnet /opt/fsi/packages/HostExec.dll\n";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static string Install(ToolRoot root, GnuTool tool)
{
ArgumentNullException.ThrowIfNull(root);
var name = Name(tool);
var relative = $"usr/bin/{name}";
var executable = root.Resolve(relative);
if (!File.Exists(executable))
{
throw new FileNotFoundException("GNU tool is unavailable", executable);
}
var target = root.Resolve($"usr/local/bin/{name}");
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(target)!);
File.WriteAllText(target, Launcher);
File.SetUnixFileMode(
target,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
File.WriteAllText($"{target}.json", JsonSerializer.Serialize(new ProxyFile(1, "gnu", relative), Json));
return target;
}
private static string Name(GnuTool tool) =>
tool switch
{
GnuTool.C => "cc",
GnuTool.Cpp => "c++",
GnuTool.Gcc => "gcc",
GnuTool.Gxx => "g++",
_ => throw new ArgumentOutOfRangeException(nameof(tool))
};
}
internal sealed record ProxyFile(int Version, string Kind, string File);

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<UseAppHost>false</UseAppHost>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using Quorate.HostExec;
internal static class Program
{
[SuppressMessage("Design", "CA1031", Justification = "The executable boundary maps failures to the proxy exit code.")]
private static async Task<int> Main(string[] args)
{
if (args.Length == 0)
{
await Console.Error.WriteLineAsync("proxy path is missing").ConfigureAwait(false);
return 125;
}
try
{
return await Proxy.Run(args[0], args[1..]).ConfigureAwait(false);
}
catch (Exception error)
{
await Console.Error.WriteLineAsync($"{error.GetType().Name}: {error.Message}").ConfigureAwait(false);
return 125;
}
}
}

View file

@ -0,0 +1,119 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace Quorate.HostExec;
internal static class Proxy
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true
};
public static async Task<int> Run(string wrapper, IReadOnlyList<string> arguments)
{
var fullWrapper = Path.GetFullPath(wrapper);
var root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullWrapper)!, "../../.."));
var config = Read($"{fullWrapper}.json");
if (config.Version != 1 || config.Kind != "gnu")
{
throw new InvalidDataException("unsupported proxy configuration");
}
var toolRoot = ToolRoot.Open(root);
var executable = toolRoot.Resolve(config.File);
if (!File.Exists(executable))
{
throw new FileNotFoundException("proxied tool is unavailable", executable);
}
var start = new ProcessStartInfo(executable)
{
UseShellExecute = false
};
foreach (var argument in WithSysroot(root, arguments))
{
start.ArgumentList.Add(argument);
}
using var stop = new CancellationTokenSource();
var signalCode = 143;
using var interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
{
context.Cancel = true;
signalCode = 130;
stop.Cancel();
});
using var terminate = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;
signalCode = 143;
stop.Cancel();
});
using var child = Process.Start(start) ?? throw new InvalidOperationException("proxied tool did not start");
try
{
await child.WaitForExitAsync(stop.Token).ConfigureAwait(false);
return child.ExitCode;
}
catch (OperationCanceledException) when (stop.IsCancellationRequested)
{
if (!child.HasExited)
{
child.Kill(true);
await child.WaitForExitAsync().ConfigureAwait(false);
}
return signalCode;
}
}
internal static IReadOnlyList<string> WithSysroot(string root, IReadOnlyList<string> arguments)
{
var fullRoot = Path.GetFullPath(root);
var found = false;
for (var index = 0; index < arguments.Count; index++)
{
var argument = arguments[index];
string? value = null;
if (argument == "--sysroot")
{
if (++index >= arguments.Count)
{
throw new ArgumentException("sysroot value is missing", nameof(arguments));
}
value = arguments[index];
}
else if (argument.StartsWith("--sysroot=", StringComparison.Ordinal))
{
value = argument[10..];
}
if (value is not null)
{
if (!Path.GetFullPath(value).Equals(fullRoot, StringComparison.Ordinal))
{
throw new ArgumentException("conflicting sysroot", nameof(arguments));
}
found = true;
}
}
return found ? arguments : [$"--sysroot={fullRoot}", .. arguments];
}
private static ProxyFile Read(string path)
{
var value = JsonSerializer.Deserialize<ProxyFile>(File.ReadAllBytes(path), Json);
return value ?? throw new InvalidDataException("proxy configuration is empty");
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="MSTest.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CA1515;NETSDK1188</NoWarn>
<RunnerSourceRoot Condition="'$(RunnerSourceRoot)' == ''">..</RunnerSourceRoot>
<TestingExtensionsProfile>None</TestingExtensionsProfile>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="$(RunnerSourceRoot)/oci/Oci.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,764 @@
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();
}

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Oci.Tests")]

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show more