Skip to content

Commit

Permalink
Implement IResourceWithArgs on ProjectResource (#3559)
Browse files Browse the repository at this point in the history
Also fix the command line arg parsing to be the same as .NET Process's:
* double quotes around single arguments
* consecutive double quotes inside a quoted argument means one double quote

Fix #3306

Co-authored-by: Eric Erhardt <[email protected]>
  • Loading branch information
github-actions[bot] and eerhardt authored Apr 10, 2024
1 parent 9a507d0 commit f1b9c55
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ namespace Aspire.Hosting.ApplicationModel;
/// A resource that represents a specified .NET project.
/// </summary>
/// <param name="name">The name of the resource.</param>
public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithServiceDiscovery
public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithServiceDiscovery
{
}
4 changes: 2 additions & 2 deletions src/Aspire.Hosting/Dcp/ApplicationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1138,8 +1138,8 @@ private void PrepareProjectExecutables()
var launchProfile = project.GetEffectiveLaunchProfile();
if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs))
{
var cmdArgs = launchProfile.CommandLineArgs.Split((string?)null, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (cmdArgs is not null && cmdArgs.Length > 0)
var cmdArgs = CommandLineArgsParser.Parse(launchProfile.CommandLineArgs);
if (cmdArgs.Count > 0)
{
exeSpec.Args.Add("--");
exeSpec.Args.AddRange(cmdArgs);
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
}
});

// TODO: Process command line arguments here
// NOTE: the launch profile command line arguments will be processed by ApplicationExecutor.PrepareProjectExecutables() (either by the IDE or manually passed to run)
}
else
{
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ private async Task WriteProjectAsync(ProjectResource project)

Writer.WriteString("path", relativePathToProjectFile);

await WriteCommandLineArgumentsAsync(project).ConfigureAwait(false);

await WriteEnvironmentVariablesAsync(project).ConfigureAwait(false);
WriteBindings(project);
}
Expand Down
123 changes: 123 additions & 0 deletions src/Aspire.Hosting/Utils/CommandLineArgsParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;

namespace Aspire.Hosting.Utils;

