diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLink/AzureDevOpsSourceLinkUrlParser.cs b/tracer/src/Datadog.Trace/PDBs/SourceLink/AzureDevOpsSourceLinkUrlParser.cs index ee460d462c34..cee590a114db 100644 --- a/tracer/src/Datadog.Trace/PDBs/SourceLink/AzureDevOpsSourceLinkUrlParser.cs +++ b/tracer/src/Datadog.Trace/PDBs/SourceLink/AzureDevOpsSourceLinkUrlParser.cs @@ -82,64 +82,40 @@ internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out st return false; } + /// + /// Builds the repository URL by locating /_apis/git/repositories/ in the path. + /// Works for all Azure DevOps variants: + /// visualstudio.com: /{project}/_apis/git/repositories/{repo}/items + /// dev.azure.com: /{org}/{project}/_apis/git/repositories/{repo}/items + /// TFS on-prem: [/{vdir}][/{collection}]/{project}/_apis/git/repositories/{repo}/items + /// The repo URL is everything before _apis, plus /_git/{repo}. + /// private static string? BuildRepositoryUrl(Uri uri) { - ReadOnlySpan segment0 = default; - ReadOnlySpan segment1 = default; - ReadOnlySpan segment4 = default; - ReadOnlySpan segment5 = default; - var segmentCount = 0; + var path = uri.AbsolutePath; - foreach (var segment in uri.AbsolutePath.SplitIntoSpans('/')) + // Find /_apis/git/repositories/ in the path + const string marker = "/_apis/git/repositories/"; + var markerPos = path.IndexOf(marker, StringComparison.Ordinal); + if (markerPos <= 0) { - ReadOnlySpan span = segment; - if (span.IsEmpty) - { - continue; - } - - switch (segmentCount) - { - case 0: segment0 = span; break; - case 1: segment1 = span; break; - case 4: segment4 = span; break; - case 5: segment5 = span; break; - } - - segmentCount++; + // markerPos == 0 means nothing before _apis (no project); < 0 means not found + return null; } - if (uri.Host.EndsWith("visualstudio.com", StringComparison.OrdinalIgnoreCase)) - { - if (segmentCount < 5) - { - return null; - } - - // Legacy format: https://{organization}.visualstudio.com -#if NET6_0_OR_GREATER - return $"https://{uri.Host}/{segment0}/_git/{segment4}"; -#else - return $"https://{uri.Host}/{segment0.ToString()}/_git/{segment4.ToString()}"; -#endif - } + // The prefix path (project and any virtual dir/collection) is everything before /_apis + var prefixPath = path.Substring(0, markerPos); - if (uri.Host.EndsWith("dev.azure.com", StringComparison.OrdinalIgnoreCase)) + // Extract the repo name after /_apis/git/repositories/ + var afterMarker = path.Substring(markerPos + marker.Length); + var repoEndSlash = afterMarker.IndexOf('/'); + if (repoEndSlash <= 0) { - if (segmentCount < 6) - { - return null; - } - - // New format: https://dev.azure.com/{organization} -#if NET6_0_OR_GREATER - return $"https://{uri.Host}/{segment0}/{segment1}/_git/{segment5}"; -#else - return $"https://{uri.Host}/{segment0.ToString()}/{segment1.ToString()}/_git/{segment5.ToString()}"; -#endif + return null; } - Log.Error("Unsupported Azure DevOps host: {Host}", uri.Host); - return null; + var repo = afterMarker.Substring(0, repoEndSlash); + + return $"{uri.Scheme}://{uri.Authority}{prefixPath}/_git/{repo}"; } } diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLink/BitBucketServerSourceLinkUrlParser.cs b/tracer/src/Datadog.Trace/PDBs/SourceLink/BitBucketServerSourceLinkUrlParser.cs new file mode 100644 index 000000000000..83110ee3b305 --- /dev/null +++ b/tracer/src/Datadog.Trace/PDBs/SourceLink/BitBucketServerSourceLinkUrlParser.cs @@ -0,0 +1,109 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using Datadog.Trace.Util; + +namespace Datadog.Trace.Pdb.SourceLink; + +internal sealed class BitBucketServerSourceLinkUrlParser : SourceLinkUrlParser +{ + /// + /// Extract the git commit sha and repository url from a Bitbucket Server / Data Center SourceLink mapping string. + /// Supports two URL forms: + /// >= 4.7: https://{host}[/base]/projects/{project}/repos/{repo}/raw/*?at={sha} + /// < 4.7: https://{host}[/base]/projects/{project}/repos/{repo}/browse/*?at={sha}&raw + /// + internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out string? commitSha, [NotNullWhen(true)] out string? repositoryUrl) + { + commitSha = null; + repositoryUrl = null; + + try + { + var path = uri.AbsolutePath; + var query = uri.Query; + + // Bitbucket Server paths are /projects/{project}/repos/{repo}/..., so /projects/ must precede /repos/ + var projectsIdx = path.IndexOf("/projects/", StringComparison.Ordinal); + var reposIdx = path.IndexOf("/repos/", StringComparison.Ordinal); + if (projectsIdx < 0 || reposIdx <= projectsIdx) + { + return false; + } + + var afterRepos = path.Substring(reposIdx + "/repos/".Length); + + // Find the repo name (next segment after /repos/) + var repoEndSlash = afterRepos.IndexOf('/'); + if (repoEndSlash <= 0) + { + return false; + } + + var afterRepo = afterRepos.Substring(repoEndSlash); + + bool isBrowseForm; + if (afterRepo.StartsWith("/raw/", StringComparison.Ordinal)) + { + isBrowseForm = false; + } + else if (afterRepo.StartsWith("/browse/", StringComparison.Ordinal)) + { + isBrowseForm = true; + } + else + { + return false; + } + + // Walk the query string once: extract the at={sha} pair and, for browse form, also + // look for a standalone "raw" flag (as opposed to an unrelated substring match). + ReadOnlySpan shaSpan = default; + var hasRawFlag = false; + foreach (var pair in query.SplitIntoSpans('&')) + { + ReadOnlySpan pairSpan = ((ReadOnlySpan)pair).TrimStart('?'); + var eqIndex = pairSpan.IndexOf('='); + var key = eqIndex < 0 ? pairSpan : pairSpan.Slice(0, eqIndex); + + if (key.SequenceEqual("at".AsSpan()) && eqIndex >= 0) + { + shaSpan = pairSpan.Slice(eqIndex + 1); + } + else if (key.SequenceEqual("raw".AsSpan())) + { + hasRawFlag = true; + } + } + + if (isBrowseForm && !hasRawFlag) + { + return false; + } + + if (!IsValidCommitSha(shaSpan)) + { + return false; + } + + // Build repo URL: {scheme}://{authority}[/base]/projects/{project}/repos/{repo} + // This is everything up to and including the repo name segment + var repoUrlPath = path.Substring(0, reposIdx + "/repos/".Length + repoEndSlash); + + repositoryUrl = $"{uri.Scheme}://{uri.Authority}{repoUrlPath}"; + commitSha = shaSpan.ToString(); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error while trying to parse Bitbucket Server SourceLink URL"); + } + + return false; + } +} diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLink/CompositeSourceLinkUrlParser.cs b/tracer/src/Datadog.Trace/PDBs/SourceLink/CompositeSourceLinkUrlParser.cs index 7c0f9a83c509..592422402f7f 100644 --- a/tracer/src/Datadog.Trace/PDBs/SourceLink/CompositeSourceLinkUrlParser.cs +++ b/tracer/src/Datadog.Trace/PDBs/SourceLink/CompositeSourceLinkUrlParser.cs @@ -20,6 +20,7 @@ internal sealed class CompositeSourceLinkUrlParser : SourceLinkUrlParser { new GitHubSourceLinkUrlParser(), new BitBucketSourceLinkUrlParser(), + new BitBucketServerSourceLinkUrlParser(), new AzureDevOpsSourceLinkUrlParser(), new GitLabSourceLinkUrlParser() }; diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLink/GitHubSourceLinkUrlParser.cs b/tracer/src/Datadog.Trace/PDBs/SourceLink/GitHubSourceLinkUrlParser.cs index d93c6ccd346f..68c76ece60c7 100644 --- a/tracer/src/Datadog.Trace/PDBs/SourceLink/GitHubSourceLinkUrlParser.cs +++ b/tracer/src/Datadog.Trace/PDBs/SourceLink/GitHubSourceLinkUrlParser.cs @@ -14,12 +14,11 @@ namespace Datadog.Trace.Pdb.SourceLink; internal sealed class GitHubSourceLinkUrlParser : SourceLinkUrlParser { /// - /// Extract the git commit sha and repository url from a GitHub SourceLink mapping string. - /// For example, for the following SourceLink mapping string: - /// https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/dd35903c688a74b62d1c6a9e4f41371c65704db8/* - /// It will return: - /// - commit sha: dd35903c688a74b62d1c6a9e4f41371c65704db8 - /// - repository URL: https://github.com/DataDog/dd-trace-dotnet + /// Extract the git commit sha and repository url from a GitHub or GitHub Enterprise SourceLink mapping string. + /// Supports three URL forms: + /// 1. GitHub.com: https://raw.githubusercontent.com/{owner}/{repo}/{sha}/* + /// 2. GHE subdomain isolation: https://raw.{host}/{owner}/{repo}/{sha}/* + /// 3. GHE main-host /raw/: https://{host}/raw/{owner}/{repo}/{sha}/* /// internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out string? commitSha, [NotNullWhen(true)] out string? repositoryUrl) { @@ -28,52 +27,129 @@ internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out st try { - if (uri.Host != "raw.githubusercontent.com") + // Case 1: GitHub.com — https://raw.githubusercontent.com/{owner}/{repo}/{sha}/* + if (uri.Host.Equals("raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase)) { - return false; + return TryParseStandardPath(uri, "https://github.com", out commitSha, out repositoryUrl); } - ReadOnlySpan org = default; - ReadOnlySpan repo = default; - ReadOnlySpan sha = default; - var segmentCount = 0; + // Case 2: GHE with subdomain isolation — https://raw.{host}/{owner}/{repo}/{sha}/* + if (uri.Host.StartsWith("raw.", StringComparison.OrdinalIgnoreCase) && uri.Host.Length > 4) + { + var enterpriseHost = uri.Host.Substring(4); + return TryParseStandardPath(uri, BuildBaseUrl(uri, enterpriseHost), out commitSha, out repositoryUrl); + } - foreach (var segment in uri.AbsolutePath.SplitIntoSpans('/')) + // Case 3: GHE without subdomain isolation — https://{host}/raw/{owner}/{repo}/{sha}/* + return TryParseRawPrefixPath(uri, out commitSha, out repositoryUrl); + } + catch (Exception ex) + { + Log.Error(ex, "Error while trying to parse GitHub SourceLink URL"); + } + + return false; + } + + /// + /// Parses /{owner}/{repo}/{sha}/* (4 segments) and builds repo URL with the given base. + /// Used for GitHub.com and GHE with subdomain isolation. + /// + private static bool TryParseStandardPath(Uri uri, string repoUrlBase, out string? commitSha, out string? repositoryUrl) + { + commitSha = null; + repositoryUrl = null; + + ReadOnlySpan org = default; + ReadOnlySpan repo = default; + ReadOnlySpan sha = default; + var segmentCount = 0; + + foreach (var segment in uri.AbsolutePath.SplitIntoSpans('/')) + { + ReadOnlySpan span = segment; + if (span.IsEmpty) { - ReadOnlySpan span = segment; - if (span.IsEmpty) - { - continue; - } - - switch (segmentCount) - { - case 0: org = span; break; - case 1: repo = span; break; - case 2: sha = span; break; - } - - segmentCount++; + continue; } - if (segmentCount != 4 || !IsValidCommitSha(sha)) + switch (segmentCount) { - return false; + case 0: org = span; break; + case 1: repo = span; break; + case 2: sha = span; break; } + segmentCount++; + } + + if (segmentCount != 4 || !IsValidCommitSha(sha)) + { + return false; + } + #if NET6_0_OR_GREATER - repositoryUrl = $"https://github.com/{org}/{repo}"; + repositoryUrl = $"{repoUrlBase}/{org}/{repo}"; #else - repositoryUrl = $"https://github.com/{org.ToString()}/{repo.ToString()}"; + repositoryUrl = $"{repoUrlBase}/{org.ToString()}/{repo.ToString()}"; #endif - commitSha = sha.ToString(); - return true; + commitSha = sha.ToString(); + return true; + } + + /// + /// Parses /raw/{owner}/{repo}/{sha}/* (5 segments) for GHE without subdomain isolation. + /// + private static bool TryParseRawPrefixPath(Uri uri, out string? commitSha, out string? repositoryUrl) + { + commitSha = null; + repositoryUrl = null; + + ReadOnlySpan owner = default; + ReadOnlySpan repo = default; + ReadOnlySpan sha = default; + var segmentCount = 0; + + foreach (var segment in uri.AbsolutePath.SplitIntoSpans('/')) + { + ReadOnlySpan span = segment; + if (span.IsEmpty) + { + continue; + } + + switch (segmentCount) + { + case 0: + if (!span.SequenceEqual("raw".AsSpan())) + { + return false; + } + + break; + case 1: owner = span; break; + case 2: repo = span; break; + case 3: sha = span; break; + } + + segmentCount++; } - catch (Exception ex) + + if (segmentCount != 5 || !IsValidCommitSha(sha)) { - Log.Error(ex, "Error while trying to parse GitHub SourceLink URL"); + return false; } - return false; + var repoUrlBase = BuildBaseUrl(uri, uri.Host); +#if NET6_0_OR_GREATER + repositoryUrl = $"{repoUrlBase}/{owner}/{repo}"; +#else + repositoryUrl = $"{repoUrlBase}/{owner.ToString()}/{repo.ToString()}"; +#endif + commitSha = sha.ToString(); + return true; } + + private static string BuildBaseUrl(Uri uri, string host) + => uri.IsDefaultPort ? $"{uri.Scheme}://{host}" : $"{uri.Scheme}://{host}:{uri.Port}"; } diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLink/GitLabSourceLinkUrlParser.cs b/tracer/src/Datadog.Trace/PDBs/SourceLink/GitLabSourceLinkUrlParser.cs index 739aec88afc7..9452d3f714ce 100644 --- a/tracer/src/Datadog.Trace/PDBs/SourceLink/GitLabSourceLinkUrlParser.cs +++ b/tracer/src/Datadog.Trace/PDBs/SourceLink/GitLabSourceLinkUrlParser.cs @@ -6,7 +6,6 @@ #nullable enable using System; using System.Diagnostics.CodeAnalysis; -using Datadog.Trace.Util; namespace Datadog.Trace.Pdb.SourceLink; @@ -14,11 +13,9 @@ internal sealed class GitLabSourceLinkUrlParser : SourceLinkUrlParser { /// /// Extract the git commit sha and repository url from a GitLab SourceLink mapping string. - /// For example, for the following SourceLink mapping string: - /// https://test-gitlab-domain/test-org/test-repo/raw/dd35903c688a74b62d1c6a9e4f41371c65704db8/* - /// It will return: - /// - commit sha: dd35903c688a74b62d1c6a9e4f41371c65704db8 - /// - repository URL: https://test-gitlab-domain/test-org/test-repo + /// Supports both old and new GitLab URL formats, including nested groups/subgroups: + /// GitLab >= 12.0: https://{host}/{group}[/{subgroup}/...]/{repo}/-/raw/{sha}/* + /// GitLab < 12.0: https://{host}/{group}[/{subgroup}/...]/{repo}/raw/{sha}/* /// internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out string? commitSha, [NotNullWhen(true)] out string? repositoryUrl) { @@ -27,53 +24,58 @@ internal override bool TryParseSourceLinkUrl(Uri uri, [NotNullWhen(true)] out st try { - ReadOnlySpan org = default; - ReadOnlySpan repo = default; - ReadOnlySpan sha = default; - var segmentCount = 0; + var path = uri.AbsolutePath; - foreach (var segment in uri.AbsolutePath.SplitIntoSpans('/')) + // Try /-/raw/ first (GitLab >= 12.0), then /raw/ (GitLab < 12.0). + // Use LastIndexOf so that repo paths containing "raw" as a segment name don't confuse us. + int rawMarkerIndex = path.LastIndexOf("/-/raw/", StringComparison.Ordinal); + int repoPathEnd; + int afterRawStart; + + if (rawMarkerIndex >= 0) + { + // /-/raw/ found — new format + repoPathEnd = rawMarkerIndex; + afterRawStart = rawMarkerIndex + "/-/raw/".Length; + } + else { - ReadOnlySpan span = segment; - if (span.IsEmpty) + rawMarkerIndex = path.LastIndexOf("/raw/", StringComparison.Ordinal); + if (rawMarkerIndex <= 0) { - continue; + // Not found, or /raw/ is at position 0 (which is the GHE pattern, not GitLab) + return false; } - switch (segmentCount) - { - case 0: org = span; break; - case 1: repo = span; break; - case 2: - if (!span.SequenceEqual("raw".AsSpan())) - { - return false; - } + repoPathEnd = rawMarkerIndex; + afterRawStart = rawMarkerIndex + "/raw/".Length; + } - break; - case 3: sha = span; break; - case 4: - if (!span.SequenceEqual("*".AsSpan())) - { - return false; - } + // After the raw marker we expect "{sha}/*" + var afterRaw = path.AsSpan().Slice(afterRawStart); + var slashIdx = afterRaw.IndexOf('/'); + if (slashIdx <= 0) + { + return false; + } - break; - } + var sha = afterRaw.Slice(0, slashIdx); + var rest = afterRaw.Slice(slashIdx + 1); - segmentCount++; + if (!rest.SequenceEqual("*".AsSpan()) || !IsValidCommitSha(sha)) + { + return false; } - if (segmentCount != 5 || !IsValidCommitSha(sha)) + // Require at least 2 non-empty segments before the raw marker (group + repo, or group/sub/repo). + // After trimming the leading/trailing '/', an inner '/' proves two segments exist. + var repoPath = path.AsSpan(0, repoPathEnd).TrimStart('/').TrimEnd('/'); + if (repoPath.IndexOf('/') <= 0) { return false; } -#if NET6_0_OR_GREATER - repositoryUrl = $"{uri.Scheme}://{uri.Authority}/{org}/{repo}"; -#else - repositoryUrl = $"{uri.Scheme}://{uri.Authority}/{org.ToString()}/{repo.ToString()}"; -#endif + repositoryUrl = $"{uri.Scheme}://{uri.Authority}{path.Substring(0, repoPathEnd)}"; commitSha = sha.ToString(); return true; } diff --git a/tracer/src/Datadog.Trace/PDBs/SourceLinkInformationExtractor.cs b/tracer/src/Datadog.Trace/PDBs/SourceLinkInformationExtractor.cs index 42a0c6313b23..898e666b0270 100644 --- a/tracer/src/Datadog.Trace/PDBs/SourceLinkInformationExtractor.cs +++ b/tracer/src/Datadog.Trace/PDBs/SourceLinkInformationExtractor.cs @@ -117,7 +117,7 @@ private static bool TryExtractFromAssemblyAttributes(Assembly assembly, [NotNull case AssemblyInformationalVersionAttribute { InformationalVersion: { } informationalVersion }: { var parts = informationalVersion.Split('+'); - if (parts.Length == 2) + if (parts.Length == 2 && !StringUtil.IsNullOrEmpty(parts[1])) { commitSha = parts[1]; } diff --git a/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/SourceLinkInformationExtractorTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/SourceLinkInformationExtractorTests.cs new file mode 100644 index 000000000000..c0126769cbc5 --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/SourceLinkInformationExtractorTests.cs @@ -0,0 +1,131 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System; +using System.Reflection; +using System.Reflection.Emit; +using Datadog.Trace.Pdb; +using FluentAssertions; +using Xunit; + +#nullable enable + +namespace Datadog.Trace.ClrProfiler.Managed.Tests +{ + public class SourceLinkInformationExtractorTests + { + [Theory] + [InlineData("1.0.0+abc123", "abc123")] // Valid case - exactly 2 parts + [InlineData("2.1.0-beta+def456", "def456")] // Valid case with prerelease + public void TryExtractFromAssemblyAttributes_ValidInformationalVersion_ExtractsCommitSha(string informationalVersion, string expectedCommitSha) + { + var assembly = CreateTestAssembly(repositoryUrl: "https://github.com/test/repo", informationalVersion: informationalVersion); + + var result = SourceLinkInformationExtractor.TryGetSourceLinkInfo(assembly, out var commitSha, out var repositoryUrl); + + result.Should().BeTrue(); + commitSha.Should().Be(expectedCommitSha); + repositoryUrl.Should().Be("https://github.com/test/repo"); + } + + [Theory] + [InlineData("1.0.0")] // No plus signs (parts.Length = 1) + [InlineData("1.0.0+build+abc123")] // Multiple plus signs (parts.Length = 3) + [InlineData("1.0.0-beta+build+abc123")] // Three parts with prerelease + [InlineData("1.0.0+")] // Empty after plus + [InlineData("1.0.0+build.meta.data+abc123")] // Multiple metadata with dots + [InlineData("1.0.0+20130313144700+abc123")] // Complex build metadata + [InlineData("1.0.0-alpha.1+build+abc123")] // With prerelease and multiple plus + [InlineData("1.0.0-rc.1+exp.sha.5114f85+abc123")] // Complex prerelease with multiple plus + [InlineData("1.0.0+build+meta+abc123")] // Four parts total + [InlineData("1.0.0+abc123+def456+ghi789")] // Five parts total + public void TryExtractFromAssemblyAttributes_InvalidInformationalVersion_FailsToExtractCommitSha(string informationalVersion) + { + var assembly = CreateTestAssembly(repositoryUrl: "https://github.com/test/repo", informationalVersion: informationalVersion); + + var result = SourceLinkInformationExtractor.TryGetSourceLinkInfo(assembly, out var commitSha, out var repositoryUrl); + + // Should return false because commit SHA extraction failed (even though repository URL is present) + result.Should().BeFalse(); + commitSha.Should().BeNull(); + repositoryUrl.Should().BeNull(); + } + + [Fact] + public void TryExtractFromAssemblyAttributes_MissingRepositoryUrl_ReturnsFalse() + { + var assembly = CreateTestAssembly(repositoryUrl: null, informationalVersion: "1.0.0+abc123"); + + var result = SourceLinkInformationExtractor.TryGetSourceLinkInfo(assembly, out var commitSha, out var repositoryUrl); + + result.Should().BeFalse(); + commitSha.Should().BeNull(); + repositoryUrl.Should().BeNull(); + } + + [Fact] + public void TryExtractFromAssemblyAttributes_MissingInformationalVersion_ReturnsFalse() + { + var assembly = CreateTestAssembly(repositoryUrl: "https://github.com/test/repo", informationalVersion: null); + + var result = SourceLinkInformationExtractor.TryGetSourceLinkInfo(assembly, out var commitSha, out var repositoryUrl); + + result.Should().BeFalse(); + commitSha.Should().BeNull(); + repositoryUrl.Should().BeNull(); + } + + [Fact] + public void TryExtractFromAssemblyAttributes_EmptyInformationalVersion_ReturnsFalse() + { + var assembly = CreateTestAssembly(repositoryUrl: "https://github.com/test/repo", informationalVersion: string.Empty); + + var result = SourceLinkInformationExtractor.TryGetSourceLinkInfo(assembly, out var commitSha, out var repositoryUrl); + + result.Should().BeFalse(); + commitSha.Should().BeNull(); + repositoryUrl.Should().BeNull(); + } + + private static Assembly CreateTestAssembly(string? repositoryUrl, string? informationalVersion) + { + var assemblyName = new AssemblyName($"TestAssembly_{System.Guid.NewGuid():N}"); + var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run); + + if (repositoryUrl != null) + { + var repositoryUrlAttribute = new AssemblyMetadataAttribute("RepositoryUrl", repositoryUrl); + assemblyBuilder.SetCustomAttribute(CreateCustomAttributeBuilder(repositoryUrlAttribute)); + } + + if (!string.IsNullOrEmpty(informationalVersion)) + { + var informationalVersionAttribute = new AssemblyInformationalVersionAttribute(informationalVersion); + assemblyBuilder.SetCustomAttribute(CreateCustomAttributeBuilder(informationalVersionAttribute)); + } + + return assemblyBuilder; + } + + private static CustomAttributeBuilder CreateCustomAttributeBuilder(Attribute attribute) + { + var attributeType = attribute.GetType(); + var constructor = attributeType.GetConstructors()[0]; + var constructorArgs = new object[constructor.GetParameters().Length]; + + if (attribute is AssemblyMetadataAttribute metadataAttr) + { + constructorArgs[0] = metadataAttr.Key; + constructorArgs[1] = metadataAttr.Value!; + } + else if (attribute is AssemblyInformationalVersionAttribute versionAttr) + { + constructorArgs[0] = versionAttr.InformationalVersion; + } + + return new CustomAttributeBuilder(constructor, constructorArgs); + } + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/AzureDevOpsSourceLinkUrlParserTests.cs b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/AzureDevOpsSourceLinkUrlParserTests.cs index 2626eaf63097..95625d1042f2 100644 --- a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/AzureDevOpsSourceLinkUrlParserTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/AzureDevOpsSourceLinkUrlParserTests.cs @@ -19,10 +19,12 @@ public class AzureDevOpsSourceLinkUrlParserTests private readonly AzureDevOpsSourceLinkUrlParser _parser = new(); [Theory] + // Legacy visualstudio.com format [InlineData( "https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", ValidSha, "https://test.visualstudio.com/test-org/_git/my-repo")] + // Modern dev.azure.com format [InlineData( "https://dev.azure.com/organisation/project/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", ValidSha, @@ -31,6 +33,21 @@ public class AzureDevOpsSourceLinkUrlParserTests "https://dev.azure.com/org/proj/_apis/git/repositories/example.shopping.api/items?api-version=1.0&versionType=commit&version=0e4d29442102e6cef1c271025d513c8b2187bcd6&path=/*", "0e4d29442102e6cef1c271025d513c8b2187bcd6", "https://dev.azure.com/org/proj/_git/example.shopping.api")] + // Azure DevOps Server / TFS on-prem with DefaultCollection + [InlineData( + "https://tfs-server.localdomain.com/DefaultCollection/TestProject_git/_apis/git/repositories/TestRepo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", + ValidSha, + "https://tfs-server.localdomain.com/DefaultCollection/TestProject_git/_git/TestRepo")] + // TFS on-prem with virtual directory and collection + [InlineData( + "https://tfs.example.com/tfs/DefaultCollection/MyProject/_apis/git/repositories/MyRepo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", + ValidSha, + "https://tfs.example.com/tfs/DefaultCollection/MyProject/_git/MyRepo")] + // On-prem with custom port + [InlineData( + "https://azdo.internal:8080/MyCollection/MyProject/_apis/git/repositories/MyRepo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", + ValidSha, + "https://azdo.internal:8080/MyCollection/MyProject/_git/MyRepo")] public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expectedSha, string expectedRepoUrl) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); @@ -46,10 +63,9 @@ public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expect [InlineData("https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&path=/*")] // missing version= [InlineData("https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&version=" + ValidSha)] // missing path=/* [InlineData("https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&version=invalid-sha&path=/*")] // invalid sha - [InlineData("https://github.com/org/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*")] // unsupported host - [InlineData("https://test.visualstudio.com/_apis/git/repositories/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*")] // too few path segments (< 5) [InlineData("https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&version=&path=/*")] // empty version value [InlineData("https://dev.azure.com/org/proj/_apis/git/repositories/?versionType=commit&version=" + ValidSha + "&path=/*")] // missing repo name (trailing slash, empty segment) + [InlineData("https://example.com/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*")] // nothing before _apis (no project) public void TryParseSourceLinkUrl_InvalidUrl_ReturnsFalse(string url) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out _, out var repositoryUrl); diff --git a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/BitBucketServerSourceLinkUrlParserTests.cs b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/BitBucketServerSourceLinkUrlParserTests.cs new file mode 100644 index 000000000000..6f1f8de87d91 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/BitBucketServerSourceLinkUrlParserTests.cs @@ -0,0 +1,77 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using Datadog.Trace.Pdb.SourceLink; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.Pdb.SourceLink; + +public class BitBucketServerSourceLinkUrlParserTests +{ + private const string ValidSha = "dd35903c688a74b62d1c6a9e4f41371c65704db8"; + + private readonly BitBucketServerSourceLinkUrlParser _parser = new(); + + [Theory] + // Bitbucket Server >= 4.7: /projects/{project}/repos/{repo}/raw/*?at={sha} + [InlineData( + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/raw/*?at=" + ValidSha, + ValidSha, + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo")] + // With base path + [InlineData( + "https://stash.example.com/base/projects/PROJ/repos/my-repo/raw/*?at=" + ValidSha, + ValidSha, + "https://stash.example.com/base/projects/PROJ/repos/my-repo")] + // Bitbucket Server < 4.7: /projects/{project}/repos/{repo}/browse/*?at={sha}&raw + [InlineData( + "http://stash.mycompany.com:7990/projects/cclcom/repos/myrepo/browse/*?at=" + ValidSha + "&raw", + ValidSha, + "http://stash.mycompany.com:7990/projects/cclcom/repos/myrepo")] + // < 4.7 with base path + [InlineData( + "https://bitbucket.internal/base/projects/TEAM/repos/core-lib/browse/*?at=" + ValidSha + "&raw", + ValidSha, + "https://bitbucket.internal/base/projects/TEAM/repos/core-lib")] + // < 4.7 with raw flag before at= (order should not matter) + [InlineData( + "https://stash.mycompany.com/projects/PROJ/repos/repo/browse/*?raw&at=" + ValidSha, + ValidSha, + "https://stash.mycompany.com/projects/PROJ/repos/repo")] + // >= 4.7 with additional query params before at= + [InlineData( + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/raw/*?limit=100&at=" + ValidSha, + ValidSha, + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo")] + public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expectedSha, string expectedRepoUrl) + { + var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); + + result.Should().BeTrue(); + commitSha.Should().Be(expectedSha); + repositoryUrl.Should().Be(expectedRepoUrl); + } + + [Theory] + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/raw/*")] // missing at= query parameter + [InlineData("https://bitbucket.mycompany.com/repos/myrepo/raw/*?at=" + ValidSha)] // missing /projects/ + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/myrepo/raw/*?at=" + ValidSha)] // missing /repos/ + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/raw/*?at=invalid-sha")] // invalid sha + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/blob/*?at=" + ValidSha)] // /blob/ instead of /raw/ or /browse/ + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/browse/*?at=" + ValidSha)] // /browse/ without &raw query flag + [InlineData("https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/browse/*?at=" + ValidSha + "&raws=1")] // "raws" is not the standalone "raw" flag + public void TryParseSourceLinkUrl_InvalidUrl_ReturnsFalse(string url) + { + var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); + + result.Should().BeFalse(); + commitSha.Should().BeNull(); + repositoryUrl.Should().BeNull(); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/CompositeSourceLinkUrlParserTests.cs b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/CompositeSourceLinkUrlParserTests.cs index 80a78655440c..2f597016c39b 100644 --- a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/CompositeSourceLinkUrlParserTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/CompositeSourceLinkUrlParserTests.cs @@ -17,26 +17,66 @@ public class CompositeSourceLinkUrlParserTests private const string ValidSha = "dd35903c688a74b62d1c6a9e4f41371c65704db8"; [Theory] + // GitHub.com [InlineData( "https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/" + ValidSha + "/*", ValidSha, "https://github.com/DataDog/dd-trace-dotnet")] + // GitHub Enterprise — subdomain isolation (raw.{host}) + [InlineData( + "https://raw.github.ecorp.test/org/repo/" + ValidSha + "/*", + ValidSha, + "https://github.ecorp.test/org/repo")] + // GitHub Enterprise — main-host /raw/ form + [InlineData( + "https://github.ecorp.test/raw/org/repo/" + ValidSha + "/*", + ValidSha, + "https://github.ecorp.test/org/repo")] + // Bitbucket Cloud (API 2.0) [InlineData( "https://api.bitbucket.org/2.0/repositories/test-org/test-repo/src/" + ValidSha + "/*", ValidSha, "https://bitbucket.org/test-org/test-repo")] + // Bitbucket Server >= 4.7 + [InlineData( + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo/raw/*?at=" + ValidSha, + ValidSha, + "https://bitbucket.mycompany.com/projects/MYPROJ/repos/myrepo")] + // Bitbucket Server < 4.7 + [InlineData( + "http://stash.mycompany.com:7990/projects/cclcom/repos/myrepo/browse/*?at=" + ValidSha + "&raw", + ValidSha, + "http://stash.mycompany.com:7990/projects/cclcom/repos/myrepo")] + // Azure DevOps — visualstudio.com [InlineData( "https://test.visualstudio.com/test-org/_apis/git/repositories/my-repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", ValidSha, "https://test.visualstudio.com/test-org/_git/my-repo")] + // Azure DevOps — dev.azure.com [InlineData( "https://dev.azure.com/org/proj/_apis/git/repositories/repo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", ValidSha, "https://dev.azure.com/org/proj/_git/repo")] + // Azure DevOps Server / TFS on-prem + [InlineData( + "https://tfs-server.localdomain.com/DefaultCollection/TestProject/_apis/git/repositories/TestRepo/items?api-version=1.0&versionType=commit&version=" + ValidSha + "&path=/*", + ValidSha, + "https://tfs-server.localdomain.com/DefaultCollection/TestProject/_git/TestRepo")] + // GitLab < 12.0 [InlineData( "https://gitlab.com/test-org/test-repo/raw/" + ValidSha + "/*", ValidSha, "https://gitlab.com/test-org/test-repo")] + // GitLab >= 12.0 + [InlineData( + "https://gitlab.com/test-org/test-repo/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/test-org/test-repo")] + // GitLab with nested groups + [InlineData( + "https://gitlab.com/group/subgroup/repo/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/group/subgroup/repo")] public void TryParseSourceLinkUrl_ValidUrl_RoutesToCorrectParser(string url, string expectedSha, string expectedRepoUrl) { var result = CompositeSourceLinkUrlParser.Instance.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); diff --git a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitHubSourceLinkUrlParserTests.cs b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitHubSourceLinkUrlParserTests.cs index 0624ec33cdb8..7d1194806a27 100644 --- a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitHubSourceLinkUrlParserTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitHubSourceLinkUrlParserTests.cs @@ -31,6 +31,24 @@ public class GitHubSourceLinkUrlParserTests "https://raw.githubusercontent.com/some.org/some.repo-name/" + ValidSha + "/*", ValidSha, "https://github.com/some.org/some.repo-name")] + // GitHub Enterprise with subdomain isolation (raw.{host} form) + [InlineData( + "https://raw.github.ecorp.example.com/taggac/vsphere-automation-sdk-.net/" + ValidSha + "/*", + ValidSha, + "https://github.ecorp.example.com/taggac/vsphere-automation-sdk-.net")] + [InlineData( + "https://raw.ghe.internal/my-org/my-repo/" + ValidSha + "/*", + ValidSha, + "https://ghe.internal/my-org/my-repo")] + // GitHub Enterprise without subdomain isolation (/raw/ path form) + [InlineData( + "https://github.ecorp.test/raw/taggac/vsphere-automation-sdk-.net/" + ValidSha + "/*", + ValidSha, + "https://github.ecorp.test/taggac/vsphere-automation-sdk-.net")] + [InlineData( + "https://ghe.internal:8443/raw/my-org/my-repo/" + ValidSha + "/*", + ValidSha, + "https://ghe.internal:8443/my-org/my-repo")] public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expectedSha, string expectedRepoUrl) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); @@ -41,12 +59,17 @@ public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expect } [Theory] - [InlineData("https://raw.example.com/DataDog/dd-trace-dotnet/" + ValidSha + "/*")] // wrong host [InlineData("https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/*")] // missing sha [InlineData("https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/" + ValidSha + "/extra/*")] // too many segments [InlineData("https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/abc123/*")] // sha too short [InlineData("https://raw.githubusercontent.com/DataDog/dd-trace-dotnet/zz35903c688a74b62d1c6a9e4f41371c65704db!/*")] // non-hex chars [InlineData("https://raw.githubusercontent.com/")] // empty path + [InlineData("https://raw./owner/repo/" + ValidSha + "/*")] // raw. with empty enterprise host + [InlineData("https://raw.ghe.internal/owner/repo/" + ValidSha)] // GHE subdomain isolation with too few segments (no trailing /*) + [InlineData("https://raw.ghe.internal/owner/" + ValidSha + "/*")] // GHE subdomain isolation with too few segments (3 instead of 4) + [InlineData("https://example.com/raw/owner/repo/not-a-sha/*")] // GHE /raw/ form with invalid sha + [InlineData("https://example.com/raw/owner/" + ValidSha + "/*")] // GHE /raw/ form with missing repo (4 segments, not 5) + [InlineData("https://example.com/notraw/owner/repo/" + ValidSha + "/*")] // /notraw/ is not /raw/ public void TryParseSourceLinkUrl_InvalidUrl_ReturnsFalse(string url) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); diff --git a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitLabSourceLinkUrlParserTests.cs b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitLabSourceLinkUrlParserTests.cs index 946af621e90a..9c8c03a69d53 100644 --- a/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitLabSourceLinkUrlParserTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Pdb/SourceLink/GitLabSourceLinkUrlParserTests.cs @@ -19,6 +19,7 @@ public class GitLabSourceLinkUrlParserTests private readonly GitLabSourceLinkUrlParser _parser = new(); [Theory] + // GitLab < 12.0 — /{group}/{repo}/raw/{sha}/* [InlineData( "https://gitlab.com/test-org/test-repo/raw/" + ValidSha + "/*", ValidSha, @@ -31,6 +32,33 @@ public class GitLabSourceLinkUrlParserTests "https://gitlab.example.com:8443/org/repo/raw/" + ValidSha + "/*", ValidSha, "https://gitlab.example.com:8443/org/repo")] + // GitLab >= 12.0 — /{group}/{repo}/-/raw/{sha}/* + [InlineData( + "https://gitlab.com/test-org/test-repo/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/test-org/test-repo")] + [InlineData( + "https://gitlab.example.com/example/example-dotnet-source-link/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.example.com/example/example-dotnet-source-link")] + // GitLab nested groups/subgroups (< 12.0 format) + [InlineData( + "https://gitlab.com/group/subgroup/repo/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/group/subgroup/repo")] + [InlineData( + "https://gitlab.com/group/sub1/sub2/repo/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/group/sub1/sub2/repo")] + // GitLab nested groups/subgroups (>= 12.0 format) + [InlineData( + "https://gitlab.com/group/subgroup/repo/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/group/subgroup/repo")] + [InlineData( + "https://gitlab.com/group/sub1/sub2/repo/-/raw/" + ValidSha + "/*", + ValidSha, + "https://gitlab.com/group/sub1/sub2/repo")] public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expectedSha, string expectedRepoUrl) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl); @@ -41,11 +69,12 @@ public void TryParseSourceLinkUrl_ValidUrl_ReturnsTrue(string url, string expect } [Theory] - [InlineData("https://gitlab.com/test-org/raw/" + ValidSha + "/*")] // too few segments (4 instead of 5) - [InlineData("https://gitlab.com/test-org/sub/test-repo/raw/" + ValidSha + "/*")] // too many segments - [InlineData("https://gitlab.com/test-org/test-repo/blob/" + ValidSha + "/*")] // segments[2] != "raw" - [InlineData("https://gitlab.com/test-org/test-repo/raw/" + ValidSha + "/specific-file")] // segments[4] != "*" + [InlineData("https://gitlab.com/test-org/raw/" + ValidSha + "/*")] // only one segment before /raw/ (need at least 2) + [InlineData("https://gitlab.com/test-org/test-repo/blob/" + ValidSha + "/*")] // "blob" is not "raw" or "-/raw" + [InlineData("https://gitlab.com/test-org/test-repo/raw/" + ValidSha + "/specific-file")] // trailing segment != "*" [InlineData("https://gitlab.com/test-org/test-repo/raw/invalid-sha/*")] // invalid sha + [InlineData("https://gitlab.com/test-org/test-repo/-/raw/invalid-sha/*")] // invalid sha with new format + [InlineData("https://gitlab.com/test-org/test-repo/-/raw/" + ValidSha + "/specific-file")] // trailing segment != "*" with new format public void TryParseSourceLinkUrl_InvalidUrl_ReturnsFalse(string url) { var result = _parser.TryParseSourceLinkUrl(new Uri(url), out var commitSha, out var repositoryUrl);