Skip to content

Commit bc16495

Browse files
authored
Merge pull request #2820 from jonsequitur/fix-2812
Improve RootCommand executable name handling in NAOT app and library scenarios
2 parents 8b00b7d + 3e45a7c commit bc16495

12 files changed

Lines changed: 431 additions & 40 deletions

File tree

‎src/System.CommandLine.Tests/CompilationTests.cs‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
using System.CommandLine.Suggest;
77
using System.CommandLine.Tests.Utility;
88
using System.IO;
9+
using System.Linq;
10+
using System.Runtime.InteropServices;
911
using System.Text;
1012
using FluentAssertions;
1113
using Microsoft.DotNet.PlatformAbstractions;
@@ -42,6 +44,119 @@ public void App_referencing_system_commandline_can_be_compiled_ahead_of_time()
4244
}
4345
}
4446

47+
[ReleaseBuildOnlyFact]
48+
public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a_native_library()
49+
{
50+
// When System.CommandLine is hosted inside a NativeAOT shared library there is no
51+
// managed entry point (Assembly.GetEntryAssembly() returns null) and
52+
// Environment.GetCommandLineArgs() reflects the host process. RootCommand must then
53+
// fall back to the executable name injected into AppContext by the build targets (the
54+
// assembly name). This exercises that path end-to-end through a real native library build.
55+
var workingDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "NativeLibrary");
56+
var publishDirectory = Path.Combine(Path.GetTempPath(), "scl-nativelib-" + Guid.NewGuid().ToString("N"));
57+
var targetsPath = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "System.CommandLine.targets");
58+
string rId = GetPortableRuntimeIdentifier();
59+
60+
Process.RunToCompletion(
61+
DotnetMuxer.Path.FullName,
62+
$"clean -c Release -r {rId} -p:SystemCommandLineTargetsPath=\"{targetsPath}\"",
63+
workingDirectory: workingDirectory);
64+
65+
var stdOut = new StringBuilder();
66+
var stdErr = new StringBuilder();
67+
68+
try
69+
{
70+
var exitCode = Process.RunToCompletion(
71+
DotnetMuxer.Path.FullName,
72+
string.Format(
73+
"publish -c Release -r {0} --self-contained -o \"{1}\" -p:SystemCommandLineDllPath=\"{2}\" -p:SystemCommandLineTargetsPath=\"{3}\" -p:TreatWarningsAsErrors=true",
74+
rId,
75+
publishDirectory,
76+
_systemCommandLineDllPath,
77+
targetsPath),
78+
s =>
79+
{
80+
_output.WriteLine(s);
81+
stdOut.Append(s);
82+
},
83+
s =>
84+
{
85+
_output.WriteLine(s);
86+
stdErr.Append(s);
87+
},
88+
workingDirectory);
89+
90+
string publishOutput = $"{Environment.NewLine}STDOUT:{Environment.NewLine}{stdOut}{Environment.NewLine}STDERR:{Environment.NewLine}{stdErr}";
91+
92+
stdOut.ToString().Should().NotContain(": error CS", "the native library should compile cleanly. Publish output:{0}", publishOutput);
93+
exitCode.Should().Be(0, "the native library should publish successfully. Publish output:{0}", publishOutput);
94+
95+
string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary"));
96+
97+
File.Exists(nativeLibraryPath).Should().BeTrue(
98+
"the published native library should exist at {0}. Publish output:{1}", nativeLibraryPath, publishOutput);
99+
100+
string executableName = InvokeGetExecutableName(nativeLibraryPath);
101+
102+
// Equality to the assembly name proves the AppContext fallback was taken: had
103+
// GetCommandLineArgs() been non-empty, the name would derive from the host path.
104+
executableName.Should().Be("NativeLibrary");
105+
}
106+
finally
107+
{
108+
try
109+
{
110+
if (Directory.Exists(publishDirectory))
111+
{
112+
Directory.Delete(publishDirectory, recursive: true);
113+
}
114+
}
115+
catch (UnauthorizedAccessException)
116+
{
117+
// On Windows the native library may still be locked after NativeLibrary.Free
118+
}
119+
}
120+
}
121+
122+
private static string NativeLibraryFileName(string assemblyName) =>
123+
OperatingSystem.IsWindows() ? $"{assemblyName}.dll"
124+
: OperatingSystem.IsMacOS() ? $"{assemblyName}.dylib"
125+
: $"{assemblyName}.so";
126+
127+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
128+
private delegate int GetExecutableNameDelegate(IntPtr buffer, int bufferLength);
129+
130+
private static string InvokeGetExecutableName(string nativeLibraryPath)
131+
{
132+
IntPtr handle = NativeLibrary.Load(nativeLibraryPath);
133+
134+
try
135+
{
136+
IntPtr export = NativeLibrary.GetExport(handle, "get_executable_name");
137+
var getExecutableName = Marshal.GetDelegateForFunctionPointer<GetExecutableNameDelegate>(export);
138+
139+
int length = getExecutableName(IntPtr.Zero, 0);
140+
byte[] buffer = new byte[length];
141+
142+
GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned);
143+
try
144+
{
145+
getExecutableName(pinned.AddrOfPinnedObject(), length);
146+
}
147+
finally
148+
{
149+
pinned.Free();
150+
}
151+
152+
return Encoding.UTF8.GetString(buffer);
153+
}
154+
finally
155+
{
156+
NativeLibrary.Free(handle);
157+
}
158+
}
159+
45160
private void PublishAndValidate(string appName, string warningText, string additionalArgs = null)
46161
{
47162
var stdOut = new StringBuilder();
@@ -86,7 +201,7 @@ private void PublishAndValidate(string appName, string warningText, string addit
86201
private static string GetPortableRuntimeIdentifier()
87202
{
88203
string osPart = OperatingSystem.IsWindows() ? "win" : (OperatingSystem.IsMacOS() ? "osx" : "linux");
89-
return $"{osPart}-{RuntimeEnvironment.RuntimeArchitecture}";
204+
return $"{osPart}-{Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment.RuntimeArchitecture}";
90205
}
91206
}
92207

‎src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs‎

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using System.CommandLine.Parsing;
5+
using System.IO;
56
using System.Linq;
7+
using System.Runtime.InteropServices;
68
using FluentAssertions;
9+
using FluentAssertions.Execution;
710
using Xunit;
811

912
namespace System.CommandLine.Tests;
@@ -15,18 +18,29 @@ public partial class RootCommandAndArg0
1518
[Fact]
1619
public void When_parsing_a_string_array_a_root_command_can_be_omitted_from_the_parsed_args()
1720
{
21+
var option = new Option<string>("-x");
1822
var command = new Command("outer")
1923
{
2024
new Command("inner")
2125
{
22-
new Option<string>("-x")
26+
option
2327
}
2428
};
2529

2630
var result1 = command.Parse(Split("inner -x hello"));
2731
var result2 = command.Parse(Split("outer inner -x hello"));
2832

33+
using var _ = new AssertionScope();
34+
2935
result1.Diagram().Should().Be(result2.Diagram());
36+
37+
foreach (var result in new[] { result1, result2 })
38+
{
39+
result.Errors.Should().BeEmpty();
40+
result.RootCommandResult.Command.Name.Should().Be("outer");
41+
result.CommandResult.Command.Name.Should().Be("inner");
42+
result.GetValue(option).Should().Be("hello");
43+
}
3044
}
3145

3246
[Fact]
@@ -40,8 +54,6 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_
4054
}
4155
};
4256