internal static class CommandLineArgsParser
{
/// <summary>Parses a command-line argument string into a list of arguments.</summary>
public static List<string> Parse(string arguments)
{
var result = new List<string>();
ParseArgumentsIntoList(arguments, result);
return result;
}

/// <summary>Parses a command-line argument string into a list of arguments.</summary>
/// <param name="arguments">The argument string.</param>
/// <param name="results">The list into which the component arguments should be stored.</param>
/// <remarks>
/// This follows the rules outlined in "Parsing C++ Command-Line Arguments" at
/// https://msdn.microsoft.com/en-us/library/17w5ykft.aspx.
/// </remarks>
// copied from https://github.com/dotnet/runtime/blob/404b286b23093cd93a985791934756f64a33483e/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs#L846-L945
private static void ParseArgumentsIntoList(string arguments, List<string> results)
{
// Iterate through all of the characters in the argument string.
for (int i = 0; i < arguments.Length; i++)
{
while (i < arguments.Length && (arguments[i] == ' ' || arguments[i] == '\t'))
{
i++;
}

if (i == arguments.Length)
{
break;
}

results.Add(GetNextArgument(arguments, ref i));
}
}

private static string GetNextArgument(string arguments, ref int i)
{
var currentArgument = new StringBuilder();
bool inQuotes = false;

while (i < arguments.Length)
{
// From the current position, iterate through contiguous backslashes.
int backslashCount = 0;
while (i < arguments.Length && arguments[i] == '\\')
{
i++;
backslashCount++;
}

if (backslashCount > 0)
{
if (i >= arguments.Length || arguments[i] != '"')
{
// Backslashes not followed by a double quote:
// they should all be treated as literal backslashes.
currentArgument.Append('\\', backslashCount);
}
else
{
// Backslashes followed by a double quote:
// - Output a literal slash for each complete pair of slashes
// - If one remains, use it to make the subsequent quote a literal.
currentArgument.Append('\\', backslashCount / 2);
if (backslashCount % 2 != 0)
{
currentArgument.Append('"');
i++;
}
}

continue;
}

char c = arguments[i];

// If this is a double quote, track whether we're inside of quotes or not.
// Anything within quotes will be treated as a single argument, even if
// it contains spaces.
if (c == '"')
{
if (inQuotes && i < arguments.Length - 1 && arguments[i + 1] == '"')
{
// Two consecutive double quotes inside an inQuotes region should result in a literal double quote
// (the parser is left in the inQuotes region).
// This behavior is not part of the spec of code:ParseArgumentsIntoList, but is compatible with CRT
// and .NET Framework.
currentArgument.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}

i++;
continue;
}

// If this is a space/tab and we're not in quotes, we're done with the current
// argument, it should be added to the results and then reset for the next one.
if ((c == ' ' || c == '\t') && !inQuotes)
{
break;
}

// Nothing special; add the character to the current argument.
currentArgument.Append(c);
i++;
}

return currentArgument.ToString();
}
}
79 changes: 79 additions & 0 deletions tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,84 @@ public async Task VerifyManifest(bool disableForwardedHeaders)
Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task VerifyManifestWithArgs()
{
var appBuilder = CreateBuilder();

appBuilder.AddProject<TestProjectWithLaunchSettings>("projectName")
.WithArgs("one", "two");

using var app = appBuilder.Build();

var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

var projectResources = appModel.GetProjectResources();

var resource = Assert.Single(projectResources);

var manifest = await ManifestUtils.GetManifest(resource);

var expectedManifest = $$"""
{
"type": "project.v0",
"path": "another-path",
"args": [
"one",
"two"
],
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http"
},
"https": {
"scheme": "https",
"protocol": "tcp",
"transport": "http"
}
}
}
""";

Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task AddProjectWithArgs()
{
var appBuilder = DistributedApplication.CreateBuilder();

var c1 = appBuilder.AddContainer("c1", "image2")
.WithEndpoint("ep", e =>
{
e.UriScheme = "http";
e.AllocatedEndpoint = new(e, "localhost", 1234);
});

var project = appBuilder.AddProject<TestProjectWithLaunchSettings>("projectName")
.WithArgs(context =>
{
context.Args.Add("arg1");
context.Args.Add(c1.GetEndpoint("ep"));
});

using var app = appBuilder.Build();

var args = await ArgumentEvaluator.GetArgumentListAsync(project.Resource);

Assert.Collection(args,
arg => Assert.Equal("arg1", arg),
arg => Assert.Equal("http://localhost:1234", arg));
}

private static IDistributedApplicationBuilder CreateBuilder(string[]? args = null, DistributedApplicationOperation operation = DistributedApplicationOperation.Publish)
{
var resolvedArgs = new List<string>();
Expand Down Expand Up @@ -437,6 +515,7 @@ private sealed class TestProjectWithLaunchSettings : IProjectMetadata
["http"] = new()
{
CommandName = "Project",
CommandLineArgs = "arg1 arg2",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031",
EnvironmentVariables = new()
Expand Down
30 changes: 30 additions & 0 deletions tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Xunit;
using static Aspire.Hosting.Utils.CommandLineArgsParser;

namespace Aspire.Hosting.Tests.Utils;

public class CommandLineArgsParserTests
{
[Theory]
[InlineData("", new string[] { })]
[InlineData("single", new[] { "single" })]
[InlineData("hello world", new[] { "hello", "world" })]
[InlineData("foo bar baz", new[] { "foo", "bar", "baz" })]
[InlineData("foo\tbar\tbaz", new[] { "foo", "bar", "baz" })]
[InlineData("\"quoted string\"", new[] { "quoted string" })]
[InlineData("\"quoted\tstring\"", new[] { "quoted\tstring" })]
[InlineData("\"quoted \"\" string\"", new[] { "quoted \" string" })]
// Single quotes are not treated as string delimiters
[InlineData("\"hello 'world'\"", new[] { "hello 'world'" })]
[InlineData("'single quoted'", new[] { "'single", "quoted'" })]
[InlineData("'foo \"bar\" baz'", new[] { "'foo", "bar", "baz'" })]
public void TestParse(string commandLine, string[] expectedParsed)
{
var actualParsed = Parse(commandLine);

Assert.Equal(expectedParsed, actualParsed.ToArray());
}
}

0 comments on commit f1b9c55

Please sign in to comment.