Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<Compile Include="$(RepoRoot)src\Shared\AzureRoleAssignmentUtils.cs" />
<Compile Include="..\Aspire.Hosting\ApplicationModel\ModelName.cs" Link="ModelName.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ public static IResourceBuilder<AzureWebPubSubHubResource> AddHub(this IResourceB
// Use the resource name as the hub name if it's not provided
hubName ??= name;

ModelName.ValidateName(nameof(Resource), name);

AzureWebPubSubHubResource? hubResource;
if (!builder.Resource.Hubs.TryGetValue(hubName, out hubResource))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,7 @@ private static void AddInstaller<TResource>(IResourceBuilder<TResource> resource
}

var installer = new JavaScriptInstallerResource(installerName, resource.Resource.WorkingDirectory);
installer.Annotations.Add(NameValidationPolicyAnnotation.None);
var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
.WithParentRelationship(resource.Resource)
.ExcludeFromManifest()
Expand Down
16 changes: 4 additions & 12 deletions src/Aspire.Hosting.JavaScript/JavaScriptInstallerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,7 @@ namespace Aspire.Hosting.JavaScript;
/// <summary>
/// A resource that represents a package installer for a JavaScript app.
/// </summary>
public class JavaScriptInstallerResource : ExecutableResource
{
/// <summary>
/// Initializes a new instance of the <see cref="JavaScriptInstallerResource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command.</param>
public JavaScriptInstallerResource(string name, string workingDirectory)
: base(name, "node", workingDirectory, skipValidation: true) // Validation is skipped because appending "-installer" to the parent name can exceed the 64-char limit.
{
}
}
/// <param name="name">The name of the resource.</param>
/// <param name="workingDirectory">The working directory to use for the command.</param>
public class JavaScriptInstallerResource(string name, string workingDirectory)
: ExecutableResource(name, "node", workingDirectory);
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,7 @@ private static void AddInstaller<T>(IResourceBuilder<T> builder, bool install) w
}

var installer = new PythonInstallerResource(installerName, builder.Resource);
installer.Annotations.Add(NameValidationPolicyAnnotation.None);
var installerBuilder = builder.ApplicationBuilder.AddResource(installer)
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest()
Expand Down Expand Up @@ -1393,6 +1394,7 @@ private static void CreateVenvCreatorIfNeeded<T>(IResourceBuilder<T> builder) wh

// Create new venv creator resource
var venvCreator = new PythonVenvCreatorResource(venvCreatorName, builder.Resource, venvPath);
venvCreator.Annotations.Add(NameValidationPolicyAnnotation.None);

