Skip to content

Commit 68ed6d9

Browse files
committed
Fix assembly load when file path contains URI reserved chars
1 parent 6b25538 commit 68ed6d9

File tree

6 files changed

+154
-8
lines changed

6 files changed

+154
-8
lines changed

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/ExportProviderBuilderTests.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using Basic.Reference.Assemblies;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
using Microsoft.CodeAnalysis.Test.Utilities;
8+
using Microsoft.CodeAnalysis.Text;
9+
using Roslyn.Test.Utilities;
510
using Xunit.Abstractions;
611

712
namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests
@@ -49,6 +54,101 @@ public async Task MultipleMefCompositionsAreCached()
4954
AssertCachedCompositionCountEquals(expectedCount: 2);
5055
}
5156

57+
[Theory, WorkItem("https://github.com/microsoft/vscode-dotnettools/issues/1686")]
58+
// gen-delims
59+
[InlineData("#"),
60+
InlineData(":"),
61+
InlineData("?"),
62+
InlineData("["),
63+
InlineData("]"),
64+
InlineData("@"),
65+
// sub-delims
66+
InlineData("!"),
67+
InlineData("$"),
68+
InlineData("&"),
69+
InlineData("'"),
70+
InlineData("("),
71+
InlineData(")"),
72+
InlineData("*"),
73+
InlineData("+"),
74+
InlineData(","),
75+
InlineData(";"),
76+
InlineData("=")]
77+
public async Task CanFindCodeBaseWhenReservedCharactersInPath(string reservedCharacter)
78+
{
79+
// Test that given an unescaped code base URI (as vs-mef gives us), we can resolve the file path even if it contains reserved characters.
80+
81+
// Certain characters aren't valid file paths on different file systems and so can't be in the path.
82+
if (ExecutionConditionUtil.IsWindows && reservedCharacter is "*" or ":" or "?")
83+
{
84+
return;
85+
}
86+
87+
var dllPath = GenerateDll(reservedCharacter, out var assemblyName);
88+
89+
var firstServer = await TestLspServer.CreateAsync(new Roslyn.LanguageServer.Protocol.ClientCapabilities(), TestOutputLogger, MefCacheDirectory.Path, includeDevKitComponents: true, [dllPath]);
90+
await firstServer.DisposeAsync();
91+
92+
await using var testServer = await TestLspServer.CreateAsync(new Roslyn.LanguageServer.Protocol.ClientCapabilities(), TestOutputLogger, MefCacheDirectory.Path, includeDevKitComponents: true, [dllPath]);
93+
94+
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
95+
var assembly = Assert.Single(assemblies, a => a.GetName().Name == assemblyName);
96+
var type = Assert.Single(assembly.GetTypes(), t => t.FullName?.Contains("ExportedType") == true);
97+
var values = testServer.ExportProvider.GetExportedValues(type, contractName: null);
98+
Assert.Single(values);
99+
}
100+
101+
private string GenerateDll(string reservedCharacter, out string assemblyName)
102+
{
103+
var directory = TempRoot.CreateDirectory();
104+
CSharpCompilationOptions options = new(OutputKind.DynamicallyLinkedLibrary);
105+
106+
// Create a dll that exports and imports a mef type to ensure that MEF attempts to load and create a MEF graph
107+
// using this assembly.
108+
var source = """
109+
namespace MyTestExportNamespace
110+
{
111+
[System.ComponentModel.Composition.Export(typeof(ExportedType))]
112+
public class ExportedType { }
113+
114+
public class ImportType
115+
{
116+
[System.ComponentModel.Composition.ImportingConstructorAttribute]
117+
public ImportType(ExportedType t) { }
118+
}
119+
}
120+
""";
121+
122+
// Generate an assembly name associated with the character we're testing - this ensures
123+
// that if multiple of these tests run in the same process we're making sure that the correct expected assembly is loaded.
124+
assemblyName = "MyAssembly" + reservedCharacter.GetHashCode();
125+
#pragma warning disable RS0030 // Do not use banned APIs - intentionally using System.ComponentModel.Composition to verify mef construction.
126+
var compilation = CSharpCompilation.Create(
127+
assemblyName,
128+
syntaxTrees: [SyntaxFactory.ParseSyntaxTree(SourceText.From(source, encoding: null, SourceHashAlgorithms.Default))],
129+
references:
130+
[
131+
NetStandard20.References.mscorlib,
132+
NetStandard20.References.netstandard,
133+
NetStandard20.References.SystemRuntime,
134+
MetadataReference.CreateFromFile(typeof(System.ComponentModel.Composition.ExportAttribute).Assembly.Location)
135+
],
136+
options: options);
137+
#pragma warning restore RS0030 // Do not use banned APIs
138+
139+
// Write a dll to a subdir with a reserved character in the name.
140+
var tempSubDir = directory.CreateDirectory(reservedCharacter);
141+
var tempFile = tempSubDir.CreateFile($"{assemblyName}.dll");
142+
var dllData = compilation.EmitToStream();
143+
tempFile.WriteAllBytes(dllData.ToArray());
144+
145+
// Mark the file as read only to prevent mutations.
146+
var fileInfo = new FileInfo(tempFile.Path);
147+
fileInfo.IsReadOnly = true;
148+
149+
return tempFile.Path;
150+
}
151+
52152
private async Task AssertCacheWriteWasAttemptedAsync()
53153
{
54154
var cacheWriteTask = ExportProviderBuilder.TestAccessor.GetCacheWriteTask();

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/TelemetryReporterTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ private async Task<ITelemetryReporter> CreateReporterAsync()
1818
TestOutputLogger.Factory,
1919
includeDevKitComponents: true,
2020
MefCacheDirectory.Path,
21+
[],
2122
out var _,
2223
out var _);
2324

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/AbstractLanguageServerHostTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ protected sealed class TestLspServer : IAsyncDisposable
4242

