acdream/src/AcDream.App/Credentials/AppCredentialResolver.cs

156 lines
5.8 KiB
C#

using AcDream.App.Configuration;
using AcDream.App.Platform;
namespace AcDream.App.Credentials;
/// <summary>
/// Campaign LA slice LA1: resolves a <c>--session-config</c> session's
/// credential reference — the App-side mirror of
/// <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c> (see that
/// type's own file for why this is an independent port rather than a shared
/// reference). Supports the same three providers with the same semantics:
/// <c>environment</c> (read an env var), <c>standardInput</c> (read one line
/// from stdin, mirroring <c>HeadlessCredentialResolver.ResolveStandardInput</c>),
/// and <c>file</c> (read a credential file relative to a base directory,
/// rejecting symlinks and, on Linux, group/other-readable permissions).
/// </summary>
internal sealed class AppCredentialResolver
{
private const UnixFileMode NonUserPermissionMask =
UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;
private readonly TextReader _standardInput;
private readonly string _credentialBaseDirectory;
private readonly bool _isLinux;
/// <summary>
/// <paramref name="isLinux"/> is the caller-supplied platform-policy
/// value from <c>GraphicalHostPlatformServices</c>. This file still uses
/// <c>RuntimePlatformGuard.IsLinuxRuntime</c> below as the narrow
/// CA1416-recognized runtime guard required before calling
/// <c>File.GetUnixFileMode</c>; it does not independently select the host
/// platform or bypass the platform-services owner.
/// </summary>
internal AppCredentialResolver(
TextReader standardInput,
string credentialBaseDirectory,
bool isLinux)
{
_standardInput = standardInput
?? throw new ArgumentNullException(nameof(standardInput));
ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory);
_credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory);
_isLinux = isLinux;
}
internal AppCredentialSecret Resolve(
string sessionId,
SessionCredentialDescriptor credential)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(credential);
string value;
try
{
value = credential.Provider switch
{
SessionCredentialProviderKind.Environment =>
ResolveEnvironment(credential.Reference),
SessionCredentialProviderKind.StandardInput =>
ResolveStandardInput(credential.Reference),
SessionCredentialProviderKind.File =>
ResolveFile(credential.Reference),
_ => throw new AppCredentialException(
$"Session '{sessionId}' uses an unsupported credential provider."),
};
}
catch (AppCredentialException)
{
throw;
}
catch (Exception error)
when (error is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException)
{
throw new AppCredentialException(
$"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.",
error);
}
try
{
return new AppCredentialSecret(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 static string ResolveEnvironment(string reference)
{
string? value = Environment.GetEnvironmentVariable(reference);
if (string.IsNullOrEmpty(value))
{
throw new AppCredentialException(
$"Credential environment reference '{reference}' is unavailable.");
}
return value;
}
private string ResolveStandardInput(string reference)
{
string? value = _standardInput.ReadLine();
if (string.IsNullOrEmpty(value))
{
throw new AppCredentialException(
$"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 AppCredentialException(
$"Credential file reference '{reference}' cannot be a symbolic link.");
}
// RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard
// for File.GetUnixFileMode below; _isLinux is the separate,
// caller-injected value tests use for deterministic cross-platform
// coverage (see the constructor's own doc).
if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux)
{
UnixFileMode mode = File.GetUnixFileMode(path);
if ((mode & NonUserPermissionMask) != 0
|| (mode & UnixFileMode.UserRead) == 0)
{
throw new AppCredentialException(
$"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 AppCredentialException(
$"Credential file reference '{reference}' is empty.");
}
return value;
}
}