// Determine which Python command to use
string pythonCommand;
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.Python/PythonInstallerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ namespace Aspire.Hosting.Python;
/// <param name="name">The name of the resource.</param>
/// <param name="parent">The parent Python application resource.</param>
internal sealed class PythonInstallerResource(string name, PythonAppResource parent)
: ExecutableResource(name, "python", parent.WorkingDirectory, skipValidation: true) // Validation is skipped because appending "-installer" to the parent name can exceed the 64-char limit.
: ExecutableResource(name, "python", parent.WorkingDirectory)
{
}
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.Python/PythonVenvCreatorResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Aspire.Hosting.Python;
/// <param name="parent">The parent Python application resource.</param>
/// <param name="venvPath">The path where the virtual environment should be created.</param>
internal sealed class PythonVenvCreatorResource(string name, PythonAppResource parent, string venvPath)
: ExecutableResource(name, "python", parent.WorkingDirectory, skipValidation: true) // Validation is skipped because appending "-venv-creator" to the parent name can exceed the 64-char limit.
: ExecutableResource(name, "python", parent.WorkingDirectory)
{
/// <summary>
/// Gets the path where the virtual environment will be created.
Expand Down
10 changes: 1 addition & 9 deletions src/Aspire.Hosting/ApplicationModel/ExecutableResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,7 @@ public class ExecutableResource : Resource, IResourceWithEnvironment, IResourceW
/// <param name="name">The name of the resource.</param>
/// <param name="command">The command to execute.</param>
/// <param name="workingDirectory">The working directory of the executable. Can be empty.</param>
public ExecutableResource(string name, string command, string workingDirectory) : this(name, command, workingDirectory, skipValidation: false)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="ExecutableResource"/> class without name validation.
/// Used for internal resources that are never deployed and don't need to follow naming rules.
/// </summary>
internal ExecutableResource(string name, string command, string workingDirectory, bool skipValidation) : base(name, skipValidation)
public ExecutableResource(string name, string command, string workingDirectory) : base(name)
{
Annotations.Add(new ExecutableAnnotation
{
Expand Down
86 changes: 59 additions & 27 deletions src/Aspire.Hosting/ApplicationModel/ModelName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ namespace Aspire.Hosting.ApplicationModel;
/// </summary>
internal static class ModelName
{
internal static bool IsValidName(string target, string name) => TryValidateName(target, name, out _);
public const int DefaultMaxLength = 64;
public const bool DefaultValidateStartsWithLetter = true;
public const bool DefaultValidateAllowedCharacters = true;
public const bool DefaultValidateNoConsecutiveHyphens = true;
public const bool DefaultValidateNoTrailingHyphen = true;

internal static bool IsValidName(string target, string name) => TryValidateName(target, name, DefaultMaxLength, DefaultValidateStartsWithLetter, DefaultValidateAllowedCharacters, DefaultValidateNoConsecutiveHyphens, DefaultValidateNoTrailingHyphen, out _);

internal static void ValidateName(string target, string name)
{
Expand All @@ -24,60 +30,86 @@ internal static void ValidateName(string target, string name)
}
#pragma warning restore CA1510

if (!TryValidateName(target, name, out var validationMessage))
if (!TryValidateName(target, name, DefaultMaxLength, DefaultValidateStartsWithLetter, DefaultValidateAllowedCharacters, DefaultValidateNoConsecutiveHyphens, DefaultValidateNoTrailingHyphen, out var validationMessage))
{
throw new ArgumentException(validationMessage, nameof(name));
}
}

internal static void ValidateName(string target, string name, int? maxLength, bool validateStartsWithLetter, bool validateAllowedCharacters, bool validateNoConsecutiveHyphens, bool validateNoTrailingHyphen)
{
#pragma warning disable CA1510 // Use ArgumentNullException throw helper
// This file is included in projects targeting netstandard2.0
if (target is null)
{
throw new ArgumentNullException(nameof(target));
}
if (name is null)
{
throw new ArgumentNullException(nameof(name));
}
#pragma warning restore CA1510

if (!TryValidateName(target, name, maxLength, validateStartsWithLetter, validateAllowedCharacters, validateNoConsecutiveHyphens, validateNoTrailingHyphen, out var validationMessage))
{
throw new ArgumentException(validationMessage, nameof(name));
}
}

/// <summary>
/// Validate that a model name is valid.
/// - Must start with an ASCII letter.
/// - Must contain only ASCII letters, digits, and hyphens.
/// - Must not end with a hyphen.
/// - Must not contain consecutive hyphens.
/// - Must be between 1 and 64 characters long.
/// Validate that a model name is valid using the default validation rules.
/// </summary>
internal static bool TryValidateName(string target, string name, out string? validationMessage)
{
return TryValidateName(target, name, DefaultMaxLength, DefaultValidateStartsWithLetter, DefaultValidateAllowedCharacters, DefaultValidateNoConsecutiveHyphens, DefaultValidateNoTrailingHyphen, out validationMessage);
}

/// <summary>
/// Validate that a model name is valid using the specified validation rules.
/// </summary>
internal static bool TryValidateName(string target, string name, int? maxLength, bool validateStartsWithLetter, bool validateAllowedCharacters, bool validateNoConsecutiveHyphens, bool validateNoTrailingHyphen, out string? validationMessage)
{
validationMessage = null;

if (name.Length < 1 || name.Length > 64)
if (maxLength is not null && (name.Length < 1 || name.Length > maxLength.Value))
{
validationMessage = $"{target} name '{name}' is invalid. Name must be between 1 and 64 characters long.";
validationMessage = $"{target} name '{name}' is invalid. Name must be between 1 and {maxLength.Value} characters long.";
return false;
}

var lastCharacterHyphen = false;
for (var i = 0; i < name.Length; i++)
if (validateAllowedCharacters || validateNoConsecutiveHyphens)
{
if (name[i] == '-')
var lastCharacterHyphen = false;
for (var i = 0; i < name.Length; i++)
{
if (lastCharacterHyphen)
if (name[i] == '-')
{
validationMessage = $"{target} name '{name}' is invalid. Name cannot contain consecutive hyphens.";
if (validateNoConsecutiveHyphens && lastCharacterHyphen)
{
validationMessage = $"{target} name '{name}' is invalid. Name cannot contain consecutive hyphens.";
return false;
}
lastCharacterHyphen = true;
}
else if (validateAllowedCharacters && !IsAsciiLetterOrDigit(name[i]))
{
validationMessage = $"{target} name '{name}' is invalid. Name must contain only ASCII letters, digits, and hyphens.";
return false;
}
lastCharacterHyphen = true;
}
else if (!IsAsciiLetterOrDigit(name[i]))
{
validationMessage = $"{target} name '{name}' is invalid. Name must contain only ASCII letters, digits, and hyphens.";
return false;
}
else
{
lastCharacterHyphen = false;
else
{
lastCharacterHyphen = false;
}
}
}

if (!IsAsciiLetter(name[0]))
if (validateStartsWithLetter && name.Length > 0 && !IsAsciiLetter(name[0]))
{
validationMessage = $"{target} name '{name}' is invalid. Name must start with an ASCII letter.";
return false;
}

if (name[name.Length - 1] == '-')
if (validateNoTrailingHyphen && name.Length > 0 && name[name.Length - 1] == '-')
{
validationMessage = $"{target} name '{name}' is invalid. Name cannot end with a hyphen.";
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents an annotation that customizes the name validation rules applied to a resource when
/// it is added to the application model.
/// </summary>
/// <remarks>
/// By default, resource names must be 1–64 ASCII characters long, start with a letter, contain only letters, digits,
/// and hyphens, and not contain consecutive or trailing hyphens. Use this annotation to relax individual rules.
/// The <see cref="None"/> policy disables every rule, which is useful for internal resources (such as installers
/// or rebuilders) that append suffixes to user-provided resource names and are never deployed.
/// </remarks>
[DebuggerDisplay("Type = {GetType().Name,nq}")]
public sealed class NameValidationPolicyAnnotation : IResourceAnnotation
{
/// <summary>
/// The default policy that enforces all standard name validation rules.
/// </summary>
public static readonly NameValidationPolicyAnnotation Default = new();

/// <summary>
/// A policy that disables all name validation rules.
/// </summary>
public static readonly NameValidationPolicyAnnotation None = new()
{
MaxLength = null,
ValidateStartsWithLetter = false,
ValidateAllowedCharacters = false,
ValidateNoConsecutiveHyphens = false,
ValidateNoTrailingHyphen = false
};

/// <summary>
/// Gets the maximum allowed length for the resource name, or <see langword="null"/> to disable length validation.
/// Defaults to <see cref="ModelName.DefaultMaxLength"/>.
/// </summary>
public int? MaxLength { get; init; } = ModelName.DefaultMaxLength;

/// <summary>
/// Gets a value indicating whether to validate that the name starts with an ASCII letter.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool ValidateStartsWithLetter { get; init; } = ModelName.DefaultValidateStartsWithLetter;

/// <summary>
/// Gets a value indicating whether to validate that the name contains only ASCII letters, digits, and hyphens.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool ValidateAllowedCharacters { get; init; } = ModelName.DefaultValidateAllowedCharacters;

/// <summary>
/// Gets a value indicating whether to validate that the name does not contain consecutive hyphens.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool ValidateNoConsecutiveHyphens { get; init; } = ModelName.DefaultValidateNoConsecutiveHyphens;

/// <summary>
/// Gets a value indicating whether to validate that the name does not end with a hyphen.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool ValidateNoTrailingHyphen { get; init; } = ModelName.DefaultValidateNoTrailingHyphen;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal sealed class ProjectRebuilderResource : ExecutableResource, IResourceWi
/// <param name="parent">The project resource this rebuilder is associated with.</param>
/// <param name="projectPath">The path to the project file.</param>
public ProjectRebuilderResource(string name, ProjectResource parent, string projectPath)
: base(name, "dotnet", Path.GetDirectoryName(projectPath)!, skipValidation: true) // Validation is skipped because appending "-rebuilder" to the parent name can exceed the 64-char limit.
: base(name, "dotnet", Path.GetDirectoryName(projectPath)!)
{
Parent = parent;
ProjectPath = projectPath;
Expand Down
17 changes: 2 additions & 15 deletions src/Aspire.Hosting/ApplicationModel/Resource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,9 @@ public abstract class Resource : IResource
/// Initializes a new instance of the <see cref="Resource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
protected Resource(string name) : this(name, skipValidation: false)
protected Resource(string name)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="Resource"/> class without name validation.
/// Used for internal resources that are never deployed and don't need to follow naming rules.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="skipValidation">When <c>true</c>, skips name validation.</param>
internal Resource(string name, bool skipValidation)
{
if (!skipValidation)
{
ModelName.ValidateName(nameof(Resource), name);
}
ArgumentException.ThrowIfNullOrWhiteSpace(name);

Name = name;
}
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,12 @@ public DistributedApplication Build()
throw new DistributedApplicationException($"Multiple resources with the name '{duplicateResourceName}'. Resource names are case-insensitive.");
}

// Validate resource names. Resources added directly to the collection bypass AddResource validation.
foreach (var resource in Resources)
{
ValidateResourceName(resource);
}

var application = new DistributedApplication(_innerBuilder.Build());

_executionContextOptions.ServiceProvider = application.Services.GetRequiredService<IServiceProvider>();
Expand All @@ -757,6 +763,8 @@ public IResourceBuilder<T> AddResource<T>(T resource) where T : IResource
{
ArgumentNullException.ThrowIfNull(resource);

ValidateResourceName(resource);

if (Resources.FirstOrDefault(r => string.Equals(r.Name, resource.Name, StringComparisons.ResourceName)) is { } existingResource)
{
throw new DistributedApplicationException($"Cannot add resource of type '{resource.GetType()}' with name '{resource.Name}' because resource of type '{existingResource.GetType()}' with that name already exists. Resource names are case-insensitive.");
Expand Down Expand Up @@ -834,6 +842,16 @@ internal static void RemoveUserSecretsSource(IConfigurationManager configuration
}
}

private static void ValidateResourceName(IResource resource)
{
if (!resource.TryGetLastAnnotation<NameValidationPolicyAnnotation>(out var policy))
{
policy = NameValidationPolicyAnnotation.Default;
}

ModelName.ValidateName(nameof(Resource), resource.Name, policy.MaxLength, policy.ValidateStartsWithLetter, policy.ValidateAllowedCharacters, policy.ValidateNoConsecutiveHyphens, policy.ValidateNoTrailingHyphen);
}

private static bool PathsEqual(string left, string right) =>
string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,7 @@ private static void AddRebuilderResource<TProjectResource>(IResourceBuilder<TPro

var rebuilderName = $"{projectResource.Name}-rebuilder";
var rebuilder = new ProjectRebuilderResource(rebuilderName, projectResource, projectMetadata.ProjectPath);
rebuilder.Annotations.Add(NameValidationPolicyAnnotation.None);
var rebuilderBuilder = builder.ApplicationBuilder.AddResource(rebuilder);

rebuilderBuilder
Expand Down
16 changes: 16 additions & 0 deletions tests/Aspire.Hosting.JavaScript.Tests/PackageInstallationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,22 @@ public void WithBun_DefaultsArgsInPublishMode()
Assert.Equal(["install", "--frozen-lockfile"], installCommand.Args);
}

[Fact]
public void InstallerResourceHasNameValidationPolicyAnnotation()
{
var builder = DistributedApplication.CreateBuilder();

builder.AddJavaScriptApp("nodeApp", "./test-app")
.WithNpm(install: true);

using var app = builder.Build();

var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var installerResource = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
Assert.True(installerResource.TryGetLastAnnotation<NameValidationPolicyAnnotation>(out var policy));
Assert.Same(NameValidationPolicyAnnotation.None, policy);
}

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
}
Loading
Loading