Skip to content

Commit 0e54217

Browse files
authored
Fix assembly load when file path contains URI reserved chars (#76617)
Resolves microsoft/vscode-dotnettools#1686 When vscode is installed in (or an extension assembly is loaded from) a path that contains a URI reserved character, the server crashes. This is because VS-MEF (for compatibility with clr behavior) sets the code base to a file URI with an unencoded path. When parsing the URI, some reserved characters cause the path component to be incorrectly split into query or fragment components if unescaped. Then we get an invalid file path from the URI and cannot load the extension dll.
2 parents 4c6dfb8 + e067c25 commit 0e54217

File tree

6 files changed

+151
-8
lines changed

6 files changed

+151
-8
lines changed

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

Lines changed: 97 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,98 @@ 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+
await using var testServer = await TestLspServer.CreateAsync(new Roslyn.LanguageServer.Protocol.ClientCapabilities(), TestOutputLogger, MefCacheDirectory.Path, includeDevKitComponents: true, [dllPath]);
90+
91+
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
92+
var assembly = Assert.Single(assemblies, a => a.GetName().Name == assemblyName);
93+
var type = Assert.Single(assembly.GetTypes(), t => t.FullName?.Contains("ExportedType") == true);
94+
var values = testServer.ExportProvider.GetExportedValues(type, contractName: null);
95+
Assert.Single(values);
96+
}
97+
98+
private string GenerateDll(string reservedCharacter, out string assemblyName)
99+
{
100+
var directory = TempRoot.CreateDirectory();
101+
CSharpCompilationOptions options = new(OutputKind.DynamicallyLinkedLibrary);
102+
103+
// Create a dll that exports and imports a mef type to ensure that MEF attempts to load and create a MEF graph
104+
// using this assembly.
105+
var source = """
106+
namespace MyTestExportNamespace
107+
{
108+
[System.ComponentModel.Composition.Export(typeof(ExportedType))]
109+
public class ExportedType { }
110+
111+
public class ImportType
112+
{
113+
[System.ComponentModel.Composition.ImportingConstructorAttribute]
114+
public ImportType(ExportedType t) { }
115+
}
116+
}
117+
""";
118+
119+
// Generate an assembly name associated with the character we're testing - this ensures
120+
// that if multiple of these tests run in the same process we're making sure that the correct expected assembly is loaded.
121+
assemblyName = "MyAssembly" + reservedCharacter.GetHashCode();
122+
#pragma warning disable RS0030 // Do not use banned APIs - intentionally using System.ComponentModel.Composition to verify mef construction.
123+
var compilation = CSharpCompilation.Create(
124+
assemblyName,
125+
syntaxTrees: [SyntaxFactory.ParseSyntaxTree(SourceText.From(source, encoding: null, SourceHashAlgorithms.Default))],
126+
references:
127+
[
128+
NetStandard20.References.mscorlib,
129+
NetStandard20.References.netstandard,
130+
NetStandard20.References.SystemRuntime,
131+
MetadataReference.CreateFromFile(typeof(System.ComponentModel.Composition.ExportAttribute).Assembly.Location)
132+
],
133+
options: options);
134+
#pragma warning restore RS0030 // Do not use banned APIs
135+
136+
// Write a dll to a subdir with a reserved character in the name.
137+
var tempSubDir = directory.CreateDirectory(reservedCharacter);
138+
var tempFile = tempSubDir.CreateFile($"{assemblyName}.dll");
139+
var dllData = compilation.EmitToStream();
140+
tempFile.WriteAllBytes(dllData.ToArray());
141+
142+
// Mark the file as read only to prevent mutations.
143+
var fileInfo = new FileInfo(tempFile.Path);
144+
fileInfo.IsReadOnly = true;
145+
146+
return tempFile.Path;
147+
}
148+
52149
private async Task AssertCacheWriteWasAttemptedAsync()
53150
{
54151
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 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)