diff --git a/SourceLink.sln b/SourceLink.sln index b185660ea..39b64cbdc 100644 --- a/SourceLink.sln +++ b/SourceLink.sln @@ -58,7 +58,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.GitWeb.UnitTests", "src\SourceLink.GitWeb.UnitTests\Microsoft.SourceLink.GitWeb.UnitTests.csproj", "{50503A43-08C0-493B-B8CC-F368983644C1}" EndProject +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.SourceLink.Tools", "src\SourceLink.Tools\Microsoft.SourceLink.Tools.shproj", "{5DF76CC2-5F0E-45A6-AD56-6BBBCCBC1A78}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.Tools.Package", "src\SourceLink.Tools\Microsoft.SourceLink.Tools.Package.csproj", "{141E6850-B424-47AD-884A-CED362027382}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.SourceLink.Tools.UnitTests", "src\SourceLink.Tools.UnitTests\Microsoft.SourceLink.Tools.UnitTests.csproj", "{99D113A9-24EC-471D-9F74-D2AC2F16220B}" +EndProject Global + GlobalSection(SharedMSBuildProjectFiles) = preSolution + src\SourceLink.Tools\Microsoft.SourceLink.Tools.projitems*{5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78}*SharedItemsImports = 13 + src\SourceLink.Tools\Microsoft.SourceLink.Tools.projitems*{99d113a9-24ec-471d-9f74-d2ac2f16220b}*SharedItemsImports = 5 + EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU @@ -148,6 +158,14 @@ Global {50503A43-08C0-493B-B8CC-F368983644C1}.Debug|Any CPU.Build.0 = Debug|Any CPU {50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {50503A43-08C0-493B-B8CC-F368983644C1}.Release|Any CPU.Build.0 = Release|Any CPU + {141E6850-B424-47AD-884A-CED362027382}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {141E6850-B424-47AD-884A-CED362027382}.Debug|Any CPU.Build.0 = Debug|Any CPU + {141E6850-B424-47AD-884A-CED362027382}.Release|Any CPU.ActiveCfg = Release|Any CPU + {141E6850-B424-47AD-884A-CED362027382}.Release|Any CPU.Build.0 = Release|Any CPU + {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99D113A9-24EC-471D-9F74-D2AC2F16220B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/eng/Versions.props b/eng/Versions.props index 1955a904c..ec61a7739 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -21,6 +21,7 @@ 2.1.0 4.9.2 4.5.0 + 4.7.2 0.26.0-preview-0070 diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index d60172286..a4b4d2c13 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -17,7 +17,11 @@ false - + + $(_ProjectDefinedPackageId) $(AssemblyName) diff --git a/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj b/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj new file mode 100644 index 000000000..194ea0a73 --- /dev/null +++ b/src/SourceLink.Tools.UnitTests/Microsoft.SourceLink.Tools.UnitTests.csproj @@ -0,0 +1,12 @@ + + + net472;netcoreapp3.1 + + + + + + + + + diff --git a/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs b/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs new file mode 100644 index 000000000..e143fae15 --- /dev/null +++ b/src/SourceLink.Tools.UnitTests/SourceLinkMapTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using TestUtilities; +using Xunit; + +namespace Microsoft.SourceLink.Tools.UnitTests +{ + public class SourceLinkMapTests + { + private IEnumerable Inspect(SourceLinkMap map) + => map.Entries.Select(e => $"('{e.FilePath.Path}', {(e.FilePath.IsPrefix ? "*" : "")}) -> ('{e.Uri.Prefix}', '{e.Uri.Suffix}')"); + + [Theory] + [InlineData(@"{}")] + [InlineData(@"{""xxx"":{}}")] + [InlineData(@"{""documents"":{}}")] + public void Empty(string json) + { + var map = SourceLinkMap.Parse(json); + Assert.Empty(map.Entries); + } + + [Fact] + public void Extra() + { + var map = SourceLinkMap.Parse(@" +{ + ""documents"" : + { + ""C:\\a*"": ""http://server/1/a*"", + }, + ""extra"": {} +}"); + AssertEx.Equal(new[] { "('C:\\a', *) -> ('http://server/1/a', '')" }, Inspect(map)); + } + + [Fact] + public void Entries() + { + var map = SourceLinkMap.Parse(@" +{ + ""documents"" : + { + ""C:\\a*"": ""http://server/[*]"", + ""C:\\a*"": ""http://a/"", + ""C:\\a"": ""http://a/"", + ""C:\\a*"": ""http://*a"", + ""C:\\b"": ""http://b"", + } +}"); + AssertEx.Equal(new[] + { + @"('C:\a', *) -> ('http://server/[', ']')", + @"('C:\a', *) -> ('http://a/', '')", + @"('C:\a', ) -> ('http://a/', '')", + @"('C:\a', *) -> ('http://', 'a')", + @"('C:\b', ) -> ('http://b', '')" + }, Inspect(map)); + + Assert.True(map.TryGetUrl(@"C:\a", out var url)); + Assert.Equal("http://server/[]", url); + + Assert.True(map.TryGetUrl(@"C:\a\b\c\d\e", out url)); + Assert.Equal("http://server/[/b/c/d/e]", url); + + Assert.True(map.TryGetUrl(@"C:\b", out url)); + Assert.Equal("http://b", url); + + Assert.False(map.TryGetUrl(@"C:\b\c", out _)); + } + + [Fact] + public void Order1() + { + var map = SourceLinkMap.Parse(@" +{ + ""documents"" : + { + ""C:\\a\\b*"": ""2:*"", + ""C:\\a\\b\\c*"": ""1:*"", + ""C:\\a*"": ""3:*"", + } +}"); + AssertEx.Equal(new[] + { + @"('C:\a\b\c', *) -> ('1:', '')", + @"('C:\a\b', *) -> ('2:', '')", + @"('C:\a', *) -> ('3:', '')" + }, Inspect(map)); + + string? url; + Assert.True(map.TryGetUrl(@"C:\a\b\c\d\e", out url)); + Assert.Equal("1:/d/e", url); + + Assert.True(map.TryGetUrl(@"C:\a\b\", out url)); + Assert.Equal("2:/", url); + + Assert.True(map.TryGetUrl(@"C:\a\x", out url)); + Assert.Equal("3:/x", url); + + Assert.False(map.TryGetUrl(@"D:\x", out _)); + } + + [Fact] + public void Order2() + { + var map = SourceLinkMap.Parse(@" +{ + ""documents"" : + { + ""C:\\aaa\\bbb*"": ""1:*"", + ""C:\\aaa\\bb*"": ""2:*"", + } +}"); + AssertEx.Equal(new[] + { + @"('C:\aaa\bbb', *) -> ('1:', '')", + @"('C:\aaa\bb', *) -> ('2:', '')", + }, Inspect(map)); + + string? url; + Assert.True(map.TryGetUrl(@"C:\aaa\bbbb", out url)); + Assert.Equal("1:b", url); + + Assert.True(map.TryGetUrl(@"C:\aaa\bbb", out url)); + Assert.Equal("1:", url); + + Assert.True(map.TryGetUrl(@"C:\aaa\bb", out url)); + Assert.Equal("2:", url); + + Assert.False(map.TryGetUrl(@"C:\aaa\b", out _)); + } + + [Fact] + public void TryGetUrl_Star() + { + var map = SourceLinkMap.Parse(@"{""documents"":{}}"); + Assert.False(map.TryGetUrl("path*", out _)); + } + + [Fact] + public void TryGetUrl_InvalidArgument() + { + var map = SourceLinkMap.Parse(@"{""documents"":{}}"); + Assert.Throws(() => map.TryGetUrl(null!, out _)); + } + + [Theory] + [InlineData(@"{")] + [InlineData(@"{""documents"" : { ""x"": ""y"" // comments not allowed +} }")] + [InlineData(@"{""documents"" : { 1: ""y"" } }")] + public void BadJson_Key(string json) + { + Assert.ThrowsAny(() => SourceLinkMap.Parse(json)); + } + + [Theory] + [InlineData(@"1")] + [InlineData(@"{""documents"": 1}")] + [InlineData(@"{""documents"":{""1"": 1}}")] + [InlineData(@"{""documents"":{""1"": null}}")] + [InlineData(@"{""documents"":{""1"": {}}}")] + [InlineData(@"{""documents"":{""1"": []}}")] + public void InvalidJsonTypes(string json) + { + Assert.Throws(() => SourceLinkMap.Parse(json)); + } + + [Theory] + [InlineData(@"{""documents"":{"""": ""x""}}")] + [InlineData(@"{""documents"":{""1**"": ""x""}}")] + [InlineData(@"{""documents"":{""*1*"": ""x""}}")] + [InlineData(@"{""documents"":{""1*"": ""**x""}}")] + [InlineData(@"{""documents"":{""*1"": ""x*""}}")] + [InlineData(@"{""documents"":{""1"": ""x*""}}")] + public void InvalidWildcards(string json) + { + Assert.Throws(() => SourceLinkMap.Parse(json)); + } + + [Fact] + public void Parse_InvalidArgument() + { + Assert.Throws(() => SourceLinkMap.Parse(null!)); + } + } +} diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj new file mode 100644 index 000000000..578cfef0c --- /dev/null +++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.Package.csproj @@ -0,0 +1,24 @@ + + + + + netcoreapp3.1;net472 + false + none + false + + + true + true + Microsoft.SourceLink.Tools + false + + Package containing sources of tools for reading Source Link. + + + $(NoWarn);NU5128 + + + + + \ No newline at end of file diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems new file mode 100644 index 000000000..e52c86e4f --- /dev/null +++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78 + + + Microsoft.SourceLink.Tools + + + + + \ No newline at end of file diff --git a/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj new file mode 100644 index 000000000..9e5b2a149 --- /dev/null +++ b/src/SourceLink.Tools/Microsoft.SourceLink.Tools.shproj @@ -0,0 +1,13 @@ + + + + 5df76cc2-5f0e-45a6-ad56-6bbbccbc1a78 + 14.0 + + + + + + + + diff --git a/src/SourceLink.Tools/SourceLinkMap.cs b/src/SourceLink.Tools/SourceLinkMap.cs new file mode 100644 index 000000000..776c75c63 --- /dev/null +++ b/src/SourceLink.Tools/SourceLinkMap.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; + +#if NETCOREAPP +using System.Diagnostics.CodeAnalysis; +#endif + +namespace Microsoft.SourceLink.Tools +{ + /// + /// Source Link URL map. Maps file paths matching Source Link patterns to URLs. + /// + internal readonly struct SourceLinkMap + { + private readonly ReadOnlyCollection _entries; + + private SourceLinkMap(ReadOnlyCollection mappings) + { + _entries = mappings; + } + + public readonly struct Entry + { + public readonly FilePathPattern FilePath; + public readonly UriPattern Uri; + + public Entry(FilePathPattern filePath, UriPattern uri) + { + FilePath = filePath; + Uri = uri; + } + + public void Deconstruct(out FilePathPattern filePath, out UriPattern uri) + { + filePath = FilePath; + uri = Uri; + } + } + + public readonly struct FilePathPattern + { + public readonly string Path; + public readonly bool IsPrefix; + + public FilePathPattern(string path, bool isPrefix) + { + Path = path; + IsPrefix = isPrefix; + } + } + + public readonly struct UriPattern + { + public readonly string Prefix; + public readonly string Suffix; + + public UriPattern(string prefix, string suffix) + { + Prefix = prefix; + Suffix = suffix; + } + } + + public IReadOnlyList Entries => _entries; + + /// + /// Parses Source Link JSON string. + /// + /// is null. + /// The JSON does not follow Source Link specification. + /// is not valid JSON string. + public static SourceLinkMap Parse(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + var list = new List(); + + var root = JsonDocument.Parse(json, new JsonDocumentOptions() { AllowTrailingCommas = true }).RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException(); + } + + foreach (var rootEntry in root.EnumerateObject()) + { + if (!rootEntry.NameEquals("documents")) + { + // potential future extensibility + continue; + } + + if (rootEntry.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException(); + } + + foreach (var documentsEntry in rootEntry.Value.EnumerateObject()) + { + if (documentsEntry.Value.ValueKind != JsonValueKind.String || + !TryParseEntry(documentsEntry.Name, documentsEntry.Value.GetString(), out var entry)) + { + throw new InvalidDataException(); + } + + list.Add(entry); + } + } + + // Sort the map by decreasing file path length. This ensures that the most specific paths will checked before the least specific + // and that absolute paths will be checked before a wildcard path with a matching base + list.Sort((left, right) => -left.FilePath.Path.Length.CompareTo(right.FilePath.Path.Length)); + + return new SourceLinkMap(new ReadOnlyCollection(list)); + } + + private static bool TryParseEntry(string key, string value, out Entry entry) + { + entry = default; + + // VALIDATION RULES + // 1. The only acceptable wildcard is one and only one '*', which if present will be replaced by a relative path + // 2. If the filepath does not contain a *, the uri cannot contain a * and if the filepath contains a * the uri must contain a * + // 3. If the filepath contains a *, it must be the final character + // 4. If the uri contains a *, it may be anywhere in the uri + if (key.Length == 0) + { + return false; + } + + int filePathStar = key.IndexOf('*'); + if (filePathStar == key.Length - 1) + { + key = key.Substring(0, filePathStar); + } + else if (filePathStar >= 0) + { + return false; + } + + string uriPrefix, uriSuffix; + int uriStar = value.IndexOf('*'); + if (uriStar >= 0) + { + if (filePathStar < 0) + { + return false; + } + + uriPrefix = value.Substring(0, uriStar); + uriSuffix = value.Substring(uriStar + 1); + + if (uriSuffix.IndexOf('*') >= 0) + { + return false; + } + } + else + { + uriPrefix = value; + uriSuffix = ""; + } + + entry = new Entry( + new FilePathPattern(key, isPrefix: filePathStar >= 0), + new UriPattern(uriPrefix, uriSuffix)); + + return true; + } + + /// + /// Maps specified to the corresponding URL. + /// + /// is null. + public bool TryGetUrl( + string path, +#if NETCOREAPP + [NotNullWhen(true)] +#endif + out string? url) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + if (path.IndexOf('*') >= 0) + { + url = null; + return false; + } + + // Note: the mapping function is case-insensitive. + + foreach (var (file, mappedUrl) in _entries) + { + if (file.IsPrefix) + { + if (path.StartsWith(file.Path, StringComparison.OrdinalIgnoreCase)) + { + var escapedPath = string.Join("/", path.Substring(file.Path.Length).Split(new[] { '/', '\\' }).Select(Uri.EscapeDataString)); + url = mappedUrl.Prefix + escapedPath + mappedUrl.Suffix; + return true; + } + } + else if (string.Equals(path, file.Path, StringComparison.OrdinalIgnoreCase)) + { + Debug.Assert(mappedUrl.Suffix.Length == 0); + url = mappedUrl.Prefix; + return true; + } + } + + url = null; + return false; + } + } +} \ No newline at end of file