43-
command.Parse(Split("inner -x hello")).Errors.Should().BeEmpty();
44-
4557
var parserResult = command.Parse(Split($"\"{RootCommand.ExecutablePath}\" inner -x hello"));
4658
parserResult
4759
.Errors
@@ -52,18 +64,29 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_
5264
[Fact]
5365
public void When_parsing_an_unsplit_string_a_root_command_can_be_omitted_from_the_parsed_args()
5466
{
67+
var option = new Option<string>("-x");
5568
var command = new Command("outer")
5669
{
5770
new Command("inner")
5871
{
59-
new Option<string>("-x")
72+
option
6073
}
6174
};
6275

6376
var result1 = command.Parse("inner -x hello");
6477
var result2 = command.Parse("outer inner -x hello");
6578

79+
using var _ = new AssertionScope();
80+
6681
result1.Diagram().Should().Be(result2.Diagram());
82+
83+
foreach (var result in new[] { result1, result2 })
84+
{
85+
result.Errors.Should().BeEmpty();
86+
result.RootCommandResult.Command.Name.Should().Be("outer");
87+
result.CommandResult.Command.Name.Should().Be("inner");
88+
result.GetValue(option).Should().Be("hello");
89+
}
6790
}
6891

6992
[Fact]
@@ -82,25 +105,6 @@ public void When_parsing_an_unsplit_string_then_input_a_full_path_to_an_executab
82105
result2.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutablePath);
83106
}
84107

85-
[Fact]
86-
public void When_parsing_an_unsplit_string_then_a_renamed_RootCommand_can_be_omitted_from_the_parsed_args()
87-
{
88-
var rootCommand = new Command("outer")
89-
{
90-
new Command("inner")
91-
{
92-
new Option<string>("-x")
93-
}
94-
};
95-
96-
var result1 = rootCommand.Parse("inner -x hello");
97-
var result2 = rootCommand.Parse("outer inner -x hello");
98-
var result3 = rootCommand.Parse($"{RootCommand.ExecutableName} inner -x hello");
99-
100-
result2.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command);
101-
result3.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command);
102-
}
103-
104108
[Fact]
105109
public void When_parsing_a_string_array_option_values_containing_command_name_after_slash_are_not_treated_as_root_command()
106110
{
@@ -112,6 +116,8 @@ public void When_parsing_a_string_array_option_values_containing_command_name_af
112116

113117
var result = command.Parse(Split("/p:Key=something/myapp"));
114118

119+
using var _ = new AssertionScope();
120+
115121
result.Errors.Should().BeEmpty();
116122
result.GetResult(option).Should().NotBeNull();
117123
}
@@ -130,10 +136,101 @@ public void When_parsing_a_string_array_option_values_ending_with_slash_command_
130136

