Skip to content

Commit ba380dc

Browse files
committed
Encode connection properties and endpoints env var names
1 parent 0ff1b5c commit ba380dc

File tree

9 files changed

+171
-9
lines changed

9 files changed

+171
-9
lines changed

src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<Compile Include="$(SharedDir)StringComparers.cs" LinkBase="Shared" />
1515
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared" />
1616
<Compile Include="$(SharedDir)PortAllocator.cs" LinkBase="Shared" />
17+
<Compile Include="$(SharedDir)EnvironmentVariableNameEncoder.cs" LinkBase="Shared" />
1718
<Compile Include="$(SharedDir)/Model/KnownRelationshipTypes.cs" LinkBase="Shared" />
1819
</ItemGroup>
1920

src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,8 @@ public static IResourceBuilder<TResource> WithReference<TResource>(this IResourc
432432
{
433433
var serviceName = targetResource.Resource.Name;
434434
var endpointName = port.TargetEndpoint.EndpointName;
435+
var encodedServiceName = EnvironmentVariableNameEncoder.Encode(serviceName);
436+
var encodedEndpointName = EnvironmentVariableNameEncoder.Encode(endpointName);
435437

436438
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery))
437439
{
@@ -440,7 +442,8 @@ public static IResourceBuilder<TResource> WithReference<TResource>(this IResourc
440442

441443
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints))
442444
{
443-
context.EnvironmentVariables[$"{serviceName.ToUpperInvariant()}_{endpointName.ToUpperInvariant()}"] = port.TunnelEndpoint;
445+
var endpointKey = $"{encodedServiceName.ToUpperInvariant()}_{encodedEndpointName.ToUpperInvariant()}";
446+
context.EnvironmentVariables[endpointKey] = port.TunnelEndpoint;
444447
}
445448
}
446449
});

src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<ItemGroup>
1919
<Compile Include="..\Shared\PathNormalizer.cs" Link="Shared\PathNormalizer.cs" />
2020
<Compile Include="..\Shared\StringComparers.cs" Link="Shared\StringComparers.cs" />
21+
<Compile Include="..\Shared\EnvironmentVariableNameEncoder.cs" Link="Shared\EnvironmentVariableNameEncoder.cs" />
2122
</ItemGroup>
2223

2324
<ItemGroup>

src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ private static void ApplyOtlpConfigurationToPlatform<T>(
158158

159159
// Also remove the {RESOURCENAME}_{ENDPOINTNAME} format variable (e.g., MAUIAPP-OTLP_OTLP)
160160
// The resource name keeps its case/dashes, endpoint name is uppercased
161-
var directEndpointKey = $"{tunnelConfig.OtlpStub.Name.ToUpperInvariant()}_OTLP";
161+
var directEndpointKey = $"{EnvironmentVariableNameEncoder.Encode(tunnelConfig.OtlpStub.Name).ToUpperInvariant()}_OTLP";
162162
context.EnvironmentVariables.Remove(directEndpointKey);
163163
}
164164
});

src/Aspire.Hosting/Aspire.Hosting.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<Compile Include="$(SharedDir)IConfigurationExtensions.cs" Link="Utils\IConfigurationExtensions.cs" />
2222
<Compile Include="$(SharedDir)KnownFormats.cs" Link="Utils\KnownFormats.cs" />
2323
<Compile Include="$(SharedDir)KnownResourceNames.cs" Link="Utils\KnownResourceNames.cs" />
24+
<Compile Include="$(SharedDir)EnvironmentVariableNameEncoder.cs" Link="ApplicationModel\EnvironmentVariableNameEncoder.cs" />
2425
<Compile Include="$(SharedDir)KnownConfigNames.cs" Link="Utils\KnownConfigNames.cs" />
2526
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
2627
<Compile Include="$(SharedDir)StringComparers.cs" Link="Utils\StringComparers.cs" />

src/Aspire.Hosting/ResourceBuilderExtensions.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ private static Action<EnvironmentCallbackContext> CreateEndpointReferenceEnviron
418418
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints))
419419
{
420420
var serviceKey = name is null ? serviceName.ToUpperInvariant() : name;
421-
context.EnvironmentVariables[$"{serviceKey}_{endpointName.ToUpperInvariant()}"] = endpoint;
421+
context.EnvironmentVariables[$"{EnvironmentVariableNameEncoder.Encode(serviceKey)}_{endpointName.ToUpperInvariant()}"] = endpoint;
422422
}
423423