4343
private ServerCapabilities? _serverCapabilities;
4444

45-
internal static async Task<TestLspServer> CreateAsync(ClientCapabilities clientCapabilities, TestOutputLogger logger, string cacheDirectory, bool includeDevKitComponents = true)
45+
internal static async Task<TestLspServer> CreateAsync(ClientCapabilities clientCapabilities, TestOutputLogger logger, string cacheDirectory, bool includeDevKitComponents = true, string[]? extensionPaths = null)
4646
{
4747
var exportProvider = await LanguageServerTestComposition.CreateExportProviderAsync(
48-
logger.Factory, includeDevKitComponents, cacheDirectory, out var _, out var assemblyLoader);
48+
logger.Factory, includeDevKitComponents, cacheDirectory, extensionPaths, out var _, out var assemblyLoader);
4949
var testLspServer = new TestLspServer(exportProvider, logger, assemblyLoader);
5050
var initializeResponse = await testLspServer.ExecuteRequestAsync<InitializeParams, InitializeResult>(Methods.InitializeName, new InitializeParams { Capabilities = clientCapabilities }, CancellationToken.None);
5151
Assert.NotNull(initializeResponse?.Capabilities);

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public static Task<ExportProvider> CreateExportProviderAsync(
2424
ILoggerFactory loggerFactory,
2525
bool includeDevKitComponents,
2626
string cacheDirectory,
27+
string[]? extensionPaths,
2728
out ServerConfiguration serverConfiguration,
2829
out IAssemblyLoader assemblyLoader)
2930
{
@@ -33,7 +34,7 @@ public static Task<ExportProvider> CreateExportProviderAsync(
3334
StarredCompletionsPath: null,
3435
TelemetryLevel: null,
3536
SessionId: null,
36-
ExtensionAssemblyPaths: [],
37+
ExtensionAssemblyPaths: extensionPaths ?? [],
3738
DevKitDependencyPath: devKitDependencyPath,
3839
RazorSourceGenerator: null,
3940
RazorDesignTimePath: null,

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/WorkspaceProjectFactoryServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public async Task CreateProjectAndBatch()
1919
{
2020
var loggerFactory = new LoggerFactory();
2121
using var exportProvider = await LanguageServerTestComposition.CreateExportProviderAsync(
22-
loggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, out var serverConfiguration, out var _);
22+
loggerFactory, includeDevKitComponents: false, MefCacheDirectory.Path, [], out var serverConfiguration, out var _);
2323

2424
exportProvider.GetExportedValue<ServerConfigurationFactory>()
2525
.InitializeConfiguration(serverConfiguration);

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/CustomExportAssemblyLoader.cs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Diagnostics.CodeAnalysis;
56
using System.Reflection;
67
using System.Runtime.Loader;
78
using Microsoft.CodeAnalysis.LanguageServer.Services;
@@ -74,7 +75,13 @@ private Assembly LoadAssembly(AssemblyName assemblyName, string? codeBasePath)
7475

7576
private Assembly LoadAssemblyFromCodeBase(AssemblyName assemblyName, string codeBaseUriStr)
7677
{
77-
// CodeBase is spec'd as being a URL string.
78+
// CodeBase is spec'd as being a URI string - however VS-MEF doesn't always give us a URI, nor do they always give us a valid URI representation of the code base (for compatibility with clr behavior).
79+
// For example, when doing initial part discovery, we get a normal path as a string. But when loading the assembly to create the graph, VS-MEF will pass us
80+
// a file URI with an unescaped path part.
81+
// See https://github.com/microsoft/vs-mef/blob/21feb66e55cbef129801de3a6d572c087ee5b0b6/src/Microsoft.VisualStudio.Composition/Resolver.cs#L172
82+
//
83+
// This can cause issues during URI parsing, but we will handle that below. First we try to parse the code base as a normal URI, which handles all the cases where we get
84+
// a non-URI string as well as the majority of the cases where we get a file URI string.
7885
var codeBaseUri = ProtocolConversions.CreateAbsoluteUri(codeBaseUriStr);
7986
if (!codeBaseUri.IsFile)
8087
{
@@ -83,15 +90,52 @@ private Assembly LoadAssemblyFromCodeBase(AssemblyName assemblyName, string code
8390

8491
var codeBasePath = codeBaseUri.LocalPath;
8592

86-
var assembly = extensionAssemblyManager.TryLoadAssemblyInExtensionContext(codeBasePath);
87-
if (assembly is not null)
93+
if (TryLoadAssemblyFromCodeBasePath(assemblyName, codeBasePath, out var assembly))
94+
{
95+
return assembly;
96+
}
97+
98+
// As described above, we can get a code base URI that contains the unescaped code base file path. This can cause issues when we parse it as a URI if the code base file path
99+
// contains URI reserved characters (for example '#') which are left unescaped in the URI string. While we it is a well formed URI, when System.Uri parses the code base URI
100+
// the path component can get mangled and longer accurately represent the actual file system path.
101+
//
102+
// A concrete example - given code base URI 'file:///c:/Learn C#/file.dll', the path component from System.Uri will be 'c:/learn c' and '#/file.dll' is parsed as part of the fragment.
103+
// Of course we do not find a dll at 'c:/learn c' and crash.
104+
//
105+
// Unfortunately, solving this can be difficult - there is an EscapedCodeBase property on AssemblyName, but it does not escape reserved characters. It uses
106+
// the same implementation as Uri.EscapeUriString (which explicitly does not escape reserved characters as it cannot accurately do so).
107+
//
108+
// However - we do know that if we are given a file URI, the scheme and authority parts of the URI are correct (only the path can have unescaped reserved characters, which comes after both).
109+
// We can attempt to reconstruct the real code base file path by combining all the URI parts following the authority (the path, query, and fragment).
110+
// Note - System.URI returns the escaped versions of all these parts, so we unescape them first.
111+
var possibleCodeBasePath = Uri.UnescapeDataString(codeBaseUri.PathAndQuery) + Uri.UnescapeDataString(codeBaseUri.Fragment);
112+
if (TryLoadAssemblyFromCodeBasePath(assemblyName, possibleCodeBasePath, out assembly))
88113
{
89-
_logger.LogTrace("{assemblyName} with code base {codeBase} found in extension context.", assemblyName, codeBasePath);
90114
return assembly;
91115
}
92116

93117
// We were given an explicit code base path, but no extension context had the assembly.
94118
// This is unexpected, so we'll throw an exception.
95119
throw new FileNotFoundException($"Could not find assembly {assemblyName} with code base {codeBasePath} in any extension context.");
96120
}
121+
122+
private bool TryLoadAssemblyFromCodeBasePath(AssemblyName assemblyName, string codeBasePath, [NotNullWhen(true)] out Assembly? assembly)
123+
{
124+
assembly = null;
125+
if (!File.Exists(codeBasePath))
126+
{
127+
_logger.LogTrace("Code base {codeBase} does not exist for {assemblyName}", codeBasePath, assemblyName);
128+
return false;
129+
}
130+
131+
assembly = extensionAssemblyManager.TryLoadAssemblyInExtensionContext(codeBasePath);
132+
if (assembly is not null)
133+
{
134+
_logger.LogTrace("{assemblyName} with code base {codeBase} found in extension context.", assemblyName, codeBasePath);
135+
return true;
136+
}
137+
138+
_logger.LogTrace("Code base {codeBase} not found in any extension context for {assemblyName}", codeBasePath, assemblyName);
139+
return false;
140+
}
97141
}

0 commit comments

Comments
 (0)