131137
var result = command.Parse(Split(arg));
132138

139+
using var _ = new AssertionScope();
140+
133141
result.Errors.Should().BeEmpty();
134142
result.GetResult(option).Should().NotBeNull();
135143
}
136144

145+
[Theory]
146+
[MemberData(nameof(PathLikeFirstArgsMatchingTheRootCommand))]
147+
public void When_parsing_a_string_array_a_path_like_first_arg_matching_the_root_command_is_treated_as_the_root_command(string pathLikeRootArg)
148+
{
149+
var option = new Option<string>("-x");
150+
var command = new Command("outer")
151+
{
152+
new Command("inner")
153+
{
154+
option
155+
}
156+
};
157+
158+
var result = command.Parse([pathLikeRootArg, "inner", "-x", "hello"]);
159+
160+
using var _ = new AssertionScope();
161+
162+
result.Errors.Should().BeEmpty();
163+
result.RootCommandResult.IdentifierToken.Value.Should().Be(pathLikeRootArg);
164+
result.CommandResult.Command.Name.Should().Be("inner");
165+
result.GetValue(option).Should().Be("hello");
166+
}
167+
168+
public static TheoryData<string> PathLikeFirstArgsMatchingTheRootCommand
169+
{
170+
get
171+
{
172+
var data = new TheoryData<string>
173+
{
174+
"./outer",
175+
"tools/outer"
176+
};
177+
178+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
179+
{
180+
data.Add(@".\outer");
181+
data.Add(@"tools\outer");
182+
}
183+
184+
return data;
185+
}
186+
}
187+
188+
[Fact]
189+
public void RootCommand_matches_its_executable_name_as_the_first_arg_the_same_way_a_named_Command_matches_its_name()
190+
{
191+
// Guards the assumption that a named Command can stand in for a RootCommand
192+
// in arg0-matching tests: RootCommand.Name is forced to the executable name and
193+
// cannot be changed, so the equivalent scenario uses the executable name as arg0.
194+
var option = new Option<string>("-x");
195+
var rootCommand = new RootCommand
196+
{
197+
new Command("inner")
198+
{
199+
option
200+
}
201+
};
202+
203+
var result = rootCommand.Parse([RootCommand.ExecutableName, "inner", "-x", "hello"]);
204+
205+
using var _ = new AssertionScope();
206+
207+
result.Errors.Should().BeEmpty();
208+
result.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutableName);
209+
result.CommandResult.Command.Name.Should().Be("inner");
210+
result.GetValue(option).Should().Be("hello");
211+
}
212+
213+
#if NETFRAMEWORK
214+
[Fact]
215+
public void When_parsing_a_string_array_arguments_containing_invalid_path_characters_the_netframework_PathGetFileName_exception_is_ignored()
216+
{
217+
const string invalidPath = "my\0app";
218+
219+
// On .NET Framework, this throws ArgumentException for illegal characters in path.
220+
Action getFileName = () => Path.GetFileName(invalidPath);
221+
222+
var argument = new Argument<string>("value");
223+
var command = new Command("myapp") { argument };
224+
var result = command.Parse(invalidPath);
225+
226+
using var _ = new AssertionScope();
227+
228+
getFileName.Should().Throw<ArgumentException>();
229+
result.Errors.Should().BeEmpty();
230+
result.GetValue(argument).Should().Be(invalidPath);
231+
}
232+
#endif
233+
137234
string[] Split(string value) => CommandLineParser.SplitCommandLine(value).ToArray();
138235
}
139236
}

‎src/System.CommandLine.Tests/RootCommandTests.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,17 @@ public void Setting_HelpName_does_not_change_Name()
4848

4949
rootCommand.Name.Should().Be(RootCommand.ExecutableName);
5050
}
51+
52+
[Fact]
53+
public void ExecutablePath_is_not_null()
54+
{
55+
RootCommand.ExecutablePath.Should().NotBeNull();
56+
}
57+
58+
[Fact]
59+
public void ExecutableName_is_not_null_or_empty()
60+
{
61+
RootCommand.ExecutableName.Should().NotBeNullOrEmpty();
62+
}
5163
}
5264
}

‎src/System.CommandLine.Tests/System.CommandLine.Tests.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ItemGroup>
1111
<Content Include="TestApps/**/*.csproj" CopyToOutputDirectory="PreserveNewest" />
1212
<Content Include="TestApps/**/*.cs" CopyToOutputDirectory="PreserveNewest" />
13+
<Content Include="..\System.CommandLine\build\System.CommandLine.targets" CopyToOutputDirectory="PreserveNewest" Link="TestApps\System.CommandLine.targets" />
1314
</ItemGroup>
1415

1516
<ItemGroup>

0 commit comments

Comments
 (0)