424424
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery))
@@ -491,7 +491,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
491491
var prefix = connectionName switch
492492
{
493493
"" => "",
494-
_ => $"{connectionName.ToUpperInvariant()}_"
494+
_ => $"{EnvironmentVariableNameEncoder.Encode(connectionName).ToUpperInvariant()}_"
495495
};
496496

497497
SplatConnectionProperties(resource, prefix, context);
@@ -607,7 +607,7 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
607607

608608
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints))
609609
{
610-
builder.WithEnvironment($"{name}", uri.ToString());
610+
builder.WithEnvironment(EnvironmentVariableNameEncoder.Encode(name), uri.ToString());
611611
}
612612

613613
return builder;
@@ -637,8 +637,8 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
637637
{
638638
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints))
639639
{
640-
var envVarName = $"{externalService.Resource.Name.ToUpperInvariant()}";
641-
builder.WithEnvironment(envVarName, uri.ToString());
640+
var encodedResourceName = EnvironmentVariableNameEncoder.Encode(externalService.Resource.Name);
641+
builder.WithEnvironment(encodedResourceName.ToUpperInvariant(), uri.ToString());
642642
}
643643

644644
if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery))
@@ -653,17 +653,19 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(this IR
653653
{
654654
string discoveryEnvVarName;
655655
string endpointEnvVarName;
656+
var encodedResourceName = EnvironmentVariableNameEncoder.Encode(externalService.Resource.Name);
656657

657658
if (context.ExecutionContext.IsPublishMode)
658659
{
659660
// In publish mode we can't read the parameter value to get the scheme so use 'default'
660661
discoveryEnvVarName = $"services__{externalService.Resource.Name}__default__0";
661-
endpointEnvVarName = externalService.Resource.Name.ToUpperInvariant();
662+
endpointEnvVarName = encodedResourceName.ToUpperInvariant();
662663
}
663664
else if (ExternalServiceResource.UrlIsValidForExternalService(await externalService.Resource.UrlParameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false), out var uri, out var message))
664665
{
665666
discoveryEnvVarName = $"services__{externalService.Resource.Name}__{uri.Scheme}__0";
666-
endpointEnvVarName = $"{externalService.Resource.Name.ToUpperInvariant()}_{uri.Scheme.ToUpperInvariant()}";
667+
var encodedScheme = EnvironmentVariableNameEncoder.Encode(uri.Scheme);
668+
endpointEnvVarName = $"{encodedResourceName.ToUpperInvariant()}_{encodedScheme.ToUpperInvariant()}";
667669
}
668670
else
669671
{
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
using System.Text.RegularExpressions;
4+
5+
namespace Aspire.Hosting.ApplicationModel;
6+
7+
/// <summary>
8+
/// Provides helpers for producing environment variable friendly names.
9+
/// </summary>
10+
internal static partial class EnvironmentVariableNameEncoder
11+
{
12+
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant)]
13+
private static partial Regex ValidNameRegex();
14+
15+
/// <summary>
16+
/// Returns an environment-variable-safe representation of the provided name.
17+
/// </summary>
18+
/// <param name="name">The raw name.</param>
19+
/// <returns>A string that is safe to use as part of an environment variable.</returns>
20+
public static string Encode(string name)
21+
{
22+
if (string.IsNullOrEmpty(name))
23+
{
24+
return "_";
25+
}
26+
27+
if (ValidNameRegex().IsMatch(name))
28+
{
29+
return name;
30+
}
31+
32+
Span<char> buffer = stackalloc char[name.Length + 1];
33+
var index = 0;
34+
35+
if (char.IsAsciiDigit(name[0]))
36+
{
37+
buffer[index++] = '_';
38+
}
39+
40+
foreach (var c in name)
41+
{
42+
buffer[index++] = char.IsAsciiLetterOrDigit(c) ? c : '_';
43+
}
44+
45+
return new string(buffer[..index]);
46+
}
47+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Hosting.Tests.Utils;
5+
6+
public class EnvironmentVariableNameEncoderTests
7+
{
8+
[Theory]
9+
[InlineData("resource")]
10+
[InlineData("RESOURCE_123")]
11+
[InlineData("Already_Valid_Name")]
12+
public void Encode_WhenNameAlreadyValid_ReturnsOriginalValue(string name)
13+
{
14+
var result = EnvironmentVariableNameEncoder.Encode(name);
15+
16+
Assert.Equal(name, result);
17+
}
18+
19+
[Theory]
20+
[InlineData("service-name", "service_name")]
21+
[InlineData("service.name", "service_name")]
22+
[InlineData("multi--segment", "multi__segment")]
23+
public void Encode_ReplacesInvalidCharactersWithUnderscore(string name, string expected)
24+
{
25+
var result = EnvironmentVariableNameEncoder.Encode(name);
26+
27+
Assert.Equal(expected, result);
28+
}
29+
30+
[Theory]
31+
[InlineData("1service", "_1service")]
32+
[InlineData("9-service", "_9_service")]
33+
public void Encode_WhenNameStartsWithDigit_PrependsUnderscore(string name, string expected)
34+
{
35+
var result = EnvironmentVariableNameEncoder.Encode(name);
36+
37+
Assert.Equal(expected, result);
38+
}
39+
40+
[Fact]
41+
public void Encode_WhenNameIsNullOrEmpty_ReturnsSingleUnderscore()
42+
{
43+
Assert.Equal("_", EnvironmentVariableNameEncoder.Encode(null!));
44+
Assert.Equal("_", EnvironmentVariableNameEncoder.Encode(string.Empty));
45+
}
46+
}

tests/Aspire.Hosting.Tests/WithReferenceTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,46 @@ public async Task ResourceWithSingleEndpointProducesSimplifiedEnvironmentVariabl
3636
Assert.Same(projectA.Resource, r.Resource);
3737
}
3838

