feat(headless): complete portable single-session host

This commit is contained in:
Erik 2026-07-27 07:36:53 +02:00
parent fbebb91848
commit f8cb840fb1
30 changed files with 3571 additions and 32 deletions

View file

@ -0,0 +1,16 @@
namespace AcDream.Headless.Credentials;
internal sealed class HeadlessCredentialException : Exception
{
internal HeadlessCredentialException(string message)
: base(message)
{
}
internal HeadlessCredentialException(
string message,
Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,159 @@
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Credentials;
internal interface IHeadlessCredentialEnvironment
{
bool IsLinux { get; }
string? GetEnvironmentVariable(string name);
}
internal sealed class HeadlessCredentialEnvironment
: IHeadlessCredentialEnvironment
{
internal static HeadlessCredentialEnvironment Instance { get; } = new();
private HeadlessCredentialEnvironment()
{
}
public bool IsLinux => OperatingSystem.IsLinux();
public string? GetEnvironmentVariable(string name) =>
Environment.GetEnvironmentVariable(name);
}
internal sealed class HeadlessCredentialResolver
{
private const UnixFileMode NonUserPermissionMask =
UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;
private readonly TextReader _standardInput;
private readonly IHeadlessCredentialEnvironment _environment;
private readonly string _credentialBaseDirectory;
internal HeadlessCredentialResolver(
TextReader standardInput,
string credentialBaseDirectory,
IHeadlessCredentialEnvironment? environment = null)
{
_standardInput = standardInput
?? throw new ArgumentNullException(nameof(standardInput));
ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory);
_credentialBaseDirectory = Path.GetFullPath(
credentialBaseDirectory);
_environment =
environment ?? HeadlessCredentialEnvironment.Instance;
}
internal HeadlessCredentialSecret Resolve(
string sessionId,
HeadlessCredentialReference credential)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(credential);
string value;
try
{
value = credential.Provider switch
{
HeadlessCredentialProviderKind.Environment =>
ResolveEnvironment(credential.Reference),
HeadlessCredentialProviderKind.StandardInput =>
ResolveStandardInput(credential.Reference),
HeadlessCredentialProviderKind.File =>
ResolveFile(credential.Reference),
_ => throw new HeadlessCredentialException(
$"Session '{sessionId}' uses an unsupported credential provider."),
};
}
catch (HeadlessCredentialException)
{
throw;
}
catch (Exception error)
when (error is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException)
{
throw new HeadlessCredentialException(
$"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.",
error);
}
try
{
return new HeadlessCredentialSecret(
credential.Reference,
value.AsSpan());
}
finally
{
// The BCL returns immutable strings from environment, TextReader,
// and File APIs. Do not retain another copy in the resolver; the
// erasable char[] owner becomes the sole explicit retained copy.
value = string.Empty;
}
}
private string ResolveEnvironment(string reference)
{
string? value = _environment.GetEnvironmentVariable(reference);
if (string.IsNullOrEmpty(value))
{
throw new HeadlessCredentialException(
$"Credential environment reference '{reference}' is unavailable.");
}
return value;
}
private string ResolveStandardInput(string reference)
{
string? value = _standardInput.ReadLine();
if (string.IsNullOrEmpty(value))
{
throw new HeadlessCredentialException(
$"Credential standard-input reference '{reference}' is unavailable.");
}
return value;
}
private string ResolveFile(string reference)
{
string path = Path.GetFullPath(
reference,
_credentialBaseDirectory);
var file = new FileInfo(path);
if (file.LinkTarget is not null)
{
throw new HeadlessCredentialException(
$"Credential file reference '{reference}' cannot be a symbolic link.");
}
if (OperatingSystem.IsLinux() && _environment.IsLinux)
{
UnixFileMode mode = File.GetUnixFileMode(path);
if ((mode & NonUserPermissionMask) != 0
|| (mode & UnixFileMode.UserRead) == 0)
{
throw new HeadlessCredentialException(
$"Credential file reference '{reference}' must be readable only by its owner.");
}
}
string value = File.ReadAllText(path).TrimEnd('\r', '\n');
if (value.Length == 0)
{
throw new HeadlessCredentialException(
$"Credential file reference '{reference}' is empty.");
}
return value;
}
}

View file

@ -0,0 +1,50 @@
using System.Security.Cryptography;
namespace AcDream.Headless.Credentials;
/// <summary>
/// Retains a resolved credential in erasable memory. The network boundary
/// still requires one short-lived immutable string; callers must not retain
/// that value beyond construction of the connect request.
/// </summary>
internal sealed class HeadlessCredentialSecret : IDisposable
{
private char[]? _buffer;
internal HeadlessCredentialSecret(
string referenceId,
ReadOnlySpan<char> value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(referenceId);
if (value.IsEmpty)
{
throw new HeadlessCredentialException(
$"Credential '{referenceId}' resolved to an empty secret.");
}
ReferenceId = referenceId;
_buffer = value.ToArray();
}
internal string ReferenceId { get; }
internal bool IsDisposed => _buffer is null;
internal string Reveal()
{
ObjectDisposedException.ThrowIf(_buffer is null, this);
return new string(_buffer);
}
public void Dispose()
{
char[]? buffer = Interlocked.Exchange(ref _buffer, null);
if (buffer is null)
return;
CryptographicOperations.ZeroMemory(
System.Runtime.InteropServices.MemoryMarshal.AsBytes(
buffer.AsSpan()));
}
public override string ToString() =>
$"[redacted:{ReferenceId}]";
}