// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Reflection;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.TypeSystem;
namespace Aspire.Hosting.RemoteHost.Ats;
/// <summary>
/// Exception used to convert raw hosting failures into polyglot-friendly ATS errors.
/// </summary>
internal sealed class PolyglotCapabilityInvocationException : Exception
{
private PolyglotCapabilityInvocationException(
string capabilityId,
string errorCode,
string message,
string? parameterName = null,
string? expected = null,
string? actual = null,
Exception? innerException = null)
: base(message, innerException)
{
CapabilityId = capabilityId;
ErrorCode = errorCode;
ParameterName = parameterName;
Expected = expected;
Actual = actual;
}
/// <summary>
/// Gets the capability ID that failed.
/// </summary>
public string CapabilityId { get; }
/// <summary>
/// Gets the ATS error code to return.
/// </summary>
public string ErrorCode { get; }
/// <summary>
/// Gets the parameter name when the failure is tied to one.
/// </summary>
public string? ParameterName { get; }
/// <summary>
/// Gets the expected type description for type mismatch failures.
/// </summary>
public string? Expected { get; }
/// <summary>
/// Gets the actual type description for type mismatch failures.
/// </summary>
public string? Actual { get; }
/// <summary>
/// Creates a type mismatch error.
/// </summary>
public static PolyglotCapabilityInvocationException TypeMismatch(
string capabilityId,
string parameterName,
string message,
string expected,
string actual,
Exception? innerException = null)
{
return new PolyglotCapabilityInvocationException(
capabilityId,
AtsErrorCodes.TypeMismatch,
message,
parameterName,
expected,
actual,
innerException);
}
/// <summary>
/// Creates an internal error with a user-facing message.
/// </summary>
public static PolyglotCapabilityInvocationException InternalError(
string capabilityId,
string message,
Exception? innerException = null,
string? errorCode = null)
{
return new PolyglotCapabilityInvocationException(
capabilityId,
errorCode ?? AtsErrorCodes.InternalError,
message,
innerException: innerException);
}
/// <summary>
/// Converts this exception to a structured ATS <see cref="CapabilityException"/>.
/// </summary>
public CapabilityException ToCapabilityException()
{
var error = new AtsError
{
Code = ErrorCode,
Message = Message,
Capability = CapabilityId,
Details = ParameterName is null && Expected is null && Actual is null
? null
: new AtsErrorDetails
{
Parameter = ParameterName,
Expected = Expected,
Actual = Actual
}
};
return InnerException is not null
? new CapabilityException(error, InnerException)
: new CapabilityException(error);
}
}
/// <summary>
/// Formats capability failures for polyglot callers.
/// </summary>
internal static partial class PolyglotCapabilityErrorFormatter
{
public static PolyglotCapabilityInvocationException CreateInternalError(
string capabilityId,
string? polyglotMethodName,
string? clrMemberName,
JsonObject? args,
HandleRegistry handles,
Exception exception,
IReadOnlyDictionary<string, HashSet<string>>? polyglotMethodNamesByClrName = null,
string? targetParameterName = null,
string? errorCode = null)
{
var methodName = polyglotMethodName ?? AtsCapabilityScanner.DeriveMethodName(capabilityId);
var targetContext = TryGetTargetContext(args, handles, targetParameterName);
var scrubbedMessage = ScrubMessage(exception.Message, polyglotMethodName, clrMemberName, polyglotMethodNamesByClrName);
var message = exception.GetType().FullName == "Aspire.Hosting.DistributedApplicationException"
? scrubbedMessage
: $"Could not invoke '{methodName}'{targetContext}: {scrubbedMessage}";
return PolyglotCapabilityInvocationException.InternalError(
capabilityId,
message,
exception,
errorCode);
}
public static PolyglotCapabilityInvocationException CreateTypeMismatch(
string capabilityId,
string? polyglotMethodName,
JsonObject? args,
HandleRegistry handles,
string parameterName,
Type expectedType,
object actualValue,
string? targetParameterName = null,
Exception? innerException = null)
{
var methodName = polyglotMethodName ?? AtsCapabilityScanner.DeriveMethodName(capabilityId);
var targetContext = TryGetTargetContext(args, handles, targetParameterName);
var expectedDescription = DescribeExpectedType(expectedType);
var actualDescription = DescribeActualValue(actualValue);
var message = $"Could not invoke '{methodName}'{targetContext} because parameter '{parameterName}' expects {expectedDescription}, but got {actualDescription}.";
return PolyglotCapabilityInvocationException.TypeMismatch(
capabilityId,
parameterName,
message,
expectedDescription,
actualDescription,
innerException);
}
public static object ResolveHandleArgument(
string capabilityId,
string? polyglotMethodName,
JsonObject? args,
HandleRegistry handles,
string parameterName,
Type expectedType,
object handleObject,
string? targetParameterName = null)
{
if (TryConvertHandle(handleObject, expectedType, out var converted))
{
return converted!;
}
throw CreateTypeMismatch(
capabilityId,
polyglotMethodName,
args,
handles,
parameterName,
expectedType,
handleObject,
targetParameterName);
}
private static bool TryConvertHandle(object handleObject, Type expectedType, out object? converted)
{
if (expectedType.IsInstanceOfType(handleObject))
{
converted = handleObject;
return true;
}
if (CanUseOpenGenericResourceBuilder(expectedType, handleObject))
{
converted = handleObject;
return true;
}
if (HostingTypeHelpers.IsResourceBuilderType(handleObject.GetType()) && HostingTypeHelpers.IsResourceType(expectedType))
{
var resource = handleObject.GetType()
.GetProperty("Resource")?
.GetValue(handleObject);
if (resource is not null && expectedType.IsInstanceOfType(resource))
{
converted = resource;
return true;
}
}
converted = null;
return false;
}
internal static bool CanUseOpenGenericResourceBuilder(Type expectedType, object handleObject)
{
if (!expectedType.ContainsGenericParameters ||
!HostingTypeHelpers.IsResourceBuilderType(expectedType) ||
!HostingTypeHelpers.IsResourceBuilderType(handleObject.GetType()))
{
return false;
}
var expectedResourceType = TryGetBuilderResourceType(expectedType);
var actualResourceType = TryGetBuilderResourceType(handleObject);
return expectedResourceType is not null &&
actualResourceType is not null &&
SatisfiesExpectedResourceType(actualResourceType, expectedResourceType);
}
private static string TryGetTargetContext(JsonObject? args, HandleRegistry handles, string? targetParameterName)
{
if (args is null)
{
return string.Empty;
}
targetParameterName ??= "context";
if (!args.TryGetPropertyValue(targetParameterName, out var targetNode))
{
return string.Empty;
}
var resourceName = TryGetResourceName(targetNode, handles);
return resourceName is null ? string.Empty : $" on resource '{resourceName}'";
}
private static string? TryGetResourceName(JsonNode? node, HandleRegistry handles)
{
var handleRef = HandleRef.FromJsonNode(node);
if (handleRef is null || !handles.TryGet(handleRef.HandleId, out var handleObject, out _))
{
return null;
}
return TryGetResourceName(handleObject);
}
private static string? TryGetResourceName(object? value)
{
if (value is null)
{
return null;
}
if (HostingTypeHelpers.IsResourceType(value.GetType()))
{
return value.GetType().GetProperty("Name")?.GetValue(value)?.ToString();
}
if (HostingTypeHelpers.IsResourceBuilderType(value.GetType()))
{
var resource = value.GetType().GetProperty("Resource")?.GetValue(value);
return TryGetResourceName(resource);
}
return null;
}
private static string DescribeExpectedType(Type type)
{
if (type.IsGenericParameter)
{
return DescribeGenericParameter(type);
}
if (HostingTypeHelpers.IsResourceBuilderType(type) && type.IsGenericType)
{
return $"a resource builder for {DescribeResourceContract(type.GetGenericArguments()[0])}";
}
if (HostingTypeHelpers.IsResourceType(type))
{
return DescribeResourceContract(type);
}
return DescribeType(type, article: true);
}
private static string DescribeActualValue(object value)
{
if (HostingTypeHelpers.IsResourceBuilderType(value.GetType()))
{
return DescribeBuilderValue(value);
}
if (TryGetResourceName(value) is { } resourceName)
{
return $"resource '{resourceName}'";
}
return DescribeType(value.GetType(), article: true);
}
private static string DescribeBuilderValue(object value)
{
var resourceType = TryGetBuilderResourceType(value);
var resourceName = TryGetResourceName(value);
if (resourceType is not null)
{
var resourceDescription = DescribeResourceContract(resourceType);
return resourceName is not null
? $"a resource builder for {resourceDescription} named '{resourceName}'"
: $"a resource builder for {resourceDescription}";
}
return resourceName is not null
? $"a resource builder for resource '{resourceName}'"
: "a resource builder";
}
private static Type? TryGetBuilderResourceType(object value)
{
var valueType = value.GetType();
if (valueType.IsGenericType && HostingTypeHelpers.IsResourceBuilderType(valueType))
{
return valueType.GetGenericArguments()[0];
}
var resource = valueType.GetProperty("Resource")?.GetValue(value);
return resource?.GetType();
}
private static Type? TryGetBuilderResourceType(Type builderType)
{
if (builderType.IsGenericType && HostingTypeHelpers.IsResourceBuilderType(builderType))
{
return builderType.GetGenericArguments()[0];
}
var builderInterface = builderType.GetInterfaces().FirstOrDefault(static type =>
type.IsGenericType &&
string.Equals(type.GetGenericTypeDefinition().FullName, HostingTypeNames.ResourceBuilderInterface, StringComparison.Ordinal));
return builderInterface?.GetGenericArguments()[0];
}
private static bool SatisfiesExpectedResourceType(Type actualResourceType, Type expectedResourceType)
{
if (expectedResourceType.IsGenericParameter)
{
return SatisfiesGenericParameterConstraints(actualResourceType, expectedResourceType);
}
return !expectedResourceType.ContainsGenericParameters && expectedResourceType.IsAssignableFrom(actualResourceType);
}
private static bool SatisfiesGenericParameterConstraints(Type actualResourceType, Type genericParameter)
{
var specialConstraints = genericParameter.GenericParameterAttributes & GenericParameterAttributes.SpecialConstraintMask;
if (specialConstraints.HasFlag(GenericParameterAttributes.ReferenceTypeConstraint) && actualResourceType.IsValueType)
{
return false;
}
if (specialConstraints.HasFlag(GenericParameterAttributes.NotNullableValueTypeConstraint) &&
(!actualResourceType.IsValueType || Nullable.GetUnderlyingType(actualResourceType) is not null))
{
return false;
}
if (specialConstraints.HasFlag(GenericParameterAttributes.DefaultConstructorConstraint) &&
!actualResourceType.IsValueType &&
actualResourceType.GetConstructor(Type.EmptyTypes) is null)
{
return false;
}
foreach (var constraint in genericParameter.GetGenericParameterConstraints())
{
if (!constraint.IsAssignableFrom(actualResourceType))
{
return false;
}
}
return true;
}
private static string DescribeResourceContract(Type type)
{
if (type.IsGenericParameter)
{
return DescribeGenericParameter(type);
}
var typeName = type.Name;
// String literals are used because this project does not reference Aspire.Hosting
// and therefore cannot use typeof() for these interfaces.
return type.FullName switch
{
"Aspire.Hosting.ApplicationModel.IResourceWithConnectionString" => "a resource with a connection string",
"Aspire.Hosting.ApplicationModel.IResourceWithServiceDiscovery" => "a resource with service discovery",
"Aspire.Hosting.ApplicationModel.IResourceWithEndpoints" => "a resource with endpoints",
"Aspire.Hosting.ApplicationModel.IResourceWithEnvironment" => "a resource with environment variables",
_ when typeName == "ExternalServiceResource" => "an external service resource",
_ => DescribeType(type, article: true)
};
}
private static string DescribeGenericParameter(Type type)
{
var constraints = type.GetGenericParameterConstraints();
foreach (var constraint in constraints)
{
if (constraint.FullName == "Aspire.Hosting.ApplicationModel.IResource")
{
continue;
}
if (HostingTypeHelpers.IsResourceType(constraint))
{
return DescribeResourceContract(constraint);
}
}
return "a compatible value";
}
private static string DescribeType(Type type, bool article)
{
var name = type.IsGenericType
? type.Name[..type.Name.IndexOf('`')]
: type.Name;
name = name switch
{
"String" => "string",
"Int32" => "integer",
"Boolean" => "boolean",
"Uri" => "URI",
_ => name.EndsWith("Resource", StringComparison.Ordinal) ? name[..^"Resource".Length] + " resource" : name
};
if (!article)
{
return name;
}
var articleValue = name.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? string.Empty
: StartsWithVowelSound(name) ? "an " : "a ";
return articleValue + name;
}
private static bool StartsWithVowelSound(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
// Words starting with vowel letters but consonant sounds (e.g., "URI" = "yoo-are-eye")
if (value.StartsWith("URI", StringComparison.Ordinal) ||
value.StartsWith("URL", StringComparison.Ordinal))
{
return false;
}
return "AEIOUaeiou".Contains(value[0]);
}
private static string ScrubMessage(string message, string? polyglotMethodName, string? clrMemberName, IReadOnlyDictionary<string, HashSet<string>>? polyglotMethodNamesByClrName)
{
if (string.IsNullOrEmpty(message))
{
return message;
}
message = RewritePolyglotUnfriendlyGuidance(message);
if (!string.IsNullOrEmpty(polyglotMethodName) && !string.IsNullOrEmpty(clrMemberName))
{
message = Regex.Replace(
message,
$@"\b{Regex.Escape(clrMemberName)}\b",
polyglotMethodName);
}
if (polyglotMethodNamesByClrName is not null)
{
// Replace longest CLR names first to avoid partial matches.
foreach (var alias in polyglotMethodNamesByClrName.OrderByDescending(static pair => pair.Key.Length))
{
// Pick the first polyglot name from the set (typically there is only one).
var replacement = alias.Value.First();
message = Regex.Replace(
message,
$@"\b{Regex.Escape(alias.Key)}\b",
replacement);
}
}
message = message.ReplaceLineEndings(" ");
message = DoubleWhitespaceRegex().Replace(message, " ");
message = ControlCharacterRegex().Replace(message, " ");
return message.Trim();
}
// Rewrites guidance baked into framework exception messages so it makes sense for
// polyglot (non-.NET) app hosts that flow through this formatter. Native .NET app
// hosts never go through PolyglotCapabilityErrorFormatter, so their messages are
// unchanged.
//
// ASP.NET Core Kestrel surfaces the missing/untrusted HTTPS dev-cert state by
// throwing a plain InvalidOperationException with no HResult and no specific
// exception type, so the message is the only signal available. The text is
// sourced from the resx entry `NoCertSpecifiedNoDevelopmentCertificateFound` and
// is thrown unconditionally from `TlsConfigurationLoader.UseHttpsWithDefaults`:
// https://github.com/dotnet/aspnetcore/blob/main/src/Servers/Kestrel/Core/src/CoreStrings.resx
// https://github.com/dotnet/aspnetcore/blob/main/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs
//
// The fwlink identifier `848054` has been the unique sentinel for this exact
// error since .NET Core 2.1 (it was added when the dev-cert tooling shipped) and
// does not appear in any other Kestrel error, so we use it as a single detection
// anchor and replace the entire message with our own polyglot-friendly text. This
// is more robust than search/replacing pieces of the original message because it
// doesn't depend on the exact wording of any sentence other than the fwlink itself.
//
// If a future framework release reworks the message such that the fwlink is no
// longer present, the rewrite degrades gracefully: detection misses, the original
// (less friendly) text propagates unchanged, and no incorrect substitution occurs.
//
// See https://github.com/microsoft/aspire/issues/17273.
private const string KestrelHttpsFwlinkSentinel = "https://go.microsoft.com/fwlink/?linkid=848054";
private const string AspirePolyglotDevCertMessage =
"Unable to configure HTTPS endpoint. No server certificate was specified, and the default " +
"developer certificate could not be found or is out of date. To generate and trust a developer " +
"certificate run 'aspire certs trust'. For more information on configuring HTTPS see " +
"https://aspire.dev/docs/.";
private static string RewritePolyglotUnfriendlyGuidance(string message)
{
if (message.Contains(KestrelHttpsFwlinkSentinel, StringComparison.Ordinal))
{
return AspirePolyglotDevCertMessage;
}
return message;
}
[GeneratedRegex(@"\s{2,}")]
private static partial Regex DoubleWhitespaceRegex();
[GeneratedRegex(@"\p{Cc}+")]
private static partial Regex ControlCharacterRegex();
}