39+
[Fact]
40+
public async Task ResourceNamesWithDashesAreEncodedInEnvironmentVariables()
41+
{
42+
using var builder = TestDistributedApplicationBuilder.Create();
43+
44+
var projectA = builder.AddProject<ProjectA>("project-a")
45+
.WithHttpsEndpoint(1000, 2000, "mybinding")
46+
.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000));
47+
48+
var projectB = builder.AddProject<ProjectB>("consumer")
49+
.WithReference(projectA);
50+
51+
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
52+
53+
Assert.Equal("https://localhost:2000", config["services__project-a__mybinding__0"]);
54+
Assert.Equal("https://localhost:2000", config["PROJECT_A_MYBINDING"]);
55+
Assert.DoesNotContain("services__project_a__mybinding__0", config.Keys);
56+
Assert.DoesNotContain("PROJECT-A_MYBINDING", config.Keys);
57+
}
58+
59+
[Fact]
60+
public async Task OverriddenServiceNamesAreEncodedInEnvironmentVariables()
61+
{
62+
using var builder = TestDistributedApplicationBuilder.Create();
63+
64+
var projectA = builder.AddProject<ProjectA>("project-a")
65+
.WithHttpsEndpoint(1000, 2000, "mybinding")
66+
.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000));
67+
68+
var projectB = builder.AddProject<ProjectB>("consumer")
69+
.WithReference(projectA, "custom-name");
70+
71+
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
72+
73+
Assert.Equal("https://localhost:2000", config["services__custom-name__mybinding__0"]);
74+
Assert.Equal("https://localhost:2000", config["custom_name_MYBINDING"]);
75+
Assert.DoesNotContain("services__custom_name__mybinding__0", config.Keys);
76+
Assert.DoesNotContain("custom-name_MYBINDING", config.Keys);
77+
}
78+
3979
[Theory]
4080
[InlineData(ReferenceEnvironmentInjectionFlags.All)]
4181
[InlineData(ReferenceEnvironmentInjectionFlags.ConnectionProperties)]
@@ -668,6 +708,27 @@ public async Task ConnectionStringResourceWithConnectionPropertiesOverwriteName(
668708
Assert.DoesNotContain(config, kvp => kvp.Key == "RESOURCE_PORT");
669709
}
670710

711+
[Fact]
712+
public async Task ConnectionPropertiesWithDashedNamesAreEncoded()
713+
{
714+
using var builder = TestDistributedApplicationBuilder.Create();
715+
716+
var resource = builder.AddResource(new TestResourceWithProperties("resource-with-dash")
717+
{
718+
ConnectionString = "Server=localhost;Database=mydb"
719+
});
720+
721+
var projectB = builder.AddProject<ProjectB>("projectb")
722+
.WithReference(resource);
723+
724+
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
725+
726+
Assert.Contains(config, kvp => kvp.Key == "RESOURCE_WITH_DASH_HOST" && kvp.Value == "localhost");
727+
Assert.Contains(config, kvp => kvp.Key == "RESOURCE_WITH_DASH_PORT" && kvp.Value == "5432");
728+
Assert.DoesNotContain(config, kvp => kvp.Key == "RESOURCE-WITH-DASH_HOST");
729+
Assert.DoesNotContain(config, kvp => kvp.Key == "RESOURCE-WITH-DASH_PORT");
730+
}
731+
671732
private sealed class TestResourceWithProperties(string name) : Resource(name), IResourceWithConnectionString
672733
{
673734
public string? ConnectionString { get; set; }

0 commit comments

Comments
 (0)