From c14a320bebfa686552ff188a64f07feed89c99a0 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Fri, 21 Aug 2026 13:27:29 +0200 Subject: [PATCH 01/25] [CI Visibility] Fix CODEOWNERS parser correctness and normalize CI source paths --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 55 ++- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 158 ++++++-- .../CI/CodeOwnersFallbackTests.cs | 119 ++++++ .../CI/CodeOwnersTests.cs | 6 +- .../Ci/CodeOwnersRepositoryTests.cs | 124 +++++++ .../Ci/CodeOwnersSpecTests.cs | 347 ++++++++++++++++++ 6 files changed, 781 insertions(+), 28 deletions(-) create mode 100644 tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index a35e4de1d3f3..1e5546ed3cd2 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -28,8 +28,10 @@ internal abstract class CIEnvironmentValues private static readonly Lazy LazyInstance = new(Create); private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled); private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static readonly char[] ForwardSlashCharacters = { '/' }; private readonly object _codeOwnersLock = new(); private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); @@ -611,10 +613,10 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa else { // For relative paths, enforce that they stay within the CODEOWNERS root. - // Relative paths must stay within the codeowners root; otherwise we skip. + // Relative paths must stay within the codeowners root; otherwise we try to anchor them. if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) { - return false; + return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, out codeOwnersRelativePath); } absolutePath = resolvedPath; @@ -630,13 +632,56 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa relativePath.StartsWith("../", StringComparison.Ordinal) || relativePath.StartsWith("..\\", StringComparison.Ordinal)) { - return false; + return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, out codeOwnersRelativePath); } codeOwnersRelativePath = relativePath; return true; } + private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, [NotNullWhen(true)] out string? codeOwnersRelativePath) + { + // Compiler-recorded paths can be relative to a different base directory than the current + // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict + // resolution fails, anchor the path by finding the longest suffix that exists under the + // CODEOWNERS root, independently of the CI provider layout that produced the prefix. + codeOwnersRelativePath = null; + if (StringUtil.IsNullOrWhiteSpace(sourceFilePath) || Path.IsPathRooted(sourceFilePath)) + { + // Only relative paths recorded against a foreign base directory are anchored; absolute + // paths pointing outside the repository must not be re-anchored into it. + return false; + } + + var normalizedPath = sourceFilePath.Replace('\\', '/'); + var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) + { + // Never anchor bare file names: too easy to match an unrelated file. + return false; + } + + // Skip leading "." / ".." navigation segments: they belong to the foreign base directory. + var start = 0; + while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) + { + start++; + } + + for (var i = start; i < segments.Length - 1; i++) + { + var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); + var candidatePath = Path.Combine(codeOwnersRoot, candidateSuffix); + if (!Path.IsPathRooted(candidateSuffix) && File.Exists(candidatePath)) + { + codeOwnersRelativePath = string.Join("/", segments, i, segments.Length - i); + return true; + } + } + + return false; + } + private string MakeRelativePath(string? basePath, string absolutePath, bool useOSSeparator) { var pivotFolder = basePath; diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index ef1d69d2bd54..d6c831fb726f 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -14,15 +14,21 @@ namespace Datadog.Trace.Ci { /// - /// A feature‑complete, allocation‑conscious CODEOWNERS parser that supports both GitHub and GitLab - /// semantics (sections, exclusions, optional sections, approval counts, role owners, globstar, etc.). + /// A CODEOWNERS parser that follows the GitHub and GitLab specifications: last matching rule wins, + /// rooted and unrooted (globstar-relative) paths, directory and wildcard patterns, globstars (**), + /// inline comments (GitHub), sections with default owners, optional sections, approval counts, + /// role owners (@@role) and exclusion patterns (GitLab). Matching is case-sensitive. /// Usage: /// var owners = new CodeOwners(pathToFile, CodeOwners.Platform.GitLab).Match("src/app/Program.cs"); /// // owners is an IEnumerable{string} of unique owners that apply to that file. /// internal sealed class CodeOwners { - private readonly IReadOnlyList
_sections; + // Upper bound for any single glob evaluation: protects the process from pathological + // patterns in huge CODEOWNERS files. Timed-out rules are treated as non-matching. + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(3); + + private readonly List
_sections; private readonly Platform _platform; public CodeOwners(string filePath, Platform platform) @@ -44,10 +50,36 @@ public IEnumerable Match(string path) { var owners = new HashSet(StringComparer.Ordinal); var normalizedPath = path.IndexOf('\\') >= 0 ? path.Replace('\\', '/') : path; + if (normalizedPath.Length == 0 || normalizedPath[0] != '/') + { + // Rooted patterns are anchored to the repository root, so ensure a leading slash. + normalizedPath = "/" + normalizedPath; + } + + if (_platform == Platform.GitHub) + { + // GitHub has no sections: the whole file is a single ordered rule set where + // the last matching pattern takes precedence over all previous ones. + for (var i = _sections.Count - 1; i >= 0; i--) + { + if (_sections[i].TryMatchGitHub(normalizedPath, out var sectionOwners)) + { + foreach (var o in sectionOwners) + { + owners.Add(o); + } + break; + } + } + + return owners; + } + + // GitLab evaluates each section independently and combines their owners. foreach (var section in _sections) { - if (section.TryMatch(normalizedPath, _platform, out var sectionOwners)) + if (section.TryMatchGitLab(normalizedPath, out var sectionOwners)) { foreach (var o in sectionOwners) { @@ -69,7 +101,7 @@ private static List
Parse(IEnumerable lines, Platform platform) foreach (var line in lines) { lineNo++; - var raw = line.TrimEnd(); + var raw = line.Trim(); if (raw.Length == 0) { continue; @@ -143,6 +175,9 @@ private static Regex CompileGlob(string pattern) // Temporary sentinel for ** that we restore after dealing with single *. rx = rx.Replace("\\*\\*", "§§DOUBLESTAR§§"); rx = rx.Replace("\\*", "[^/]*"); // single‑level wildcard + // A slash right after ** means it can match zero or more intermediate directories: + // `a/**/b` must also match `a/b`. + rx = rx.Replace("§§DOUBLESTAR§§/", "(?:.*/)?"); rx = rx.Replace("§§DOUBLESTAR§§", ".*"); // multi‑level wildcard rx = rx.Replace("\\?", "."); // single char @@ -163,7 +198,7 @@ private static Regex CompileGlob(string pattern) } rx += "$"; - return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant); + return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant, RegexTimeout); } #pragma warning disable SA1201 @@ -220,19 +255,45 @@ public Section(string name, bool required, int approvalCount, string[] defaultOw public void Seal() => _cache = _entries.AsEnumerable().Reverse().ToArray(); - public bool TryMatch(string path, Platform platform, out IEnumerable owners) + /// + /// GitHub evaluation: exclusion rules are unsupported and ignored, section default owners don't + /// exist, and the caller stops at the first (i.e. last in file order) matching rule. + /// + public bool TryMatchGitHub(string path, [NotNullWhen(true)] out IEnumerable? owners) { - owners = []; var rules = _cache ?? []; foreach (var rule in rules) { - // GitHub doesn’t support exclusion rules. Keep them parse‑able but ignore when evaluating. - if (rule.IsExclusion && platform == Platform.GitHub) + // GitHub doesn't support exclusion rules. Keep them parse‑able but ignore when evaluating. + if (rule.IsExclusion || !rule.Match(path)) { continue; } + owners = rule.Owners; + return true; + } + + owners = null; + return false; + } + + /// + /// GitLab evaluation: rules are evaluated in file order within the section; the last matching + /// entry wins, an exclusion exempts the path for the whole section (later rules cannot + /// re-include it), and entries without owners inherit the section default owners. + /// + public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable? owners) + { + var rules = _cache ?? []; + string[]? matchedOwners = null; + var excluded = false; + + // The cache holds the entries in reverse file order, so walk it backwards. + for (var i = rules.Length - 1; i >= 0; i--) + { + var rule = rules[i]; if (!rule.Match(path)) { continue; @@ -240,33 +301,39 @@ public bool TryMatch(string path, Platform platform, out IEnumerable own if (rule.IsExclusion) { - // Excluded for this section; stop evaluating this section. - return false; + // Exclusions are terminal for the section: later rules cannot re-include the path. + excluded = true; + break; } - owners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners; - return owners.Any(); + if (!excluded) + { + matchedOwners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners; + } } - if (DefaultOwners.Length > 0) + if (excluded || matchedOwners is null || matchedOwners.Length == 0) { - owners = DefaultOwners; - return true; + owners = null; + return false; } - return false; + owners = matchedOwners; + return true; } } private sealed class Entry { private readonly Regex _regex; + private readonly bool _isDirectoryPattern; - private Entry(Regex regex, bool exclusion, string[] owners) + private Entry(Regex regex, bool exclusion, string[] owners, bool isDirectoryPattern) { _regex = regex; IsExclusion = exclusion; Owners = owners; + _isDirectoryPattern = isDirectoryPattern; } public bool IsExclusion { get; } @@ -321,10 +388,59 @@ private Entry(Regex regex, bool exclusion, string[] owners) // 5. Compile the glob var rx = CompileGlob(patternToken); - return new Entry(rx, isExclusion, owners); + // GitHub owns the contents of directories matched by wildcard-free patterns (e.g. + // `**/logs`); GitLab requires an explicit trailing slash for directory ownership. + var isDirectoryPattern = platform == Platform.GitHub && IsDirectoryPattern(patternToken); + return new Entry(rx, isExclusion, owners, isDirectoryPattern); } - public bool Match(string path) => _regex.IsMatch(path); + private static bool IsDirectoryPattern(string patternToken) + { + var lastSegmentStart = patternToken.LastIndexOf('/'); + var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken; + return lastSegment.Length > 0 && + lastSegment.IndexOf('*') < 0 && + lastSegment.IndexOf('?') < 0; + } + + public bool Match(string path) + { + if (IsMatch(path)) + { + return true; + } + + // Patterns whose last segment is wildcard-free also own everything inside a matched + // directory (e.g. `**/logs` owns `/build/logs/error.txt`), while wildcard segments like + // `docs/*` match individual entries only. + return _isDirectoryPattern && MatchesAncestor(path); + } + + private bool MatchesAncestor(string path) + { + for (var i = path.IndexOf('/', 1); i > 0 && i < path.Length - 1; i = path.IndexOf('/', i + 1)) + { + if (IsMatch(path.Substring(0, i))) + { + return true; + } + } + + return false; + } + + private bool IsMatch(string input) + { + try + { + return _regex.IsMatch(input); + } + catch (RegexMatchTimeoutException) + { + // A pathological pattern must never hang the process: treat it as non-matching. + return false; + } + } } } } diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs index 02a9bb77351d..98bc2a3824da 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs @@ -251,6 +251,125 @@ public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile() Assert.Null(ciValues.CodeOwners); } + [SkippableFact] + public void AnchorsForeignRelativePathsToCodeOwnersRoot() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + // Paths recorded against a foreign base directory (e.g. "../../../_/..." on CI agents) + // must be anchored back to a repository-relative path. + var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs"; + Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, false, out var codeOwnersRelativePath)); + Assert.Equal("tracer/test/SpanBenchmark.cs", codeOwnersRelativePath); + + var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void MakeRelativePathFromSourceRootWithFallbackNormalizesForeignPrefixes() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("../../../_/tracer/test/SpanBenchmark.cs", false); + Assert.Equal("tracer/test/SpanBenchmark.cs", relative); + } + + [SkippableFact] + public void DoesNotAnchorForeignPathsWhenSuffixDoesNotExistUnderRoot() + { + using var repoDirectory = new TemporaryDirectory(); + using var externalDirectory = new TemporaryDirectory(); + + var repoRoot = repoDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, "src")); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var externalFile = Path.Combine(externalDirectory.RootPath, "other", "SpanBenchmark.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!); + File.WriteAllText(externalFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + // A foreign path whose suffix does not exist under the repository must not be re-anchored. + Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/SpanBenchmark.cs", false, out _)); + } + + [SkippableFact] + public void AnchorsAzurePipelinesCompilerRecordedPaths() + { + // Reproduces the reported CI Visibility issue: on Azure Pipelines agents, compiler-recorded + // source paths are relative to a foreign base directory (e.g. + // "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"), + // which made every test span lose its test.codeowners tag once the CODEOWNERS rules were + // changed back to rooted patterns. + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "ExceptionsTests.cs"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n"); + File.WriteAllText(sourceFile, "// test"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Azure.TFBuild] = "True", + [PlatformKeys.Ci.Azure.SystemTeamFoundationServerUri] = "https://dev.azure.com/datadoghq/", + [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, + [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, + [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + Assert.NotNull(ciValues.CodeOwners); + + var foreignRelativePath = "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"; + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignRelativePath, false); + Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative); + + var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@DataDog/tracing-dotnet" }, owners); + } + private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs index c13f2001fd95..474234a43c0c 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs @@ -44,13 +44,15 @@ public CodeOwnersTests() [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] [InlineData(@"apps\test.cs", "[\"@octocat\"]")] [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")] - [InlineData(@"docs\getting-started.md", "[\"docs@example.com\"]")] + [InlineData(@"docs\getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")] [InlineData(@"\apps\github", null)] [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")] // New GitHub quirks [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // **/logs pattern - [InlineData("docs/getting-started.md", "[\"docs@example.com\"]")] // docs/* pattern + // Rooted patterns match regardless of a leading slash, so the later `/docs/` rule + // (last match wins) takes precedence over the earlier `docs/*` rule. + [InlineData("docs/getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence public void CheckGithubCodeOwners(string value, string expected) { var match = _githubCodeOwners.Match(value); diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs new file mode 100644 index 000000000000..9d32b95455ad --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs @@ -0,0 +1,124 @@ +// +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using Datadog.Trace.Ci; +using Datadog.Trace.TestHelpers; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.Ci; + +public class CodeOwnersRepositoryTests +{ + [SkippableFact] + public void EveryTestFileHasAnOwner() + { + var repoRoot = GetRepositoryRoot(); + Skip.If(repoRoot is null, "Could not locate the repository root"); + + var codeOwners = new CodeOwners(Path.Combine(repoRoot!, ".github", "CODEOWNERS"), CodeOwners.Platform.GitHub); + var unownedFiles = new List(); + var totalFiles = 0; + + foreach (var testRoot in new[] { "tracer/test", "profiler/test" }) + { + var fullRoot = Path.Combine(repoRoot!, testRoot.Replace('/', Path.DirectorySeparatorChar)); + if (!Directory.Exists(fullRoot)) + { + continue; + } + + foreach (var file in Directory.EnumerateFiles(fullRoot, "*", SearchOption.AllDirectories)) + { + var relativePath = file.Substring(repoRoot!.Length + 1).Replace('\\', '/'); + totalFiles++; + if (!codeOwners.Match("/" + relativePath).Any()) + { + unownedFiles.Add(relativePath); + } + } + } + + totalFiles.Should().BeGreaterThan(0, "expected to find test files in the repository"); + unownedFiles.Should().BeEmpty("every test file should be owned by at least one team in .github/CODEOWNERS"); + } + + [SkippableTheory] + [InlineData("/docs/development/AzureFunctions.md", new[] { "@DataDog/tracing-dotnet", "@DataDog/apm-serverless", "@DataDog/serverless-azure-and-gcp" })] + // `**/` must also match zero directories: a file directly under tracer/test/ still matches /tracer/test/**/*Lambda* + [InlineData("/tracer/test/FooLambdaTests.cs", new[] { "@DataDog/tracing-dotnet", "@DataDog/apm-serverless", "@DataDog/serverless-aws" })] + // Rooted patterns must match paths passed without a leading slash too + [InlineData("tracer/src/Datadog.Trace/Ci/CodeOwners.cs", new[] { "@DataDog/ci-app-libraries-dotnet", "@DataDog/apm-dotnet" })] + [InlineData("/tracer/src/Datadog.Trace/Ci/CodeOwners.cs", new[] { "@DataDog/ci-app-libraries-dotnet", "@DataDog/apm-dotnet" })] + public void RepositoryCodeOwnersMatchesExpectedTeams(string path, string[] expected) + { + var repoRoot = GetRepositoryRoot(); + Skip.If(repoRoot is null, "Could not locate the repository root"); + + var codeOwners = new CodeOwners(Path.Combine(repoRoot!, ".github", "CODEOWNERS"), CodeOwners.Platform.GitHub); + codeOwners.Match(path).OrderBy(o => o).Should().Equal(expected.OrderBy(o => o)); + } + + [SkippableFact] + public void SectionDefaultOwnersDoNotApplyToUnmatchedPaths() + { + var filePath = Path.Combine(Path.GetTempPath(), "dd-codeowners-" + Guid.NewGuid().ToString("N")); + try + { + // Owners listed on a section header line are not a catch-all rule for every other path. + File.WriteAllText(filePath, "[Section] @team\n/src/ @owner\n"); + var codeOwners = new CodeOwners(filePath, CodeOwners.Platform.GitLab); + + codeOwners.Match("/src/code.cs").Should().Equal(["@owner"]); + codeOwners.Match("/other/file.cs").Should().BeEmpty(); + } + finally + { + File.Delete(filePath); + } + } + + [SkippableFact] + public void GitLabExclusionRuleRemovesPathFromSection() + { + var filePath = Path.Combine(Path.GetTempPath(), "dd-codeowners-" + Guid.NewGuid().ToString("N")); + try + { + File.WriteAllText(filePath, "* @global\n/docs/ @docs\n!/docs/generated/\n"); + var codeOwners = new CodeOwners(filePath, CodeOwners.Platform.GitLab); + + codeOwners.Match("/docs/a.cs").Should().Equal(["@docs"]); + codeOwners.Match("/docs/generated/x.cs").Should().BeEmpty(); + codeOwners.Match("/other/file.cs").Should().Equal(["@global"]); + } + finally + { + File.Delete(filePath); + } + } + + private static string? GetRepositoryRoot() + { + // The solution directory is the repository root in this repo, but walk up as a safety net + // in case the solution is ever moved into a subdirectory. + var current = new DirectoryInfo(EnvironmentTools.GetSolutionDirectory()); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, ".github", "CODEOWNERS"))) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs new file mode 100644 index 000000000000..a783c62321d0 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -0,0 +1,347 @@ +// +// 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.IO; +using System.Linq; +using Datadog.Trace.Ci; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.Ci; + +/// +/// Specification tests for the CODEOWNERS parser based on the official GitHub and GitLab documentation: +/// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +/// https://docs.gitlab.com/user/project/codeowners/reference/ +/// +public class CodeOwnersSpecTests +{ + private const string GitlabSectionsExample = """ + * @admin + + [README Owners] + README.md @user1 @user2 + internal/README.md @user4 + + [README other owners] + README.md @user3 + """; + + [SkippableFact] + public void GithubInlineCommentsAndEmailOwners() + { + var codeOwners = Create("* @global-owner1 @global-owner2\n*.js @js-owner #This is an inline comment.\n*.go docs@example.com\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/app.js").Should().Equal(["@js-owner"]); + Match(codeOwners, "/app.go").Should().Equal(["docs@example.com"]); + Match(codeOwners, "/file.rb").Should().Equal(["@global-owner1", "@global-owner2"]); + } + + [SkippableFact] + public void GithubRootedDirectoryPatternMatchesSubdirectoriesOnlyAtRoot() + { + // "/build/logs/" owns the root build/logs directory and all its subdirectories + var codeOwners = Create("/build/logs/ @doctocat\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/build/logs/build-app/error.txt").Should().Equal(["@doctocat"]); + Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@doctocat"]); + Match(codeOwners, "/x/build/logs/error.txt").Should().BeEmpty(); + } + + [SkippableFact] + public void GithubWildcardSegmentDoesNotOwnNestedFiles() + { + // `docs/*` matches files like `docs/getting-started.md` but not deeper nested files like + // `docs/build-app/troubleshooting.md` + var codeOwners = Create("* @global\ndocs/* docs@example.com\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/docs/getting-started.md").Should().Equal(["docs@example.com"]); + Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@global"]); + } + + [SkippableFact] + public void GithubDirectoryPatternOwnsEverythingUnderneath() + { + var codeOwners = Create("* @global\n/docs/ @doctocat\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/docs/getting-started.md").Should().Equal(["@doctocat"]); + Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@doctocat"]); + } + + [SkippableFact] + public void GithubRootedVersusUnrootedPatterns() + { + // Unrooted patterns match anywhere in the repository, rooted ones only at the repository root + Match(Create("/apps/ @root-apps\n", CodeOwners.Platform.GitHub), "/apps/a.go").Should().Equal(["@root-apps"]); + Match(Create("/apps/ @root-apps\n", CodeOwners.Platform.GitHub), "/x/apps/a.go").Should().BeEmpty(); + Match(Create("apps/ @anywhere\n", CodeOwners.Platform.GitHub), "/x/apps/a.go").Should().Equal(["@anywhere"]); + } + + [SkippableFact] + public void GithubGlobstarDirectoryPatternOwnsDirectoryContents() + { + // `**/logs` owns any file in a logs directory such as `/build/logs`, `/scripts/logs`, + // and `/deeply/nested/logs` + var codeOwners = Create("**/logs @octocat\n*.tmp @temp-team\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@octocat"]); + Match(codeOwners, "/deeply/nested/logs/x.txt").Should().Equal(["@octocat"]); + Match(codeOwners, "/logs").Should().Equal(["@octocat"]); + Match(codeOwners, "/catalog/data.tmp").Should().Equal(["@temp-team"]); // no partial segment matches + } + + [SkippableFact] + public void OwnerlessEntryLeavesSubtreeUnowned() + { + // Example from the GitHub documentation: `/apps/github` has no owners, so any change inside + // it can be made with the approval of any user with write access (i.e. it is unowned). + var codeOwners = Create("* @global-owner1 @global-owner2\n*.go docs@example.com\n/apps/ @octocat\n/apps/github\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/apps/github").Should().BeEmpty(); + Match(codeOwners, "/apps/github/proj/main.go").Should().BeEmpty(); + Match(codeOwners, "/other.go").Should().Equal(["docs@example.com"]); + } + + [SkippableFact] + public void PathsAreCaseSensitive() + { + var codeOwners = Create("Readme.MD @docs\n*.Txt @txt\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/readme.md").Should().BeEmpty(); + Match(codeOwners, "/Readme.MD").Should().Equal(["@docs"]); + Match(codeOwners, "/a.txt").Should().BeEmpty(); + Match(codeOwners, "/a.Txt").Should().Equal(["@txt"]); + } + + [SkippableFact] + public void LastMatchingRuleWinsGloballyForGitHub() + { + var codeOwners = Create("*.js @first\n*.js @second\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/x/y.js").Should().Equal(["@second"]); + } + + [SkippableFact] + public void GlobstarMatchesZeroDirectories() + { + var codeOwners = Create("/db/**/index.md @index-docs\n/docs/**/*.md @markdown-docs\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/db/index.md").Should().Equal(["@index-docs"]); + Match(codeOwners, "/db/v2/index.md").Should().Equal(["@index-docs"]); + Match(codeOwners, "/docs/index.md").Should().Equal(["@markdown-docs"]); + Match(codeOwners, "/docs/api/graphql/index.md").Should().Equal(["@markdown-docs"]); + Match(codeOwners, "/docs/api/index.xml").Should().BeEmpty(); + } + + [SkippableFact] + public void RelativePathsMatchAtAnyDepth() + { + // GitLab: paths without a leading slash are treated as globstar paths and match at any depth + var codeOwners = Create("internal/README.md @user4\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/internal/README.md").Should().Equal(["@user4"]); + Match(codeOwners, "/docs/api/internal/README.md").Should().Equal(["@user4"]); + Match(codeOwners, "/docs/README.md").Should().BeEmpty(); + } + + [SkippableFact] + public void GitLabSectionsAreEvaluatedIndependentlyAndCombined() + { + var codeOwners = Create(GitlabSectionsExample, CodeOwners.Platform.GitLab); + // The last matching entry in each section is used, and all sections are combined + Match(codeOwners, "/README.md").Should().Equal(["@admin", "@user1", "@user2", "@user3"]); + Match(codeOwners, "/internal/README.md").Should().Equal(["@admin", "@user3", "@user4"]); + } + + [SkippableFact] + public void SectionDefaultOwnersApplyOnlyToEntriesWithoutExplicitOwners() + { + // Example from the GitLab documentation + var content = """ + [Database] @database-team @agarcia + model/db/ + config/db/database-setup.md @docs-team + """; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/model/db/schema.rb").Should().Equal(["@agarcia", "@database-team"]); + Match(codeOwners, "/config/db/database-setup.md").Should().Equal(["@docs-team"]); + // Paths not covered by any entry of the section are not owned by the section default + Match(codeOwners, "/other/file.txt").Should().BeEmpty(); + } + + [SkippableFact] + public void OptionalSectionsAndApprovalCountsDoNotAffectMatching() + { + var codeOwners = Create("^[Go]\n*.go @go-owner\n[Big][5]\nbig/ @big-owner\n[Team] @default-team\nteam/\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/x.go").Should().Equal(["@go-owner"]); + Match(codeOwners, "/big/file.txt").Should().Equal(["@big-owner"]); + Match(codeOwners, "/team/file.txt").Should().Equal(["@default-team"]); // inherits section defaults + } + + [SkippableFact] + public void RoleOwnersAreKeptAsOwners() + { + var codeOwners = Create("/config/setup.yml @@maintainer\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/config/setup.yml").Should().Equal(["@@maintainer"]); + } + + [SkippableFact] + public void ExclusionsAreStickyWithinSection() + { + // Example from the GitLab documentation: once a path is excluded, later rules in the same + // section cannot re-include it. + var codeOwners = Create("* @default-owner\n!*.rb\n/special/*.rb @ruby-owner\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/special/foo.rb").Should().BeEmpty(); + Match(codeOwners, "/code.rb").Should().BeEmpty(); + Match(codeOwners, "/other.txt").Should().Equal(["@default-owner"]); + } + + [SkippableFact] + public void ExclusionsApplyPerSection() + { + // Example from the GitLab documentation: use multiple sections to exclude with one owner set + // and still require approval from another. + var content = """ + [Ruby] + *.rb @ruby-team + !/config/**/*.rb + + [Config] + /config/ @ops-team + """; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/config/routes.rb").Should().Equal(["@ops-team"]); + Match(codeOwners, "/lib/foo.rb").Should().Equal(["@ruby-team"]); + Match(codeOwners, "/config/other.xml").Should().Equal(["@ops-team"]); + } + + [SkippableFact] + public void InlineCommentsAreUnsupportedInGitLab() + { + var codeOwners = Create("*.rb @ruby-owner # note to self\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/a.rb").Should().Equal(["@ruby-owner"]); + } + + [SkippableFact] + public void CommentLinesWithLeadingWhitespaceAreIgnored() + { + foreach (var platform in new[] { CodeOwners.Platform.GitHub, CodeOwners.Platform.GitLab }) + { + var codeOwners = Create(" # indented comment\n *.md @md-owner\n", platform); + Match(codeOwners, "/a.md").Should().Equal(["@md-owner"]); + // The indented comment must not be parsed as a "#" pattern entry + Match(codeOwners, "/#").Should().BeEmpty(); + } + } + + [SkippableFact] + public void DirectoryPatternsMatchWithWindowsSeparators() + { + var codeOwners = Create("**/logs @octocat\n/build/logs/ @doctocat\n", CodeOwners.Platform.GitHub); + Match(codeOwners, @"\build\logs\error.txt").Should().Equal(["@doctocat"]); + Match(codeOwners, @"\scripts\logs\x.txt").Should().Equal(["@octocat"]); + } + + [SkippableFact] + public void DuplicateEntriesUseLastWithinSection() + { + // "If an entry is duplicated in a section, the last entry is used" + var codeOwners = Create("README.md @old\nREADME.md @new\n", CodeOwners.Platform.GitLab); + Match(codeOwners, "/README.md").Should().Equal(["@new"]); + } + + [SkippableFact] + public void RealWorldHashicorpTerraformGitHubFile() + { + // Based on https://github.com/hashicorp/terraform/blob/main/CODEOWNERS + const string content = """ + # The rules are evaluated in order, if a file matches multiple patterns, the last match "wins". + * @hashicorp/terraform-core + + # Remote-state backend # Maintainer + /internal/backend/remote-state/azure @hashicorp/terraform-core @hashicorp/terraform-azure + #/internal/backend/remote-state/consul Unmaintained + /internal/backend/remote-state/s3 @hashicorp/terraform-core @hashicorp/terraform-aws + + # Cloud backend + /internal/backend/remote @hashicorp/terraform-core @hashicorp/tf-core-cloud + /internal/cloud @hashicorp/terraform-core @hashicorp/tf-core-cloud + + # Provisioners + builtin/provisioners/file @hashicorp/terraform-core + builtin/provisioners/local-exec @hashicorp/terraform-core + + # Actions + /internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions @hashicorp/terraform-core + """; + var codeOwners = Create(content, CodeOwners.Platform.GitHub); + + Match(codeOwners, "/main.go").Should().Equal(["@hashicorp/terraform-core"]); + // Several teams on one line + Match(codeOwners, "/internal/backend/remote-state/s3/backend.go").Should().Equal(["@hashicorp/terraform-aws", "@hashicorp/terraform-core"]); + // Commented-out entry is skipped: consul falls back to the `*` rule + Match(codeOwners, "/internal/backend/remote-state/consul/backend.go").Should().Equal(["@hashicorp/terraform-core"]); + // Unrooted patterns match anywhere in the repository + Match(codeOwners, "/x/builtin/provisioners/file/resource.go").Should().Equal(["@hashicorp/terraform-core"]); + Match(codeOwners, "/internal/command/jsonplan/action_invocations.go").Should().Equal(["@hashicorp/team-tf-actions", "@hashicorp/terraform-core"]); + Match(codeOwners, "/internal/cloud/backend_run.go").Should().Equal(["@hashicorp/terraform-core", "@hashicorp/tf-core-cloud"]); + } + + [SkippableFact] + public void RealWorldGitLabSectionedFile() + { + // Based on https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/CODEOWNERS + const string content = """ + [Maintainers] @gl-dx/maintainers @gitlab-org/maintainers/rails-backend + * + + /* @gitlab-org/maintainers/frontend @gitlab-org/maintainers/database + *.rb @gitlab-org/maintainers/rails-backend + /app/ @gitlab-org/maintainers/rails-backend + /workhorse/ @gitlab-org/maintainers/gitlab-workhorse + + ^[Database] @gitlab-org/maintainers/database + /spec/lib/gitlab/background_migration/ + + ^[Frontend dependency patches] @markrian @xanf @thutterer + /patches/ + """; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + // The bare `*` entry has no owners and inherits the [Maintainers] section defaults + Match(codeOwners, "/random/path.txt") + .Should().Equal(["@gitlab-org/maintainers/rails-backend", "@gl-dx/maintainers"]); + // The /* root-level rule is defined after the bare `*` entry, so it overrides the defaults + // for top-level files only + Match(codeOwners, "/README.md") + .Should().Equal(["@gitlab-org/maintainers/database", "@gitlab-org/maintainers/frontend"]); + // Last matching entry within the section wins (/app/ is defined after *.rb) + Match(codeOwners, "/app/models/user.rb").Should().Equal(["@gitlab-org/maintainers/rails-backend"]); + Match(codeOwners, "/workhorse/Makefile").Should().Equal(["@gitlab-org/maintainers/gitlab-workhorse"]); + // Optional section entries without owners inherit that section's default owners, + // combined with the results from the [Maintainers] section where *.rb overrides + // the bare `*` defaults + var backgroundMigration = Match(codeOwners, "/spec/lib/gitlab/background_migration/foo_spec.rb"); + backgroundMigration.Should().Contain("@gitlab-org/maintainers/database"); + backgroundMigration.Should().Contain("@gitlab-org/maintainers/rails-backend"); + backgroundMigration.Should().HaveCount(2); + var patchFiles = Match(codeOwners, "/patches/foo.diff"); + patchFiles.Should().Contain("@markrian"); + patchFiles.Should().Contain("@thutterer"); + patchFiles.Should().HaveCount(5); + } + + private static CodeOwners Create(string content, CodeOwners.Platform platform) + { + var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-spec-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(path, content); + + try + { + return new CodeOwners(path, platform); + } + finally + { + File.Delete(path); + } + } + + private static string[] Match(CodeOwners codeOwners, string path) + => codeOwners.Match(path).OrderBy(o => o, StringComparer.Ordinal).ToArray(); +} From 7604e57da7d629dca85524ce6e3bed7125c4451b Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 13:39:12 +0200 Subject: [PATCH 02/25] [CI Visibility] Address code review: single-regex descendant matching and dead code cleanup --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 2 +- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 48 ++++++++----------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 1e5546ed3cd2..8ed49c1a7339 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -672,7 +672,7 @@ private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string { var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); var candidatePath = Path.Combine(codeOwnersRoot, candidateSuffix); - if (!Path.IsPathRooted(candidateSuffix) && File.Exists(candidatePath)) + if (File.Exists(candidatePath)) { codeOwnersRelativePath = string.Join("/", segments, i, segments.Length - i); return true; diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index d6c831fb726f..f63de3577e34 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -166,8 +166,10 @@ private static bool TryParseSectionHeader(string raw, [NotNullWhen(true)] out Se /// /// Converts a CODEOWNERS ‑style glob into a Regex. /// Supports **, *, ?, /‑rooted, and trailing / semantics. + /// When is set, a direct match also owns every + /// descendant path in a single evaluation instead of testing each ancestor separately. /// - private static Regex CompileGlob(string pattern) + private static Regex CompileGlob(string pattern, bool includeDescendants) { // Escape regex metachars first. var rx = Regex.Escape(pattern); @@ -197,7 +199,7 @@ private static Regex CompileGlob(string pattern) rx = "(^|.*/)" + rx; } - rx += "$"; + rx += includeDescendants ? "(?:/.*)?$" : "$"; return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant, RegexTimeout); } @@ -306,10 +308,7 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable 0 ? rule.Owners : DefaultOwners; - } + matchedOwners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners; } if (excluded || matchedOwners is null || matchedOwners.Length == 0) @@ -326,11 +325,13 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable 1 ? string.Join(" ", tokens.Skip(1)) : string.Empty; var owners = OwnerTokenizer.Tokenize(ownersSegment).ToArray(); - // 5. Compile the glob - var rx = CompileGlob(patternToken); // GitHub owns the contents of directories matched by wildcard-free patterns (e.g. // `**/logs`); GitLab requires an explicit trailing slash for directory ownership. var isDirectoryPattern = platform == Platform.GitHub && IsDirectoryPattern(patternToken); - return new Entry(rx, isExclusion, owners, isDirectoryPattern); + + // 5. Compile the glob + var rx = CompileGlob(patternToken, includeDescendants: false); + // The descendants variant is only evaluated when _isDirectoryPattern is true. + var rxDirectories = isDirectoryPattern ? CompileGlob(patternToken, includeDescendants: true) : rx; + return new Entry(rx, rxDirectories, isExclusion, owners, isDirectoryPattern); } private static bool IsDirectoryPattern(string patternToken) @@ -405,35 +409,23 @@ private static bool IsDirectoryPattern(string patternToken) public bool Match(string path) { - if (IsMatch(path)) + if (IsMatch(_regex, path)) { return true; } // Patterns whose last segment is wildcard-free also own everything inside a matched // directory (e.g. `**/logs` owns `/build/logs/error.txt`), while wildcard segments like - // `docs/*` match individual entries only. - return _isDirectoryPattern && MatchesAncestor(path); - } - - private bool MatchesAncestor(string path) - { - for (var i = path.IndexOf('/', 1); i > 0 && i < path.Length - 1; i = path.IndexOf('/', i + 1)) - { - if (IsMatch(path.Substring(0, i))) - { - return true; - } - } - - return false; + // `docs/*` match individual entries only. The descendant variant of the glob accepts any + // path below a direct match in a single evaluation. + return _isDirectoryPattern && IsMatch(_descendantsRegex, path); } - private bool IsMatch(string input) + private static bool IsMatch(Regex regex, string input) { try { - return _regex.IsMatch(input); + return regex.IsMatch(input); } catch (RegexMatchTimeoutException) { From f9a437941ae02d7435c3de5424f2956d2fa256e1 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 14:06:20 +0200 Subject: [PATCH 03/25] [CI Visibility] Clarify evaluation order comment in TryMatchGitLab --- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index f63de3577e34..4d7f53784706 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -292,7 +292,9 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable= 0; i--) { var rule = rules[i]; From 31bd1222bd94577135904da7a990f7f2ea97e8e5 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 14:18:32 +0200 Subject: [PATCH 04/25] [CI Visibility] Harden CODEOWNERS path anchoring, lazy descendant regex, and move fallback tests to unit test project --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 10 + tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 49 +- .../CI/CodeOwnersFallbackTests.cs | 412 ----------------- .../Ci/CodeOwnersFallbackTests.cs | 435 ++++++++++++++++++ .../Ci/CodeOwnersSpecTests.cs | 26 ++ 5 files changed, 498 insertions(+), 434 deletions(-) delete mode 100644 tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 8ed49c1a7339..8481e1e2385f 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -668,6 +668,16 @@ private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string start++; } + // Never anchor paths with interior navigation segments: their resolution depends on the + // unknown base directory and would produce malformed repository-relative paths. + for (var i = start; i < segments.Length; i++) + { + if (segments[i] == "." || segments[i] == "..") + { + return false; + } + } + for (var i = start; i < segments.Length - 1; i++) { var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 4d7f53784706..5b1f22b663a4 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -50,11 +50,10 @@ public IEnumerable Match(string path) { var owners = new HashSet(StringComparer.Ordinal); var normalizedPath = path.IndexOf('\\') >= 0 ? path.Replace('\\', '/') : path; - if (normalizedPath.Length == 0 || normalizedPath[0] != '/') - { - // Rooted patterns are anchored to the repository root, so ensure a leading slash. - normalizedPath = "/" + normalizedPath; - } + + // Rooted patterns are anchored to the repository root, so collapse any leading slashes: + // "", "/", and "//C:/file" all normalize to a single rooted form. + normalizedPath = "/" + normalizedPath.TrimStart('/'); if (_platform == Platform.GitHub) { @@ -327,13 +326,14 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable -// 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.Collections.Generic; -using System.IO; -using System.Linq; -using Datadog.Trace.Ci.CiEnvironment; -using Datadog.Trace.Configuration; -using Xunit; - -namespace Datadog.Trace.ClrProfiler.IntegrationTests.CI -{ - public class CodeOwnersFallbackTests - { - private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b"; - - [SkippableFact] - public void UsesFallbackRootWhenSourceRootIsDifferent() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = Path.Combine(repoRoot, "other"), - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); - - Assert.Equal("src/SpanBenchmark.cs", relative); - - var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@owner" }, owners); - } - - [SkippableFact] - public void UsesFallbackRootWhenSourceRootIsSubdirectory() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = srcDir, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); - - Assert.Equal("src/SpanBenchmark.cs", relative); - - var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@owner" }, owners); - } - - [SkippableFact] - public void DoesNotUseCurrentDirectoryForRelativeSourceFile() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var originalDirectory = Environment.CurrentDirectory; - Environment.CurrentDirectory = repoRoot; - try - { - var ciValues = CIEnvironmentValues.Create(env); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("src/SpanBenchmark.cs", false); - - Assert.Equal("src/SpanBenchmark.cs", relative); - Assert.Null(ciValues.CodeOwners); - } - finally - { - Environment.CurrentDirectory = originalDirectory; - } - } - - [SkippableFact] - public void AllowsFallbackRetryWithDifferentStartPath() - { - using var repoDirectory = new TemporaryDirectory(); - using var otherDirectory = new TemporaryDirectory(); - - var repoRoot = repoDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var otherRoot = otherDirectory.RootPath; - var otherSrcDir = Path.Combine(otherRoot, "src"); - Directory.CreateDirectory(otherSrcDir); - var otherFile = Path.Combine(otherSrcDir, "Other.cs"); - File.WriteAllText(otherFile, "class Other {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = otherRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - var otherRelative = ciValues.MakeRelativePathFromSourceRootWithFallback(otherFile, false); - - Assert.Equal("src/Other.cs", otherRelative); - Assert.Null(ciValues.CodeOwners); - - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); - - Assert.Equal("src/SpanBenchmark.cs", relative); - Assert.NotNull(ciValues.CodeOwners); - - Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath)); - var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@owner" }, owners); - } - - [SkippableFact] - public void DoesNotMatchCodeOwnersForFileOutsideRoot() - { - using var repoDirectory = new TemporaryDirectory(); - using var externalDirectory = new TemporaryDirectory(); - - var repoRoot = repoDirectory.RootPath; - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n"); - - var externalFile = Path.Combine(externalDirectory.RootPath, "SpanBenchmark.cs"); - File.WriteAllText(externalFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - Assert.NotNull(ciValues.CodeOwners); - Assert.False(ciValues.TryGetCodeOwnersRelativePath(externalFile, false, out _)); - } - - [SkippableFact] - public void KeepsSourceRootMatchWhenFallbackCannotResolve() - { - using var repoDirectory = new TemporaryDirectory(); - var repoRoot = repoDirectory.RootPath; - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - var externalRoot = Path.Combine(Path.GetTempPath(), "dd-ci-outside-" + Guid.NewGuid().ToString("N")); - var sourceFile = Path.Combine(externalRoot, "tracer", "test", "Snapshots", "Snapshot.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!); - File.WriteAllText(sourceFile, "class Snapshot {}"); - - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); - - Assert.StartsWith("..", relative, StringComparison.Ordinal); - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - - var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@global" }, owners); - } - - [SkippableFact] - public void UsesWorkspaceFallbackWhenSourceRootIsDifferent() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "tracer", "test", "benchmarks", "Benchmarks.Trace"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/benchmarks/Benchmarks.Trace/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var ciValues = new TestCIEnvironmentValues("/go/src/github.com/DataDog/apm-reliability/dd-trace-dotnet", repoRoot); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); - - Assert.Equal("tracer/test/benchmarks/Benchmarks.Trace/SpanBenchmark.cs", relative); - - Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath)); - var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@owner" }, owners); - } - - [SkippableFact] - public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile() - { - using var repoDirectory = new TemporaryDirectory(); - using var outsideDirectory = new TemporaryDirectory(); - - var repoRoot = repoDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var outsideRoot = outsideDirectory.RootPath; - File.WriteAllText(Path.Combine(outsideRoot, "CODEOWNERS"), "* @owner\n"); - File.WriteAllText(Path.Combine(outsideRoot, "SpanBenchmark.cs"), "class SpanBenchmark {}"); - - var outsideFolderName = Path.GetFileName(outsideRoot); - var relativeSourcePath = Path.Combine("..", outsideFolderName, "SpanBenchmark.cs"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(relativeSourcePath, false, out _)); - Assert.Null(ciValues.CodeOwners); - } - - [SkippableFact] - public void AnchorsForeignRelativePathsToCodeOwnersRoot() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "tracer", "test"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - // Paths recorded against a foreign base directory (e.g. "../../../_/..." on CI agents) - // must be anchored back to a repository-relative path. - var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs"; - Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, false, out var codeOwnersRelativePath)); - Assert.Equal("tracer/test/SpanBenchmark.cs", codeOwnersRelativePath); - - var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@owner" }, owners); - } - - [SkippableFact] - public void MakeRelativePathFromSourceRootWithFallbackNormalizesForeignPrefixes() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "tracer", "test"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("../../../_/tracer/test/SpanBenchmark.cs", false); - Assert.Equal("tracer/test/SpanBenchmark.cs", relative); - } - - [SkippableFact] - public void DoesNotAnchorForeignPathsWhenSuffixDoesNotExistUnderRoot() - { - using var repoDirectory = new TemporaryDirectory(); - using var externalDirectory = new TemporaryDirectory(); - - var repoRoot = repoDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, "src")); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); - File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}"); - - var externalFile = Path.Combine(externalDirectory.RootPath, "other", "SpanBenchmark.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!); - File.WriteAllText(externalFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - // A foreign path whose suffix does not exist under the repository must not be re-anchored. - Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/SpanBenchmark.cs", false, out _)); - } - - [SkippableFact] - public void AnchorsAzurePipelinesCompilerRecordedPaths() - { - // Reproduces the reported CI Visibility issue: on Azure Pipelines agents, compiler-recorded - // source paths are relative to a foreign base directory (e.g. - // "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"), - // which made every test span lose its test.codeowners tag once the CODEOWNERS rules were - // changed back to rooted patterns. - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests"); - Directory.CreateDirectory(sourceDir); - var sourceFile = Path.Combine(sourceDir, "ExceptionsTests.cs"); - Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); - File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n"); - File.WriteAllText(sourceFile, "// test"); - - var env = new Dictionary - { - [PlatformKeys.Ci.Azure.TFBuild] = "True", - [PlatformKeys.Ci.Azure.SystemTeamFoundationServerUri] = "https://dev.azure.com/datadoghq/", - [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, - [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, - [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - Assert.NotNull(ciValues.CodeOwners); - - var foreignRelativePath = "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"; - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignRelativePath, false); - Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative); - - var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); - Assert.Equal(new[] { "@DataDog/tracing-dotnet" }, owners); - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - RootPath = Path.Combine(Path.GetTempPath(), "dd-ci-codeowners-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(RootPath); - } - - public string RootPath { get; } - - public void Dispose() - { - try - { - if (Directory.Exists(RootPath)) - { - Directory.Delete(RootPath, recursive: true); - } - } - catch - { - // Cleanup failure should not fail tests. - } - } - } - - private sealed class TestCIEnvironmentValues : CIEnvironmentValues - { - public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath) - { - SourceRoot = sourceRoot; - WorkspacePath = workspacePath; - } - - protected override void Setup(IGitInfo gitInfo) - { - } - } - } -} diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs new file mode 100644 index 000000000000..671ec9fa6787 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -0,0 +1,435 @@ +// +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using Datadog.Trace.Ci.CiEnvironment; +using Datadog.Trace.Configuration; +using Xunit; + +namespace Datadog.Trace.Tests.Ci; + +public class CodeOwnersFallbackTests +{ + private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b"; + + [SkippableFact] + public void UsesFallbackRootWhenSourceRootIsDifferent() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = Path.Combine(repoRoot, "other"), + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); + + Assert.Equal("src/SpanBenchmark.cs", relative); + + var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void UsesFallbackRootWhenSourceRootIsSubdirectory() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = srcDir, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); + + Assert.Equal("src/SpanBenchmark.cs", relative); + + var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void DoesNotUseCurrentDirectoryForRelativeSourceFile() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var originalDirectory = Environment.CurrentDirectory; + Environment.CurrentDirectory = repoRoot; + try + { + var ciValues = CIEnvironmentValues.Create(env); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("src/SpanBenchmark.cs", false); + + Assert.Equal("src/SpanBenchmark.cs", relative); + Assert.Null(ciValues.CodeOwners); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + } + } + + [SkippableFact] + public void AllowsFallbackRetryWithDifferentStartPath() + { + using var repoDirectory = new TemporaryDirectory(); + using var otherDirectory = new TemporaryDirectory(); + + var repoRoot = repoDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var otherRoot = otherDirectory.RootPath; + var otherSrcDir = Path.Combine(otherRoot, "src"); + Directory.CreateDirectory(otherSrcDir); + var otherFile = Path.Combine(otherSrcDir, "Other.cs"); + File.WriteAllText(otherFile, "class Other {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = otherRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var otherRelative = ciValues.MakeRelativePathFromSourceRootWithFallback(otherFile, false); + + Assert.Equal("src/Other.cs", otherRelative); + Assert.Null(ciValues.CodeOwners); + + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); + + Assert.Equal("src/SpanBenchmark.cs", relative); + Assert.NotNull(ciValues.CodeOwners); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath)); + var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void DoesNotMatchCodeOwnersForFileOutsideRoot() + { + using var repoDirectory = new TemporaryDirectory(); + using var externalDirectory = new TemporaryDirectory(); + + var repoRoot = repoDirectory.RootPath; + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n"); + + var externalFile = Path.Combine(externalDirectory.RootPath, "SpanBenchmark.cs"); + File.WriteAllText(externalFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.NotNull(ciValues.CodeOwners); + Assert.False(ciValues.TryGetCodeOwnersRelativePath(externalFile, false, out _)); + } + + [SkippableFact] + public void KeepsSourceRootMatchWhenFallbackCannotResolve() + { + using var repoDirectory = new TemporaryDirectory(); + var repoRoot = repoDirectory.RootPath; + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + var externalRoot = Path.Combine(Path.GetTempPath(), "dd-ci-outside-" + Guid.NewGuid().ToString("N")); + var sourceFile = Path.Combine(externalRoot, "tracer", "test", "Snapshots", "Snapshot.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!); + File.WriteAllText(sourceFile, "class Snapshot {}"); + + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); + + Assert.StartsWith("..", relative, StringComparison.Ordinal); + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + + var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@global" }, owners); + } + + [SkippableFact] + public void UsesWorkspaceFallbackWhenSourceRootIsDifferent() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test", "benchmarks", "Benchmarks.Trace"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/benchmarks/Benchmarks.Trace/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var ciValues = new TestCIEnvironmentValues("/go/src/github.com/DataDog/apm-reliability/dd-trace-dotnet", repoRoot); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false); + + Assert.Equal("tracer/test/benchmarks/Benchmarks.Trace/SpanBenchmark.cs", relative); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath)); + var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile() + { + using var repoDirectory = new TemporaryDirectory(); + using var outsideDirectory = new TemporaryDirectory(); + + var repoRoot = repoDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var outsideRoot = outsideDirectory.RootPath; + File.WriteAllText(Path.Combine(outsideRoot, "CODEOWNERS"), "* @owner\n"); + File.WriteAllText(Path.Combine(outsideRoot, "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var outsideFolderName = Path.GetFileName(outsideRoot); + var relativeSourcePath = Path.Combine("..", outsideFolderName, "SpanBenchmark.cs"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(relativeSourcePath, false, out _)); + Assert.Null(ciValues.CodeOwners); + } + + [SkippableFact] + public void AnchorsForeignRelativePathsToCodeOwnersRoot() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + // Paths recorded against a foreign base directory (e.g. "../../../_/..." on CI agents) + // must be anchored back to a repository-relative path. + var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs"; + Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, false, out var codeOwnersRelativePath)); + Assert.Equal("tracer/test/SpanBenchmark.cs", codeOwnersRelativePath); + + var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@owner" }, owners); + } + + [SkippableFact] + public void MakeRelativePathFromSourceRootWithFallbackNormalizesForeignPrefixes() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("../../../_/tracer/test/SpanBenchmark.cs", false); + Assert.Equal("tracer/test/SpanBenchmark.cs", relative); + } + + [SkippableFact] + public void DoesNotAnchorForeignPathsWhenSuffixDoesNotExistUnderRoot() + { + using var repoDirectory = new TemporaryDirectory(); + using var externalDirectory = new TemporaryDirectory(); + + var repoRoot = repoDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, "src")); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var externalFile = Path.Combine(externalDirectory.RootPath, "other", "SpanBenchmark.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!); + File.WriteAllText(externalFile, "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + // A foreign path whose suffix does not exist under the repository must not be re-anchored. + Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/SpanBenchmark.cs", false, out _)); + } + + [SkippableFact] + public void AnchorsAzurePipelinesCompilerRecordedPaths() + { + // Reproduces the reported CI Visibility issue: on Azure Pipelines agents, compiler-recorded + // source paths are relative to a foreign base directory (e.g. + // "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"), + // which made every test span lose its test.codeowners tag once the CODEOWNERS rules were + // changed back to rooted patterns. + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "ExceptionsTests.cs"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n"); + File.WriteAllText(sourceFile, "// test"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Azure.TFBuild] = "True", + [PlatformKeys.Ci.Azure.SystemTeamFoundationServerUri] = "https://dev.azure.com/datadoghq/", + [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, + [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, + [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + Assert.NotNull(ciValues.CodeOwners); + + var foreignRelativePath = "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"; + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignRelativePath, false); + Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative); + + var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray(); + Assert.Equal(new[] { "@DataDog/tracing-dotnet" }, owners); + } + + [SkippableFact] + public void DoesNotAnchorPathsWithInteriorNavigationSegments() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + // An interior navigation segment depends on the unknown base directory where the path was + // recorded; anchoring it would produce a malformed repository-relative path. + Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/src/../src/SpanBenchmark.cs", false, out _)); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + RootPath = Path.Combine(Path.GetTempPath(), "dd-ci-codeowners-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RootPath); + } + + public string RootPath { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + } + catch + { + // Cleanup failure should not fail tests. + } + } + } + + private sealed class TestCIEnvironmentValues : CIEnvironmentValues + { + public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath) + { + SourceRoot = sourceRoot; + WorkspacePath = workspacePath; + } + + protected override void Setup(IGitInfo gitInfo) + { + } + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index a783c62321d0..7dc9ddc23391 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -327,6 +327,32 @@ [Maintainers] @gl-dx/maintainers @gitlab-org/maintainers/rails-backend patchFiles.Should().HaveCount(5); } + [SkippableFact] + public void MultipleLeadingSlashesAreNormalized() + { + // Callers prepend "/" to relative paths; a path that already contains leading slashes (or is + // empty) must still normalize to a single rooted form instead of failing to match. + var codeOwners = Create("*.md @md\n/docs/ @doctocat\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "//docs/getting-started.md").Should().Equal(["@doctocat"]); + Match(codeOwners, "/docs/getting-started.md").Should().Equal(["@doctocat"]); + Match(codeOwners, string.Empty).Should().BeEmpty(); + } + + [SkippableFact] + public void DescendantMatchingIsStableAcrossRepeatedCalls() + { + // The descendant glob variant is compiled lazily on first use and cached; repeated matches + // must keep returning the same results (e.g. no recompilation or caching regression). + var codeOwners = Create("**/logs @octocat\n", CodeOwners.Platform.GitHub); + for (var i = 0; i < 3; i++) + { + Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@octocat"]); + Match(codeOwners, "/build/logs/nested/error.txt").Should().Equal(["@octocat"]); + Match(codeOwners, "/catalog/data.tmp").Should().BeEmpty(); + Match(codeOwners, "/logs").Should().Equal(["@octocat"]); + } + } + private static CodeOwners Create(string content, CodeOwners.Platform platform) { var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-spec-" + Guid.NewGuid().ToString("N")); From ac2a16dae758858a115ebf509d8aa83bac45365c Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 14:25:41 +0200 Subject: [PATCH 05/25] [CI Visibility] Fix misplaced comment above Seal loop --- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 5b1f22b663a4..e40d3bddc9d2 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -127,7 +127,8 @@ private static List
Parse(IEnumerable lines, Platform platform) } } - // Last‑rule precedence: iterate rules in reverse order at run‑time without additional copies. + // Reverse the entries of every section so the last rule in the file is evaluated first + // at match time, without additional copies. foreach (var s in sections) { s.Seal(); From e1366932abb47cceb23ac7cd336e3e24307fcf1c Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 14:34:48 +0200 Subject: [PATCH 06/25] [CI Visibility] Make CODEOWNERS Match null-safe and allocation-light for common cases --- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 49 +++++++++++++++---- .../Ci/CodeOwnersSpecTests.cs | 8 +++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index e40d3bddc9d2..98105420437f 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -48,7 +48,12 @@ public CodeOwners(string filePath, Platform platform) /// public IEnumerable Match(string path) { - var owners = new HashSet(StringComparer.Ordinal); + if (path is null) + { + // No callers pass null today, but normalizing here keeps the API safe to use. + return []; + } + var normalizedPath = path.IndexOf('\\') >= 0 ? path.Replace('\\', '/') : path; // Rooted patterns are anchored to the repository root, so collapse any leading slashes: @@ -63,23 +68,21 @@ public IEnumerable Match(string path) { if (_sections[i].TryMatchGitHub(normalizedPath, out var sectionOwners)) { - foreach (var o in sectionOwners) - { - owners.Add(o); - } - - break; + return DeduplicateOwners(sectionOwners); } } - return owners; + return []; } // GitLab evaluates each section independently and combines their owners. + // The set is allocated lazily because most paths match at most one section. + HashSet? owners = null; foreach (var section in _sections) { if (section.TryMatchGitLab(normalizedPath, out var sectionOwners)) { + owners ??= new HashSet(StringComparer.Ordinal); foreach (var o in sectionOwners) { owners.Add(o); @@ -87,6 +90,32 @@ public IEnumerable Match(string path) } } + return owners ?? []; + } + + private static IEnumerable DeduplicateOwners(string[] owners) + { + // Owner lists are tiny (typically a single entry), so scan for duplicates first and skip + // the HashSet allocation entirely in the common case. + for (var i = 0; i < owners.Length; i++) + { + for (var j = 0; j < i; j++) + { + if (!string.Equals(owners[i], owners[j], StringComparison.Ordinal)) + { + continue; + } + + var unique = new HashSet(StringComparer.Ordinal); + foreach (var owner in owners) + { + unique.Add(owner); + } + + return unique; + } + } + return owners; } @@ -261,7 +290,7 @@ public Section(string name, bool required, int approvalCount, string[] defaultOw /// GitHub evaluation: exclusion rules are unsupported and ignored, section default owners don't /// exist, and the caller stops at the first (i.e. last in file order) matching rule. /// - public bool TryMatchGitHub(string path, [NotNullWhen(true)] out IEnumerable? owners) + public bool TryMatchGitHub(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; @@ -286,7 +315,7 @@ public bool TryMatchGitHub(string path, [NotNullWhen(true)] out IEnumerable - public bool TryMatchGitLab(string path, [NotNullWhen(true)] out IEnumerable? owners) + public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; string[]? matchedOwners = null; diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index 7dc9ddc23391..2b4ec048946a 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -338,6 +338,14 @@ public void MultipleLeadingSlashesAreNormalized() Match(codeOwners, string.Empty).Should().BeEmpty(); } + [SkippableFact] + public void NullPathReturnsNoOwners() + { + // Defensive: a null path must not throw and simply has no owners. + var codeOwners = Create("* @global\n", CodeOwners.Platform.GitHub); + Match(codeOwners, null!).Should().BeEmpty(); + } + [SkippableFact] public void DescendantMatchingIsStableAcrossRepeatedCalls() { From 410c18abd049c500151ebacf09d0da2ca21baa0c Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 15:02:08 +0200 Subject: [PATCH 07/25] [CI Visibility] Simplify owner token validation condition --- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 98105420437f..8982a6e85be2 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -251,7 +251,7 @@ public static IEnumerable Tokenize(string segment) foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) { - if (token.StartsWith("@@") || token.StartsWith("@") || token.Contains("@")) + if (token.Contains('@')) { yield return token; } From dd18633375b747bac88c211a21b0759e94bf1943 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 15:15:51 +0200 Subject: [PATCH 08/25] [CI Visibility] Fix stale comment in CodeOwners integration test --- .../CI/CodeOwnersTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs index 474234a43c0c..274c1b7b9d9e 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs @@ -49,7 +49,7 @@ public CodeOwnersTests() [InlineData(@"\apps\github", null)] [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")] // New GitHub quirks - [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // **/logs pattern + [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // matches the `*.txt` rule at any depth // Rooted patterns match regardless of a leading slash, so the later `/docs/` rule // (last match wins) takes precedence over the earlier `docs/*` rule. [InlineData("docs/getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence From 8b25aa733c3a0a9dbdd1a3bc9bc9b247744f0255 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 15:37:46 +0200 Subject: [PATCH 09/25] [CI Visibility] Align CODEOWNERS glob semantics and honor useOSSeparator when anchoring - Honor useOSSeparator in TryAnchorPathToCodeOwnersRoot - Treat ? as a single non-slash character and ** as a globstar only when it is a whole path segment - Drop unused lineNo and fix the CompileGlob comment - Move fixture-based CodeOwnersTests into the unit-test project --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 9 +- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 27 +++-- .../CI/CodeOwnersTests.cs | 97 ----------------- .../Ci/CodeOwnersFallbackTests.cs | 27 +++++ .../Ci/CodeOwnersSpecTests.cs | 20 ++++ .../Datadog.Trace.Tests/Ci/CodeOwnersTests.cs | 100 ++++++++++++++++++ 6 files changed, 170 insertions(+), 110 deletions(-) delete mode 100644 tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 8481e1e2385f..a83f55705b29 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -616,7 +616,7 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa // Relative paths must stay within the codeowners root; otherwise we try to anchor them. if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) { - return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, out codeOwnersRelativePath); + return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); } absolutePath = resolvedPath; @@ -632,14 +632,14 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa relativePath.StartsWith("../", StringComparison.Ordinal) || relativePath.StartsWith("..\\", StringComparison.Ordinal)) { - return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, out codeOwnersRelativePath); + return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); } codeOwnersRelativePath = relativePath; return true; } - private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, [NotNullWhen(true)] out string? codeOwnersRelativePath) + private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { // Compiler-recorded paths can be relative to a different base directory than the current // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict @@ -684,7 +684,8 @@ private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string var candidatePath = Path.Combine(codeOwnersRoot, candidateSuffix); if (File.Exists(candidatePath)) { - codeOwnersRelativePath = string.Join("/", segments, i, segments.Length - i); + var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; + codeOwnersRelativePath = string.Join(separator, segments, i, segments.Length - i); return true; } } diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 8982a6e85be2..8a70086c8137 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -125,10 +125,8 @@ private static List
Parse(IEnumerable lines, Platform platform) var current = Section.CreateUnnamed(); sections.Add(current); - var lineNo = 0; foreach (var line in lines) { - lineNo++; var raw = line.Trim(); if (raw.Length == 0) { @@ -149,7 +147,7 @@ private static List
Parse(IEnumerable lines, Platform platform) continue; } - var entry = Entry.Parse(raw, platform, lineNo); + var entry = Entry.Parse(raw, platform); if (entry is not null) { current.Add(entry); @@ -203,14 +201,25 @@ private static Regex CompileGlob(string pattern, bool includeDescendants) // Escape regex metachars first. var rx = Regex.Escape(pattern); - // Temporary sentinel for ** that we restore after dealing with single *. - rx = rx.Replace("\\*\\*", "§§DOUBLESTAR§§"); + // ** is a globstar only as a whole path segment (git / GitHub / GitLab). `foo**bar` + // is two regular asterisks and must not cross directories. After Regex.Escape a + // whole-segment ** is the token "\\*\\*". + var segments = rx.Split('/'); + for (var i = 0; i < segments.Length; i++) + { + if (segments[i] == "\\*\\*") + { + segments[i] = "§§DOUBLESTAR§§"; + } + } + + rx = string.Join("/", segments); rx = rx.Replace("\\*", "[^/]*"); // single‑level wildcard // A slash right after ** means it can match zero or more intermediate directories: // `a/**/b` must also match `a/b`. rx = rx.Replace("§§DOUBLESTAR§§/", "(?:.*/)?"); rx = rx.Replace("§§DOUBLESTAR§§", ".*"); // multi‑level wildcard - rx = rx.Replace("\\?", "."); // single char + rx = rx.Replace("\\?", "[^/]"); // single char within a path segment if (pattern.EndsWith("/")) { @@ -224,8 +233,8 @@ private static Regex CompileGlob(string pattern, bool includeDescendants) } else { - // Allowed anywhere in repo tree; use non‑capturing look‑behind to avoid double counting. - rx = "(^|.*/)" + rx; + // Allowed anywhere in repo tree. + rx = "(?:^|.*/)" + rx; } rx += includeDescendants ? "(?:/.*)?$" : "$"; @@ -373,7 +382,7 @@ private Entry(Regex regex, string patternToken, bool exclusion, string[] owners, public string[] Owners { get; } - public static Entry? Parse(string raw, Platform platform, int lineNo) + public static Entry? Parse(string raw, Platform platform) { // Strip inline comments for GitHub. GitLab treats everything after # as data (inline comments unsupported). var idxHash = raw.IndexOf('#'); diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs deleted file mode 100644 index 274c1b7b9d9e..000000000000 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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.IO; -using System.Linq; -using Datadog.Trace.Ci; -using Xunit; - -namespace Datadog.Trace.ClrProfiler.IntegrationTests.CI -{ - public class CodeOwnersTests - { - private readonly CodeOwners _githubCodeOwners; - private readonly CodeOwners _gitlabCodeOwners; - - public CodeOwnersTests() - { - var ciDataFolder = DataHelpers.GetCiDataDirectory(); - - var githubCodeOwnersFile = Path.Combine(ciDataFolder, "CODEOWNERS_GITHUB"); - _githubCodeOwners = new CodeOwners(githubCodeOwnersFile, CodeOwners.Platform.GitHub); - - var gitlabCodeOwnersFile = Path.Combine(ciDataFolder, "CODEOWNERS_GITLAB"); - _gitlabCodeOwners = new CodeOwners(gitlabCodeOwnersFile, CodeOwners.Platform.GitLab); - } - - [SkippableTheory] - // Existing baseline expectations - [InlineData("unexistent/path/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] - [InlineData("apps/test.cs", "[\"@octocat\"]")] - [InlineData("/example/apps/test.cs", "[\"@octocat\"]")] - [InlineData("/docs/test.cs", "[\"@doctocat\"]")] - [InlineData("/examples/docs/test.cs", "[\"docs@example.com\"]")] - [InlineData("/src/vendor/match.go", "[\"docs@example.com\"]")] - [InlineData("/examples/docs/inside/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] - [InlineData("/component/path/test.js", "[\"@js-owner\"]")] - [InlineData("/mytextbox.txt", "[\"@octo-org/octocats\"]")] - [InlineData("/scripts/artifacts/value.js", "[\"@doctocat\",\"@octocat\"]")] - [InlineData("/apps/octo/test.cs", "[\"@octocat\"]")] - [InlineData("/apps/github", null)] - // Windows path separators - [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] - [InlineData(@"apps\test.cs", "[\"@octocat\"]")] - [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")] - [InlineData(@"docs\getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence - [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")] - [InlineData(@"\apps\github", null)] - [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")] - // New GitHub quirks - [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // matches the `*.txt` rule at any depth - // Rooted patterns match regardless of a leading slash, so the later `/docs/` rule - // (last match wins) takes precedence over the earlier `docs/*` rule. - [InlineData("docs/getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence - public void CheckGithubCodeOwners(string value, string expected) - { - var match = _githubCodeOwners.Match(value); - var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null; - Assert.Equal(expected, actual); - } - - [SkippableTheory] - // Existing baseline expectations - [InlineData("apps/README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")] - [InlineData("model/db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")] - [InlineData("/config/data.conf", "[\"@config-owner\"]")] - [InlineData("/docs/root.md", "[\"@root-docs\"]")] - [InlineData("/docs/sub/root.md", "[\"@all-docs\"]")] - [InlineData("/src/README", "[\"@group\",\"@group/with-nested/subgroup\"]")] - [InlineData("/src/lib/internal.h", "[\"@lib-owner\"]")] - [InlineData("src/ee/docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")] - // Windows path separators - [InlineData(@"apps\README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")] - [InlineData(@"model\db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")] - [InlineData(@"\config\data.conf", "[\"@config-owner\"]")] - [InlineData(@"\docs\root.md", "[\"@root-docs\"]")] - [InlineData(@"\docs\sub\root.md", "[\"@all-docs\"]")] - [InlineData(@"\src\README", "[\"@group\",\"@group/with-nested/subgroup\"]")] - [InlineData(@"\src\lib\internal.h", "[\"@lib-owner\"]")] - [InlineData(@"src\ee\docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")] - [InlineData(@"path with spaces\example.txt", "[\"@space-owner\"]")] - [InlineData(@"src\app\sample.rb", "[\"@ruby-owner\"]")] - // New GitLab quirks present in existing fixture - [InlineData("#file_with_pound.rb", "[\"@owner-file-with-pound\"]")] // escaped # char - [InlineData("path with spaces/example.txt", "[\"@space-owner\"]")] // escaped spaces in path - [InlineData("src/app/sample.rb", "[\"@ruby-owner\"]")] // *.rb pattern - [InlineData("random/file.xyz", "[\"@code\",\"@multiple\",\"@owners\"]")] // last * rule wins - [InlineData("LICENSE", "[\"@legal\",\"janedoe@gitlab.com\"]")] // username + email - public void CheckGitlabCodeOwners(string value, string expected) - { - var match = _gitlabCodeOwners.Match(value); - var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null; - Assert.Equal(expected, actual); - } - } -} diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 671ec9fa6787..1163e510464c 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -281,6 +281,33 @@ public void AnchorsForeignRelativePathsToCodeOwnersRoot() Assert.Equal(new[] { "@owner" }, owners); } + [SkippableFact] + public void AnchoredPathRespectsUseOSSeparator() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "tracer", "test"); + Directory.CreateDirectory(srcDir); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n"); + File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs"; + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, useOSSeparator: false, out var forwardSlashPath)); + Assert.Equal("tracer/test/SpanBenchmark.cs", forwardSlashPath); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, useOSSeparator: true, out var osPath)); + Assert.Equal(Path.Combine("tracer", "test", "SpanBenchmark.cs"), osPath); + } + [SkippableFact] public void MakeRelativePathFromSourceRootWithFallbackNormalizesForeignPrefixes() { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index 2b4ec048946a..e4a94f41af8f 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -346,6 +346,26 @@ public void NullPathReturnsNoOwners() Match(codeOwners, null!).Should().BeEmpty(); } + [SkippableFact] + public void QuestionMarkDoesNotMatchSlash() + { + var codeOwners = Create("a?c @segment\n", CodeOwners.Platform.GitHub); + Match(codeOwners, "/abc").Should().Equal(["@segment"]); + Match(codeOwners, "/aXc").Should().Equal(["@segment"]); + Match(codeOwners, "/a/c").Should().BeEmpty(); + } + + [SkippableFact] + public void DoubleStarIsGlobstarOnlyAsAWholeSegment() + { + var codeOwners = Create("foo**bar @stars\n**/index.md @index\n", CodeOwners.Platform.GitHub); + // Adjacent asterisks inside a segment are two single-level wildcards, not a globstar. + Match(codeOwners, "/fooXbar").Should().Equal(["@stars"]); + Match(codeOwners, "/foo/x/bar").Should().BeEmpty(); + Match(codeOwners, "/docs/index.md").Should().Equal(["@index"]); + Match(codeOwners, "/index.md").Should().Equal(["@index"]); + } + [SkippableFact] public void DescendantMatchingIsStableAcrossRepeatedCalls() { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs new file mode 100644 index 000000000000..358804d92735 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs @@ -0,0 +1,100 @@ +// +// 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.IO; +using System.Linq; +using Datadog.Trace.Ci; +using Datadog.Trace.TestHelpers; +using Xunit; + +namespace Datadog.Trace.Tests.Ci; + +public class CodeOwnersTests +{ + private readonly CodeOwners _githubCodeOwners; + private readonly CodeOwners _gitlabCodeOwners; + + public CodeOwnersTests() + { + var ciDataFolder = Path.Combine( + EnvironmentTools.GetSolutionDirectory(), + "tracer", + "test", + "Datadog.Trace.ClrProfiler.IntegrationTests", + "CI", + "Data"); + + _githubCodeOwners = new CodeOwners(Path.Combine(ciDataFolder, "CODEOWNERS_GITHUB"), CodeOwners.Platform.GitHub); + _gitlabCodeOwners = new CodeOwners(Path.Combine(ciDataFolder, "CODEOWNERS_GITLAB"), CodeOwners.Platform.GitLab); + } + + [SkippableTheory] + // Existing baseline expectations + [InlineData("unexistent/path/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] + [InlineData("apps/test.cs", "[\"@octocat\"]")] + [InlineData("/example/apps/test.cs", "[\"@octocat\"]")] + [InlineData("/docs/test.cs", "[\"@doctocat\"]")] + [InlineData("/examples/docs/test.cs", "[\"docs@example.com\"]")] + [InlineData("/src/vendor/match.go", "[\"docs@example.com\"]")] + [InlineData("/examples/docs/inside/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] + [InlineData("/component/path/test.js", "[\"@js-owner\"]")] + [InlineData("/mytextbox.txt", "[\"@octo-org/octocats\"]")] + [InlineData("/scripts/artifacts/value.js", "[\"@doctocat\",\"@octocat\"]")] + [InlineData("/apps/octo/test.cs", "[\"@octocat\"]")] + [InlineData("/apps/github", null)] + // Windows path separators + [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] + [InlineData(@"apps\test.cs", "[\"@octocat\"]")] + [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")] + [InlineData(@"docs\getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence + [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")] + [InlineData(@"\apps\github", null)] + [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")] + // New GitHub quirks + [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // matches the `*.txt` rule at any depth + // Rooted patterns match regardless of a leading slash, so the later `/docs/` rule + // (last match wins) takes precedence over the earlier `docs/*` rule. + [InlineData("docs/getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence + public void CheckGithubCodeOwners(string value, string expected) + { + var match = _githubCodeOwners.Match(value); + var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null; + Assert.Equal(expected, actual); + } + + [SkippableTheory] + // Existing baseline expectations + [InlineData("apps/README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")] + [InlineData("model/db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")] + [InlineData("/config/data.conf", "[\"@config-owner\"]")] + [InlineData("/docs/root.md", "[\"@root-docs\"]")] + [InlineData("/docs/sub/root.md", "[\"@all-docs\"]")] + [InlineData("/src/README", "[\"@group\",\"@group/with-nested/subgroup\"]")] + [InlineData("/src/lib/internal.h", "[\"@lib-owner\"]")] + [InlineData("src/ee/docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")] + // Windows path separators + [InlineData(@"apps\README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")] + [InlineData(@"model\db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")] + [InlineData(@"\config\data.conf", "[\"@config-owner\"]")] + [InlineData(@"\docs\root.md", "[\"@root-docs\"]")] + [InlineData(@"\docs\sub\root.md", "[\"@all-docs\"]")] + [InlineData(@"\src\README", "[\"@group\",\"@group/with-nested/subgroup\"]")] + [InlineData(@"\src\lib\internal.h", "[\"@lib-owner\"]")] + [InlineData(@"src\ee\docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")] + [InlineData(@"path with spaces\example.txt", "[\"@space-owner\"]")] + [InlineData(@"src\app\sample.rb", "[\"@ruby-owner\"]")] + // New GitLab quirks present in existing fixture + [InlineData("#file_with_pound.rb", "[\"@owner-file-with-pound\"]")] // escaped # char + [InlineData("path with spaces/example.txt", "[\"@space-owner\"]")] // escaped spaces in path + [InlineData("src/app/sample.rb", "[\"@ruby-owner\"]")] // *.rb pattern + [InlineData("random/file.xyz", "[\"@code\",\"@multiple\",\"@owners\"]")] // last * rule wins + [InlineData("LICENSE", "[\"@legal\",\"janedoe@gitlab.com\"]")] // username + email + public void CheckGitlabCodeOwners(string value, string expected) + { + var match = _gitlabCodeOwners.Match(value); + var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null; + Assert.Equal(expected, actual); + } +} From ea3eeb809d69f046b92013890e1a0af70e904545 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 22:45:27 +0200 Subject: [PATCH 10/25] [CI Visibility] Harden CODEOWNERS parsing and fallback discovery --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 279 +++- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 1266 +++++++++++++++-- tracer/src/Datadog.Trace/Ci/Test.cs | 12 +- .../Ci/CodeOwnersFallbackTests.cs | 508 +++++++ .../Ci/CodeOwnersRepositoryTests.cs | 57 +- .../Ci/CodeOwnersSpecTests.cs | 425 +++++- .../Datadog.Trace.Tests/Ci/CodeOwnersTests.cs | 3 +- 7 files changed, 2341 insertions(+), 209 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index a83f55705b29..8aae02a3acb7 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -9,8 +9,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using System.Threading; using Datadog.Trace.Ci.Tags; using Datadog.Trace.Logging; using Datadog.Trace.Telemetry.Metrics; @@ -36,6 +38,8 @@ internal abstract class CIEnvironmentValues private readonly object _codeOwnersLock = new(); private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); + private CodeOwnersState? _codeOwnersState; + private int _environmentReloadVersion; private string? _gitSearchFolder; public static CIEnvironmentValues Instance => LazyInstance.Value; @@ -126,9 +130,9 @@ public string? GitSearchFolder public string? HeadMessage { get; protected set; } - public CodeOwners? CodeOwners { get; protected set; } + public CodeOwners? CodeOwners => Volatile.Read(ref _codeOwnersState)?.Parser; - internal string? CodeOwnersRoot { get; private set; } + internal string? CodeOwnersRoot => Volatile.Read(ref _codeOwnersState)?.Root; public Dictionary? VariablesToBypass { get; protected set; } @@ -307,6 +311,51 @@ private static bool HasGitDirectory(string path) return Directory.Exists(gitPath) || File.Exists(gitPath); } + private static string? GetCodeOwnersSearchBoundary(DirectoryInfo startDirectory, string? workspacePath) + { + // A real git boundary takes precedence, including when the CI workspace points at a + // subdirectory of the checkout. + for (var current = startDirectory; current is not null; current = current.Parent) + { + if (HasGitDirectory(current.FullName)) + { + return current.FullName; + } + } + + if (StringUtil.IsNullOrWhiteSpace(workspacePath) || !Path.IsPathRooted(workspacePath)) + { + return null; + } + + try + { + var fullWorkspacePath = Path.GetFullPath(workspacePath!); + var fullStartPath = Path.GetFullPath(startDirectory.FullName); + if (CodeOwnersSearchComparer.Equals(fullStartPath, fullWorkspacePath)) + { + return fullWorkspacePath; + } + + var workspaceWithSeparator = fullWorkspacePath; + if (!workspaceWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) && + !workspaceWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + workspaceWithSeparator += Path.DirectorySeparatorChar; + } + + var comparison = FrameworkDescription.Instance.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return fullStartPath.StartsWith(workspaceWithSeparator, comparison) ? fullWorkspacePath : null; + } + catch (Exception ex) + { + Log.Debug(ex, "Error resolving CODEOWNERS workspace boundary from '{Path}'", workspacePath); + return null; + } + } + private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) { absolutePath = null; @@ -359,9 +408,9 @@ private static bool TryResolvePathWithinBase(string relativePath, string basePat return false; } - private static bool TryGetCodeOwnersPath(string sourceRoot, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) + private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) { - foreach (var path in GetCodeOwnersPaths(sourceRoot)) + foreach (var path in GetCodeOwnersPaths(sourceRoot, platform)) { if (logLookup) { @@ -379,12 +428,22 @@ private static bool TryGetCodeOwnersPath(string sourceRoot, bool logLookup, [Not return false; } - private static IEnumerable GetCodeOwnersPaths(string sourceRoot) + private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) { - yield return Path.Combine(sourceRoot, "CODEOWNERS"); - yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS"); - yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS"); - yield return Path.Combine(sourceRoot, ".docs", "CODEOWNERS"); + if (platform == CodeOwners.Platform.GitHub) + { + // GitHub searches .github first, then the repository root, then docs. + yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); + } + else + { + // GitLab searches the repository root first, then docs, then .gitlab. + yield return Path.Combine(sourceRoot, "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS"); + } } public void DecorateSpan(Span span) @@ -443,6 +502,25 @@ public void DecorateSpan(Span span) } protected void ReloadEnvironmentData() + { + // Reload changes the source root and its CODEOWNERS parser as one logical state transition. + // Serialize the complete transition with fallback discovery so neither can publish state + // derived from a root that the other operation is replacing. + lock (_codeOwnersLock) + { + Interlocked.Increment(ref _environmentReloadVersion); + try + { + ReloadEnvironmentDataCore(); + } + finally + { + Interlocked.Increment(ref _environmentReloadVersion); + } + } + } + + private void ReloadEnvironmentDataCore() { // ********** // Setup variables @@ -472,12 +550,8 @@ protected void ReloadEnvironmentData() CommitterDate = null; Message = null; SourceRoot = null; - CodeOwners = null; - CodeOwnersRoot = null; - lock (_codeOwnersLock) - { - _codeOwnersSearchStarts.Clear(); - } + Volatile.Write(ref _codeOwnersState, null); + _codeOwnersSearchStarts.Clear(); Setup(string.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder!)); @@ -508,11 +582,11 @@ protected void ReloadEnvironmentData() // ********** if (!string.IsNullOrEmpty(SourceRoot)) { - if (TryGetCodeOwnersPath(SourceRoot!, logLookup: true, out var codeOwnersPath)) + var platform = GetCodeOwnersPlatform(); + if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath)) { Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); - CodeOwners = new CodeOwners(codeOwnersPath, GetCodeOwnersPlatform()); - CodeOwnersRoot = SourceRoot; + PublishCodeOwners(codeOwnersPath, platform, SourceRoot!); } } } @@ -554,20 +628,58 @@ public string MakeRelativePathFromSourceRoot(string absolutePath, bool useOSSepa } internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) + => MakeRelativePathFromSourceRootWithFallback(sourceFilePath, useOSSeparator, out _); + + internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator, out string[] codeOwners) { - var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); - // If CODEOWNERS is rooted elsewhere, normalize SourceFile to that root for consistent matching. - if (TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath)) + // The normal path stays lock-free. A version change makes the operation retry, while an + // active reload waits on the same lock used for the state transition. This ensures that + // SourceRoot, the relative path, and CODEOWNERS all come from one completed reload. + while (true) { - return codeOwnersRelativePath; - } + var reloadVersion = Volatile.Read(ref _environmentReloadVersion); + if ((reloadVersion & 1) != 0) + { + lock (_codeOwnersLock) + { + } + + continue; + } - return sourceRelativePath; + var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); + string result; + string[] matchedOwners; + if (TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath, out var parser)) + { + result = codeOwnersRelativePath; + matchedOwners = parser.Match("/" + codeOwnersRelativePath).ToArray(); + } + else + { + result = sourceRelativePath; + matchedOwners = []; + } + + if (reloadVersion == Volatile.Read(ref _environmentReloadVersion)) + { + codeOwners = matchedOwners; + return result; + } + } } internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) + => TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out codeOwnersRelativePath, out _); + + private bool TryGetCodeOwnersRelativePath( + string sourceFilePath, + bool useOSSeparator, + [NotNullWhen(true)] out string? codeOwnersRelativePath, + [NotNullWhen(true)] out CodeOwners? parser) { codeOwnersRelativePath = null; + parser = null; if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) { @@ -578,12 +690,13 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization. EnsureCodeOwnersFromFallback(sourceFilePath); - if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot)) + var codeOwnersState = Volatile.Read(ref _codeOwnersState); + if (codeOwnersState is null || StringUtil.IsNullOrWhiteSpace(codeOwnersState.Root)) { return false; } - var codeOwnersRoot = CodeOwnersRoot!; + var codeOwnersRoot = codeOwnersState.Root; if (!Path.IsPathRooted(codeOwnersRoot)) { // If SourceRoot was relative, re-anchor to WorkspacePath before matching. @@ -595,7 +708,7 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa // Require a CODEOWNERS file at the resolved root to avoid mismatched roots. // Avoid mixing CODEOWNERS content from one root with a different resolved root. - if (!TryGetCodeOwnersPath(resolvedRoot, logLookup: false, out _)) + if (!TryGetCodeOwnersPath(resolvedRoot, codeOwnersState.Platform, logLookup: false, out _)) { return false; } @@ -616,7 +729,13 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa // Relative paths must stay within the codeowners root; otherwise we try to anchor them. if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) { - return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); + var anchored = TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); + if (anchored) + { + parser = codeOwnersState.Parser; + } + + return anchored; } absolutePath = resolvedPath; @@ -632,21 +751,30 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa relativePath.StartsWith("../", StringComparison.Ordinal) || relativePath.StartsWith("..\\", StringComparison.Ordinal)) { - return TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); + var anchored = TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); + if (anchored) + { + parser = codeOwnersState.Parser; + } + + return anchored; } codeOwnersRelativePath = relativePath; + parser = codeOwnersState.Parser; return true; } - private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) + private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { // Compiler-recorded paths can be relative to a different base directory than the current // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict // resolution fails, anchor the path by finding the longest suffix that exists under the // CODEOWNERS root, independently of the CI provider layout that produced the prefix. codeOwnersRelativePath = null; - if (StringUtil.IsNullOrWhiteSpace(sourceFilePath) || Path.IsPathRooted(sourceFilePath)) + if (StringUtil.IsNullOrWhiteSpace(sourceFilePath) || + Path.IsPathRooted(sourceFilePath) || + Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) { // Only relative paths recorded against a foreign base directory are anchored; absolute // paths pointing outside the repository must not be re-anchored into it. @@ -654,6 +782,21 @@ private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string } var normalizedPath = sourceFilePath.Replace('\\', '/'); + var pathWithoutForeignPrefix = normalizedPath; + while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || + pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) + { + var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; + pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); + } + + if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) + { + // A drive, UNC path, Unix root, or URI embedded after navigation segments is still + // absolute. Reject the whole source path instead of matching a shorter local suffix. + return false; + } + var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); if (segments.Length < 2) { @@ -681,8 +824,7 @@ private static bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string for (var i = start; i < segments.Length - 1; i++) { var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); - var candidatePath = Path.Combine(codeOwnersRoot, candidateSuffix); - if (File.Exists(candidatePath)) + if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath)) { var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; codeOwnersRelativePath = string.Join(separator, segments, i, segments.Length - i); @@ -736,14 +878,14 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO private void EnsureCodeOwnersFromFallback(string? sourceFilePath) { - if (CodeOwners is not null) + if (Volatile.Read(ref _codeOwnersState) is not null) { return; } lock (_codeOwnersLock) { - if (CodeOwners is not null) + if (Volatile.Read(ref _codeOwnersState) is not null) { return; } @@ -756,7 +898,7 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) return; } - TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null); + TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, WorkspacePath); } } @@ -791,29 +933,76 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } - // Walk parent directories until we find CODEOWNERS or hit a git boundary. + var repositoryBoundary = GetCodeOwnersSearchBoundary(directoryInfo, basePath); + string? nearestCodeOwnersPath = null; + string? nearestCodeOwnersRoot = null; + + // When a repository boundary exists, only its repository-level CODEOWNERS locations are + // valid. If git metadata is unavailable, a containing workspace is the safest boundary. + // Retain the nearest candidate solely when neither boundary can be discovered. while (directoryInfo != null) { - if (TryGetCodeOwnersPath(directoryInfo.FullName, logLookup: false, out var codeOwnersPath)) + var isRepositoryBoundary = repositoryBoundary is not null && + CodeOwnersSearchComparer.Equals(directoryInfo.FullName, repositoryBoundary); + if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath)) { - Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); - CodeOwners = new CodeOwners(codeOwnersPath, platform); - CodeOwnersRoot = directoryInfo.FullName; - return true; + if (isRepositoryBoundary) + { + PublishFallbackCodeOwners(codeOwnersPath, platform, directoryInfo.FullName); + return true; + } + + nearestCodeOwnersPath ??= codeOwnersPath; + nearestCodeOwnersRoot ??= directoryInfo.FullName; } - // Stop walking when we hit a git boundary. - if (HasGitDirectory(directoryInfo.FullName)) + if (isRepositoryBoundary) { - break; + // A nested CODEOWNERS candidate is not valid for this repository. Do not fall + // through to it when the actual repository root has no CODEOWNERS file. + return false; } directoryInfo = directoryInfo.Parent; } + if (nearestCodeOwnersPath is not null && nearestCodeOwnersRoot is not null) + { + PublishFallbackCodeOwners(nearestCodeOwnersPath, platform, nearestCodeOwnersRoot); + return true; + } + return false; } + private void PublishFallbackCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) + { + Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); + PublishCodeOwners(codeOwnersPath, platform, root); + } + + private void PublishCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) + { + var state = new CodeOwnersState(new CodeOwners(codeOwnersPath, platform), root, platform); + Volatile.Write(ref _codeOwnersState, state); + } + private CodeOwners.Platform GetCodeOwnersPlatform() => GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + + private sealed class CodeOwnersState + { + public CodeOwnersState(CodeOwners parser, string root, CodeOwners.Platform platform) + { + Parser = parser; + Root = root; + Platform = platform; + } + + public CodeOwners Parser { get; } + + public string Root { get; } + + public CodeOwners.Platform Platform { get; } + } } diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 8a70086c8137..a241649f68d6 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -9,7 +9,9 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; +using Datadog.Trace.Logging; namespace Datadog.Trace.Ci { @@ -24,9 +26,22 @@ namespace Datadog.Trace.Ci /// internal sealed class CodeOwners { - // Upper bound for any single glob evaluation: protects the process from pathological - // patterns in huge CODEOWNERS files. Timed-out rules are treated as non-matching. - private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(3); + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + private static readonly Regex SectionHeaderRegex = new( + @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex StrictSectionHeaderRegex = new( + @"^\^?\[[^\]]+\](?:\[\d+\])?(?:\s+[@\w.\-/\s]+)?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex GitLabEmailReferenceRegex = new( + @"[^@\s]{1,100}@[^@\s]{1,255}(? _sections; private readonly Platform _platform; @@ -39,9 +54,19 @@ public CodeOwners(string filePath, Platform platform) } _platform = platform; - _sections = Parse(File.ReadLines(filePath), platform); + _sections = Parse(File.ReadLines(filePath), platform, out var parsingDiagnosticsCount); + ParsingDiagnosticsCount = parsingDiagnosticsCount; + if (parsingDiagnosticsCount > 0) + { + Log.Warning( + "CODEOWNERS file contains {Count} invalid lines. Invalid rules were ignored or parsed with errors: {Path}", + parsingDiagnosticsCount, + filePath); + } } + internal int ParsingDiagnosticsCount { get; } + /// /// Returns the complete, de‑duplicated owner set that applies to . /// Callers can post‑process the set depending on platform‑specific approval rules. @@ -68,7 +93,7 @@ public IEnumerable Match(string path) { if (_sections[i].TryMatchGitHub(normalizedPath, out var sectionOwners)) { - return DeduplicateOwners(sectionOwners); + return sectionOwners; } } @@ -93,36 +118,15 @@ public IEnumerable Match(string path) return owners ?? []; } - private static IEnumerable DeduplicateOwners(string[] owners) - { - // Owner lists are tiny (typically a single entry), so scan for duplicates first and skip - // the HashSet allocation entirely in the common case. - for (var i = 0; i < owners.Length; i++) - { - for (var j = 0; j < i; j++) - { - if (!string.Equals(owners[i], owners[j], StringComparison.Ordinal)) - { - continue; - } - - var unique = new HashSet(StringComparer.Ordinal); - foreach (var owner in owners) - { - unique.Add(owner); - } - - return unique; - } - } - - return owners; - } - - private static List
Parse(IEnumerable lines, Platform platform) + private static List
Parse(IEnumerable lines, Platform platform, out int parsingDiagnosticsCount) { + parsingDiagnosticsCount = 0; var sections = new List
(); var current = Section.CreateUnnamed(); + var currentDefaultOwners = current.DefaultOwners; + Dictionary? namedSections = platform == Platform.GitLab + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : null; sections.Add(current); foreach (var line in lines) @@ -133,10 +137,34 @@ private static List
Parse(IEnumerable lines, Platform platform) continue; } - if (TryParseSectionHeader(raw, out var newSection)) + if (TryParseSectionHeader(raw, platform, out var newSection, out var sectionHasDiagnostics)) + { + if (sectionHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + currentDefaultOwners = newSection.DefaultOwners; + if (namedSections is not null && namedSections.TryGetValue(newSection.Name, out var existingSection)) + { + existingSection.MergeMetadata(newSection); + current = existingSection; + } + else + { + current = newSection; + sections.Add(current); + namedSections?.Add(current.Name, current); + } + + continue; + } + + if (platform == Platform.GitLab && IsUnparsableSectionHeader(raw)) { - current = newSection; - sections.Add(current); + // GitLab reports malformed header-like lines and skips them rather than + // reinterpreting them as path patterns. + parsingDiagnosticsCount++; continue; } @@ -147,10 +175,19 @@ private static List
Parse(IEnumerable lines, Platform platform) continue; } - var entry = Entry.Parse(raw, platform); + var entry = Entry.Parse(raw, platform, currentDefaultOwners, out var entryHasDiagnostics); if (entry is not null) { - current.Add(entry); + if (entryHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + current.Add(entry, replaceDuplicatePattern: platform == Platform.GitLab); + } + else + { + parsingDiagnosticsCount++; } } @@ -164,107 +201,882 @@ private static List
Parse(IEnumerable lines, Platform platform) return sections; } - private static bool TryParseSectionHeader(string raw, [NotNullWhen(true)] out Section? section) + private static bool TryParseSectionHeader( + string raw, + Platform platform, + [NotNullWhen(true)] out Section? section, + out bool hasDiagnostics) { // Accepted forms: // [Docs] // ^[Go] // [Backend][2] @team @another - var m = Regex.Match(raw, @"^\s*(\^)?\[(?[^\]]+)\](?:\[(?\d+)\])?(?.*)$"); + var m = SectionHeaderRegex.Match(raw); if (!m.Success) { section = null; + hasDiagnostics = false; return false; } var required = !m.Groups[1].Success; // ^ prefix => optional section var name = m.Groups["name"].Value.Trim(); + hasDiagnostics = platform == Platform.GitHub || + name.Length == 0 || + !StrictSectionHeaderRegex.IsMatch(raw); + var approvals = 0; - if (m.Groups["cnt"].Success && int.TryParse(m.Groups["cnt"].Value, out var val)) + if (m.Groups["cnt"].Success) { - approvals = val; + if (int.TryParse(m.Groups["cnt"].Value, out var val)) + { + approvals = val; + } + else + { + hasDiagnostics = true; + } } - var defaults = OwnerTokenizer.Tokenize(m.Groups["rest"].Value).ToArray(); + hasDiagnostics |= !required && approvals > 0; + + // Only parse the owner span recognized by GitLab's permissive header grammar. Text + // after a malformed suffix (for example an extra ']') must never leak into defaults. + var defaults = OwnerTokenizer.Tokenize(m.Groups["defaults"].Value, platform, out var allDefaultsValid); + hasDiagnostics |= !allDefaultsValid; section = new Section(name, required, approvals, defaults); return true; } + private static bool IsUnparsableSectionHeader(string raw) + => raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal); + /// - /// Converts a CODEOWNERS ‑style glob into a Regex. - /// Supports **, *, ?, /‑rooted, and trailing / semantics. - /// When is set, a direct match also owns every - /// descendant path in a single evaluation instead of testing each ancestor separately. + /// Compiles a CODEOWNERS-style glob into a deterministic matcher. + /// Supports **, *, ?, rooted paths, and trailing slash semantics. + /// Both platforms support escaped literals; GitLab additionally supports shell-style character classes. + /// Matching is deterministic and bounded by the pattern and path lengths, without regex backtracking. /// - private static Regex CompileGlob(string pattern, bool includeDescendants) + private static GlobPattern? CompileGlob(string pattern, Platform platform, bool includeDescendants) + => GlobPattern.Compile(pattern, platform, includeDescendants); + +#pragma warning disable SA1201 + public enum Platform +#pragma warning restore SA1201 + { + GitHub, + GitLab + } + + private enum CharacterClassParseResult + { + NotAClass, + Success, + Invalid + } + + private enum SegmentTokenType + { + Literal, + AnyCharacter, + Star, + CharacterClass + } + + private readonly struct SegmentToken { - // Escape regex metachars first. - var rx = Regex.Escape(pattern); + private readonly SegmentTokenType _type; + private readonly char _literal; + private readonly GlobCharacterClass? _characterClass; - // ** is a globstar only as a whole path segment (git / GitHub / GitLab). `foo**bar` - // is two regular asterisks and must not cross directories. After Regex.Escape a - // whole-segment ** is the token "\\*\\*". - var segments = rx.Split('/'); - for (var i = 0; i < segments.Length; i++) + private SegmentToken(SegmentTokenType type, char literal = default, GlobCharacterClass? characterClass = null) { - if (segments[i] == "\\*\\*") + _type = type; + _literal = literal; + _characterClass = characterClass; + } + + public static SegmentToken Star { get; } = new(SegmentTokenType.Star); + + public static SegmentToken AnyCharacter { get; } = new(SegmentTokenType.AnyCharacter); + + public bool IsStar => _type == SegmentTokenType.Star; + + public static SegmentToken Literal(char value) => new(SegmentTokenType.Literal, literal: value); + + public static SegmentToken CharacterClass(GlobCharacterClass value) => new(SegmentTokenType.CharacterClass, characterClass: value); + + public bool Matches(char value) + => _type == SegmentTokenType.AnyCharacter || + (_type == SegmentTokenType.Literal && value == _literal) || + (_type == SegmentTokenType.CharacterClass && _characterClass!.Matches(value)); + } + + private readonly struct CharacterClassAtom + { + public CharacterClassAtom(char value, bool escaped) + { + Value = value; + Escaped = escaped; + } + + public char Value { get; } + + public bool Escaped { get; } + } + + private readonly struct CharacterRange + { + public CharacterRange(char start, char end) + { + Start = start; + End = end; + } + + public char Start { get; } + + public char End { get; } + } + + private readonly struct GlobPathSegment + { + private readonly SegmentPattern? _segment; + + private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresSegment) + { + _segment = segment; + IsGlobStar = isGlobStar; + RequiresSegment = requiresSegment; + } + + public bool IsGlobStar { get; } + + public bool RequiresSegment { get; } + + public static GlobPathSegment GlobStar(bool requiresSegment) => new(null, isGlobStar: true, requiresSegment: requiresSegment); + + public static GlobPathSegment Pattern(SegmentPattern segment) => new(segment, isGlobStar: false, requiresSegment: false); + + public bool Matches(string path, int start, int end) => _segment!.IsMatch(path, start, end); + } + + private static class OwnerTokenizer + { + public static string[] Tokenize(string segment, Platform platform, out bool allValid) + { + if (string.IsNullOrWhiteSpace(segment)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) { - segments[i] = "§§DOUBLESTAR§§"; + if (platform == Platform.GitHub) + { + if (IsValidGitHubOwner(token)) + { + AddUnique(owners, uniqueOwners, token); + } + else + { + allValid = false; + } + } + else if (!ExtractGitLabOwners(token, owners, uniqueOwners)) + { + allValid = false; + } } + + return owners.Count == 0 ? [] : owners.ToArray(); } - rx = string.Join("/", segments); - rx = rx.Replace("\\*", "[^/]*"); // single‑level wildcard - // A slash right after ** means it can match zero or more intermediate directories: - // `a/**/b` must also match `a/b`. - rx = rx.Replace("§§DOUBLESTAR§§/", "(?:.*/)?"); - rx = rx.Replace("§§DOUBLESTAR§§", ".*"); // multi‑level wildcard - rx = rx.Replace("\\?", "[^/]"); // single char within a path segment + private static void AddUnique(List owners, HashSet uniqueOwners, string owner) + { + if (uniqueOwners.Add(owner)) + { + owners.Add(owner); + } + } - if (pattern.EndsWith("/")) + private static bool IsValidGitHubOwner(string token) + => IsValidGitHubNamespaceReference(token) || IsWholeEmailReference(token); + + private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) { - rx += ".*"; // directory pattern matches everything underneath + // Keep the overwhelmingly common canonical forms allocation-light. + if (IsValidNamespaceReference(token) || IsValidGitLabRole(token) || IsWholeGitLabEmailReference(token)) + { + AddUnique(owners, uniqueOwners, token); + return true; + } + + // GitLab extracts references from surrounding punctuation instead of returning + // the entire token verbatim (for example "(@team)" becomes "@team"). + var foundReference = false; + var searchStart = 0; + string? reference; + while (TryExtractNamespaceReference(token, searchStart, out reference, out searchStart)) + { + AddUnique(owners, uniqueOwners, reference); + foundReference = true; + } + + var roleMatches = GitLabRoleReferenceRegex.Matches(token); + for (var i = 0; i < roleMatches.Count; i++) + { + var roleMatch = roleMatches[i]; + AddUnique(owners, uniqueOwners, roleMatch.Value); + foundReference = true; + } + + var emailMatches = GitLabEmailReferenceRegex.Matches(token); + for (var i = 0; i < emailMatches.Count; i++) + { + var emailMatch = emailMatches[i]; + // GitLab's permissive email expression can overlap a namespace reference + // (for example "(@team"). Such a value cannot resolve as an email, while + // the namespace extracted independently can resolve, so keep only the latter. + if (!ContainsNamespaceReference(emailMatch.Value)) + { + AddUnique(owners, uniqueOwners, emailMatch.Value); + foundReference = true; + } + } + + return foundReference; + } + + private static bool ContainsNamespaceReference(string value) + => TryExtractNamespaceReference(value, 0, out _, out _); + + private static bool IsValidGitLabRole(string token) + { + if (!token.StartsWith("@@", StringComparison.Ordinal) || token.Length <= 2) + { + return false; + } + + var role = token.Substring(2); + return role.Equals("developer", StringComparison.OrdinalIgnoreCase) || + role.Equals("developers", StringComparison.OrdinalIgnoreCase) || + role.Equals("maintainer", StringComparison.OrdinalIgnoreCase) || + role.Equals("maintainers", StringComparison.OrdinalIgnoreCase) || + role.Equals("owner", StringComparison.OrdinalIgnoreCase) || + role.Equals("owners", StringComparison.OrdinalIgnoreCase); } - if (pattern.StartsWith("/")) + private static bool IsValidNamespaceReference(string token) + => token.Length > 1 && + token[0] == '@' && + token[1] != '@' && + IsValidNamespace(token, 1, token.Length); + + private static bool IsValidGitHubNamespaceReference(string token) { - // keep the escaped leading slash so paths like "/foo/bar" match - rx = "^" + rx; + if (token.Length <= 1 || token[0] != '@' || token[1] == '@') + { + return false; + } + + var slash = token.IndexOf('/'); + if (slash < 0) + { + return IsValidGitHubIdentifier(token, 1, token.Length); + } + + return token.IndexOf('/', slash + 1) < 0 && + IsValidGitHubIdentifier(token, 1, slash) && + IsValidGitHubIdentifier(token, slash + 1, token.Length); + } + + private static bool IsValidGitHubIdentifier(string value, int start, int end) + { + if (start >= end || !IsAsciiLetterOrDigit(value[start]) || !IsAsciiLetterOrDigit(value[end - 1])) + { + return false; + } + + var previousWasHyphen = false; + for (var i = start; i < end; i++) + { + var character = value[i]; + if (!IsAsciiLetterOrDigit(character) && character != '-') + { + return false; + } + + if (character == '-' && previousWasHyphen) + { + return false; + } + + previousWasHyphen = character == '-'; + } + + return true; } - else + + private static bool IsValidNamespace(string value, int start, int end) { - // Allowed anywhere in repo tree. - rx = "(?:^|.*/)" + rx; + var segmentStart = start; + for (var i = start; i < end; i++) + { + var character = value[i]; + if (character == '/') + { + if (i == segmentStart || !IsNamespaceEnd(value[i - 1])) + { + return false; + } + + segmentStart = i + 1; + } + else if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character)) + { + return false; + } + } + + return segmentStart < end && IsNamespaceEnd(value[end - 1]); } - rx += includeDescendants ? "(?:/.*)?$" : "$"; - return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant, RegexTimeout); + private static bool TryExtractNamespaceReference( + string token, + int searchStart, + [NotNullWhen(true)] out string? reference, + out int nextSearchStart) + { + for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) + { + nextSearchStart = atIndex + 1; + if ((atIndex > 0 && (IsWordCharacter(token[atIndex - 1]) || token[atIndex - 1] == '@')) || + atIndex + 1 >= token.Length || + token[atIndex + 1] == '@' || + !IsNamespaceStart(token[atIndex + 1])) + { + continue; + } + + var segmentStart = atIndex + 1; + var lastValidEnd = -1; + for (var i = segmentStart; i < token.Length; i++) + { + var character = token[i]; + if (character == '/') + { + if (i == segmentStart || lastValidEnd != i) + { + break; + } + + segmentStart = i + 1; + continue; + } + + if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character)) + { + break; + } + + if (IsNamespaceEnd(character)) + { + lastValidEnd = i + 1; + } + } + + if (lastValidEnd > atIndex + 1) + { + reference = token.Substring(atIndex, lastValidEnd - atIndex); + nextSearchStart = lastValidEnd; + return true; + } + } + + reference = null; + nextSearchStart = token.Length; + return false; + } + + private static bool IsWholeEmailReference(string token) + => TryExtractEmailReference(token, out var reference) && + reference.Length == token.Length; + + private static bool IsWholeGitLabEmailReference(string token) + { + var match = GitLabEmailReferenceRegex.Match(token); + return match.Success && match.Index == 0 && match.Length == token.Length; + } + + private static bool TryExtractEmailReference(string token, [NotNullWhen(true)] out string? reference) + { + for (var atIndex = token.IndexOf('@'); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) + { + if (atIndex == 0) + { + continue; + } + + var localStart = atIndex - 1; + while (localStart >= 0 && IsEmailLocalCharacter(token[localStart])) + { + localStart--; + } + + localStart++; + var localLength = atIndex - localStart; + if (localLength is < 1 or > 100) + { + continue; + } + + var domainEnd = atIndex + 1; + var lastValidDomainEnd = -1; + while (domainEnd < token.Length && IsEmailDomainCharacter(token[domainEnd])) + { + if (IsWordCharacter(token[domainEnd])) + { + lastValidDomainEnd = domainEnd + 1; + } + + domainEnd++; + } + + if (lastValidDomainEnd <= atIndex + 1 || lastValidDomainEnd - atIndex - 1 > 255) + { + continue; + } + + reference = token.Substring(localStart, lastValidDomainEnd - localStart); + return true; + } + + reference = null; + return false; + } + + private static bool IsNamespaceStart(char character) + => IsAsciiLetterOrDigit(character) || character is '_' or '.'; + + private static bool IsNamespaceCharacter(char character) + => IsNamespaceStart(character) || character == '-'; + + private static bool IsNamespaceEnd(char character) + => IsAsciiLetterOrDigit(character) || character == '_'; + + private static bool IsEmailLocalCharacter(char character) + => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; + + private static bool IsEmailDomainCharacter(char character) + => IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_'; + + private static bool IsWordCharacter(char character) + => char.IsLetterOrDigit(character) || character == '_'; + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } -#pragma warning disable SA1201 - public enum Platform -#pragma warning restore SA1201 + private sealed class GlobPattern { - GitHub, - GitLab + private readonly GlobPathSegment[] _segments; + + private GlobPattern(GlobPathSegment[] segments) + { + _segments = segments; + } + + public static GlobPattern? Compile(string pattern, Platform platform, bool includeDescendants) + { + var rawSegments = SplitPattern(pattern, out var firstSeparator, out var hasTrailingSlash); + var rooted = (rawSegments.Length > 0 && rawSegments[0].Length == 0) || + (platform == Platform.GitHub && firstSeparator >= 0 && firstSeparator < pattern.Length - 1); + var firstSegment = rooted && rawSegments.Length > 0 && rawSegments[0].Length == 0 ? 1 : 0; + var lastSegment = rawSegments.Length; + if (hasTrailingSlash && lastSegment > firstSegment && rawSegments[lastSegment - 1].Length == 0) + { + lastSegment--; + } + + var segments = new List(rawSegments.Length + 2); + if (!rooted) + { + AddGlobStar(segments, requiresSegment: false); + } + + for (var i = firstSegment; i < lastSegment; i++) + { + if (rawSegments[i] == "**") + { + // A terminal /** means contents below the preceding directory and must + // consume at least one path segment. Middle globstars may consume none. + AddGlobStar(segments, requiresSegment: i == lastSegment - 1); + } + else if (SegmentPattern.TryCompile(rawSegments[i], platform, out var segment)) + { + segments.Add(GlobPathSegment.Pattern(segment)); + } + else + { + // Invalid shell character classes invalidate only their own entry. + return null; + } + } + + if (hasTrailingSlash || includeDescendants) + { + // A trailing slash denotes a directory, so it cannot match a same-named file. + // Descendant expansion inferred for GitHub patterns remains optional because + // the base pattern itself may denote either a file or a directory. + AddGlobStar(segments, requiresSegment: hasTrailingSlash); + } + + return new GlobPattern(segments.ToArray()); + } + + private static string[] SplitPattern(string pattern, out int firstSeparator, out bool hasTrailingSlash) + { + var segments = new List(); + var segment = new StringBuilder(pattern.Length); + firstSeparator = -1; + hasTrailingSlash = false; + + for (var i = 0; i < pattern.Length; i++) + { + var character = pattern[i]; + if (character == '\\' && i + 1 < pattern.Length) + { + var escapedCharacter = pattern[i + 1]; + if (escapedCharacter != '/') + { + // Preserve non-separator escapes for SegmentPattern to compile. + segment.Append(character); + segment.Append(escapedCharacter); + i++; + hasTrailingSlash = false; + continue; + } + + // An escaped slash is still the path separator in gitignore-style globs; + // consume the escape before splitting so it cannot leave a trailing '\\'. + i++; + } + else if (character != '/') + { + segment.Append(character); + hasTrailingSlash = false; + continue; + } + + firstSeparator = firstSeparator < 0 ? i : firstSeparator; + segments.Add(segment.ToString()); + segment.Clear(); + hasTrailingSlash = i == pattern.Length - 1; + } + + segments.Add(segment.ToString()); + return segments.ToArray(); + } + + public bool IsMatch(string path) + { + var patternIndex = 0; + var pathSegmentStart = path.Length > 1 ? 1 : -1; + var globStarIndex = -1; + var globStarPathStart = -1; + + while (pathSegmentStart >= 0) + { + if (patternIndex < _segments.Length && _segments[patternIndex].IsGlobStar) + { + var globStar = _segments[patternIndex]; + globStarIndex = patternIndex++; + if (globStar.RequiresSegment) + { + // Consume the required first segment immediately. If the remainder + // fails, the fallback below grows the same globstar one segment at a time. + var requiredSegmentEnd = GetSegmentEnd(path, pathSegmentStart); + pathSegmentStart = GetNextSegmentStart(path, requiredSegmentEnd); + globStarPathStart = pathSegmentStart; + } + else + { + // First try the zero-directory interpretation. + globStarPathStart = pathSegmentStart; + } + + continue; + } + + var pathSegmentEnd = GetSegmentEnd(path, pathSegmentStart); + if (patternIndex < _segments.Length && + !_segments[patternIndex].IsGlobStar && + _segments[patternIndex].Matches(path, pathSegmentStart, pathSegmentEnd)) + { + patternIndex++; + pathSegmentStart = GetNextSegmentStart(path, pathSegmentEnd); + continue; + } + + if (globStarIndex < 0 || globStarPathStart < 0) + { + return false; + } + + var globStarSegmentEnd = GetSegmentEnd(path, globStarPathStart); + globStarPathStart = GetNextSegmentStart(path, globStarSegmentEnd); + pathSegmentStart = globStarPathStart; + patternIndex = globStarIndex + 1; + } + + while (patternIndex < _segments.Length && + _segments[patternIndex].IsGlobStar && + !_segments[patternIndex].RequiresSegment) + { + patternIndex++; + } + + return patternIndex == _segments.Length; + } + + private static void AddGlobStar(List segments, bool requiresSegment) + { + if (segments.Count > 0 && segments[segments.Count - 1].IsGlobStar) + { + // zero-or-more followed by one-or-more (or vice versa) is one-or-more. + if (requiresSegment && !segments[segments.Count - 1].RequiresSegment) + { + segments[segments.Count - 1] = GlobPathSegment.GlobStar(requiresSegment: true); + } + + return; + } + + segments.Add(GlobPathSegment.GlobStar(requiresSegment)); + } + + private static int GetSegmentEnd(string path, int segmentStart) + { + var separator = path.IndexOf('/', segmentStart); + return separator >= 0 ? separator : path.Length; + } + + private static int GetNextSegmentStart(string path, int segmentEnd) + => segmentEnd < path.Length - 1 ? segmentEnd + 1 : -1; } - private static class OwnerTokenizer + private sealed class SegmentPattern { - public static IEnumerable Tokenize(string segment) + private readonly SegmentToken[] _tokens; + + private SegmentPattern(SegmentToken[] tokens) { - if (string.IsNullOrWhiteSpace(segment)) + _tokens = tokens; + } + + public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(true)] out SegmentPattern? segment) + { + var tokens = new List(pattern.Length); + for (var i = 0; i < pattern.Length; i++) { - yield break; + var character = pattern[i]; + if (character == '\\') + { + // Both gitignore-style GitHub patterns and GitLab File.fnmatch patterns use + // a backslash to escape the following character. A trailing backslash is invalid. + if (i + 1 >= pattern.Length) + { + segment = null; + return false; + } + + tokens.Add(SegmentToken.Literal(pattern[++i])); + } + else if (character == '*') + { + if (tokens.Count == 0 || !tokens[tokens.Count - 1].IsStar) + { + tokens.Add(SegmentToken.Star); + } + } + else if (character == '?') + { + tokens.Add(SegmentToken.AnyCharacter); + } + else if (platform == Platform.GitLab && character == '[') + { + var result = TryParseCharacterClass(pattern, i, out var closingBracket, out var characterClass); + if (result == CharacterClassParseResult.Invalid) + { + segment = null; + return false; + } + + if (result == CharacterClassParseResult.Success) + { + tokens.Add(SegmentToken.CharacterClass(characterClass!)); + i = closingBracket; + } + else + { + tokens.Add(SegmentToken.Literal(character)); + } + } + else + { + tokens.Add(SegmentToken.Literal(character)); + } } - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + segment = new SegmentPattern(tokens.ToArray()); + return true; + } + + public bool IsMatch(string path, int start, int end) + { + var tokenIndex = 0; + var pathIndex = start; + var starTokenIndex = -1; + var starPathIndex = -1; + + while (pathIndex < end) + { + if (tokenIndex < _tokens.Length && _tokens[tokenIndex].IsStar) + { + starTokenIndex = tokenIndex++; + starPathIndex = pathIndex; + continue; + } + + if (tokenIndex < _tokens.Length && _tokens[tokenIndex].Matches(path[pathIndex])) + { + tokenIndex++; + pathIndex++; + continue; + } + + if (starTokenIndex < 0) + { + return false; + } + + tokenIndex = starTokenIndex + 1; + pathIndex = ++starPathIndex; + } + + while (tokenIndex < _tokens.Length && _tokens[tokenIndex].IsStar) + { + tokenIndex++; + } + + return tokenIndex == _tokens.Length; + } + + private static CharacterClassParseResult TryParseCharacterClass( + string pattern, + int openingBracket, + out int closingBracket, + [NotNullWhen(true)] out GlobCharacterClass? characterClass) + { + closingBracket = -1; + characterClass = null; + var contentStart = openingBracket + 1; + var negated = contentStart < pattern.Length && pattern[contentStart] is '!' or '^'; + var atomStart = negated ? contentStart + 1 : contentStart; + var searchStart = atomStart; + + // A closing bracket immediately after the optional negation is a literal member. + if (searchStart < pattern.Length && pattern[searchStart] == ']') + { + searchStart++; + } + + for (var i = searchStart; i < pattern.Length; i++) + { + if (pattern[i] == '\\' && i + 1 < pattern.Length) + { + i++; + } + else if (pattern[i] == ']') + { + closingBracket = i; + break; + } + } + + if (closingBracket < 0) + { + return CharacterClassParseResult.NotAClass; + } + + var atoms = new List(); + for (var i = atomStart; i < closingBracket; i++) + { + if (pattern[i] == '\\' && i + 1 < closingBracket) + { + atoms.Add(new CharacterClassAtom(pattern[++i], escaped: true)); + } + else + { + atoms.Add(new CharacterClassAtom(pattern[i], escaped: false)); + } + } + + if (atoms.Count == 0) + { + return CharacterClassParseResult.Invalid; + } + + var ranges = new List(atoms.Count); + for (var i = 0; i < atoms.Count; i++) + { + if (i + 2 < atoms.Count && atoms[i + 1].Value == '-' && !atoms[i + 1].Escaped) + { + if (atoms[i].Value > atoms[i + 2].Value) + { + return CharacterClassParseResult.Invalid; + } + + ranges.Add(new CharacterRange(atoms[i].Value, atoms[i + 2].Value)); + i += 2; + } + else + { + ranges.Add(new CharacterRange(atoms[i].Value, atoms[i].Value)); + } + } + + characterClass = new GlobCharacterClass(negated, ranges.ToArray()); + return CharacterClassParseResult.Success; + } + } + + private sealed class GlobCharacterClass + { + private readonly bool _negated; + private readonly CharacterRange[] _ranges; + + public GlobCharacterClass(bool negated, CharacterRange[] ranges) + { + _negated = negated; + _ranges = ranges; + } + + public bool Matches(char value) + { + foreach (var range in _ranges) { - if (token.Contains('@')) + if (value >= range.Start && value <= range.End) { - yield return token; + return !_negated; } } + + return _negated; } } @@ -272,6 +1084,7 @@ private sealed class Section { private readonly List _entries = new(); private Entry[]? _cache; + private bool _replaceDuplicatePatterns; public Section(string name, bool required, int approvalCount, string[] defaultOwners) { @@ -283,17 +1096,55 @@ public Section(string name, bool required, int approvalCount, string[] defaultOw public string Name { get; } - public bool Required { get; } + public bool Required { get; private set; } - public int ApprovalCount { get; } + public int ApprovalCount { get; private set; } public string[] DefaultOwners { get; } public static Section CreateUnnamed() => new(string.Empty, required: true, approvalCount: 0, defaultOwners: []); - public void Add(Entry entry) => _entries.Add(entry); + public void Add(Entry entry, bool replaceDuplicatePattern) + { + _replaceDuplicatePatterns |= replaceDuplicatePattern; + _entries.Add(entry); + } + + public void MergeMetadata(Section other) + { + // Duplicate GitLab sections are combined case-insensitively. The most restrictive + // requirement wins; matching defaults remain attached to entries from each header. + Required |= other.Required; + ApprovalCount = Math.Max(ApprovalCount, other.ApprovalCount); + } - public void Seal() => _cache = _entries.AsEnumerable().Reverse().ToArray(); + public void Seal() + { + if (_replaceDuplicatePatterns) + { + // GitLab replaces duplicate normalized patterns and moves the replacement to + // the end. Build the reverse-order cache in one pass instead of repeatedly + // removing from the middle of the list. + var seenPatterns = new HashSet(StringComparer.Ordinal); + var cache = new List(_entries.Count); + for (var i = _entries.Count - 1; i >= 0; i--) + { + var entry = _entries[i]; + if (seenPatterns.Add(entry.PatternKey)) + { + cache.Add(entry); + } + } + + _cache = cache.ToArray(); + } + else + { + _cache = _entries.AsEnumerable().Reverse().ToArray(); + } + + _entries.Clear(); + } /// /// GitHub evaluation: exclusion rules are unsupported and ignored, section default owners don't @@ -305,8 +1156,7 @@ public bool TryMatchGitHub(string path, [NotNullWhen(true)] out string[]? owners foreach (var rule in rules) { - // GitHub doesn't support exclusion rules. Keep them parse‑able but ignore when evaluating. - if (rule.IsExclusion || !rule.Match(path)) + if (!rule.Match(path)) { continue; } @@ -348,7 +1198,7 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners break; } - matchedOwners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners; + matchedOwners = rule.Owners; } if (excluded || matchedOwners is null || matchedOwners.Length == 0) @@ -364,121 +1214,241 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners private sealed class Entry { - private readonly Regex _regex; - private readonly string _patternToken; - private readonly bool _isDirectoryPattern; - private Regex? _descendantsRegex; + private readonly GlobPattern _glob; - private Entry(Regex regex, string patternToken, bool exclusion, string[] owners, bool isDirectoryPattern) + private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owners) { - _regex = regex; - _patternToken = patternToken; + _glob = glob; + PatternKey = patternKey; IsExclusion = exclusion; Owners = owners; - _isDirectoryPattern = isDirectoryPattern; } public bool IsExclusion { get; } public string[] Owners { get; } - public static Entry? Parse(string raw, Platform platform) + public string PatternKey { get; } + + public static Entry? Parse(string raw, Platform platform, string[] defaultOwners, out bool hasDiagnostics) { + hasDiagnostics = false; + if (platform == Platform.GitHub && raw.StartsWith("\\#")) + { + // GitHub does not support escaping a leading #; the line is invalid, not a + // literal pattern beginning with #. + return null; + } + // Strip inline comments for GitHub. GitLab treats everything after # as data (inline comments unsupported). - var idxHash = raw.IndexOf('#'); + var idxHash = platform == Platform.GitHub ? FindUnescapedCharacter(raw, '#') : -1; var effective = idxHash >= 0 && platform == Platform.GitHub ? raw.Substring(0, idxHash).TrimEnd() : raw; if (string.IsNullOrWhiteSpace(effective)) { return null; } - // 2. Tokenise - // * GitHub: simple whitespace split - // * GitLab: split on whitespace NOT escaped with back-slash - string[] tokens; + // 2. Tokenise on unescaped whitespace. Both platforms support escaped literals + // in patterns; owner tokens themselves are not unescaped. + string patternToken; + string ownersSegment; + bool hasExplicitOwners; + SplitEscapedEntry(effective, out patternToken, out ownersSegment, out hasExplicitOwners); - if (platform == Platform.GitLab) + // 3. Pattern & exclusion + var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!"); + if (isExclusion) { - // Split on space / tab that are **not** escaped: (? t.Length > 0) - // Undo the escaping: "\ " → " ", "\#" → "#", "\\" → "\" - .Select(t => Regex.Replace(t, @"\\([ #\\])", "$1")) - .ToArray(); + patternToken = patternToken.Substring(1, patternToken.Length - 1); } - else + + if (platform == Platform.GitHub && IsUnsupportedGitHubPattern(patternToken)) { - tokens = effective.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + // GitHub skips invalid CODEOWNERS lines instead of interpreting unsupported + // gitignore constructs as literal file names. + return null; } - if (tokens.Length == 0) + if (patternToken.Length == 0) { return null; } - // 3. Pattern & exclusion - var patternToken = tokens[0]; - var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!"); + // 4. Owners. GitLab exclusions deliberately ignore any trailing owner text. + string[] owners; + var allOwnersValid = true; if (isExclusion) { - patternToken = patternToken.Substring(1, patternToken.Length - 1); + owners = []; + } + else + { + owners = OwnerTokenizer.Tokenize(ownersSegment, platform, out allOwnersValid); } - // 4. Owners (validate through OwnerTokenizer to drop any bogus tokens) - var ownersSegment = tokens.Length > 1 ? string.Join(" ", tokens.Skip(1)) : string.Empty; - var owners = OwnerTokenizer.Tokenize(ownersSegment).ToArray(); + hasDiagnostics = !allOwnersValid; + if (platform == Platform.GitHub && hasDiagnostics) + { + // GitHub skips a whole rule containing a malformed owner token. + return null; + } + + if (platform == Platform.GitLab && !isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) + { + owners = defaultOwners; + } + + if (platform == Platform.GitLab && !isExclusion && owners.Length == 0) + { + // GitLab keeps an ownerless rule because it can intentionally auto-approve a + // path, but reports the missing owner as a parsing diagnostic. + hasDiagnostics = true; + } // GitHub owns the contents of directories matched by wildcard-free patterns (e.g. // `**/logs`); GitLab requires an explicit trailing slash for directory ownership. var isDirectoryPattern = platform == Platform.GitHub && IsDirectoryPattern(patternToken); // 5. Compile the glob - var rx = CompileGlob(patternToken, includeDescendants: false); - return new Entry(rx, patternToken, isExclusion, owners, isDirectoryPattern); + var glob = CompileGlob(patternToken, platform, includeDescendants: isDirectoryPattern); + if (glob is null) + { + return null; + } + + var patternKey = platform == Platform.GitLab ? NormalizeGitLabPatternKey(patternToken) : patternToken; + return new Entry(glob, patternKey, isExclusion, owners); } - private static bool IsDirectoryPattern(string patternToken) + private static void SplitEscapedEntry(string entry, out string pattern, out string owners, out bool hasExplicitOwners) { - var lastSegmentStart = patternToken.LastIndexOf('/'); - var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken; - return lastSegment.Length > 0 && - lastSegment.IndexOf('*') < 0 && - lastSegment.IndexOf('?') < 0; + var patternEnd = entry.Length; + for (var i = 0; i < entry.Length; i++) + { + if (entry[i] == '\\' && i + 1 < entry.Length) + { + i++; + } + else if (entry[i] is ' ' or '\t') + { + patternEnd = i; + break; + } + } + + pattern = entry.Substring(0, patternEnd); + owners = patternEnd < entry.Length ? entry.Substring(patternEnd).Trim() : string.Empty; + hasExplicitOwners = owners.Length > 0; } - private static bool IsMatch(Regex regex, string input) + private static int FindUnescapedCharacter(string value, char character) { - try + for (var i = 0; i < value.Length; i++) { - return regex.IsMatch(input); + if (value[i] == '\\' && i + 1 < value.Length) + { + i++; + } + else if (value[i] == character) + { + return i; + } } - catch (RegexMatchTimeoutException) + + return -1; + } + + private static string NormalizeGitLabPatternKey(string patternToken) + { + if (patternToken == "*") { - // A pathological pattern must never hang the process: treat it as non-matching. - return false; + return "/**/*"; } + + var normalizedToken = NormalizeGitLabEscapes(patternToken); + var normalized = normalizedToken.StartsWith("/") ? normalizedToken : "/**/" + normalizedToken; + return normalized.EndsWith("/") ? normalized + "**/*" : normalized; } - public bool Match(string path) + private static string NormalizeGitLabEscapes(string patternToken) { - if (IsMatch(_regex, path)) + StringBuilder? builder = null; + var copyStart = 0; + for (var i = 0; i + 1 < patternToken.Length; i++) + { + var unescape = i == 0 && patternToken[i] == '\\' && patternToken[i + 1] == '#'; + unescape |= patternToken[i] == '\\' && char.IsWhiteSpace(patternToken[i + 1]); + if (!unescape) + { + continue; + } + + builder ??= new StringBuilder(patternToken.Length); + builder.Append(patternToken, copyStart, i - copyStart); + copyStart = i + 1; + } + + if (builder is null) + { + return patternToken; + } + + builder.Append(patternToken, copyStart, patternToken.Length - copyStart); + return builder.ToString(); + } + + private static bool IsUnsupportedGitHubPattern(string patternToken) + { + if (patternToken.StartsWith("!")) { return true; } - // Patterns whose last segment is wildcard-free also own everything inside a matched - // directory (e.g. `**/logs` owns `/build/logs/error.txt`), while wildcard segments like - // `docs/*` match individual entries only. The descendant variant of the glob accepts any - // path below a direct match in a single evaluation. - return _isDirectoryPattern && IsMatch(LazyGetDescendantsRegex(), path); + var hasOpeningBracket = false; + for (var i = 0; i < patternToken.Length; i++) + { + if (patternToken[i] == '\\' && i + 1 < patternToken.Length) + { + i++; + } + else if (patternToken[i] == '[') + { + hasOpeningBracket = true; + } + else if (patternToken[i] == ']' && hasOpeningBracket) + { + return true; + } + } + + return false; } - private Regex LazyGetDescendantsRegex() + private static bool IsDirectoryPattern(string patternToken) { - // Compiled lazily because most rules never need the descendant variant. The race of two - // threads compiling simultaneously is benign: both produce identical regexes. - return _descendantsRegex ??= CompileGlob(_patternToken, includeDescendants: true); + var lastSegmentStart = patternToken.LastIndexOf('/'); + var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken; + if (lastSegment.Length == 0) + { + return false; + } + + for (var i = 0; i < lastSegment.Length; i++) + { + if (lastSegment[i] == '\\' && i + 1 < lastSegment.Length) + { + i++; + } + else if (lastSegment[i] is '*' or '?') + { + return false; + } + } + + return true; } + + public bool Match(string path) => _glob.IsMatch(path); } } } diff --git a/tracer/src/Datadog.Trace/Ci/Test.cs b/tracer/src/Datadog.Trace/Ci/Test.cs index 8011a11addcf..c2fb99343dba 100644 --- a/tracer/src/Datadog.Trace/Ci/Test.cs +++ b/tracer/src/Datadog.Trace/Ci/Test.cs @@ -247,7 +247,7 @@ public void SetTestMethodInfo(MethodInfo methodInfo) var ciValues = TestOptimization.Instance.CIValues; var tags = (TestSpanTags)_scope.Span.Tags; - tags.SourceFile = ciValues.MakeRelativePathFromSourceRootWithFallback(methodSymbol.File, false); + tags.SourceFile = ciValues.MakeRelativePathFromSourceRootWithFallback(methodSymbol.File, false, out var owners); tags.SourceStart = startLine; tags.SourceEnd = methodSymbol.EndLine; _testOptimization.ImpactedTestsDetectionFeature?.ImpactedTestsAnalyzer.Analyze(this); @@ -259,15 +259,7 @@ public void SetTestMethodInfo(MethodInfo methodInfo) static suiteTags => suiteTags.SourceFile, static (suiteTags, value) => suiteTags.SourceFile = value); - string[]? owners; - if (ciValues.CodeOwners is { } codeOwners && - (owners = codeOwners.Match("/" + tags.SourceFile).ToArray()) is { Length: > 0 }) - { - SetCodeOwnersOnTags(tags, Suite.Tags, owners); - } - else if (ciValues.TryGetCodeOwnersRelativePath(methodSymbol.File, false, out var codeOwnersRelativePath) && - ciValues.CodeOwners is { } fallbackCodeOwners && - (owners = fallbackCodeOwners.Match("/" + codeOwnersRelativePath).ToArray()) is { Length: > 0 }) + if (owners.Length > 0) { SetCodeOwnersOnTags(tags, Suite.Tags, owners); } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 1163e510464c..2a51301a26bc 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -8,15 +8,19 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Datadog.Trace.Ci.CiEnvironment; using Datadog.Trace.Configuration; using Xunit; namespace Datadog.Trace.Tests.Ci; +[Collection(nameof(EnvironmentVariablesTestCollection))] public class CodeOwnersFallbackTests { private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b"; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); [SkippableFact] public void UsesFallbackRootWhenSourceRootIsDifferent() @@ -51,6 +55,7 @@ public void UsesFallbackRootWhenSourceRootIsSubdirectory() using var tempDirectory = new TemporaryDirectory(); var repoRoot = tempDirectory.RootPath; var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); Directory.CreateDirectory(srcDir); var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n"); @@ -147,6 +152,154 @@ public void AllowsFallbackRetryWithDifferentStartPath() Assert.Equal(new[] { "@owner" }, owners); } + [SkippableFact] + public void GitRepositoryRootCodeOwnersWinsOverNestedCandidate() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDirectory = Path.Combine(repoRoot, "src", "nested"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "* @root-owner\n"); + File.WriteAllText(Path.Combine(repoRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); + var sourceFile = Path.Combine(sourceDirectory, "File.cs"); + File.WriteAllText(sourceFile, string.Empty); + + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); + Assert.Equal("src/nested/File.cs", relativePath); + Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); + Assert.Equal(["@root-owner"], ciValues.CodeOwners!.Match("/" + relativePath)); + } + + [SkippableFact] + public void NestedCandidateIsIgnoredWhenGitRepositoryRootHasNoCodeOwners() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDirectory = Path.Combine(repoRoot, "src", "nested"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(repoRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); + var sourceFile = Path.Combine(sourceDirectory, "File.cs"); + File.WriteAllText(sourceFile, string.Empty); + + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + Assert.Null(ciValues.CodeOwners); + Assert.Null(ciValues.CodeOwnersRoot); + } + + [SkippableFact] + public void WorkspaceRootCodeOwnersWinsOverNestedCandidateWithoutGitMetadata() + { + using var tempDirectory = new TemporaryDirectory(); + var workspaceRoot = tempDirectory.RootPath; + var sourceDirectory = Path.Combine(workspaceRoot, "src", "nested"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(workspaceRoot, "CODEOWNERS"), "* @workspace-owner\n"); + File.WriteAllText(Path.Combine(workspaceRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); + var sourceFile = Path.Combine(sourceDirectory, "File.cs"); + File.WriteAllText(sourceFile, string.Empty); + + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: workspaceRoot); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); + Assert.Equal("src/nested/File.cs", relativePath); + Assert.Equal(workspaceRoot, ciValues.CodeOwnersRoot); + Assert.Equal(["@workspace-owner"], ciValues.CodeOwners!.Match("/" + relativePath)); + } + + [SkippableFact] + public void DoesNotLoadCodeOwnersAboveWorkspaceWithoutGitMetadata() + { + using var tempDirectory = new TemporaryDirectory(); + var parentRoot = tempDirectory.RootPath; + var workspaceRoot = Path.Combine(parentRoot, "workspace"); + var sourceDirectory = Path.Combine(workspaceRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(parentRoot, "CODEOWNERS"), "* @parent-decoy\n"); + var sourceFile = Path.Combine(sourceDirectory, "File.cs"); + File.WriteAllText(sourceFile, string.Empty); + + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: workspaceRoot); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + Assert.Null(ciValues.CodeOwners); + Assert.Null(ciValues.CodeOwnersRoot); + } + + [SkippableFact] + public void GitHubUsesOfficialCodeOwnersLocationPriority() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); + Directory.CreateDirectory(Path.Combine(repoRoot, "docs")); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "* @github-directory\n"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n"); + File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n"); + var ciValues = new ReloadingGithubEnvironmentValues(repoRoot); + + ciValues.Reload(); + Assert.Equal(["@github-directory"], ciValues.CodeOwners!.Match("/file.cs")); + + File.Delete(Path.Combine(repoRoot, ".github", "CODEOWNERS")); + ciValues.Reload(); + Assert.Equal(["@repository-root"], ciValues.CodeOwners!.Match("/file.cs")); + + File.Delete(Path.Combine(repoRoot, "CODEOWNERS")); + ciValues.Reload(); + Assert.Equal(["@docs-directory"], ciValues.CodeOwners!.Match("/file.cs")); + } + + [SkippableFact] + public void GitLabUsesOfficialCodeOwnersLocationPriority() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, "docs")); + Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab")); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n"); + File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n"); + File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "* @gitlab-directory\n"); + var ciValues = new ReloadingGitlabEnvironmentValues(repoRoot); + + ciValues.Reload(); + Assert.Equal(["@repository-root"], ciValues.CodeOwners!.Match("/file.cs")); + + File.Delete(Path.Combine(repoRoot, "CODEOWNERS")); + ciValues.Reload(); + Assert.Equal(["@docs-directory"], ciValues.CodeOwners!.Match("/file.cs")); + + File.Delete(Path.Combine(repoRoot, "docs", "CODEOWNERS")); + ciValues.Reload(); + Assert.Equal(["@gitlab-directory"], ciValues.CodeOwners!.Match("/file.cs")); + } + + [SkippableFact] + public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations() + { + using var githubDirectory = new TemporaryDirectory(); + Directory.CreateDirectory(Path.Combine(githubDirectory.RootPath, ".gitlab")); + File.WriteAllText(Path.Combine(githubDirectory.RootPath, ".gitlab", "CODEOWNERS"), "* @gitlab-only\n"); + var githubValues = new ReloadingGithubEnvironmentValues(githubDirectory.RootPath); + + githubValues.Reload(); + Assert.Null(githubValues.CodeOwners); + + using var gitlabDirectory = new TemporaryDirectory(); + Directory.CreateDirectory(Path.Combine(gitlabDirectory.RootPath, ".github")); + File.WriteAllText(Path.Combine(gitlabDirectory.RootPath, ".github", "CODEOWNERS"), "* @github-only\n"); + var gitlabValues = new ReloadingGitlabEnvironmentValues(gitlabDirectory.RootPath); + + gitlabValues.Reload(); + Assert.Null(gitlabValues.CodeOwners); + } + [SkippableFact] public void DoesNotMatchCodeOwnersForFileOutsideRoot() { @@ -421,6 +574,281 @@ public void DoesNotAnchorPathsWithInteriorNavigationSegments() Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/src/../src/SpanBenchmark.cs", false, out _)); } + [SkippableTheory] + [InlineData("file:///outside/src/SpanBenchmark.cs")] + [InlineData("https://example.com/src/SpanBenchmark.cs")] + [InlineData("../../C:/outside/src/SpanBenchmark.cs")] + [InlineData("../..//outside/src/SpanBenchmark.cs")] + [InlineData(@"..\..\\server\share\src\SpanBenchmark.cs")] + public void DoesNotAnchorAbsoluteOrEmbeddedRootedPathsWithMatchingRepositorySuffix(string sourceFilePath) + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFilePath, false, out _)); + } + + [SkippableFact] + public void DoesNotAnchorEmbeddedWindowsAbsolutePathOutsideRoot() + { + Skip.If(Path.DirectorySeparatorChar != '\\', "This regression exercises Windows drive-rooted Path.Combine behavior."); + + using var repoDirectory = new TemporaryDirectory(); + using var externalDirectory = new TemporaryDirectory(); + var repoRoot = repoDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, "src")); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}"); + + var externalFile = Path.Combine(externalDirectory.RootPath, "src", "SpanBenchmark.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!); + File.WriteAllText(externalFile, "class ExternalSpanBenchmark {}"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var embeddedAbsolutePath = "../../" + externalFile.Replace('\\', '/'); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(embeddedAbsolutePath, false, out _)); + } + + [SkippableFact] + public void ConcurrentFallbackPublishesCodeOwnersAndRootTogether() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var srcDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(srcDir); + var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + const int concurrency = 64; + var results = new bool[concurrency]; + var tasks = new Task[concurrency]; + using var start = new ManualResetEventSlim(initialState: false); + + for (var i = 0; i < concurrency; i++) + { + var index = i; + tasks[index] = Task.Run(() => + { + Assert.True(start.Wait(TestTimeout), "concurrent fallback start signal was not received"); + results[index] = ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath) && + relativePath == "src/SpanBenchmark.cs"; + }); + } + + start.Set(); + Assert.True(Task.WaitAll(tasks, TestTimeout), "concurrent fallback lookup must not deadlock"); + + Assert.All(results, Assert.True); + Assert.NotNull(ciValues.CodeOwners); + Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); + } + + [SkippableFact] + public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() + { + using var firstRepository = new TemporaryDirectory(); + using var secondRepository = new TemporaryDirectory(); + var firstSourceDirectory = Path.Combine(firstRepository.RootPath, "layoutA", "src"); + var secondSourceDirectory = Path.Combine(secondRepository.RootPath, "src"); + Directory.CreateDirectory(firstSourceDirectory); + Directory.CreateDirectory(secondSourceDirectory); + File.WriteAllText(Path.Combine(firstSourceDirectory, "SpanBenchmark.cs"), string.Empty); + File.WriteAllText(Path.Combine(secondSourceDirectory, "SpanBenchmark.cs"), string.Empty); + File.WriteAllText(Path.Combine(firstRepository.RootPath, "CODEOWNERS"), "* @first-global\n/layoutA/src/ @first\n"); + File.WriteAllText(Path.Combine(secondRepository.RootPath, "CODEOWNERS"), "* @second-global\n/src/ @second\n"); + + using var setupEntered = new ManualResetEventSlim(initialState: false); + using var continueSetup = new ManualResetEventSlim(initialState: false); + using var matchStarted = new ManualResetEventSlim(initialState: false); + var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.Reload(); + const string foreignSourcePath = "../../layoutA/src/SpanBenchmark.cs"; + + var firstRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out var firstOwners); + Assert.Equal("layoutA/src/SpanBenchmark.cs", firstRelativePath); + Assert.Equal(["@first"], firstOwners); + + ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); + var reloadTask = Task.Run(ciValues.Reload); + Task? matchTask = null; + string? secondRelativePath = null; + string[]? secondOwners = null; + var completedDuringReload = false; + try + { + Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); + matchTask = Task.Run(() => + { + matchStarted.Set(); + secondRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out secondOwners); + }); + Assert.True(matchStarted.Wait(TestTimeout), "snapshot reader did not start"); + completedDuringReload = matchTask.Wait(TimeSpan.FromMilliseconds(250)); + } + finally + { + continueSetup.Set(); + } + + Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); + Assert.NotNull(matchTask); + Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); + Assert.False(completedDuringReload, "the reader must not observe partially reloaded state"); + Assert.Equal("src/SpanBenchmark.cs", secondRelativePath); + Assert.Equal(["@second"], secondOwners); + } + + [SkippableFact] + public void RelativePathUsesCompletedReloadStateWhenNewRepositoryHasNoCodeOwners() + { + using var firstRepository = new TemporaryDirectory(); + using var secondRepository = new TemporaryDirectory(); + var firstSourceDirectory = Path.Combine(firstRepository.RootPath, "src"); + var secondSourceDirectory = Path.Combine(secondRepository.RootPath, "src"); + Directory.CreateDirectory(firstSourceDirectory); + Directory.CreateDirectory(secondSourceDirectory); + File.WriteAllText(Path.Combine(firstRepository.RootPath, "CODEOWNERS"), "* @first\n"); + File.WriteAllText(Path.Combine(firstSourceDirectory, "SpanBenchmark.cs"), string.Empty); + var secondSource = Path.Combine(secondSourceDirectory, "SpanBenchmark.cs"); + File.WriteAllText(secondSource, string.Empty); + + using var setupEntered = new ManualResetEventSlim(initialState: false); + using var continueSetup = new ManualResetEventSlim(initialState: false); + using var matchStarted = new ManualResetEventSlim(initialState: false); + var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.Reload(); + + ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); + var reloadTask = Task.Run(ciValues.Reload); + Task? matchTask = null; + string? relativePath = null; + string[]? owners = null; + var completedDuringReload = false; + try + { + Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); + matchTask = Task.Run(() => + { + matchStarted.Set(); + relativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(secondSource, false, out owners); + }); + Assert.True(matchStarted.Wait(TestTimeout), "snapshot reader did not start"); + completedDuringReload = matchTask.Wait(TimeSpan.FromMilliseconds(250)); + } + finally + { + continueSetup.Set(); + } + + Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); + Assert.NotNull(matchTask); + Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); + Assert.False(completedDuringReload, "the reader must wait for the SourceRoot transition"); + Assert.Equal("src/SpanBenchmark.cs", relativePath); + Assert.Empty(owners!); + Assert.Null(ciValues.CodeOwners); + } + + [SkippableFact] + public void MalformedGitLabClassDoesNotAbortFallbackPublication() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "file.txt"); + File.WriteAllText(sourceFile, string.Empty); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @fallback\nfile[z-a].txt @invalid\n"); + + var ciValues = new TestGitlabEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + + Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); + Assert.Equal("src/file.txt", relativePath); + Assert.Equal(["@fallback"], ciValues.CodeOwners!.Match("/" + relativePath)); + Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); + } + + [SkippableFact] + public void ReloadAndFallbackDiscoveryAreSerialized() + { + using var firstRepository = new TemporaryDirectory(); + using var secondRepository = new TemporaryDirectory(); + var firstSource = CreateRepository(firstRepository.RootPath, "@first"); + var secondSource = CreateRepository(secondRepository.RootPath, "@second"); + using var setupEntered = new ManualResetEventSlim(initialState: false); + using var continueSetup = new ManualResetEventSlim(initialState: false); + using var fallbackStarted = new ManualResetEventSlim(initialState: false); + + var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.Reload(); + Assert.True(ciValues.TryGetCodeOwnersRelativePath(firstSource, false, out _)); + + ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); + var reloadTask = Task.Run(ciValues.Reload); + Task? fallbackTask = null; + var fallbackResult = false; + var completedDuringReload = false; + try + { + Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); + fallbackTask = Task.Run(() => + { + fallbackStarted.Set(); + fallbackResult = ciValues.TryGetCodeOwnersRelativePath(secondSource, false, out var relativePath) && + relativePath == "src/SpanBenchmark.cs"; + }); + Assert.True(fallbackStarted.Wait(TestTimeout), "fallback task did not start"); + + completedDuringReload = fallbackTask.Wait(TimeSpan.FromMilliseconds(250)); + } + finally + { + continueSetup.Set(); + } + + Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); + Assert.NotNull(fallbackTask); + Assert.True(fallbackTask!.Wait(TestTimeout), "fallback lookup must not deadlock after reload"); + + Assert.False(completedDuringReload); + Assert.True(fallbackResult); + Assert.Equal(secondRepository.RootPath, ciValues.CodeOwnersRoot); + Assert.Equal(["@second"], ciValues.CodeOwners!.Match("/src/SpanBenchmark.cs")); + + static string CreateRepository(string root, string owner) + { + var sourceDirectory = Path.Combine(root, "src"); + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(root, "CODEOWNERS"), "* @global\n/src/ " + owner + "\n"); + var sourceFile = Path.Combine(sourceDirectory, "SpanBenchmark.cs"); + File.WriteAllText(sourceFile, "class SpanBenchmark {}"); + return sourceFile; + } + } + private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() @@ -459,4 +887,84 @@ protected override void Setup(IGitInfo gitInfo) { } } + + private sealed class TestGitlabEnvironmentValues : CIEnvironmentValues + { + public TestGitlabEnvironmentValues(string? sourceRoot, string? workspacePath) + { + SourceRoot = sourceRoot; + WorkspacePath = workspacePath; + } + + protected override void Setup(IGitInfo gitInfo) + { + } + } + + private abstract class ReloadingEnvironmentValues : CIEnvironmentValues + { + private readonly string _sourceRoot; + + protected ReloadingEnvironmentValues(string sourceRoot) + { + _sourceRoot = sourceRoot; + } + + public void Reload() => ReloadEnvironmentData(); + + protected override void Setup(IGitInfo gitInfo) + { + SourceRoot = _sourceRoot; + WorkspacePath = _sourceRoot; + } + } + + private sealed class ReloadingGithubEnvironmentValues : ReloadingEnvironmentValues + { + public ReloadingGithubEnvironmentValues(string sourceRoot) + : base(sourceRoot) + { + } + } + + private sealed class ReloadingGitlabEnvironmentValues : ReloadingEnvironmentValues + { + public ReloadingGitlabEnvironmentValues(string sourceRoot) + : base(sourceRoot) + { + } + } + + private sealed class BlockingReloadCIEnvironmentValues : CIEnvironmentValues + { + private string _nextSourceRoot; + private ManualResetEventSlim? _setupEntered; + private ManualResetEventSlim? _continueSetup; + + public BlockingReloadCIEnvironmentValues(string sourceRoot) + { + _nextSourceRoot = sourceRoot; + } + + public void Reload() => ReloadEnvironmentData(); + + public void PrepareBlockedReload(string sourceRoot, ManualResetEventSlim setupEntered, ManualResetEventSlim continueSetup) + { + _nextSourceRoot = sourceRoot; + _setupEntered = setupEntered; + _continueSetup = continueSetup; + } + + protected override void Setup(IGitInfo gitInfo) + { + _setupEntered?.Set(); + if (_continueSetup is not null && !_continueSetup.Wait(TestTimeout)) + { + throw new TimeoutException("Controlled reload was not released by the test."); + } + + SourceRoot = _nextSourceRoot; + WorkspacePath = _nextSourceRoot; + } + } } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs index 9d32b95455ad..03d18e366212 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs @@ -17,6 +17,8 @@ namespace Datadog.Trace.Tests.Ci; public class CodeOwnersRepositoryTests { + private static readonly HashSet BuildOutputDirectories = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj" }; + [SkippableFact] public void EveryTestFileHasAnOwner() { @@ -35,7 +37,7 @@ public void EveryTestFileHasAnOwner() continue; } - foreach (var file in Directory.EnumerateFiles(fullRoot, "*", SearchOption.AllDirectories)) + foreach (var file in EnumerateRepositoryFiles(fullRoot)) { var relativePath = file.Substring(repoRoot!.Length + 1).Replace('\\', '/'); totalFiles++; @@ -50,6 +52,34 @@ public void EveryTestFileHasAnOwner() unownedFiles.Should().BeEmpty("every test file should be owned by at least one team in .github/CODEOWNERS"); } + [SkippableFact] + public void RepositoryFileEnumerationSkipsBuildOutputs() + { + var root = Path.Combine(Path.GetTempPath(), "dd-codeowners-enumeration-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "nested")); + Directory.CreateDirectory(Path.Combine(root, "bin", "Debug")); + Directory.CreateDirectory(Path.Combine(root, "src", "obj", "Debug")); + File.WriteAllText(Path.Combine(root, "Source.cs"), string.Empty); + File.WriteAllText(Path.Combine(root, "src", "nested", "Nested.cs"), string.Empty); + File.WriteAllText(Path.Combine(root, "bin", "Debug", "Generated.dll"), string.Empty); + File.WriteAllText(Path.Combine(root, "src", "obj", "Debug", "Generated.cs"), string.Empty); + + EnumerateRepositoryFiles(root) + .Select(path => path.Substring(root.Length + 1).Replace('\\', '/')) + .Should() + .BeEquivalentTo(["Source.cs", "src/nested/Nested.cs"]); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + [SkippableTheory] [InlineData("/docs/development/AzureFunctions.md", new[] { "@DataDog/tracing-dotnet", "@DataDog/apm-serverless", "@DataDog/serverless-azure-and-gcp" })] // `**/` must also match zero directories: a file directly under tracer/test/ still matches /tracer/test/**/*Lambda* @@ -121,4 +151,29 @@ public void GitLabExclusionRuleRemovesPathFromSection() return null; } + + private static IEnumerable EnumerateRepositoryFiles(string root) + { + var pending = new Stack(); + pending.Push(root); + + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var file in Directory.EnumerateFiles(directory)) + { + yield return file; + } + + foreach (var child in Directory.EnumerateDirectories(directory)) + { + var name = Path.GetFileName(child); + if (!BuildOutputDirectories.Contains(name) && + (File.GetAttributes(child) & FileAttributes.ReparsePoint) == 0) + { + pending.Push(child); + } + } + } + } } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index e4a94f41af8f..5b6791509a4b 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -5,8 +5,10 @@ #nullable enable using System; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading.Tasks; using Datadog.Trace.Ci; using FluentAssertions; using Xunit; @@ -31,6 +33,8 @@ [README other owners] README.md @user3 """; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + [SkippableFact] public void GithubInlineCommentsAndEmailOwners() { @@ -68,6 +72,21 @@ public void GithubDirectoryPatternOwnsEverythingUnderneath() Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@doctocat"]); } + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void DirectoryAndTerminalGlobstarPatternsRequireDescendants(bool useGitLab) + { + var platform = useGitLab ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + var codeOwners = Create("/docs/ @directory\n/archive/** @globstar\n", platform); + + Match(codeOwners, "/docs").Should().BeEmpty("a trailing slash only denotes a directory and its contents"); + Match(codeOwners, "/docs/file.txt").Should().Equal(["@directory"]); + Match(codeOwners, "/archive").Should().BeEmpty("a terminal /** means content inside the directory"); + Match(codeOwners, "/archive/file.txt").Should().Equal(["@globstar"]); + Match(codeOwners, "/archive/deep/file.txt").Should().Equal(["@globstar"]); + } + [SkippableFact] public void GithubRootedVersusUnrootedPatterns() { @@ -77,6 +96,26 @@ public void GithubRootedVersusUnrootedPatterns() Match(Create("apps/ @anywhere\n", CodeOwners.Platform.GitHub), "/x/apps/a.go").Should().Equal(["@anywhere"]); } + [SkippableFact] + public void GithubPatternsWithMiddleSlashAreRootedWhileGitLabPatternsRemainRelative() + { + const string content = "* @global\ndocs/* @docs\na/**/b @globstar\napps/ @apps\n**/logs @logs\n"; + + var github = Create(content, CodeOwners.Platform.GitHub); + Match(github, "/docs/a.md").Should().Equal(["@docs"]); + Match(github, "/examples/docs/a.md").Should().Equal(["@global"]); + Match(github, "/a/b").Should().Equal(["@globstar"]); + Match(github, "/a/x/b").Should().Equal(["@globstar"]); + Match(github, "/x/a/b").Should().Equal(["@global"]); + Match(github, "/x/apps/a.md").Should().Equal(["@apps"]); + Match(github, "/x/logs/a.md").Should().Equal(["@logs"]); + + var gitlab = Create(content, CodeOwners.Platform.GitLab); + Match(gitlab, "/docs/a.md").Should().Equal(["@docs"]); + Match(gitlab, "/examples/docs/a.md").Should().Equal(["@docs"]); + Match(gitlab, "/x/a/b").Should().Equal(["@globstar"]); + } + [SkippableFact] public void GithubGlobstarDirectoryPatternOwnsDirectoryContents() { @@ -246,6 +285,378 @@ public void DuplicateEntriesUseLastWithinSection() Match(codeOwners, "/README.md").Should().Equal(["@new"]); } + [SkippableFact] + public void GitLabLastDuplicatePatternReplacesEarlierEntriesIncludingExclusions() + { + var exactDuplicates = Create("[Ruby]\n*.rb @old\n!*.rb\n*.rb @new\n", CodeOwners.Platform.GitLab); + Match(exactDuplicates, "/model.rb").Should().Equal(["@new"]); + + // GitLab normalizes these spellings to the same pattern key before replacing duplicates. + var normalizedDuplicates = Create("* @old\n!/**/*\n* @new\n", CodeOwners.Platform.GitLab); + Match(normalizedDuplicates, "/nested/file.cs").Should().Equal(["@new"]); + } + + [SkippableFact] + public void GitLabDuplicateNormalizationUnescapesLeadingHashBeforeReplacement() + { + var laterOwner = Create("!#file\n\\#file @new\n", CodeOwners.Platform.GitLab); + Match(laterOwner, "/#file").Should().Equal(["@new"]); + + var laterExclusion = Create("\\#file @old\n!#file\n", CodeOwners.Platform.GitLab); + Match(laterExclusion, "/#file").Should().BeEmpty(); + } + + [SkippableFact] + public void GitLabCharacterClassesSupportSetsRangesAndNegationWithoutCrossingDirectories() + { + var content = "digit[0-9].txt @digit\nletter[ab].txt @letter\nnon-digit[!0-9].txt @non-digit\npath[!x]file @same-segment\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/digit7.txt").Should().Equal(["@digit"]); + Match(codeOwners, "/digitx.txt").Should().BeEmpty(); + Match(codeOwners, "/lettera.txt").Should().Equal(["@letter"]); + Match(codeOwners, "/letterc.txt").Should().BeEmpty(); + Match(codeOwners, "/non-digita.txt").Should().Equal(["@non-digit"]); + Match(codeOwners, "/non-digit7.txt").Should().BeEmpty(); + Match(codeOwners, "/path/file").Should().BeEmpty(); + } + + [SkippableFact] + public void GitLabMalformedCharacterClassesCannotAbortParserInitialization() + { + var content = "* @fallback\nfile[z-a].txt @invalid-range\nbroken\\\nfile[---!].txt @punctuation\n"; + + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + codeOwners.ParsingDiagnosticsCount.Should().Be(2, "invalid rules should produce one aggregated parsing diagnostic count"); + Match(codeOwners, "/filex.txt").Should().Equal(["@fallback"]); + Match(codeOwners, "/file-.txt").Should().Equal(["@punctuation"]); + Match(codeOwners, "/file!.txt").Should().Equal(["@punctuation"]); + } + + [SkippableFact] + public void GitLabBackslashEscapesGlobMetacharactersAndOrdinaryCharacters() + { + var content = """ + file\?.txt @question + literal\*.txt @star + letter\a.txt @ordinary + bracket[\]].txt @closing-bracket + hyphen[\-].txt @hyphen + """; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file?.txt").Should().Equal(["@question"]); + Match(codeOwners, "/fileX.txt").Should().BeEmpty(); + Match(codeOwners, "/literal*.txt").Should().Equal(["@star"]); + Match(codeOwners, "/literal-value.txt").Should().BeEmpty(); + Match(codeOwners, "/lettera.txt").Should().Equal(["@ordinary"]); + Match(codeOwners, "/bracket].txt").Should().Equal(["@closing-bracket"]); + Match(codeOwners, "/hyphen-.txt").Should().Equal(["@hyphen"]); + } + + [SkippableFact] + public void GithubBackslashEscapesMetacharactersSpacesAndInlineCommentMarkers() + { + var content = """ + literal\*.txt @star + file\?.txt @question + bracket\[name\].txt @bracket + path\ with\ spaces/ @spaces + middle\#hash.txt @hash + trailing\ + """; + var codeOwners = Create(content, CodeOwners.Platform.GitHub); + + Match(codeOwners, "/literal*.txt").Should().Equal(["@star"]); + Match(codeOwners, "/literal-value.txt").Should().BeEmpty(); + Match(codeOwners, "/file?.txt").Should().Equal(["@question"]); + Match(codeOwners, "/fileX.txt").Should().BeEmpty(); + Match(codeOwners, "/bracket[name].txt").Should().Equal(["@bracket"]); + Match(codeOwners, "/path with spaces/file.txt").Should().Equal(["@spaces"]); + Match(codeOwners, "/middle#hash.txt").Should().Equal(["@hash"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(1, "a trailing backslash is an invalid glob"); + } + + [SkippableFact] + public void WhitespaceOnlyGitLabSectionHeaderStartsInvalidNamedSection() + { + var content = "[Docs] @docs\n[ ] @blank\nREADME.md\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/README.md").Should().Equal(["@blank"]); + Match(codeOwners, "/guide.md").Should().BeEmpty(); + codeOwners.ParsingDiagnosticsCount.Should().Be(1, "GitLab accepts the header but diagnoses its missing name"); + } + + [SkippableFact] + public void UnparsableGitLabSectionHeaderIsSkippedInsteadOfBecomingPattern() + { + var content = "* @global\n[Broken\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/[Broken").Should().Equal(["@global"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + + [SkippableFact] + public void GitLabMalformedSectionSuffixCannotLeakDefaultOwners() + { + var extraBracket = Create("[Docs]] @leaked\nREADME.md\n", CodeOwners.Platform.GitLab); + Match(extraBracket, "/README.md").Should().BeEmpty(); + extraBracket.ParsingDiagnosticsCount.Should().Be(2, "the malformed header and ownerless entry are diagnosed independently"); + + var invalidApproval = Create("[Docs][x] @leaked\nREADME.md\n", CodeOwners.Platform.GitLab); + Match(invalidApproval, "/README.md").Should().BeEmpty(); + invalidApproval.ParsingDiagnosticsCount.Should().Be(2); + } + + [SkippableFact] + public void GitLabPermissiveSectionParsingKeepsOnlyTheRecognizedOwnerSpan() + { + var codeOwners = Create("[Docs][2]@owner\nREADME.md\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/README.md").Should().Equal(["@owner"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(1, "the missing whitespace is diagnosed without discarding recognized defaults"); + } + + [SkippableTheory] + [InlineData("@@developer")] + [InlineData("@@developers")] + [InlineData("@@maintainer")] + [InlineData("@@maintainers")] + [InlineData("@@owner")] + [InlineData("@@OwNeRs")] + public void GitLabRecognizedRolesAreKeptAsOwners(string role) + { + var codeOwners = Create("*.cs " + role + "\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file.cs").Should().Equal([role]); + } + + [SkippableFact] + public void GitLabUnknownRolesAreIgnored() + { + var codeOwners = Create("*.cs @@banana @valid\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file.cs").Should().Equal(["@valid"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(1, "the invalid role was discarded from an otherwise valid rule"); + } + + [SkippableFact] + public void OwnerValidationFollowsPlatformRulesAndDoesNotApplyDefaultsToMalformedExplicitOwners() + { + var github = Create("* @global\n*.cs docs@\n*.fs @@maintainer\n", CodeOwners.Platform.GitHub); + Match(github, "/file.cs").Should().Equal(["@global"]); + Match(github, "/file.fs").Should().Equal(["@global"]); + github.ParsingDiagnosticsCount.Should().Be(2, "GitHub rejects each complete rule that contains an invalid owner"); + + var gitlab = Create("[Docs] @default malformed@\n*.md malformed@ @valid\nREADME.md malformed@\nGUIDE.md\n", CodeOwners.Platform.GitLab); + Match(gitlab, "/other.md").Should().Equal(["@valid"]); + Match(gitlab, "/README.md").Should().BeEmpty(); + Match(gitlab, "/GUIDE.md").Should().Equal(["@default"]); + gitlab.ParsingDiagnosticsCount.Should().Be(3, "invalid owners in defaults and rules are all diagnosed while valid owners remain usable"); + } + + [SkippableFact] + public void OwnerExtractionRejectsImpossibleGithubReferencesAndCanonicalizesGitLabReferences() + { + var github = Create("* @fallback\n*.cs @!\n*.fs user@example.\n*.vb (@valid)\n*.ts @bad_owner\n*.go @org/team/nested\n", CodeOwners.Platform.GitHub); + Match(github, "/file.cs").Should().Equal(["@fallback"]); + Match(github, "/file.fs").Should().Equal(["@fallback"]); + Match(github, "/file.vb").Should().Equal(["@fallback"]); + Match(github, "/file.ts").Should().Equal(["@fallback"]); + Match(github, "/file.go").Should().Equal(["@fallback"]); + github.ParsingDiagnosticsCount.Should().Be(5); + + var gitlab = Create("*.cs @! @good\n*.md (@docs)\n*.txt docs@example.\n*.go @group/nested-team\n", CodeOwners.Platform.GitLab); + Match(gitlab, "/file.cs").Should().Equal(["@good"]); + Match(gitlab, "/file.md").Should().Equal(["@docs"]); + Match(gitlab, "/file.txt").Should().Equal(["docs@example"]); + Match(gitlab, "/file.go").Should().Equal(["@group/nested-team"]); + gitlab.ParsingDiagnosticsCount.Should().Be(1, "only the token without an extractable reference is malformed"); + } + + [SkippableFact] + public void GitLabReferenceExtractionScansNamesRolesAndEmailsIndependently() + { + var content = "*.cs docs@example.com,@alice\n*.fs alice@example.com!alias\n*.vb (@@maintainer\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file.cs").Should().Equal(["@alice", "docs@example.com"]); + Match(codeOwners, "/file.fs").Should().Equal(["alice@example.com!alias"]); + Match(codeOwners, "/file.vb").Should().Equal(["@@maintainer"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(0); + } + + [SkippableFact] + public void GitLabExclusionsIgnoreOwnerTextForDiagnostics() + { + var codeOwners = Create("!*.cs definitely-not-an-owner\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file.cs").Should().BeEmpty(); + codeOwners.ParsingDiagnosticsCount.Should().Be(0); + } + + [SkippableFact] + public void GitLabOwnerlessEntriesAreDiagnosedWithoutChangingMatchingSemantics() + { + var codeOwners = Create("*.md\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/README.md").Should().BeEmpty(); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void EscapedSlashesRemainPathSeparators(bool useGitLab) + { + var platform = useGitLab ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + var codeOwners = Create("dir\\/file.txt @file\ndocs\\/ @docs\n", platform); + + Match(codeOwners, "/dir/file.txt").Should().Equal(["@file"]); + Match(codeOwners, "/docs/guide.md").Should().Equal(["@docs"]); + if (useGitLab) + { + Match(codeOwners, "/nested/dir/file.txt").Should().Equal(["@file"]); + } + else + { + Match(codeOwners, "/nested/dir/file.txt").Should().BeEmpty("a slash roots a GitHub pattern"); + } + + codeOwners.ParsingDiagnosticsCount.Should().Be(0); + } + + [SkippableFact] + public void FormerGlobstarSentinelTextIsMatchedLiterally() + { + var codeOwners = Create("* @global\n§§DOUBLESTAR§§ @literal\n", CodeOwners.Platform.GitHub); + + Match(codeOwners, "/§§DOUBLESTAR§§").Should().Equal(["@literal"]); + Match(codeOwners, "/anything-else").Should().Equal(["@global"]); + } + + [SkippableFact] + public void PathologicalPatternMatchingIsDeterministicBoundedAndThreadSafe() + { + var pathologicalPattern = string.Concat(Enumerable.Repeat("*a", 32)) + "b"; + var codeOwners = Create("* @global\n" + pathologicalPattern + " @slow\n", CodeOwners.Platform.GitHub); + var nonMatchingPath = "/" + new string('a', 2_000) + "c"; + var matchingPath = "/" + new string('a', 32) + "b"; + + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < 100; i++) + { + Match(codeOwners, nonMatchingPath).Should().Equal(["@global"]); + } + + stopwatch.Stop(); + stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "glob matching has bounded, non-backtracking cost"); + Match(codeOwners, matchingPath).Should().Equal(["@slow"], "a difficult non-match must not disable the rule globally"); + + var concurrentMatches = Enumerable.Range(0, 64) + .Select(_ => Task.Run(() => Match(codeOwners, nonMatchingPath))) + .ToArray(); + Task.WaitAll(concurrentMatches, TestTimeout).Should().BeTrue("concurrent matching must not deadlock"); + foreach (var concurrentMatch in concurrentMatches) + { + concurrentMatch.Result.Should().Equal(["@global"]); + } + } + + [SkippableFact] + public void LargeUniqueOwnerListsHaveLinearRepeatedMatchCost() + { + const int ownerCount = 5_000; + var owners = string.Join(" ", Enumerable.Range(0, ownerCount).Select(i => "@owner" + i)); + var codeOwners = Create("*.cs " + owners + "\n", CodeOwners.Platform.GitHub); + + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < 20; i++) + { + codeOwners.Match("/file.cs").Count().Should().Be(ownerCount); + } + + stopwatch.Stop(); + stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "owners are deduplicated once during parsing, not quadratically on every match"); + } + + [SkippableFact] + public void DuplicateOwnersAreDeduplicatedOnceInStableOrder() + { + var codeOwners = Create("*.cs @first @second @first @third @second\n", CodeOwners.Platform.GitHub); + + codeOwners.Match("/file.cs").Should().Equal(["@first", "@second", "@third"]); + } + + [SkippableFact] + public void LargeGitLabDuplicatePatternSetsAreCompactedLinearly() + { + const int patternCount = 20_000; + var firstDefinitions = Enumerable.Range(0, patternCount).Select(i => $"/path/{i}.cs @old"); + var replacements = Enumerable.Range(0, patternCount).Select(i => $"/path/{i}.cs @new"); + var content = string.Join("\n", firstDefinitions.Concat(replacements)) + "\n"; + + var path = WriteTemporaryCodeOwners(content); + try + { + var stopwatch = Stopwatch.StartNew(); + var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab); + stopwatch.Stop(); + + stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "duplicate replacement is a single reverse pass instead of repeated List.Remove calls"); + Match(codeOwners, "/path/0.cs").Should().Equal(["@new"]); + Match(codeOwners, $"/path/{patternCount - 1}.cs").Should().Equal(["@new"]); + } + finally + { + File.Delete(path); + } + } + + [SkippableFact] + public void GitLabDuplicateSectionsAreCombinedCaseInsensitively() + { + var content = "[Docs]\n*.md @old\n[DOCS]\nREADME.md @new\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/README.md").Should().Equal(["@new"]); + Match(codeOwners, "/guide.md").Should().Equal(["@old"]); + } + + [SkippableFact] + public void GitLabDuplicateSectionExclusionsAreStickyAcrossOccurrences() + { + var content = "[Ruby]\n*.rb @ruby-team\n[RUBY]\n!/config/**/*.rb\n/config/routes.rb @ops\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/lib/model.rb").Should().Equal(["@ruby-team"]); + Match(codeOwners, "/config/routes.rb").Should().BeEmpty(); + } + + [SkippableFact] + public void GitLabDuplicateSectionDefaultsApplyToEntriesUnderEachHeader() + { + var content = "[Docs] @old-default\n*.md\n[DOCS] @new-default\nREADME.md\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitLab); + + Match(codeOwners, "/guide.md").Should().Equal(["@old-default"]); + Match(codeOwners, "/README.md").Should().Equal(["@new-default"]); + } + + [SkippableFact] + public void UnsupportedGithubSyntaxIsIgnored() + { + var content = "* @global\n!secret.txt @negation\nfile[0].cs @range\n\\#hash.txt @hash\n"; + var codeOwners = Create(content, CodeOwners.Platform.GitHub); + + Match(codeOwners, "/!secret.txt").Should().Equal(["@global"]); + Match(codeOwners, "/file[0].cs").Should().Equal(["@global"]); + Match(codeOwners, "/#hash.txt").Should().Equal(["@global"]); + } + [SkippableFact] public void RealWorldHashicorpTerraformGitHubFile() { @@ -369,8 +780,8 @@ public void DoubleStarIsGlobstarOnlyAsAWholeSegment() [SkippableFact] public void DescendantMatchingIsStableAcrossRepeatedCalls() { - // The descendant glob variant is compiled lazily on first use and cached; repeated matches - // must keep returning the same results (e.g. no recompilation or caching regression). + // Direct directory matches and descendants share one deterministic glob; repeated calls + // must preserve both forms without recompilation or an ancestor walk. var codeOwners = Create("**/logs @octocat\n", CodeOwners.Platform.GitHub); for (var i = 0; i < 3; i++) { @@ -383,8 +794,7 @@ public void DescendantMatchingIsStableAcrossRepeatedCalls() private static CodeOwners Create(string content, CodeOwners.Platform platform) { - var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-spec-" + Guid.NewGuid().ToString("N")); - File.WriteAllText(path, content); + var path = WriteTemporaryCodeOwners(content); try { @@ -396,6 +806,13 @@ private static CodeOwners Create(string content, CodeOwners.Platform platform) } } + private static string WriteTemporaryCodeOwners(string content) + { + var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-spec-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(path, content); + return path; + } + private static string[] Match(CodeOwners codeOwners, string path) => codeOwners.Match(path).OrderBy(o => o, StringComparer.Ordinal).ToArray(); } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs index 358804d92735..02efd5d9a41e 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs @@ -36,7 +36,7 @@ public CodeOwnersTests() [InlineData("apps/test.cs", "[\"@octocat\"]")] [InlineData("/example/apps/test.cs", "[\"@octocat\"]")] [InlineData("/docs/test.cs", "[\"@doctocat\"]")] - [InlineData("/examples/docs/test.cs", "[\"docs@example.com\"]")] + [InlineData("/examples/docs/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] [InlineData("/src/vendor/match.go", "[\"docs@example.com\"]")] [InlineData("/examples/docs/inside/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] [InlineData("/component/path/test.js", "[\"@js-owner\"]")] @@ -48,6 +48,7 @@ public CodeOwnersTests() [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] [InlineData(@"apps\test.cs", "[\"@octocat\"]")] [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")] + [InlineData(@"\examples\docs\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")] [InlineData(@"docs\getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")] [InlineData(@"\apps\github", null)] From 49102e906db2381d05cec2a51be611fbdfa8fd42 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 23:08:56 +0200 Subject: [PATCH 11/25] [CI Visibility] Handle CODEOWNERS loading and owner edge cases --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 47 +++++-- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 118 +++++++++++++++--- .../Ci/CodeOwnersFallbackTests.cs | 69 ++++++---- .../Ci/CodeOwnersSpecTests.cs | 81 +++++++++++- 4 files changed, 262 insertions(+), 53 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 8aae02a3acb7..1c4f0114e106 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -134,6 +134,12 @@ public string? GitSearchFolder internal string? CodeOwnersRoot => Volatile.Read(ref _codeOwnersState)?.Root; + // Test-only synchronization hooks. They are null in production and run only on the uncommon + // paths that wait for an active reload or perform fallback discovery. + internal Action? BeforeCodeOwnersReloadWait { get; set; } + + internal Action? BeforeCodeOwnersFallbackLock { get; set; } + public Dictionary? VariablesToBypass { get; protected set; } public MetricTags.CIVisibilityTestSessionProvider MetricTag { get; protected set; } = MetricTags.CIVisibilityTestSessionProvider.Unsupported; @@ -586,7 +592,7 @@ private void ReloadEnvironmentDataCore() if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath)) { Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); - PublishCodeOwners(codeOwnersPath, platform, SourceRoot!); + TryPublishCodeOwners(codeOwnersPath, platform, SourceRoot!); } } } @@ -640,6 +646,7 @@ internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath var reloadVersion = Volatile.Read(ref _environmentReloadVersion); if ((reloadVersion & 1) != 0) { + BeforeCodeOwnersReloadWait?.Invoke(); lock (_codeOwnersLock) { } @@ -883,6 +890,7 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) return; } + BeforeCodeOwnersFallbackLock?.Invoke(); lock (_codeOwnersLock) { if (Volatile.Read(ref _codeOwnersState) is not null) @@ -921,6 +929,8 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } + var searchStartKey = directoryInfo.FullName; + // Limit cache growth to avoid unbounded memory in large test suites. if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit) { @@ -928,7 +938,7 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor } // Skip repeated lookups for the same starting directory. - if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName)) + if (!_codeOwnersSearchStarts.Add(searchStartKey)) { return false; } @@ -948,8 +958,15 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor { if (isRepositoryBoundary) { - PublishFallbackCodeOwners(codeOwnersPath, platform, directoryInfo.FullName); - return true; + if (PublishFallbackCodeOwners(codeOwnersPath, platform, directoryInfo.FullName)) + { + return true; + } + + // I/O failures can be transient (for example an editor replacing the file). + // Do not cache them as a permanent negative lookup. + _codeOwnersSearchStarts.Remove(searchStartKey); + return false; } nearestCodeOwnersPath ??= codeOwnersPath; @@ -968,23 +985,33 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor if (nearestCodeOwnersPath is not null && nearestCodeOwnersRoot is not null) { - PublishFallbackCodeOwners(nearestCodeOwnersPath, platform, nearestCodeOwnersRoot); - return true; + if (PublishFallbackCodeOwners(nearestCodeOwnersPath, platform, nearestCodeOwnersRoot)) + { + return true; + } + + _codeOwnersSearchStarts.Remove(searchStartKey); } return false; } - private void PublishFallbackCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) + private bool PublishFallbackCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) { Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); - PublishCodeOwners(codeOwnersPath, platform, root); + return TryPublishCodeOwners(codeOwnersPath, platform, root); } - private void PublishCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) + private bool TryPublishCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) { - var state = new CodeOwnersState(new CodeOwners(codeOwnersPath, platform), root, platform); + if (!CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) + { + return false; + } + + var state = new CodeOwnersState(parser, root, platform); Volatile.Write(ref _codeOwnersState, state); + return true; } private CodeOwners.Platform GetCodeOwnersPlatform() diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index a241649f68d6..daf3a1d551b6 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -7,8 +7,10 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; +using System.Security; using System.Text; using System.Text.RegularExpressions; using Datadog.Trace.Logging; @@ -26,6 +28,8 @@ namespace Datadog.Trace.Ci /// internal sealed class CodeOwners { + internal const long GitHubMaximumFileSizeBytes = 3 * 1024 * 1024; + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); private static readonly Regex SectionHeaderRegex = new( @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?", @@ -35,10 +39,6 @@ internal sealed class CodeOwners @"^\^?\[[^\]]+\](?:\[\d+\])?(?:\s+[@\w.\-/\s]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GitLabEmailReferenceRegex = new( - @"[^@\s]{1,100}@[^@\s]{1,255}(? GitHubMaximumFileSizeBytes) + { + _sections = []; + Log.Warning( + "GitHub CODEOWNERS file exceeds the {MaximumSize} byte limit and will be ignored: {Path}", + GitHubMaximumFileSizeBytes, + filePath); + return; + } + _sections = Parse(File.ReadLines(filePath), platform, out var parsingDiagnosticsCount); ParsingDiagnosticsCount = parsingDiagnosticsCount; if (parsingDiagnosticsCount > 0) @@ -67,6 +77,21 @@ public CodeOwners(string filePath, Platform platform) internal int ParsingDiagnosticsCount { get; } + internal static bool TryLoad(string filePath, Platform platform, [NotNullWhen(true)] out CodeOwners? codeOwners) + { + try + { + codeOwners = new CodeOwners(filePath, platform); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + Log.Warning(ex, "Unable to load CODEOWNERS file; ownership matching will be skipped: {Path}", filePath); + codeOwners = null; + return false; + } + } + /// /// Returns the complete, de‑duplicated owner set that applies to . /// Callers can post‑process the set depending on platform‑specific approval rules. @@ -409,7 +434,7 @@ private static bool IsValidGitHubOwner(string token) private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) { // Keep the overwhelmingly common canonical forms allocation-light. - if (IsValidNamespaceReference(token) || IsValidGitLabRole(token) || IsWholeGitLabEmailReference(token)) + if (IsValidNamespaceReference(token) || IsValidGitLabRole(token)) { AddUnique(owners, uniqueOwners, token); return true; @@ -434,16 +459,18 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS foundReference = true; } - var emailMatches = GitLabEmailReferenceRegex.Matches(token); - for (var i = 0; i < emailMatches.Count; i++) + searchStart = 0; + while (TryExtractGitLabEmailReference(token, searchStart, out var emailStart, out var emailEnd, out searchStart)) { - var emailMatch = emailMatches[i]; + var email = emailStart == 0 && emailEnd == token.Length + ? token + : token.Substring(emailStart, emailEnd - emailStart); // GitLab's permissive email expression can overlap a namespace reference // (for example "(@team"). Such a value cannot resolve as an email, while // the namespace extracted independently can resolve, so keep only the latter. - if (!ContainsNamespaceReference(emailMatch.Value)) + if (!ContainsNamespaceReference(email)) { - AddUnique(owners, uniqueOwners, emailMatch.Value); + AddUnique(owners, uniqueOwners, email); foundReference = true; } } @@ -505,7 +532,7 @@ private static bool IsValidGitHubIdentifier(string value, int start, int end) for (var i = start; i < end; i++) { var character = value[i]; - if (!IsAsciiLetterOrDigit(character) && character != '-') + if (!IsAsciiLetterOrDigit(character) && character is not '-' and not '_') { return false; } @@ -606,10 +633,60 @@ private static bool IsWholeEmailReference(string token) => TryExtractEmailReference(token, out var reference) && reference.Length == token.Length; - private static bool IsWholeGitLabEmailReference(string token) + private static bool TryExtractGitLabEmailReference( + string token, + int searchStart, + out int referenceStart, + out int referenceEnd, + out int nextSearchStart) { - var match = GitLabEmailReferenceRegex.Match(token); - return match.Success && match.Index == 0 && match.Length == token.Length; + for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) + { + var localStart = atIndex - 1; + var localLength = 0; + while (localStart >= searchStart && + localLength < 100 && + token[localStart] != '@' && + !char.IsWhiteSpace(token[localStart])) + { + localStart--; + localLength++; + } + + localStart++; + if (localLength == 0) + { + continue; + } + + var domainEnd = atIndex + 1; + var domainLimit = Math.Min(token.Length, domainEnd + 255); + var lastWordEnd = -1; + while (domainEnd < domainLimit && token[domainEnd] != '@' && !char.IsWhiteSpace(token[domainEnd])) + { + if (IsRegexWordCharacter(token[domainEnd])) + { + lastWordEnd = domainEnd + 1; + } + + domainEnd++; + } + + if (lastWordEnd <= atIndex + 1) + { + continue; + } + + referenceStart = localStart; + referenceEnd = lastWordEnd; + nextSearchStart = lastWordEnd; + return true; + } + + referenceStart = -1; + referenceEnd = -1; + nextSearchStart = token.Length; + return false; } private static bool TryExtractEmailReference(string token, [NotNullWhen(true)] out string? reference) @@ -666,7 +743,7 @@ private static bool IsNamespaceCharacter(char character) => IsNamespaceStart(character) || character == '-'; private static bool IsNamespaceEnd(char character) - => IsAsciiLetterOrDigit(character) || character == '_'; + => IsAsciiLetterOrDigit(character) || character is '_' or '-'; private static bool IsEmailLocalCharacter(char character) => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; @@ -677,6 +754,17 @@ private static bool IsEmailDomainCharacter(char character) private static bool IsWordCharacter(char character) => char.IsLetterOrDigit(character) || character == '_'; + private static bool IsRegexWordCharacter(char character) + { + if (char.IsLetterOrDigit(character)) + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; + } + private static bool IsAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 2a51301a26bc..cfc06f2ec1ca 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -404,6 +404,37 @@ public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile() Assert.Null(ciValues.CodeOwners); } + [SkippableFact] + public void LockedCodeOwnersDoesNotBreakFallbackPathResolution() + { + Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); + + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var sourceDirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); + File.WriteAllText(sourceFile, "class Test {}"); + var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); + File.WriteAllText(codeOwnersPath, "*.cs @owner\n"); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + + string[]? owners = null; + using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var exception = Record.Exception(() => ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!)); + + Assert.Null(exception); + Assert.Empty(owners!); + Assert.Null(ciValues.CodeOwners); + } + + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); + Assert.Equal(["@owner"], owners); + Assert.NotNull(ciValues.CodeOwners); + } + [SkippableFact] public void AnchorsForeignRelativePathsToCodeOwnersRoot() { @@ -682,8 +713,9 @@ public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() using var setupEntered = new ManualResetEventSlim(initialState: false); using var continueSetup = new ManualResetEventSlim(initialState: false); - using var matchStarted = new ManualResetEventSlim(initialState: false); + using var reloadWaitReached = new ManualResetEventSlim(initialState: false); var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.BeforeCodeOwnersReloadWait = reloadWaitReached.Set; ciValues.Reload(); const string foreignSourcePath = "../../layoutA/src/SpanBenchmark.cs"; @@ -696,17 +728,11 @@ public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() Task? matchTask = null; string? secondRelativePath = null; string[]? secondOwners = null; - var completedDuringReload = false; try { Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); - matchTask = Task.Run(() => - { - matchStarted.Set(); - secondRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out secondOwners); - }); - Assert.True(matchStarted.Wait(TestTimeout), "snapshot reader did not start"); - completedDuringReload = matchTask.Wait(TimeSpan.FromMilliseconds(250)); + matchTask = Task.Run(() => secondRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out secondOwners)); + Assert.True(reloadWaitReached.Wait(TestTimeout), "snapshot reader did not reach the active-reload wait"); } finally { @@ -716,7 +742,6 @@ public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); Assert.NotNull(matchTask); Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); - Assert.False(completedDuringReload, "the reader must not observe partially reloaded state"); Assert.Equal("src/SpanBenchmark.cs", secondRelativePath); Assert.Equal(["@second"], secondOwners); } @@ -737,8 +762,9 @@ public void RelativePathUsesCompletedReloadStateWhenNewRepositoryHasNoCodeOwners using var setupEntered = new ManualResetEventSlim(initialState: false); using var continueSetup = new ManualResetEventSlim(initialState: false); - using var matchStarted = new ManualResetEventSlim(initialState: false); + using var reloadWaitReached = new ManualResetEventSlim(initialState: false); var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.BeforeCodeOwnersReloadWait = reloadWaitReached.Set; ciValues.Reload(); ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); @@ -746,17 +772,11 @@ public void RelativePathUsesCompletedReloadStateWhenNewRepositoryHasNoCodeOwners Task? matchTask = null; string? relativePath = null; string[]? owners = null; - var completedDuringReload = false; try { Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); - matchTask = Task.Run(() => - { - matchStarted.Set(); - relativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(secondSource, false, out owners); - }); - Assert.True(matchStarted.Wait(TestTimeout), "snapshot reader did not start"); - completedDuringReload = matchTask.Wait(TimeSpan.FromMilliseconds(250)); + matchTask = Task.Run(() => relativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(secondSource, false, out owners)); + Assert.True(reloadWaitReached.Wait(TestTimeout), "snapshot reader did not reach the active-reload wait"); } finally { @@ -766,7 +786,6 @@ public void RelativePathUsesCompletedReloadStateWhenNewRepositoryHasNoCodeOwners Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); Assert.NotNull(matchTask); Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); - Assert.False(completedDuringReload, "the reader must wait for the SourceRoot transition"); Assert.Equal("src/SpanBenchmark.cs", relativePath); Assert.Empty(owners!); Assert.Null(ciValues.CodeOwners); @@ -800,9 +819,10 @@ public void ReloadAndFallbackDiscoveryAreSerialized() var secondSource = CreateRepository(secondRepository.RootPath, "@second"); using var setupEntered = new ManualResetEventSlim(initialState: false); using var continueSetup = new ManualResetEventSlim(initialState: false); - using var fallbackStarted = new ManualResetEventSlim(initialState: false); + using var fallbackLockReached = new ManualResetEventSlim(initialState: false); var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); + ciValues.BeforeCodeOwnersFallbackLock = fallbackLockReached.Set; ciValues.Reload(); Assert.True(ciValues.TryGetCodeOwnersRelativePath(firstSource, false, out _)); @@ -810,19 +830,15 @@ public void ReloadAndFallbackDiscoveryAreSerialized() var reloadTask = Task.Run(ciValues.Reload); Task? fallbackTask = null; var fallbackResult = false; - var completedDuringReload = false; try { Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); fallbackTask = Task.Run(() => { - fallbackStarted.Set(); fallbackResult = ciValues.TryGetCodeOwnersRelativePath(secondSource, false, out var relativePath) && relativePath == "src/SpanBenchmark.cs"; }); - Assert.True(fallbackStarted.Wait(TestTimeout), "fallback task did not start"); - - completedDuringReload = fallbackTask.Wait(TimeSpan.FromMilliseconds(250)); + Assert.True(fallbackLockReached.Wait(TestTimeout), "fallback lookup did not reach the serialized discovery lock"); } finally { @@ -833,7 +849,6 @@ public void ReloadAndFallbackDiscoveryAreSerialized() Assert.NotNull(fallbackTask); Assert.True(fallbackTask!.Wait(TestTimeout), "fallback lookup must not deadlock after reload"); - Assert.False(completedDuringReload); Assert.True(fallbackResult); Assert.Equal(secondRepository.RootPath, ciValues.CodeOwnersRoot); Assert.Equal(["@second"], ciValues.CodeOwners!.Match("/src/SpanBenchmark.cs")); diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index 5b6791509a4b..a5d573ec34c7 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using Datadog.Trace.Ci; using FluentAssertions; @@ -461,7 +462,7 @@ public void OwnerValidationFollowsPlatformRulesAndDoesNotApplyDefaultsToMalforme [SkippableFact] public void OwnerExtractionRejectsImpossibleGithubReferencesAndCanonicalizesGitLabReferences() { - var github = Create("* @fallback\n*.cs @!\n*.fs user@example.\n*.vb (@valid)\n*.ts @bad_owner\n*.go @org/team/nested\n", CodeOwners.Platform.GitHub); + var github = Create("* @fallback\n*.cs @!\n*.fs user@example.\n*.vb (@valid)\n*.ts @bad+owner\n*.go @org/team/nested\n", CodeOwners.Platform.GitHub); Match(github, "/file.cs").Should().Equal(["@fallback"]); Match(github, "/file.fs").Should().Equal(["@fallback"]); Match(github, "/file.vb").Should().Equal(["@fallback"]); @@ -477,6 +478,26 @@ public void OwnerExtractionRejectsImpossibleGithubReferencesAndCanonicalizesGitL gitlab.ParsingDiagnosticsCount.Should().Be(1, "only the token without an extractable reference is malformed"); } + [SkippableFact] + public void GithubAcceptsEnterpriseManagedUserNames() + { + var codeOwners = Create("*.cs @mona-cat_octo\n*.fs @octo_admin\n", CodeOwners.Platform.GitHub); + + Match(codeOwners, "/file.cs").Should().Equal(["@mona-cat_octo"]); + Match(codeOwners, "/file.fs").Should().Equal(["@octo_admin"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(0); + } + + [SkippableFact] + public void GitLabNamespaceReferencesMayEndWithHyphens() + { + var codeOwners = Create("*.cs @team-\n*.fs (@group-/subgroup-)\n", CodeOwners.Platform.GitLab); + + Match(codeOwners, "/file.cs").Should().Equal(["@team-"]); + Match(codeOwners, "/file.fs").Should().Equal(["@group-/subgroup-"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(0); + } + [SkippableFact] public void GitLabReferenceExtractionScansNamesRolesAndEmailsIndependently() { @@ -583,6 +604,27 @@ public void LargeUniqueOwnerListsHaveLinearRepeatedMatchCost() stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "owners are deduplicated once during parsing, not quadratically on every match"); } + [SkippableFact] + public void LongMalformedGitLabOwnerTokensHaveBoundedParsingCost() + { + var malformedOwner = new string('x', 1_000_000); + var path = WriteTemporaryCodeOwners("*.cs " + malformedOwner + "\n"); + try + { + var stopwatch = Stopwatch.StartNew(); + var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab); + stopwatch.Stop(); + + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(2), "reference extraction uses bounded deterministic scans"); + Match(codeOwners, "/file.cs").Should().BeEmpty(); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + finally + { + File.Delete(path); + } + } + [SkippableFact] public void DuplicateOwnersAreDeduplicatedOnceInStableOrder() { @@ -616,6 +658,33 @@ public void LargeGitLabDuplicatePatternSetsAreCompactedLinearly() } } + [SkippableFact] + public void GithubIgnoresCodeOwnersFilesOverThreeMegabytesOnly() + { + var belowLimit = WriteTemporaryCodeOwnersWithLength(CodeOwners.GitHubMaximumFileSizeBytes - 1); + var aboveLimit = WriteTemporaryCodeOwnersWithLength(CodeOwners.GitHubMaximumFileSizeBytes + 1); + try + { + Match(new CodeOwners(belowLimit, CodeOwners.Platform.GitHub), "/file.cs").Should().Equal(["@owner"]); + Match(new CodeOwners(aboveLimit, CodeOwners.Platform.GitHub), "/file.cs").Should().BeEmpty(); + Match(new CodeOwners(aboveLimit, CodeOwners.Platform.GitLab), "/file.cs").Should().Equal(["@owner"]); + } + finally + { + File.Delete(belowLimit); + File.Delete(aboveLimit); + } + } + + [SkippableFact] + public void TryLoadReturnsFalseWhenCodeOwnersDisappearsBeforeOpening() + { + var missingPath = Path.Combine(Path.GetTempPath(), "dd-codeowners-missing-" + Guid.NewGuid().ToString("N")); + + CodeOwners.TryLoad(missingPath, CodeOwners.Platform.GitHub, out var codeOwners).Should().BeFalse(); + codeOwners.Should().BeNull(); + } + [SkippableFact] public void GitLabDuplicateSectionsAreCombinedCaseInsensitively() { @@ -813,6 +882,16 @@ private static string WriteTemporaryCodeOwners(string content) return path; } + private static string WriteTemporaryCodeOwnersWithLength(long length) + { + var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-sized-" + Guid.NewGuid().ToString("N")); + var prefix = Encoding.ASCII.GetBytes("*.cs @owner\n#"); + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + stream.Write(prefix, 0, prefix.Length); + stream.SetLength(length); + return path; + } + private static string[] Match(CodeOwners codeOwners, string path) => codeOwners.Match(path).OrderBy(o => o, StringComparer.Ordinal).ToArray(); } From e42ef3ebc153f7bd14da63f2d798e1f3b382b9ca Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 23:38:19 +0200 Subject: [PATCH 12/25] [CI Visibility] Bound CODEOWNERS fallback cache and retries --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 168 ++++++++++++++++-- .../Ci/CodeOwnersFallbackTests.cs | 94 ++++++++++ .../Ci/CodeOwnersSpecTests.cs | 4 +- 3 files changed, 247 insertions(+), 19 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 1c4f0114e106..c3341c108fde 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -26,6 +26,7 @@ internal abstract class CIEnvironmentValues { private const int CodeOwnersSearchCacheLimit = 256; internal const string RepositoryUrlPattern = @"((http|git|ssh|http(s)|file|\/?)|(git@[\w\.\-]+))(:(\/\/)?)([\w\.@\:/\-~]+)(\.git)?(\/)?"; + internal static readonly TimeSpan CodeOwnersLoadFailureRetryDelay = TimeSpan.FromSeconds(30); protected static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(CIEnvironmentValues)); private static readonly Lazy LazyInstance = new(Create); private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled); @@ -36,7 +37,8 @@ internal abstract class CIEnvironmentValues private static readonly char[] ForwardSlashCharacters = { '/' }; private readonly object _codeOwnersLock = new(); - private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); + private readonly Dictionary> _codeOwnersSearchCache = new(CodeOwnersSearchComparer); + private readonly LinkedList _codeOwnersSearchCacheOrder = new(); private CodeOwnersState? _codeOwnersState; private int _environmentReloadVersion; @@ -140,6 +142,10 @@ public string? GitSearchFolder internal Action? BeforeCodeOwnersFallbackLock { get; set; } + internal Action? CodeOwnersFallbackSearchStarting { get; set; } + + internal Func? CodeOwnersUtcNowProvider { get; set; } + public Dictionary? VariablesToBypass { get; protected set; } public MetricTags.CIVisibilityTestSessionProvider MetricTag { get; protected set; } = MetricTags.CIVisibilityTestSessionProvider.Unsupported; @@ -452,6 +458,22 @@ private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwn } } + private static CodeOwnersFileMetadata GetCodeOwnersFileMetadata(string path) + { + try + { + var file = new FileInfo(path); + file.Refresh(); + return file.Exists + ? new CodeOwnersFileMetadata(exists: true, file.Length, file.LastWriteTimeUtc.Ticks) + : default; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return default; + } + } + public void DecorateSpan(Span span) { if (span == null) @@ -557,7 +579,7 @@ private void ReloadEnvironmentDataCore() Message = null; SourceRoot = null; Volatile.Write(ref _codeOwnersState, null); - _codeOwnersSearchStarts.Clear(); + ClearCodeOwnersSearchCache(); Setup(string.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder!)); @@ -929,21 +951,14 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } - var searchStartKey = directoryInfo.FullName; - - // Limit cache growth to avoid unbounded memory in large test suites. - if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit) - { - _codeOwnersSearchStarts.Clear(); - } - - // Skip repeated lookups for the same starting directory. - if (!_codeOwnersSearchStarts.Add(searchStartKey)) + var repositoryBoundary = GetCodeOwnersSearchBoundary(directoryInfo, basePath); + var searchCacheKey = repositoryBoundary ?? directoryInfo.FullName; + if (ShouldSkipCodeOwnersSearch(searchCacheKey)) { return false; } - var repositoryBoundary = GetCodeOwnersSearchBoundary(directoryInfo, basePath); + CodeOwnersFallbackSearchStarting?.Invoke(); string? nearestCodeOwnersPath = null; string? nearestCodeOwnersRoot = null; @@ -963,9 +978,7 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return true; } - // I/O failures can be transient (for example an editor replacing the file). - // Do not cache them as a permanent negative lookup. - _codeOwnersSearchStarts.Remove(searchStartKey); + CacheCodeOwnersLoadFailure(searchCacheKey, codeOwnersPath); return false; } @@ -977,6 +990,7 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor { // A nested CODEOWNERS candidate is not valid for this repository. Do not fall // through to it when the actual repository root has no CODEOWNERS file. + CacheCodeOwnersSearch(searchCacheKey, failedCodeOwnersPath: null); return false; } @@ -990,12 +1004,75 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return true; } - _codeOwnersSearchStarts.Remove(searchStartKey); + CacheCodeOwnersLoadFailure(searchCacheKey, nearestCodeOwnersPath); + return false; } + CacheCodeOwnersSearch(searchCacheKey, failedCodeOwnersPath: null); return false; } + private bool ShouldSkipCodeOwnersSearch(string searchCacheKey) + { + if (!_codeOwnersSearchCache.TryGetValue(searchCacheKey, out var node)) + { + return false; + } + + var entry = node.Value; + if (entry.FailedCodeOwnersPath is null) + { + return true; + } + + var metadataUnchanged = entry.FileMetadata.Equals(GetCodeOwnersFileMetadata(entry.FailedCodeOwnersPath)); + if (metadataUnchanged && GetCodeOwnersUtcNow() < entry.RetryAfterUtc) + { + return true; + } + + RemoveCodeOwnersSearchCacheEntry(node); + return false; + } + + private void CacheCodeOwnersLoadFailure(string searchCacheKey, string codeOwnersPath) + => CacheCodeOwnersSearch(searchCacheKey, codeOwnersPath); + + private void CacheCodeOwnersSearch(string searchCacheKey, string? failedCodeOwnersPath) + { + if (_codeOwnersSearchCache.TryGetValue(searchCacheKey, out var existingNode)) + { + RemoveCodeOwnersSearchCacheEntry(existingNode); + } + + while (_codeOwnersSearchCache.Count >= CodeOwnersSearchCacheLimit) + { + RemoveCodeOwnersSearchCacheEntry(_codeOwnersSearchCacheOrder.First!); + } + + var entry = new CodeOwnersSearchCacheEntry( + searchCacheKey, + failedCodeOwnersPath, + failedCodeOwnersPath is null ? default : GetCodeOwnersFileMetadata(failedCodeOwnersPath), + failedCodeOwnersPath is null ? DateTime.MaxValue : GetCodeOwnersUtcNow().Add(CodeOwnersLoadFailureRetryDelay)); + var node = _codeOwnersSearchCacheOrder.AddLast(entry); + _codeOwnersSearchCache.Add(searchCacheKey, node); + } + + private void RemoveCodeOwnersSearchCacheEntry(LinkedListNode node) + { + _codeOwnersSearchCache.Remove(node.Value.Key); + _codeOwnersSearchCacheOrder.Remove(node); + } + + private void ClearCodeOwnersSearchCache() + { + _codeOwnersSearchCache.Clear(); + _codeOwnersSearchCacheOrder.Clear(); + } + + private DateTime GetCodeOwnersUtcNow() => CodeOwnersUtcNowProvider?.Invoke() ?? DateTime.UtcNow; + private bool PublishFallbackCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) { Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); @@ -1017,6 +1094,63 @@ private bool TryPublishCodeOwners(string codeOwnersPath, CodeOwners.Platform pla private CodeOwners.Platform GetCodeOwnersPlatform() => GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + private readonly struct CodeOwnersFileMetadata : IEquatable + { + public CodeOwnersFileMetadata(bool exists, long length, long lastWriteTimeUtcTicks) + { + Exists = exists; + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + } + + public bool Exists { get; } + + public long Length { get; } + + public long LastWriteTimeUtcTicks { get; } + + public bool Equals(CodeOwnersFileMetadata other) + => Exists == other.Exists && + Length == other.Length && + LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks; + + public override bool Equals(object? obj) => obj is CodeOwnersFileMetadata other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hashCode = Exists ? 1 : 0; + hashCode = (hashCode * 397) ^ Length.GetHashCode(); + hashCode = (hashCode * 397) ^ LastWriteTimeUtcTicks.GetHashCode(); + return hashCode; + } + } + } + + private sealed class CodeOwnersSearchCacheEntry + { + public CodeOwnersSearchCacheEntry( + string key, + string? failedCodeOwnersPath, + CodeOwnersFileMetadata fileMetadata, + DateTime retryAfterUtc) + { + Key = key; + FailedCodeOwnersPath = failedCodeOwnersPath; + FileMetadata = fileMetadata; + RetryAfterUtc = retryAfterUtc; + } + + public string Key { get; } + + public string? FailedCodeOwnersPath { get; } + + public CodeOwnersFileMetadata FileMetadata { get; } + + public DateTime RetryAfterUtc { get; } + } + private sealed class CodeOwnersState { public CodeOwnersState(CodeOwners parser, string root, CodeOwners.Platform platform) diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index cfc06f2ec1ca..521ca78cf549 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -419,6 +419,8 @@ public void LockedCodeOwnersDoesNotBreakFallbackPathResolution() var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); File.WriteAllText(codeOwnersPath, "*.cs @owner\n"); var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var utcNow = DateTime.UtcNow; + ciValues.CodeOwnersUtcNowProvider = () => utcNow; string[]? owners = null; using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) @@ -430,11 +432,103 @@ public void LockedCodeOwnersDoesNotBreakFallbackPathResolution() Assert.Null(ciValues.CodeOwners); } + // The file is readable again, but an unchanged failure is held during the backoff window. + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); + Assert.Empty(owners); + Assert.Null(ciValues.CodeOwners); + + utcNow += CIEnvironmentValues.CodeOwnersLoadFailureRetryDelay; ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); Assert.Equal(["@owner"], owners); Assert.NotNull(ciValues.CodeOwners); } + [SkippableFact] + public void NegativeFallbackCacheIsSharedByRepositoryBoundaryBeyondItsCapacity() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var searches = 0; + ciValues.CodeOwnersFallbackSearchStarting = () => searches++; + var sourceFiles = new List(); + + for (var i = 0; i <= 256; i++) + { + var sourceDirectory = Path.Combine(repoRoot, "project" + i, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); + File.WriteAllText(sourceFile, string.Empty); + sourceFiles.Add(sourceFile); + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + } + + foreach (var sourceFile in sourceFiles.AsEnumerable().Reverse()) + { + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + } + + Assert.Equal(1, searches); + } + + [SkippableFact] + public void ChangedCodeOwnersRetriesBeforeLoadFailureBackoffExpires() + { + Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); + + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var sourceDirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); + File.WriteAllText(sourceFile, string.Empty); + var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); + File.WriteAllText(codeOwnersPath, "*.cs @old\n"); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var utcNow = DateTime.UtcNow; + ciValues.CodeOwnersUtcNowProvider = () => utcNow; + + using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var owners); + Assert.Empty(owners); + } + + File.WriteAllText(codeOwnersPath, "*.cs @replacement-owner\n"); + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var changedOwners); + + Assert.Equal(["@replacement-owner"], changedOwners); + Assert.NotNull(ciValues.CodeOwners); + } + + [SkippableFact] + public void FallbackCacheEvictsOnlyTheOldestRepositoryAtCapacity() + { + using var tempDirectory = new TemporaryDirectory(); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: null); + var searches = 0; + ciValues.CodeOwnersFallbackSearchStarting = () => searches++; + var sourceFiles = new List(); + + for (var i = 0; i <= 256; i++) + { + var repository = Path.Combine(tempDirectory.RootPath, "repo" + i); + Directory.CreateDirectory(Path.Combine(repository, ".git")); + var sourceFile = Path.Combine(repository, "src", "Test.cs"); + sourceFiles.Add(sourceFile); + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); + } + + Assert.Equal(257, searches); + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFiles[1], false, out _)); + Assert.True(searches == 257, "the second-oldest entry must survive a single FIFO eviction"); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFiles[0], false, out _)); + Assert.True(searches == 258, "only the oldest entry should have been evicted"); + } + [SkippableFact] public void AnchorsForeignRelativePathsToCodeOwnersRoot() { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index a5d573ec34c7..def453251464 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -607,7 +607,7 @@ public void LargeUniqueOwnerListsHaveLinearRepeatedMatchCost() [SkippableFact] public void LongMalformedGitLabOwnerTokensHaveBoundedParsingCost() { - var malformedOwner = new string('x', 1_000_000); + var malformedOwner = new string('x', 4_000_000); var path = WriteTemporaryCodeOwners("*.cs " + malformedOwner + "\n"); try { @@ -615,7 +615,7 @@ public void LongMalformedGitLabOwnerTokensHaveBoundedParsingCost() var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab); stopwatch.Stop(); - stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(2), "reference extraction uses bounded deterministic scans"); + stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "the broad timeout guards against hangs without acting as a microbenchmark"); Match(codeOwners, "/file.cs").Should().BeEmpty(); codeOwners.ParsingDiagnosticsCount.Should().Be(1); } From d2040c0c55e5f1b85d1bfdd8dfbc161cf63a027e Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Sun, 23 Aug 2026 23:59:48 +0200 Subject: [PATCH 13/25] [CI Visibility] Make CODEOWNERS fallback and parsing resilient --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 21 ++++-- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 74 +++++++++++++++++-- .../Ci/CodeOwnersFallbackTests.cs | 73 +++++++++++++++++- .../Ci/CodeOwnersSpecTests.cs | 20 +++++ 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index c3341c108fde..4d730e88160a 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -11,6 +11,7 @@ using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Security; using System.Text.RegularExpressions; using System.Threading; using Datadog.Trace.Ci.Tags; @@ -26,7 +27,7 @@ internal abstract class CIEnvironmentValues { private const int CodeOwnersSearchCacheLimit = 256; internal const string RepositoryUrlPattern = @"((http|git|ssh|http(s)|file|\/?)|(git@[\w\.\-]+))(:(\/\/)?)([\w\.@\:/\-~]+)(\.git)?(\/)?"; - internal static readonly TimeSpan CodeOwnersLoadFailureRetryDelay = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan CodeOwnersSearchRetryDelay = TimeSpan.FromSeconds(30); protected static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(CIEnvironmentValues)); private static readonly Lazy LazyInstance = new(Create); private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled); @@ -144,6 +145,8 @@ public string? GitSearchFolder internal Action? CodeOwnersFallbackSearchStarting { get; set; } + internal Action? BeforeCodeOwnersFileMetadataRead { get; set; } + internal Func? CodeOwnersUtcNowProvider { get; set; } public Dictionary? VariablesToBypass { get; protected set; } @@ -458,17 +461,18 @@ private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwn } } - private static CodeOwnersFileMetadata GetCodeOwnersFileMetadata(string path) + private CodeOwnersFileMetadata GetCodeOwnersFileMetadata(string path) { try { + BeforeCodeOwnersFileMetadataRead?.Invoke(path); var file = new FileInfo(path); file.Refresh(); return file.Exists ? new CodeOwnersFileMetadata(exists: true, file.Length, file.LastWriteTimeUtc.Ticks) : default; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) { return default; } @@ -1020,13 +1024,14 @@ private bool ShouldSkipCodeOwnersSearch(string searchCacheKey) } var entry = node.Value; - if (entry.FailedCodeOwnersPath is null) + if (GetCodeOwnersUtcNow() >= entry.RetryAfterUtc) { - return true; + RemoveCodeOwnersSearchCacheEntry(node); + return false; } - var metadataUnchanged = entry.FileMetadata.Equals(GetCodeOwnersFileMetadata(entry.FailedCodeOwnersPath)); - if (metadataUnchanged && GetCodeOwnersUtcNow() < entry.RetryAfterUtc) + if (entry.FailedCodeOwnersPath is null || + entry.FileMetadata.Equals(GetCodeOwnersFileMetadata(entry.FailedCodeOwnersPath))) { return true; } @@ -1054,7 +1059,7 @@ private void CacheCodeOwnersSearch(string searchCacheKey, string? failedCodeOwne searchCacheKey, failedCodeOwnersPath, failedCodeOwnersPath is null ? default : GetCodeOwnersFileMetadata(failedCodeOwnersPath), - failedCodeOwnersPath is null ? DateTime.MaxValue : GetCodeOwnersUtcNow().Add(CodeOwnersLoadFailureRetryDelay)); + GetCodeOwnersUtcNow().Add(CodeOwnersSearchRetryDelay)); var node = _codeOwnersSearchCacheOrder.AddLast(entry); _codeOwnersSearchCache.Add(searchCacheKey, node); } diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index daf3a1d551b6..34633fb37828 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -35,10 +35,6 @@ internal sealed class CodeOwners @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex StrictSectionHeaderRegex = new( - @"^\^?\[[^\]]+\](?:\[\d+\])?(?:\s+[@\w.\-/\s]+)?$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GitLabRoleReferenceRegex = new( @"(? raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal); + private static bool IsStrictSectionHeader(string raw) + { + var index = raw[0] == '^' ? 1 : 0; + if (index >= raw.Length || raw[index] != '[') + { + return false; + } + + var nameStart = ++index; + while (index < raw.Length && raw[index] != ']') + { + index++; + } + + if (index == nameStart || index >= raw.Length) + { + return false; + } + + index++; + if (index < raw.Length && raw[index] == '[') + { + var approvalStart = ++index; + while (index < raw.Length && char.IsDigit(raw[index])) + { + index++; + } + + if (index == approvalStart || index >= raw.Length || raw[index] != ']') + { + return false; + } + + index++; + } + + if (index == raw.Length) + { + return true; + } + + if (!char.IsWhiteSpace(raw[index])) + { + return false; + } + + for (; index < raw.Length; index++) + { + if (!IsStrictSectionOwnerCharacter(raw[index])) + { + return false; + } + } + + return true; + } + + private static bool IsStrictSectionOwnerCharacter(char character) + { + if (char.IsLetterOrDigit(character) || char.IsWhiteSpace(character) || character is '@' or '.' or '-' or '/') + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; + } + /// /// Compiles a CODEOWNERS-style glob into a deterministic matcher. /// Supports **, *, ?, rooted paths, and trailing slash semantics. diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 521ca78cf549..96872bd1dc49 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using System.Threading; using System.Threading.Tasks; using Datadog.Trace.Ci.CiEnvironment; @@ -437,12 +438,82 @@ public void LockedCodeOwnersDoesNotBreakFallbackPathResolution() Assert.Empty(owners); Assert.Null(ciValues.CodeOwners); - utcNow += CIEnvironmentValues.CodeOwnersLoadFailureRetryDelay; + utcNow += CIEnvironmentValues.CodeOwnersSearchRetryDelay; ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); Assert.Equal(["@owner"], owners); Assert.NotNull(ciValues.CodeOwners); } + [SkippableFact] + public void SecurityExceptionReadingFailureMetadataDoesNotEscape() + { + Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); + + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var sourceDirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); + File.WriteAllText(sourceFile, string.Empty); + var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); + File.WriteAllText(codeOwnersPath, "*.cs @owner\n"); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var metadataReads = 0; + ciValues.BeforeCodeOwnersFileMetadataRead = path => + { + Assert.Equal(codeOwnersPath, path); + metadataReads++; + throw new SecurityException("Simulated restricted filesystem metadata access."); + }; + + string[]? owners = null; + using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var exception = Record.Exception(() => ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!)); + + Assert.Null(exception); + Assert.Empty(owners!); + Assert.Null(ciValues.CodeOwners); + } + + // Both failure caching and the workspace retry must handle restricted metadata access. + Assert.Equal(2, metadataReads); + } + + [SkippableFact] + public void NegativeFallbackCacheExpiresAndDiscoversNewCodeOwners() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var sourceDirectory = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); + File.WriteAllText(sourceFile, string.Empty); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var utcNow = DateTime.UtcNow; + var searches = 0; + ciValues.CodeOwnersUtcNowProvider = () => utcNow; + ciValues.CodeOwnersFallbackSearchStarting = () => searches++; + + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var initialOwners); + Assert.Empty(initialOwners); + Assert.Equal(1, searches); + + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "*.cs @new-owner\n"); + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var cachedOwners); + Assert.Empty(cachedOwners); + Assert.Equal(1, searches); + + utcNow += CIEnvironmentValues.CodeOwnersSearchRetryDelay; + ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var discoveredOwners); + + Assert.Equal(["@new-owner"], discoveredOwners); + Assert.Equal(2, searches); + Assert.NotNull(ciValues.CodeOwners); + } + [SkippableFact] public void NegativeFallbackCacheIsSharedByRepositoryBoundaryBeyondItsCapacity() { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index def453251464..caec20211652 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -625,6 +625,26 @@ public void LongMalformedGitLabOwnerTokensHaveBoundedParsingCost() } } + [SkippableFact] + public void LongMalformedGitLabSectionHeadersHaveBoundedParsingCost() + { + var malformedHeader = "[Docs] " + new string(' ', 1_000_000) + "!\n"; + var path = WriteTemporaryCodeOwners(malformedHeader); + try + { + var stopwatch = Stopwatch.StartNew(); + var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab); + stopwatch.Stop(); + + stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "section validation must scan malformed headers without regex backtracking"); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + finally + { + File.Delete(path); + } + } + [SkippableFact] public void DuplicateOwnersAreDeduplicatedOnceInStableOrder() { From cc2938e19827747686ac62c60b810db36fd51cb8 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 11:00:09 +0200 Subject: [PATCH 14/25] [CI Visibility] Fix CODEOWNERS follow-up issues --- .../build/Datadog.Trace.Trimming.xml | 2 + .../Ci/CiEnvironment/CIEnvironmentValues.cs | 60 +++++++++++++++ tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 7 +- .../Ci/CodeOwnersFallbackTests.cs | 77 +++++++++++++++++++ .../Ci/CodeOwnersSpecTests.cs | 37 ++++++--- 5 files changed, 170 insertions(+), 13 deletions(-) diff --git a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml index 16f7f8773f1b..f7ee6a2f3b32 100644 --- a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml +++ b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml @@ -734,10 +734,12 @@ + + diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 4d730e88160a..bc964d78e9f3 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -749,6 +749,16 @@ private bool TryGetCodeOwnersRelativePath( codeOwnersRoot = resolvedRoot; } + // Azure Pipelines may build on Windows and run the resulting assemblies in a Linux + // container. In that case PDB paths use the Windows build-agent checkout prefix, which + // cannot be resolved directly by the current OS. Re-anchor only recognized Azure checkout + // layouts, and only when the complete repository-relative suffix exists under this root. + if (TryAnchorAzurePipelinesCompilerPath(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath)) + { + parser = codeOwnersState.Parser; + return true; + } + // Only match when the source file can be resolved under the CODEOWNERS root. string absolutePath; if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) @@ -798,6 +808,56 @@ private bool TryGetCodeOwnersRelativePath( return true; } + private bool TryAnchorAzurePipelinesCompilerPath( + string sourceFilePath, + string codeOwnersRoot, + bool useOSSeparator, + [NotNullWhen(true)] out string? codeOwnersRelativePath) + { + codeOwnersRelativePath = null; + if (!string.Equals(Provider, "azurepipelines", StringComparison.Ordinal) || + StringUtil.IsNullOrWhiteSpace(sourceFilePath) || + sourceFilePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) || + (Uri.TryCreate(sourceFilePath, UriKind.Absolute, out var uri) && !uri.IsFile)) + { + return false; + } + + var segments = sourceFilePath.Replace('\\', '/').Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); + for (var i = 2; i < segments.Length - 2; i++) + { + if (!segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) || + !int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) || + !(segments[i - 2].Equals("a", StringComparison.OrdinalIgnoreCase) || + segments[i - 2].Equals("work", StringComparison.OrdinalIgnoreCase) || + segments[i - 2].Equals("_work", StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var suffixStart = i + 1; + for (var j = suffixStart; j < segments.Length; j++) + { + if (segments[j] is "." or "..") + { + return false; + } + } + + var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, suffixStart, segments.Length - suffixStart); + if (!TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) || !File.Exists(candidatePath)) + { + return false; + } + + var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; + codeOwnersRelativePath = string.Join(separator, segments, suffixStart, segments.Length - suffixStart); + return true; + } + + return false; + } + private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { // Compiler-recorded paths can be relative to a different base directory than the current diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 34633fb37828..eb045a155718 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -862,10 +862,13 @@ private GlobPattern(GlobPathSegment[] segments) for (var i = firstSegment; i < lastSegment; i++) { - if (rawSegments[i] == "**") + if (rawSegments[i] == "**" && + !(platform == Platform.GitLab && i == lastSegment - 1)) { // A terminal /** means contents below the preceding directory and must - // consume at least one path segment. Middle globstars may consume none. + // consume at least one path segment on GitHub. GitLab delegates matching + // to File.fnmatch, where a terminal ** behaves like * within one segment. + // Middle globstars may consume none on both platforms. AddGlobStar(segments, requiresSegment: i == lastSegment - 1); } else if (SegmentPattern.TryCompile(rawSegments[i], platform, out var segment)) diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 96872bd1dc49..9df52a015868 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -746,6 +746,82 @@ public void AnchorsAzurePipelinesCompilerRecordedPaths() Assert.Equal(new[] { "@DataDog/tracing-dotnet" }, owners); } + [SkippableTheory] + [InlineData(@"D:\a\_work\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs")] + [InlineData(@"D:\a\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs")] + [InlineData("/home/vsts/work/1/s/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] + public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string compilerPath) + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests"); + Directory.CreateDirectory(sourceDir); + Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n"); + File.WriteAllText(Path.Combine(sourceDir, "ExceptionsTests.cs"), "// test"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Azure.TFBuild] = "True", + [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, + [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, + [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(compilerPath, false, out var owners); + + Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative); + Assert.Equal(["@DataDog/tracing-dotnet"], owners); + } + + [SkippableFact] + public void DoesNotAnchorAzureStyleAbsolutePathsForOtherProviders() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "/src/ @owner\n"); + File.WriteAllText(Path.Combine(sourceDir, "SpanBenchmark.cs"), "// test"); + + var env = new Dictionary + { + [PlatformKeys.Ci.GitHub.Sha] = CommitSha, + [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, + [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(@"D:\a\_work\1\s\src\SpanBenchmark.cs", false, out _)); + } + + [SkippableTheory] + [InlineData("file:///D:/a/_work/1/s/src/SpanBenchmark.cs")] + [InlineData("https://example.com/a/_work/1/s/src/SpanBenchmark.cs")] + public void DoesNotAnchorUrisThatResembleAzureCheckoutPaths(string sourcePath) + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + var sourceDir = Path.Combine(repoRoot, "src"); + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "/src/ @owner\n"); + File.WriteAllText(Path.Combine(sourceDir, "SpanBenchmark.cs"), "// test"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Azure.TFBuild] = "True", + [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, + [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, + [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourcePath, false, out _)); + } + [SkippableFact] public void DoesNotAnchorPathsWithInteriorNavigationSegments() { @@ -908,6 +984,7 @@ public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() Assert.NotNull(matchTask); Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); Assert.Equal("src/SpanBenchmark.cs", secondRelativePath); + Assert.NotNull(secondOwners); Assert.Equal(["@second"], secondOwners); } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index caec20211652..e2a46d00c5fc 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -73,13 +73,10 @@ public void GithubDirectoryPatternOwnsEverythingUnderneath() Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@doctocat"]); } - [SkippableTheory] - [InlineData(false)] - [InlineData(true)] - public void DirectoryAndTerminalGlobstarPatternsRequireDescendants(bool useGitLab) + [SkippableFact] + public void GithubDirectoryAndTerminalGlobstarPatternsRequireDescendants() { - var platform = useGitLab ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; - var codeOwners = Create("/docs/ @directory\n/archive/** @globstar\n", platform); + var codeOwners = Create("/docs/ @directory\n/archive/** @globstar\n", CodeOwners.Platform.GitHub); Match(codeOwners, "/docs").Should().BeEmpty("a trailing slash only denotes a directory and its contents"); Match(codeOwners, "/docs/file.txt").Should().Equal(["@directory"]); @@ -88,6 +85,18 @@ public void DirectoryAndTerminalGlobstarPatternsRequireDescendants(bool useGitLa Match(codeOwners, "/archive/deep/file.txt").Should().Equal(["@globstar"]); } + [SkippableFact] + public void GitlabTerminalGlobstarMatchesOnePathSegment() + { + var terminalGlobstar = Create("/archive/** @single-segment\n", CodeOwners.Platform.GitLab); + var recursiveGlobstar = Create("/archive/**/* @recursive\n", CodeOwners.Platform.GitLab); + + Match(terminalGlobstar, "/archive").Should().BeEmpty(); + Match(terminalGlobstar, "/archive/file.txt").Should().Equal(["@single-segment"]); + Match(terminalGlobstar, "/archive/deep/file.txt").Should().BeEmpty(); + Match(recursiveGlobstar, "/archive/deep/file.txt").Should().Equal(["@recursive"]); + } + [SkippableFact] public void GithubRootedVersusUnrootedPatterns() { @@ -858,12 +867,18 @@ public void QuestionMarkDoesNotMatchSlash() [SkippableFact] public void DoubleStarIsGlobstarOnlyAsAWholeSegment() { - var codeOwners = Create("foo**bar @stars\n**/index.md @index\n", CodeOwners.Platform.GitHub); + var inSegment = Create("foo**bar @stars\n", CodeOwners.Platform.GitHub); + var slashAfterSegment = Create("/foo**/bar @component-stars\n", CodeOwners.Platform.GitHub); + var globstar = Create("**/index.md @index\n", CodeOwners.Platform.GitHub); + // Adjacent asterisks inside a segment are two single-level wildcards, not a globstar. - Match(codeOwners, "/fooXbar").Should().Equal(["@stars"]); - Match(codeOwners, "/foo/x/bar").Should().BeEmpty(); - Match(codeOwners, "/docs/index.md").Should().Equal(["@index"]); - Match(codeOwners, "/index.md").Should().Equal(["@index"]); + Match(inSegment, "/fooXbar").Should().Equal(["@stars"]); + Match(slashAfterSegment, "/foo/bar").Should().Equal(["@component-stars"]); + Match(slashAfterSegment, "/fooX/bar").Should().Equal(["@component-stars"]); + Match(slashAfterSegment, "/foobar").Should().BeEmpty("the slash after an in-segment double star remains required"); + Match(slashAfterSegment, "/foo/x/bar").Should().BeEmpty("in-segment stars cannot cross a directory boundary"); + Match(globstar, "/docs/index.md").Should().Equal(["@index"]); + Match(globstar, "/index.md").Should().Equal(["@index"]); } [SkippableFact] From 7e563e5fabcf719db06138ed2252e413dc6e102f Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 12:08:42 +0200 Subject: [PATCH 15/25] [CI Visibility] Address CODEOWNERS review feedback --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 50 ++++++++++++++++++- .../Ci/CodeOwnersFallbackTests.cs | 43 ++++++++++++---- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index bc964d78e9f3..d3ba18200072 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -443,6 +443,45 @@ private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform return false; } + private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform) + { + platform = default; + if (StringUtil.IsNullOrWhiteSpace(repository)) + { + return false; + } + + string? host = null; + if (Uri.TryCreate(repository, UriKind.Absolute, out var repositoryUri) && !StringUtil.IsNullOrEmpty(repositoryUri.Host)) + { + host = repositoryUri.Host; + } + else + { + // Handle SCP-style SSH URLs such as git@gitlab.com:group/project.git. + var hostStart = repository!.IndexOf('@') + 1; + var hostEnd = repository.IndexOf(':', hostStart); + if (hostStart > 0 && hostEnd > hostStart) + { + host = repository.Substring(hostStart, hostEnd - hostStart); + } + } + + if (string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase)) + { + platform = CodeOwners.Platform.GitLab; + return true; + } + + if (string.Equals(host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + platform = CodeOwners.Platform.GitHub; + return true; + } + + return false; + } + private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) { if (platform == CodeOwners.Platform.GitHub) @@ -824,7 +863,7 @@ private bool TryAnchorAzurePipelinesCompilerPath( } var segments = sourceFilePath.Replace('\\', '/').Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); - for (var i = 2; i < segments.Length - 2; i++) + for (var i = 2; i < segments.Length - 1; i++) { if (!segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) || !int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) || @@ -1157,7 +1196,14 @@ private bool TryPublishCodeOwners(string codeOwnersPath, CodeOwners.Platform pla } private CodeOwners.Platform GetCodeOwnersPlatform() - => GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + { + if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) + { + return platform; + } + + return GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + } private readonly struct CodeOwnersFileMetadata : IEquatable { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 9df52a015868..98bc49ac335f 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -301,6 +301,30 @@ public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations() Assert.Null(gitlabValues.CodeOwners); } + [SkippableTheory] + [InlineData("https://gitlab.com/DataDog/dd-trace-dotnet.git")] + [InlineData("git@gitlab.com:DataDog/dd-trace-dotnet.git")] + public void UsesRepositoryHostToSelectCodeOwnersPlatform(string repositoryUrl) + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab")); + File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "[Section] @gitlab-owner\n*.cs\n"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Jenkins.Url] = "https://jenkins.example.com", + [PlatformKeys.Ci.Jenkins.GitUrl] = repositoryUrl, + [PlatformKeys.Ci.Jenkins.GitCommit] = CommitSha, + [PlatformKeys.Ci.Jenkins.Workspace] = repoRoot, + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.Equal("jenkins", ciValues.Provider); + Assert.Equal(["@gitlab-owner"], ciValues.CodeOwners!.Match("/file.cs")); + } + [SkippableFact] public void DoesNotMatchCodeOwnersForFileOutsideRoot() { @@ -747,18 +771,19 @@ public void AnchorsAzurePipelinesCompilerRecordedPaths() } [SkippableTheory] - [InlineData(@"D:\a\_work\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs")] - [InlineData(@"D:\a\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs")] - [InlineData("/home/vsts/work/1/s/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] - public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string compilerPath) + [InlineData(@"D:\a\_work\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] + [InlineData(@"D:\a\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] + [InlineData("/home/vsts/work/1/s/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] + [InlineData(@"D:\a\1\s\Program.cs", "Program.cs")] + public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string compilerPath, string expectedRelativePath) { using var tempDirectory = new TemporaryDirectory(); var repoRoot = tempDirectory.RootPath; - var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests"); - Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(repoRoot, expectedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!); Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); - File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n"); - File.WriteAllText(Path.Combine(sourceDir, "ExceptionsTests.cs"), "// test"); + File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), $"/{expectedRelativePath} @DataDog/tracing-dotnet\n"); + File.WriteAllText(sourceFile, "// test"); var env = new Dictionary { @@ -771,7 +796,7 @@ public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string var ciValues = CIEnvironmentValues.Create(env); var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(compilerPath, false, out var owners); - Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative); + Assert.Equal(expectedRelativePath, relative); Assert.Equal(["@DataDog/tracing-dotnet"], owners); } From ff5185b5da827479b941a3e1ff1dc1e6e21f1b45 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 13:18:58 +0200 Subject: [PATCH 16/25] [CI Visibility] Address CODEOWNERS review findings --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 34 ++++++++++++++++--- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 27 +++++++++++---- .../Ci/CodeOwnersFallbackTests.cs | 23 +++++++++++++ .../Ci/CodeOwnersSpecTests.cs | 31 +++++++++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index d3ba18200072..d8eab396f237 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -467,7 +467,7 @@ private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, o } } - if (string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase)) + if (IsGitLabHost(host)) { platform = CodeOwners.Platform.GitLab; return true; @@ -482,6 +482,11 @@ private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, o return false; } + private static bool IsGitLabHost(string? host) + => string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) || + (host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) || + (host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0); + private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) { if (platform == CodeOwners.Platform.GitHub) @@ -653,7 +658,7 @@ private void ReloadEnvironmentDataCore() // ********** if (!string.IsNullOrEmpty(SourceRoot)) { - var platform = GetCodeOwnersPlatform(); + var platform = GetCodeOwnersPlatform(SourceRoot); if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath)) { Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); @@ -1025,7 +1030,7 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) // Search order: source file path (most specific), then workspace root. // Prefer a source-file-anchored search before falling back to the workspace root. - var platform = GetCodeOwnersPlatform(); + var platform = GetCodeOwnersPlatform(SourceRoot ?? WorkspacePath); if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath)) { return; @@ -1195,14 +1200,33 @@ private bool TryPublishCodeOwners(string codeOwnersPath, CodeOwners.Platform pla return true; } - private CodeOwners.Platform GetCodeOwnersPlatform() + private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) { if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) { return platform; } - return GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub; + if (string.Equals(Provider, "gitlab", StringComparison.Ordinal) || GetType().Name.Contains("Gitlab")) + { + return CodeOwners.Platform.GitLab; + } + + if (string.Equals(Provider, "github", StringComparison.Ordinal) || GetType().Name.Contains("Github")) + { + return CodeOwners.Platform.GitHub; + } + + if (!StringUtil.IsNullOrEmpty(sourceRoot) && + File.Exists(Path.Combine(sourceRoot!, ".gitlab", "CODEOWNERS")) && + !File.Exists(Path.Combine(sourceRoot!, ".github", "CODEOWNERS"))) + { + // A platform-specific location is the only reliable signal for self-managed GitLab + // instances whose host name does not identify the product. + return CodeOwners.Platform.GitLab; + } + + return CodeOwners.Platform.GitHub; } private readonly struct CodeOwnersFileMetadata : IEquatable diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index eb045a155718..42912ca233fd 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -1026,6 +1026,9 @@ private static int GetNextSegmentStart(string path, int segmentEnd) private sealed class SegmentPattern { + private const int MaximumPatternLength = 1_024; + private const int MaximumMatchSteps = 65_536; + private readonly SegmentToken[] _tokens; private SegmentPattern(SegmentToken[] tokens) @@ -1035,6 +1038,14 @@ private SegmentPattern(SegmentToken[] tokens) public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(true)] out SegmentPattern? segment) { + // Repository path components are short, so larger segment patterns cannot provide + // useful ownership matches and can make wildcard retries disproportionately costly. + if (pattern.Length > MaximumPatternLength) + { + segment = null; + return false; + } + var tokens = new List(pattern.Length); for (var i = 0; i < pattern.Length; i++) { @@ -1097,9 +1108,16 @@ public bool IsMatch(string path, int start, int end) var pathIndex = start; var starTokenIndex = -1; var starPathIndex = -1; + var remainingSteps = MaximumMatchSteps; while (pathIndex < end) { + if (remainingSteps-- == 0) + { + // Bound adversarial star/suffix retries in the instrumented process. + return false; + } + if (tokenIndex < _tokens.Length && _tokens[tokenIndex].IsStar) { starTokenIndex = tokenIndex++; @@ -1142,15 +1160,12 @@ private static CharacterClassParseResult TryParseCharacterClass( var contentStart = openingBracket + 1; var negated = contentStart < pattern.Length && pattern[contentStart] is '!' or '^'; var atomStart = negated ? contentStart + 1 : contentStart; - var searchStart = atomStart; - - // A closing bracket immediately after the optional negation is a literal member. - if (searchStart < pattern.Length && pattern[searchStart] == ']') + if (atomStart < pattern.Length && pattern[atomStart] == ']') { - searchStart++; + return CharacterClassParseResult.Invalid; } - for (var i = searchStart; i < pattern.Length; i++) + for (var i = atomStart; i < pattern.Length; i++) { if (pattern[i] == '\\' && i + 1 < pattern.Length) { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 98bc49ac335f..f7c91304e440 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -304,6 +304,8 @@ public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations() [SkippableTheory] [InlineData("https://gitlab.com/DataDog/dd-trace-dotnet.git")] [InlineData("git@gitlab.com:DataDog/dd-trace-dotnet.git")] + [InlineData("https://gitlab.example.com/DataDog/dd-trace-dotnet.git")] + [InlineData("git@gitlab.example.com:DataDog/dd-trace-dotnet.git")] public void UsesRepositoryHostToSelectCodeOwnersPlatform(string repositoryUrl) { using var tempDirectory = new TemporaryDirectory(); @@ -325,6 +327,27 @@ public void UsesRepositoryHostToSelectCodeOwnersPlatform(string repositoryUrl) Assert.Equal(["@gitlab-owner"], ciValues.CodeOwners!.Match("/file.cs")); } + [SkippableFact] + public void UsesGitLabSpecificLocationWhenRepositoryHostIsUnknown() + { + using var tempDirectory = new TemporaryDirectory(); + var repoRoot = tempDirectory.RootPath; + Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab")); + File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "[Section] @gitlab-owner\n*.cs\n"); + + var env = new Dictionary + { + [PlatformKeys.Ci.Jenkins.Url] = "https://jenkins.example.com", + [PlatformKeys.Ci.Jenkins.GitUrl] = "https://source.example.com/DataDog/dd-trace-dotnet.git", + [PlatformKeys.Ci.Jenkins.GitCommit] = CommitSha, + [PlatformKeys.Ci.Jenkins.Workspace] = repoRoot, + }; + + var ciValues = CIEnvironmentValues.Create(env); + + Assert.Equal(["@gitlab-owner"], ciValues.CodeOwners!.Match("/file.cs")); + } + [SkippableFact] public void DoesNotMatchCodeOwnersForFileOutsideRoot() { diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index e2a46d00c5fc..fe9ff83e99ec 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -344,6 +344,17 @@ public void GitLabMalformedCharacterClassesCannotAbortParserInitialization() Match(codeOwners, "/file!.txt").Should().Equal(["@punctuation"]); } + [SkippableFact] + public void GitLabCharacterClassesCannotStartWithClosingBracket() + { + var codeOwners = Create("* @fallback\nfile[]a].txt @positive\nfile[!]].txt @negated\n", CodeOwners.Platform.GitLab); + + codeOwners.ParsingDiagnosticsCount.Should().Be(2); + Match(codeOwners, "/filea.txt").Should().Equal(["@fallback"]); + Match(codeOwners, "/file].txt").Should().Equal(["@fallback"]); + Match(codeOwners, "/filex.txt").Should().Equal(["@fallback"]); + } + [SkippableFact] public void GitLabBackslashEscapesGlobMetacharactersAndOrdinaryCharacters() { @@ -596,6 +607,26 @@ public void PathologicalPatternMatchingIsDeterministicBoundedAndThreadSafe() } } + [SkippableFact] + public void OverlongSegmentPatternIsRejectedBeforeMatching() + { + var pathologicalPattern = "*" + new string('a', 1_024) + "b"; + var codeOwners = Create("* @global\n" + pathologicalPattern + " @slow\n", CodeOwners.Platform.GitHub); + + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + Match(codeOwners, "/" + new string('a', 2_000) + "c").Should().Equal(["@global"]); + } + + [SkippableFact] + public void SegmentWildcardMatchingStopsAtWorkLimit() + { + var repeatedSuffixPattern = "*" + new string('a', 256) + "b"; + var codeOwners = Create("* @global\n" + repeatedSuffixPattern + " @slow\n", CodeOwners.Platform.GitHub); + + codeOwners.ParsingDiagnosticsCount.Should().Be(0, "the rule is valid and bounded at match time"); + Match(codeOwners, "/" + new string('a', 2_000) + "b").Should().Equal(["@global"]); + } + [SkippableFact] public void LargeUniqueOwnerListsHaveLinearRepeatedMatchCost() { From 7ecd0060c3f09c69b76a1047f745a71705937e0c Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 13:32:20 +0200 Subject: [PATCH 17/25] [CI Visibility] Use CI provider for CODEOWNERS platform --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 4 +- .../Ci/CodeOwnersFallbackTests.cs | 53 +++++-------------- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index d8eab396f237..9e409eb7bb8e 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -1207,12 +1207,12 @@ private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) return platform; } - if (string.Equals(Provider, "gitlab", StringComparison.Ordinal) || GetType().Name.Contains("Gitlab")) + if (string.Equals(Provider, "gitlab", StringComparison.Ordinal)) { return CodeOwners.Platform.GitLab; } - if (string.Equals(Provider, "github", StringComparison.Ordinal) || GetType().Name.Contains("Github")) + if (string.Equals(Provider, "github", StringComparison.Ordinal)) { return CodeOwners.Platform.GitHub; } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index f7c91304e440..b9daf209e304 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -234,7 +234,7 @@ public void DoesNotLoadCodeOwnersAboveWorkspaceWithoutGitMetadata() } [SkippableFact] - public void GitHubUsesOfficialCodeOwnersLocationPriority() + public void GitHubProviderUsesOfficialCodeOwnersLocationPriority() { using var tempDirectory = new TemporaryDirectory(); var repoRoot = tempDirectory.RootPath; @@ -243,7 +243,7 @@ public void GitHubUsesOfficialCodeOwnersLocationPriority() File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "* @github-directory\n"); File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n"); File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n"); - var ciValues = new ReloadingGithubEnvironmentValues(repoRoot); + var ciValues = new ReloadingEnvironmentValues(repoRoot, "github"); ciValues.Reload(); Assert.Equal(["@github-directory"], ciValues.CodeOwners!.Match("/file.cs")); @@ -258,7 +258,7 @@ public void GitHubUsesOfficialCodeOwnersLocationPriority() } [SkippableFact] - public void GitLabUsesOfficialCodeOwnersLocationPriority() + public void GitLabProviderUsesOfficialCodeOwnersLocationPriority() { using var tempDirectory = new TemporaryDirectory(); var repoRoot = tempDirectory.RootPath; @@ -267,7 +267,7 @@ public void GitLabUsesOfficialCodeOwnersLocationPriority() File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n"); File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n"); File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "* @gitlab-directory\n"); - var ciValues = new ReloadingGitlabEnvironmentValues(repoRoot); + var ciValues = new ReloadingEnvironmentValues(repoRoot, "gitlab"); ciValues.Reload(); Assert.Equal(["@repository-root"], ciValues.CodeOwners!.Match("/file.cs")); @@ -287,7 +287,7 @@ public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations() using var githubDirectory = new TemporaryDirectory(); Directory.CreateDirectory(Path.Combine(githubDirectory.RootPath, ".gitlab")); File.WriteAllText(Path.Combine(githubDirectory.RootPath, ".gitlab", "CODEOWNERS"), "* @gitlab-only\n"); - var githubValues = new ReloadingGithubEnvironmentValues(githubDirectory.RootPath); + var githubValues = new ReloadingEnvironmentValues(githubDirectory.RootPath, "github"); githubValues.Reload(); Assert.Null(githubValues.CodeOwners); @@ -295,7 +295,7 @@ public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations() using var gitlabDirectory = new TemporaryDirectory(); Directory.CreateDirectory(Path.Combine(gitlabDirectory.RootPath, ".github")); File.WriteAllText(Path.Combine(gitlabDirectory.RootPath, ".github", "CODEOWNERS"), "* @github-only\n"); - var gitlabValues = new ReloadingGitlabEnvironmentValues(gitlabDirectory.RootPath); + var gitlabValues = new ReloadingEnvironmentValues(gitlabDirectory.RootPath, "gitlab"); gitlabValues.Reload(); Assert.Null(gitlabValues.CodeOwners); @@ -1092,7 +1092,7 @@ public void MalformedGitLabClassDoesNotAbortFallbackPublication() File.WriteAllText(sourceFile, string.Empty); File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @fallback\nfile[z-a].txt @invalid\n"); - var ciValues = new TestGitlabEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); + var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot, provider: "gitlab"); Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); Assert.Equal("src/file.txt", relativePath); @@ -1182,10 +1182,11 @@ public void Dispose() private sealed class TestCIEnvironmentValues : CIEnvironmentValues { - public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath) + public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath, string? provider = null) { SourceRoot = sourceRoot; WorkspacePath = workspacePath; + Provider = provider; } protected override void Setup(IGitInfo gitInfo) @@ -1193,26 +1194,15 @@ protected override void Setup(IGitInfo gitInfo) } } - private sealed class TestGitlabEnvironmentValues : CIEnvironmentValues - { - public TestGitlabEnvironmentValues(string? sourceRoot, string? workspacePath) - { - SourceRoot = sourceRoot; - WorkspacePath = workspacePath; - } - - protected override void Setup(IGitInfo gitInfo) - { - } - } - - private abstract class ReloadingEnvironmentValues : CIEnvironmentValues + private sealed class ReloadingEnvironmentValues : CIEnvironmentValues { private readonly string _sourceRoot; + private readonly string _provider; - protected ReloadingEnvironmentValues(string sourceRoot) + public ReloadingEnvironmentValues(string sourceRoot, string provider) { _sourceRoot = sourceRoot; + _provider = provider; } public void Reload() => ReloadEnvironmentData(); @@ -1221,22 +1211,7 @@ protected override void Setup(IGitInfo gitInfo) { SourceRoot = _sourceRoot; WorkspacePath = _sourceRoot; - } - } - - private sealed class ReloadingGithubEnvironmentValues : ReloadingEnvironmentValues - { - public ReloadingGithubEnvironmentValues(string sourceRoot) - : base(sourceRoot) - { - } - } - - private sealed class ReloadingGitlabEnvironmentValues : ReloadingEnvironmentValues - { - public ReloadingGitlabEnvironmentValues(string sourceRoot) - : base(sourceRoot) - { + Provider = _provider; } } From d7bfb494cba05c5af6e08fa64db993c9e701ffc7 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 13:55:42 +0200 Subject: [PATCH 18/25] [CI Visibility] Simplify CODEOWNERS path handling --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 551 +++-------------- tracer/src/Datadog.Trace/Ci/Test.cs | 12 +- .../Ci/CodeOwnersFallbackTests.cs | 573 +----------------- 3 files changed, 111 insertions(+), 1025 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 9e409eb7bb8e..18c7e63683b8 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -9,11 +9,8 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; -using System.Linq; using System.Runtime.CompilerServices; -using System.Security; using System.Text.RegularExpressions; -using System.Threading; using Datadog.Trace.Ci.Tags; using Datadog.Trace.Logging; using Datadog.Trace.Telemetry.Metrics; @@ -27,22 +24,18 @@ internal abstract class CIEnvironmentValues { private const int CodeOwnersSearchCacheLimit = 256; internal const string RepositoryUrlPattern = @"((http|git|ssh|http(s)|file|\/?)|(git@[\w\.\-]+))(:(\/\/)?)([\w\.@\:/\-~]+)(\.git)?(\/)?"; - internal static readonly TimeSpan CodeOwnersSearchRetryDelay = TimeSpan.FromSeconds(30); protected static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(CIEnvironmentValues)); private static readonly Lazy LazyInstance = new(Create); private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled); private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; private static readonly char[] ForwardSlashCharacters = { '/' }; private readonly object _codeOwnersLock = new(); - private readonly Dictionary> _codeOwnersSearchCache = new(CodeOwnersSearchComparer); - private readonly LinkedList _codeOwnersSearchCacheOrder = new(); + private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); - private CodeOwnersState? _codeOwnersState; - private int _environmentReloadVersion; private string? _gitSearchFolder; public static CIEnvironmentValues Instance => LazyInstance.Value; @@ -133,21 +126,9 @@ public string? GitSearchFolder public string? HeadMessage { get; protected set; } - public CodeOwners? CodeOwners => Volatile.Read(ref _codeOwnersState)?.Parser; + public CodeOwners? CodeOwners { get; protected set; } - internal string? CodeOwnersRoot => Volatile.Read(ref _codeOwnersState)?.Root; - - // Test-only synchronization hooks. They are null in production and run only on the uncommon - // paths that wait for an active reload or perform fallback discovery. - internal Action? BeforeCodeOwnersReloadWait { get; set; } - - internal Action? BeforeCodeOwnersFallbackLock { get; set; } - - internal Action? CodeOwnersFallbackSearchStarting { get; set; } - - internal Action? BeforeCodeOwnersFileMetadataRead { get; set; } - - internal Func? CodeOwnersUtcNowProvider { get; set; } + internal string? CodeOwnersRoot { get; private set; } public Dictionary? VariablesToBypass { get; protected set; } @@ -326,51 +307,6 @@ private static bool HasGitDirectory(string path) return Directory.Exists(gitPath) || File.Exists(gitPath); } - private static string? GetCodeOwnersSearchBoundary(DirectoryInfo startDirectory, string? workspacePath) - { - // A real git boundary takes precedence, including when the CI workspace points at a - // subdirectory of the checkout. - for (var current = startDirectory; current is not null; current = current.Parent) - { - if (HasGitDirectory(current.FullName)) - { - return current.FullName; - } - } - - if (StringUtil.IsNullOrWhiteSpace(workspacePath) || !Path.IsPathRooted(workspacePath)) - { - return null; - } - - try - { - var fullWorkspacePath = Path.GetFullPath(workspacePath!); - var fullStartPath = Path.GetFullPath(startDirectory.FullName); - if (CodeOwnersSearchComparer.Equals(fullStartPath, fullWorkspacePath)) - { - return fullWorkspacePath; - } - - var workspaceWithSeparator = fullWorkspacePath; - if (!workspaceWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) && - !workspaceWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) - { - workspaceWithSeparator += Path.DirectorySeparatorChar; - } - - var comparison = FrameworkDescription.Instance.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - return fullStartPath.StartsWith(workspaceWithSeparator, comparison) ? fullWorkspacePath : null; - } - catch (Exception ex) - { - Log.Debug(ex, "Error resolving CODEOWNERS workspace boundary from '{Path}'", workspacePath); - return null; - } - } - private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) { absolutePath = null; @@ -505,23 +441,6 @@ private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwn } } - private CodeOwnersFileMetadata GetCodeOwnersFileMetadata(string path) - { - try - { - BeforeCodeOwnersFileMetadataRead?.Invoke(path); - var file = new FileInfo(path); - file.Refresh(); - return file.Exists - ? new CodeOwnersFileMetadata(exists: true, file.Length, file.LastWriteTimeUtc.Ticks) - : default; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) - { - return default; - } - } - public void DecorateSpan(Span span) { if (span == null) @@ -578,25 +497,6 @@ public void DecorateSpan(Span span) } protected void ReloadEnvironmentData() - { - // Reload changes the source root and its CODEOWNERS parser as one logical state transition. - // Serialize the complete transition with fallback discovery so neither can publish state - // derived from a root that the other operation is replacing. - lock (_codeOwnersLock) - { - Interlocked.Increment(ref _environmentReloadVersion); - try - { - ReloadEnvironmentDataCore(); - } - finally - { - Interlocked.Increment(ref _environmentReloadVersion); - } - } - } - - private void ReloadEnvironmentDataCore() { // ********** // Setup variables @@ -626,8 +526,12 @@ private void ReloadEnvironmentDataCore() CommitterDate = null; Message = null; SourceRoot = null; - Volatile.Write(ref _codeOwnersState, null); - ClearCodeOwnersSearchCache(); + CodeOwners = null; + CodeOwnersRoot = null; + lock (_codeOwnersLock) + { + _codeOwnersSearchStarts.Clear(); + } Setup(string.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder!)); @@ -662,7 +566,11 @@ private void ReloadEnvironmentDataCore() if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath)) { Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); - TryPublishCodeOwners(codeOwnersPath, platform, SourceRoot!); + if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) + { + CodeOwners = parser; + CodeOwnersRoot = SourceRoot; + } } } } @@ -704,59 +612,16 @@ public string MakeRelativePathFromSourceRoot(string absolutePath, bool useOSSepa } internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) - => MakeRelativePathFromSourceRootWithFallback(sourceFilePath, useOSSeparator, out _); - - internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator, out string[] codeOwners) { - // The normal path stays lock-free. A version change makes the operation retry, while an - // active reload waits on the same lock used for the state transition. This ensures that - // SourceRoot, the relative path, and CODEOWNERS all come from one completed reload. - while (true) - { - var reloadVersion = Volatile.Read(ref _environmentReloadVersion); - if ((reloadVersion & 1) != 0) - { - BeforeCodeOwnersReloadWait?.Invoke(); - lock (_codeOwnersLock) - { - } - - continue; - } - - var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); - string result; - string[] matchedOwners; - if (TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath, out var parser)) - { - result = codeOwnersRelativePath; - matchedOwners = parser.Match("/" + codeOwnersRelativePath).ToArray(); - } - else - { - result = sourceRelativePath; - matchedOwners = []; - } - - if (reloadVersion == Volatile.Read(ref _environmentReloadVersion)) - { - codeOwners = matchedOwners; - return result; - } - } + var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); + return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath) + ? codeOwnersRelativePath + : sourceRelativePath; } internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) - => TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out codeOwnersRelativePath, out _); - - private bool TryGetCodeOwnersRelativePath( - string sourceFilePath, - bool useOSSeparator, - [NotNullWhen(true)] out string? codeOwnersRelativePath, - [NotNullWhen(true)] out CodeOwners? parser) { codeOwnersRelativePath = null; - parser = null; if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) { @@ -767,13 +632,12 @@ private bool TryGetCodeOwnersRelativePath( // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization. EnsureCodeOwnersFromFallback(sourceFilePath); - var codeOwnersState = Volatile.Read(ref _codeOwnersState); - if (codeOwnersState is null || StringUtil.IsNullOrWhiteSpace(codeOwnersState.Root)) + if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot)) { return false; } - var codeOwnersRoot = codeOwnersState.Root; + var codeOwnersRoot = CodeOwnersRoot!; if (!Path.IsPathRooted(codeOwnersRoot)) { // If SourceRoot was relative, re-anchor to WorkspacePath before matching. @@ -785,7 +649,7 @@ private bool TryGetCodeOwnersRelativePath( // Require a CODEOWNERS file at the resolved root to avoid mismatched roots. // Avoid mixing CODEOWNERS content from one root with a different resolved root. - if (!TryGetCodeOwnersPath(resolvedRoot, codeOwnersState.Platform, logLookup: false, out _)) + if (!TryGetCodeOwnersPath(resolvedRoot, GetCodeOwnersPlatform(resolvedRoot), logLookup: false, out _)) { return false; } @@ -793,13 +657,8 @@ private bool TryGetCodeOwnersRelativePath( codeOwnersRoot = resolvedRoot; } - // Azure Pipelines may build on Windows and run the resulting assemblies in a Linux - // container. In that case PDB paths use the Windows build-agent checkout prefix, which - // cannot be resolved directly by the current OS. Re-anchor only recognized Azure checkout - // layouts, and only when the complete repository-relative suffix exists under this root. - if (TryAnchorAzurePipelinesCompilerPath(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath)) + if (TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath)) { - parser = codeOwnersState.Parser; return true; } @@ -816,13 +675,7 @@ private bool TryGetCodeOwnersRelativePath( // Relative paths must stay within the codeowners root; otherwise we try to anchor them. if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) { - var anchored = TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); - if (anchored) - { - parser = codeOwnersState.Parser; - } - - return anchored; + return false; } absolutePath = resolvedPath; @@ -838,114 +691,83 @@ private bool TryGetCodeOwnersRelativePath( relativePath.StartsWith("../", StringComparison.Ordinal) || relativePath.StartsWith("..\\", StringComparison.Ordinal)) { - var anchored = TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath); - if (anchored) - { - parser = codeOwnersState.Parser; - } - - return anchored; + return false; } codeOwnersRelativePath = relativePath; - parser = codeOwnersState.Parser; return true; } - private bool TryAnchorAzurePipelinesCompilerPath( - string sourceFilePath, - string codeOwnersRoot, - bool useOSSeparator, - [NotNullWhen(true)] out string? codeOwnersRelativePath) + private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { + // Compiler-recorded paths can be relative to a different base directory than the current + // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict + // resolution fails, anchor the path by finding the longest suffix that exists under the + // CODEOWNERS root, independently of the CI provider layout that produced the prefix. codeOwnersRelativePath = null; - if (!string.Equals(Provider, "azurepipelines", StringComparison.Ordinal) || - StringUtil.IsNullOrWhiteSpace(sourceFilePath) || - sourceFilePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) || - (Uri.TryCreate(sourceFilePath, UriKind.Absolute, out var uri) && !uri.IsFile)) + if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) { return false; } - var segments = sourceFilePath.Replace('\\', '/').Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); - for (var i = 2; i < segments.Length - 1; i++) - { - if (!segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) || - !int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) || - !(segments[i - 2].Equals("a", StringComparison.OrdinalIgnoreCase) || - segments[i - 2].Equals("work", StringComparison.OrdinalIgnoreCase) || - segments[i - 2].Equals("_work", StringComparison.OrdinalIgnoreCase))) - { - continue; - } + var normalizedPath = sourceFilePath.Replace('\\', '/'); + var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); + var start = -1; - var suffixStart = i + 1; - for (var j = suffixStart; j < segments.Length; j++) + // Azure may build on one OS and run tests on another. Its checkout marker gives us an + // unambiguous repository-relative suffix even when the recorded path is absolute here. + if (string.Equals(Provider, "azurepipelines", StringComparison.Ordinal) && + !sourceFilePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) && + (!Uri.TryCreate(sourceFilePath, UriKind.Absolute, out var uri) || uri.IsFile)) + { + for (var i = 2; i < segments.Length - 1; i++) { - if (segments[j] is "." or "..") + if (segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) && + int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) && + (segments[i - 2].Equals("a", StringComparison.OrdinalIgnoreCase) || + segments[i - 2].Equals("work", StringComparison.OrdinalIgnoreCase) || + segments[i - 2].Equals("_work", StringComparison.OrdinalIgnoreCase))) { - return false; + start = i + 1; + break; } } + } - var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, suffixStart, segments.Length - suffixStart); - if (!TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) || !File.Exists(candidatePath)) + var isAzureCheckoutPath = start >= 0; + if (!isAzureCheckoutPath) + { + if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) { return false; } - var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; - codeOwnersRelativePath = string.Join(separator, segments, suffixStart, segments.Length - suffixStart); - return true; - } - - return false; - } - - private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) - { - // Compiler-recorded paths can be relative to a different base directory than the current - // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict - // resolution fails, anchor the path by finding the longest suffix that exists under the - // CODEOWNERS root, independently of the CI provider layout that produced the prefix. - codeOwnersRelativePath = null; - if (StringUtil.IsNullOrWhiteSpace(sourceFilePath) || - Path.IsPathRooted(sourceFilePath) || - Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) - { - // Only relative paths recorded against a foreign base directory are anchored; absolute - // paths pointing outside the repository must not be re-anchored into it. - return false; - } - - var normalizedPath = sourceFilePath.Replace('\\', '/'); - var pathWithoutForeignPrefix = normalizedPath; - while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || - pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) - { - var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; - pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); - } + var pathWithoutForeignPrefix = normalizedPath; + while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || + pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) + { + var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; + pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); + } - if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) - { - // A drive, UNC path, Unix root, or URI embedded after navigation segments is still - // absolute. Reject the whole source path instead of matching a shorter local suffix. - return false; - } + if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) + { + // Reject absolute paths hidden after leading navigation segments. + return false; + } - var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); - if (segments.Length < 2) - { - // Never anchor bare file names: too easy to match an unrelated file. - return false; - } + if (segments.Length < 2) + { + // Never anchor bare file names: too easy to match an unrelated file. + return false; + } - // Skip leading "." / ".." navigation segments: they belong to the foreign base directory. - var start = 0; - while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) - { - start++; + // Leading navigation segments belong to the compiler's foreign base directory. + start = 0; + while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) + { + start++; + } } // Never anchor paths with interior navigation segments: their resolution depends on the @@ -958,7 +780,8 @@ private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwn } } - for (var i = start; i < segments.Length - 1; i++) + var lastStart = isAzureCheckoutPath ? start + 1 : segments.Length - 1; + for (var i = start; i < lastStart; i++) { var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath)) @@ -1015,15 +838,14 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO private void EnsureCodeOwnersFromFallback(string? sourceFilePath) { - if (Volatile.Read(ref _codeOwnersState) is not null) + if (CodeOwners is not null) { return; } - BeforeCodeOwnersFallbackLock?.Invoke(); lock (_codeOwnersLock) { - if (Volatile.Read(ref _codeOwnersState) is not null) + if (CodeOwners is not null) { return; } @@ -1036,7 +858,7 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) return; } - TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, WorkspacePath); + TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null); } } @@ -1059,147 +881,45 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } - var repositoryBoundary = GetCodeOwnersSearchBoundary(directoryInfo, basePath); - var searchCacheKey = repositoryBoundary ?? directoryInfo.FullName; - if (ShouldSkipCodeOwnersSearch(searchCacheKey)) + // Limit cache growth to avoid unbounded memory in large test suites. + if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit) { - return false; + _codeOwnersSearchStarts.Clear(); } - CodeOwnersFallbackSearchStarting?.Invoke(); - string? nearestCodeOwnersPath = null; - string? nearestCodeOwnersRoot = null; + // Skip repeated lookups for the same starting directory. + if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName)) + { + return false; + } - // When a repository boundary exists, only its repository-level CODEOWNERS locations are - // valid. If git metadata is unavailable, a containing workspace is the safest boundary. - // Retain the nearest candidate solely when neither boundary can be discovered. + // Walk parent directories until we find CODEOWNERS or hit a git boundary. while (directoryInfo != null) { - var isRepositoryBoundary = repositoryBoundary is not null && - CodeOwnersSearchComparer.Equals(directoryInfo.FullName, repositoryBoundary); if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath)) { - if (isRepositoryBoundary) + Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); + if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) { - if (PublishFallbackCodeOwners(codeOwnersPath, platform, directoryInfo.FullName)) - { - return true; - } - - CacheCodeOwnersLoadFailure(searchCacheKey, codeOwnersPath); - return false; + CodeOwners = parser; + CodeOwnersRoot = directoryInfo.FullName; + return true; } - nearestCodeOwnersPath ??= codeOwnersPath; - nearestCodeOwnersRoot ??= directoryInfo.FullName; - } - - if (isRepositoryBoundary) - { - // A nested CODEOWNERS candidate is not valid for this repository. Do not fall - // through to it when the actual repository root has no CODEOWNERS file. - CacheCodeOwnersSearch(searchCacheKey, failedCodeOwnersPath: null); return false; } - directoryInfo = directoryInfo.Parent; - } - - if (nearestCodeOwnersPath is not null && nearestCodeOwnersRoot is not null) - { - if (PublishFallbackCodeOwners(nearestCodeOwnersPath, platform, nearestCodeOwnersRoot)) + if (HasGitDirectory(directoryInfo.FullName)) { - return true; + break; } - CacheCodeOwnersLoadFailure(searchCacheKey, nearestCodeOwnersPath); - return false; - } - - CacheCodeOwnersSearch(searchCacheKey, failedCodeOwnersPath: null); - return false; - } - - private bool ShouldSkipCodeOwnersSearch(string searchCacheKey) - { - if (!_codeOwnersSearchCache.TryGetValue(searchCacheKey, out var node)) - { - return false; - } - - var entry = node.Value; - if (GetCodeOwnersUtcNow() >= entry.RetryAfterUtc) - { - RemoveCodeOwnersSearchCacheEntry(node); - return false; - } - - if (entry.FailedCodeOwnersPath is null || - entry.FileMetadata.Equals(GetCodeOwnersFileMetadata(entry.FailedCodeOwnersPath))) - { - return true; + directoryInfo = directoryInfo.Parent; } - RemoveCodeOwnersSearchCacheEntry(node); return false; } - private void CacheCodeOwnersLoadFailure(string searchCacheKey, string codeOwnersPath) - => CacheCodeOwnersSearch(searchCacheKey, codeOwnersPath); - - private void CacheCodeOwnersSearch(string searchCacheKey, string? failedCodeOwnersPath) - { - if (_codeOwnersSearchCache.TryGetValue(searchCacheKey, out var existingNode)) - { - RemoveCodeOwnersSearchCacheEntry(existingNode); - } - - while (_codeOwnersSearchCache.Count >= CodeOwnersSearchCacheLimit) - { - RemoveCodeOwnersSearchCacheEntry(_codeOwnersSearchCacheOrder.First!); - } - - var entry = new CodeOwnersSearchCacheEntry( - searchCacheKey, - failedCodeOwnersPath, - failedCodeOwnersPath is null ? default : GetCodeOwnersFileMetadata(failedCodeOwnersPath), - GetCodeOwnersUtcNow().Add(CodeOwnersSearchRetryDelay)); - var node = _codeOwnersSearchCacheOrder.AddLast(entry); - _codeOwnersSearchCache.Add(searchCacheKey, node); - } - - private void RemoveCodeOwnersSearchCacheEntry(LinkedListNode node) - { - _codeOwnersSearchCache.Remove(node.Value.Key); - _codeOwnersSearchCacheOrder.Remove(node); - } - - private void ClearCodeOwnersSearchCache() - { - _codeOwnersSearchCache.Clear(); - _codeOwnersSearchCacheOrder.Clear(); - } - - private DateTime GetCodeOwnersUtcNow() => CodeOwnersUtcNowProvider?.Invoke() ?? DateTime.UtcNow; - - private bool PublishFallbackCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) - { - Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); - return TryPublishCodeOwners(codeOwnersPath, platform, root); - } - - private bool TryPublishCodeOwners(string codeOwnersPath, CodeOwners.Platform platform, string root) - { - if (!CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) - { - return false; - } - - var state = new CodeOwnersState(parser, root, platform); - Volatile.Write(ref _codeOwnersState, state); - return true; - } - private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) { if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) @@ -1228,77 +948,4 @@ private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) return CodeOwners.Platform.GitHub; } - - private readonly struct CodeOwnersFileMetadata : IEquatable - { - public CodeOwnersFileMetadata(bool exists, long length, long lastWriteTimeUtcTicks) - { - Exists = exists; - Length = length; - LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; - } - - public bool Exists { get; } - - public long Length { get; } - - public long LastWriteTimeUtcTicks { get; } - - public bool Equals(CodeOwnersFileMetadata other) - => Exists == other.Exists && - Length == other.Length && - LastWriteTimeUtcTicks == other.LastWriteTimeUtcTicks; - - public override bool Equals(object? obj) => obj is CodeOwnersFileMetadata other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - var hashCode = Exists ? 1 : 0; - hashCode = (hashCode * 397) ^ Length.GetHashCode(); - hashCode = (hashCode * 397) ^ LastWriteTimeUtcTicks.GetHashCode(); - return hashCode; - } - } - } - - private sealed class CodeOwnersSearchCacheEntry - { - public CodeOwnersSearchCacheEntry( - string key, - string? failedCodeOwnersPath, - CodeOwnersFileMetadata fileMetadata, - DateTime retryAfterUtc) - { - Key = key; - FailedCodeOwnersPath = failedCodeOwnersPath; - FileMetadata = fileMetadata; - RetryAfterUtc = retryAfterUtc; - } - - public string Key { get; } - - public string? FailedCodeOwnersPath { get; } - - public CodeOwnersFileMetadata FileMetadata { get; } - - public DateTime RetryAfterUtc { get; } - } - - private sealed class CodeOwnersState - { - public CodeOwnersState(CodeOwners parser, string root, CodeOwners.Platform platform) - { - Parser = parser; - Root = root; - Platform = platform; - } - - public CodeOwners Parser { get; } - - public string Root { get; } - - public CodeOwners.Platform Platform { get; } - } } diff --git a/tracer/src/Datadog.Trace/Ci/Test.cs b/tracer/src/Datadog.Trace/Ci/Test.cs index c2fb99343dba..8011a11addcf 100644 --- a/tracer/src/Datadog.Trace/Ci/Test.cs +++ b/tracer/src/Datadog.Trace/Ci/Test.cs @@ -247,7 +247,7 @@ public void SetTestMethodInfo(MethodInfo methodInfo) var ciValues = TestOptimization.Instance.CIValues; var tags = (TestSpanTags)_scope.Span.Tags; - tags.SourceFile = ciValues.MakeRelativePathFromSourceRootWithFallback(methodSymbol.File, false, out var owners); + tags.SourceFile = ciValues.MakeRelativePathFromSourceRootWithFallback(methodSymbol.File, false); tags.SourceStart = startLine; tags.SourceEnd = methodSymbol.EndLine; _testOptimization.ImpactedTestsDetectionFeature?.ImpactedTestsAnalyzer.Analyze(this); @@ -259,7 +259,15 @@ public void SetTestMethodInfo(MethodInfo methodInfo) static suiteTags => suiteTags.SourceFile, static (suiteTags, value) => suiteTags.SourceFile = value); - if (owners.Length > 0) + string[]? owners; + if (ciValues.CodeOwners is { } codeOwners && + (owners = codeOwners.Match("/" + tags.SourceFile).ToArray()) is { Length: > 0 }) + { + SetCodeOwnersOnTags(tags, Suite.Tags, owners); + } + else if (ciValues.TryGetCodeOwnersRelativePath(methodSymbol.File, false, out var codeOwnersRelativePath) && + ciValues.CodeOwners is { } fallbackCodeOwners && + (owners = fallbackCodeOwners.Match("/" + codeOwnersRelativePath).ToArray()) is { Length: > 0 }) { SetCodeOwnersOnTags(tags, Suite.Tags, owners); } diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index b9daf209e304..27da08766609 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -8,9 +8,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Security; -using System.Threading; -using System.Threading.Tasks; using Datadog.Trace.Ci.CiEnvironment; using Datadog.Trace.Configuration; using Xunit; @@ -21,7 +18,6 @@ namespace Datadog.Trace.Tests.Ci; public class CodeOwnersFallbackTests { private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b"; - private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); [SkippableFact] public void UsesFallbackRootWhenSourceRootIsDifferent() @@ -153,86 +149,6 @@ public void AllowsFallbackRetryWithDifferentStartPath() Assert.Equal(new[] { "@owner" }, owners); } - [SkippableFact] - public void GitRepositoryRootCodeOwnersWinsOverNestedCandidate() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceDirectory = Path.Combine(repoRoot, "src", "nested"); - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "* @root-owner\n"); - File.WriteAllText(Path.Combine(repoRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); - var sourceFile = Path.Combine(sourceDirectory, "File.cs"); - File.WriteAllText(sourceFile, string.Empty); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - - Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); - Assert.Equal("src/nested/File.cs", relativePath); - Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); - Assert.Equal(["@root-owner"], ciValues.CodeOwners!.Match("/" + relativePath)); - } - - [SkippableFact] - public void NestedCandidateIsIgnoredWhenGitRepositoryRootHasNoCodeOwners() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceDirectory = Path.Combine(repoRoot, "src", "nested"); - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText(Path.Combine(repoRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); - var sourceFile = Path.Combine(sourceDirectory, "File.cs"); - File.WriteAllText(sourceFile, string.Empty); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - Assert.Null(ciValues.CodeOwners); - Assert.Null(ciValues.CodeOwnersRoot); - } - - [SkippableFact] - public void WorkspaceRootCodeOwnersWinsOverNestedCandidateWithoutGitMetadata() - { - using var tempDirectory = new TemporaryDirectory(); - var workspaceRoot = tempDirectory.RootPath; - var sourceDirectory = Path.Combine(workspaceRoot, "src", "nested"); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText(Path.Combine(workspaceRoot, "CODEOWNERS"), "* @workspace-owner\n"); - File.WriteAllText(Path.Combine(workspaceRoot, "src", "CODEOWNERS"), "* @nested-decoy\n"); - var sourceFile = Path.Combine(sourceDirectory, "File.cs"); - File.WriteAllText(sourceFile, string.Empty); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: workspaceRoot); - - Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); - Assert.Equal("src/nested/File.cs", relativePath); - Assert.Equal(workspaceRoot, ciValues.CodeOwnersRoot); - Assert.Equal(["@workspace-owner"], ciValues.CodeOwners!.Match("/" + relativePath)); - } - - [SkippableFact] - public void DoesNotLoadCodeOwnersAboveWorkspaceWithoutGitMetadata() - { - using var tempDirectory = new TemporaryDirectory(); - var parentRoot = tempDirectory.RootPath; - var workspaceRoot = Path.Combine(parentRoot, "workspace"); - var sourceDirectory = Path.Combine(workspaceRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText(Path.Combine(parentRoot, "CODEOWNERS"), "* @parent-decoy\n"); - var sourceFile = Path.Combine(sourceDirectory, "File.cs"); - File.WriteAllText(sourceFile, string.Empty); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: workspaceRoot); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - Assert.Null(ciValues.CodeOwners); - Assert.Null(ciValues.CodeOwnersRoot); - } - [SkippableFact] public void GitHubProviderUsesOfficialCodeOwnersLocationPriority() { @@ -452,201 +368,6 @@ public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile() Assert.Null(ciValues.CodeOwners); } - [SkippableFact] - public void LockedCodeOwnersDoesNotBreakFallbackPathResolution() - { - Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); - - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var sourceDirectory = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); - File.WriteAllText(sourceFile, "class Test {}"); - var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); - File.WriteAllText(codeOwnersPath, "*.cs @owner\n"); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - var utcNow = DateTime.UtcNow; - ciValues.CodeOwnersUtcNowProvider = () => utcNow; - - string[]? owners = null; - using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) - { - var exception = Record.Exception(() => ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!)); - - Assert.Null(exception); - Assert.Empty(owners!); - Assert.Null(ciValues.CodeOwners); - } - - // The file is readable again, but an unchanged failure is held during the backoff window. - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); - Assert.Empty(owners); - Assert.Null(ciValues.CodeOwners); - - utcNow += CIEnvironmentValues.CodeOwnersSearchRetryDelay; - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!); - Assert.Equal(["@owner"], owners); - Assert.NotNull(ciValues.CodeOwners); - } - - [SkippableFact] - public void SecurityExceptionReadingFailureMetadataDoesNotEscape() - { - Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); - - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var sourceDirectory = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); - File.WriteAllText(sourceFile, string.Empty); - var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); - File.WriteAllText(codeOwnersPath, "*.cs @owner\n"); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - var metadataReads = 0; - ciValues.BeforeCodeOwnersFileMetadataRead = path => - { - Assert.Equal(codeOwnersPath, path); - metadataReads++; - throw new SecurityException("Simulated restricted filesystem metadata access."); - }; - - string[]? owners = null; - using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) - { - var exception = Record.Exception(() => ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out owners!)); - - Assert.Null(exception); - Assert.Empty(owners!); - Assert.Null(ciValues.CodeOwners); - } - - // Both failure caching and the workspace retry must handle restricted metadata access. - Assert.Equal(2, metadataReads); - } - - [SkippableFact] - public void NegativeFallbackCacheExpiresAndDiscoversNewCodeOwners() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var sourceDirectory = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); - File.WriteAllText(sourceFile, string.Empty); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - var utcNow = DateTime.UtcNow; - var searches = 0; - ciValues.CodeOwnersUtcNowProvider = () => utcNow; - ciValues.CodeOwnersFallbackSearchStarting = () => searches++; - - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var initialOwners); - Assert.Empty(initialOwners); - Assert.Equal(1, searches); - - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "*.cs @new-owner\n"); - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var cachedOwners); - Assert.Empty(cachedOwners); - Assert.Equal(1, searches); - - utcNow += CIEnvironmentValues.CodeOwnersSearchRetryDelay; - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var discoveredOwners); - - Assert.Equal(["@new-owner"], discoveredOwners); - Assert.Equal(2, searches); - Assert.NotNull(ciValues.CodeOwners); - } - - [SkippableFact] - public void NegativeFallbackCacheIsSharedByRepositoryBoundaryBeyondItsCapacity() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - var searches = 0; - ciValues.CodeOwnersFallbackSearchStarting = () => searches++; - var sourceFiles = new List(); - - for (var i = 0; i <= 256; i++) - { - var sourceDirectory = Path.Combine(repoRoot, "project" + i, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); - File.WriteAllText(sourceFile, string.Empty); - sourceFiles.Add(sourceFile); - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - } - - foreach (var sourceFile in sourceFiles.AsEnumerable().Reverse()) - { - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - } - - Assert.Equal(1, searches); - } - - [SkippableFact] - public void ChangedCodeOwnersRetriesBeforeLoadFailureBackoffExpires() - { - Skip.If(Path.DirectorySeparatorChar != '\\', "FileShare.None deterministically blocks a second reader on Windows."); - - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); - var sourceDirectory = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "Test.cs"); - File.WriteAllText(sourceFile, string.Empty); - var codeOwnersPath = Path.Combine(repoRoot, "CODEOWNERS"); - File.WriteAllText(codeOwnersPath, "*.cs @old\n"); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - var utcNow = DateTime.UtcNow; - ciValues.CodeOwnersUtcNowProvider = () => utcNow; - - using (new FileStream(codeOwnersPath, FileMode.Open, FileAccess.Read, FileShare.None)) - { - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var owners); - Assert.Empty(owners); - } - - File.WriteAllText(codeOwnersPath, "*.cs @replacement-owner\n"); - ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false, out var changedOwners); - - Assert.Equal(["@replacement-owner"], changedOwners); - Assert.NotNull(ciValues.CodeOwners); - } - - [SkippableFact] - public void FallbackCacheEvictsOnlyTheOldestRepositoryAtCapacity() - { - using var tempDirectory = new TemporaryDirectory(); - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: null); - var searches = 0; - ciValues.CodeOwnersFallbackSearchStarting = () => searches++; - var sourceFiles = new List(); - - for (var i = 0; i <= 256; i++) - { - var repository = Path.Combine(tempDirectory.RootPath, "repo" + i); - Directory.CreateDirectory(Path.Combine(repository, ".git")); - var sourceFile = Path.Combine(repository, "src", "Test.cs"); - sourceFiles.Add(sourceFile); - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _)); - } - - Assert.Equal(257, searches); - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFiles[1], false, out _)); - Assert.True(searches == 257, "the second-oldest entry must survive a single FIFO eviction"); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFiles[0], false, out _)); - Assert.True(searches == 258, "only the oldest entry should have been evicted"); - } - [SkippableFact] public void AnchorsForeignRelativePathsToCodeOwnersRoot() { @@ -704,30 +425,6 @@ public void AnchoredPathRespectsUseOSSeparator() Assert.Equal(Path.Combine("tracer", "test", "SpanBenchmark.cs"), osPath); } - [SkippableFact] - public void MakeRelativePathFromSourceRootWithFallbackNormalizesForeignPrefixes() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "tracer", "test"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("../../../_/tracer/test/SpanBenchmark.cs", false); - Assert.Equal("tracer/test/SpanBenchmark.cs", relative); - } - [SkippableFact] public void DoesNotAnchorForeignPathsWhenSuffixDoesNotExistUnderRoot() { @@ -817,10 +514,10 @@ public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string }; var ciValues = CIEnvironmentValues.Create(env); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(compilerPath, false, out var owners); + var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(compilerPath, false); Assert.Equal(expectedRelativePath, relative); - Assert.Equal(["@DataDog/tracing-dotnet"], owners); + Assert.Equal(["@DataDog/tracing-dotnet"], ciValues.CodeOwners!.Match("/" + relative)); } [SkippableFact] @@ -921,239 +618,6 @@ public void DoesNotAnchorAbsoluteOrEmbeddedRootedPathsWithMatchingRepositorySuff Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFilePath, false, out _)); } - [SkippableFact] - public void DoesNotAnchorEmbeddedWindowsAbsolutePathOutsideRoot() - { - Skip.If(Path.DirectorySeparatorChar != '\\', "This regression exercises Windows drive-rooted Path.Combine behavior."); - - using var repoDirectory = new TemporaryDirectory(); - using var externalDirectory = new TemporaryDirectory(); - var repoRoot = repoDirectory.RootPath; - Directory.CreateDirectory(Path.Combine(repoRoot, "src")); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); - File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}"); - - var externalFile = Path.Combine(externalDirectory.RootPath, "src", "SpanBenchmark.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!); - File.WriteAllText(externalFile, "class ExternalSpanBenchmark {}"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - var embeddedAbsolutePath = "../../" + externalFile.Replace('\\', '/'); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(embeddedAbsolutePath, false, out _)); - } - - [SkippableFact] - public void ConcurrentFallbackPublishesCodeOwnersAndRootTogether() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var srcDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(srcDir); - var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs"); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot); - const int concurrency = 64; - var results = new bool[concurrency]; - var tasks = new Task[concurrency]; - using var start = new ManualResetEventSlim(initialState: false); - - for (var i = 0; i < concurrency; i++) - { - var index = i; - tasks[index] = Task.Run(() => - { - Assert.True(start.Wait(TestTimeout), "concurrent fallback start signal was not received"); - results[index] = ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath) && - relativePath == "src/SpanBenchmark.cs"; - }); - } - - start.Set(); - Assert.True(Task.WaitAll(tasks, TestTimeout), "concurrent fallback lookup must not deadlock"); - - Assert.All(results, Assert.True); - Assert.NotNull(ciValues.CodeOwners); - Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); - } - - [SkippableFact] - public void RelativePathAndOwnersUseTheSameSnapshotAcrossControlledReload() - { - using var firstRepository = new TemporaryDirectory(); - using var secondRepository = new TemporaryDirectory(); - var firstSourceDirectory = Path.Combine(firstRepository.RootPath, "layoutA", "src"); - var secondSourceDirectory = Path.Combine(secondRepository.RootPath, "src"); - Directory.CreateDirectory(firstSourceDirectory); - Directory.CreateDirectory(secondSourceDirectory); - File.WriteAllText(Path.Combine(firstSourceDirectory, "SpanBenchmark.cs"), string.Empty); - File.WriteAllText(Path.Combine(secondSourceDirectory, "SpanBenchmark.cs"), string.Empty); - File.WriteAllText(Path.Combine(firstRepository.RootPath, "CODEOWNERS"), "* @first-global\n/layoutA/src/ @first\n"); - File.WriteAllText(Path.Combine(secondRepository.RootPath, "CODEOWNERS"), "* @second-global\n/src/ @second\n"); - - using var setupEntered = new ManualResetEventSlim(initialState: false); - using var continueSetup = new ManualResetEventSlim(initialState: false); - using var reloadWaitReached = new ManualResetEventSlim(initialState: false); - var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); - ciValues.BeforeCodeOwnersReloadWait = reloadWaitReached.Set; - ciValues.Reload(); - const string foreignSourcePath = "../../layoutA/src/SpanBenchmark.cs"; - - var firstRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out var firstOwners); - Assert.Equal("layoutA/src/SpanBenchmark.cs", firstRelativePath); - Assert.Equal(["@first"], firstOwners); - - ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); - var reloadTask = Task.Run(ciValues.Reload); - Task? matchTask = null; - string? secondRelativePath = null; - string[]? secondOwners = null; - try - { - Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); - matchTask = Task.Run(() => secondRelativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignSourcePath, false, out secondOwners)); - Assert.True(reloadWaitReached.Wait(TestTimeout), "snapshot reader did not reach the active-reload wait"); - } - finally - { - continueSetup.Set(); - } - - Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); - Assert.NotNull(matchTask); - Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); - Assert.Equal("src/SpanBenchmark.cs", secondRelativePath); - Assert.NotNull(secondOwners); - Assert.Equal(["@second"], secondOwners); - } - - [SkippableFact] - public void RelativePathUsesCompletedReloadStateWhenNewRepositoryHasNoCodeOwners() - { - using var firstRepository = new TemporaryDirectory(); - using var secondRepository = new TemporaryDirectory(); - var firstSourceDirectory = Path.Combine(firstRepository.RootPath, "src"); - var secondSourceDirectory = Path.Combine(secondRepository.RootPath, "src"); - Directory.CreateDirectory(firstSourceDirectory); - Directory.CreateDirectory(secondSourceDirectory); - File.WriteAllText(Path.Combine(firstRepository.RootPath, "CODEOWNERS"), "* @first\n"); - File.WriteAllText(Path.Combine(firstSourceDirectory, "SpanBenchmark.cs"), string.Empty); - var secondSource = Path.Combine(secondSourceDirectory, "SpanBenchmark.cs"); - File.WriteAllText(secondSource, string.Empty); - - using var setupEntered = new ManualResetEventSlim(initialState: false); - using var continueSetup = new ManualResetEventSlim(initialState: false); - using var reloadWaitReached = new ManualResetEventSlim(initialState: false); - var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); - ciValues.BeforeCodeOwnersReloadWait = reloadWaitReached.Set; - ciValues.Reload(); - - ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); - var reloadTask = Task.Run(ciValues.Reload); - Task? matchTask = null; - string? relativePath = null; - string[]? owners = null; - try - { - Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); - matchTask = Task.Run(() => relativePath = ciValues.MakeRelativePathFromSourceRootWithFallback(secondSource, false, out owners)); - Assert.True(reloadWaitReached.Wait(TestTimeout), "snapshot reader did not reach the active-reload wait"); - } - finally - { - continueSetup.Set(); - } - - Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); - Assert.NotNull(matchTask); - Assert.True(matchTask!.Wait(TestTimeout), "snapshot reader must not deadlock after reload"); - Assert.Equal("src/SpanBenchmark.cs", relativePath); - Assert.Empty(owners!); - Assert.Null(ciValues.CodeOwners); - } - - [SkippableFact] - public void MalformedGitLabClassDoesNotAbortFallbackPublication() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceDirectory = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDirectory); - var sourceFile = Path.Combine(sourceDirectory, "file.txt"); - File.WriteAllText(sourceFile, string.Empty); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @fallback\nfile[z-a].txt @invalid\n"); - - var ciValues = new TestCIEnvironmentValues(sourceRoot: null, workspacePath: repoRoot, provider: "gitlab"); - - Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var relativePath)); - Assert.Equal("src/file.txt", relativePath); - Assert.Equal(["@fallback"], ciValues.CodeOwners!.Match("/" + relativePath)); - Assert.Equal(repoRoot, ciValues.CodeOwnersRoot); - } - - [SkippableFact] - public void ReloadAndFallbackDiscoveryAreSerialized() - { - using var firstRepository = new TemporaryDirectory(); - using var secondRepository = new TemporaryDirectory(); - var firstSource = CreateRepository(firstRepository.RootPath, "@first"); - var secondSource = CreateRepository(secondRepository.RootPath, "@second"); - using var setupEntered = new ManualResetEventSlim(initialState: false); - using var continueSetup = new ManualResetEventSlim(initialState: false); - using var fallbackLockReached = new ManualResetEventSlim(initialState: false); - - var ciValues = new BlockingReloadCIEnvironmentValues(firstRepository.RootPath); - ciValues.BeforeCodeOwnersFallbackLock = fallbackLockReached.Set; - ciValues.Reload(); - Assert.True(ciValues.TryGetCodeOwnersRelativePath(firstSource, false, out _)); - - ciValues.PrepareBlockedReload(secondRepository.RootPath, setupEntered, continueSetup); - var reloadTask = Task.Run(ciValues.Reload); - Task? fallbackTask = null; - var fallbackResult = false; - try - { - Assert.True(setupEntered.Wait(TestTimeout), "reload did not enter its controlled Setup phase"); - fallbackTask = Task.Run(() => - { - fallbackResult = ciValues.TryGetCodeOwnersRelativePath(secondSource, false, out var relativePath) && - relativePath == "src/SpanBenchmark.cs"; - }); - Assert.True(fallbackLockReached.Wait(TestTimeout), "fallback lookup did not reach the serialized discovery lock"); - } - finally - { - continueSetup.Set(); - } - - Assert.True(reloadTask.Wait(TestTimeout), "reload must complete after Setup is released"); - Assert.NotNull(fallbackTask); - Assert.True(fallbackTask!.Wait(TestTimeout), "fallback lookup must not deadlock after reload"); - - Assert.True(fallbackResult); - Assert.Equal(secondRepository.RootPath, ciValues.CodeOwnersRoot); - Assert.Equal(["@second"], ciValues.CodeOwners!.Match("/src/SpanBenchmark.cs")); - - static string CreateRepository(string root, string owner) - { - var sourceDirectory = Path.Combine(root, "src"); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText(Path.Combine(root, "CODEOWNERS"), "* @global\n/src/ " + owner + "\n"); - var sourceFile = Path.Combine(sourceDirectory, "SpanBenchmark.cs"); - File.WriteAllText(sourceFile, "class SpanBenchmark {}"); - return sourceFile; - } - } - private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() @@ -1214,37 +678,4 @@ protected override void Setup(IGitInfo gitInfo) Provider = _provider; } } - - private sealed class BlockingReloadCIEnvironmentValues : CIEnvironmentValues - { - private string _nextSourceRoot; - private ManualResetEventSlim? _setupEntered; - private ManualResetEventSlim? _continueSetup; - - public BlockingReloadCIEnvironmentValues(string sourceRoot) - { - _nextSourceRoot = sourceRoot; - } - - public void Reload() => ReloadEnvironmentData(); - - public void PrepareBlockedReload(string sourceRoot, ManualResetEventSlim setupEntered, ManualResetEventSlim continueSetup) - { - _nextSourceRoot = sourceRoot; - _setupEntered = setupEntered; - _continueSetup = continueSetup; - } - - protected override void Setup(IGitInfo gitInfo) - { - _setupEntered?.Set(); - if (_continueSetup is not null && !_continueSetup.Wait(TestTimeout)) - { - throw new TimeoutException("Controlled reload was not released by the test."); - } - - SourceRoot = _nextSourceRoot; - WorkspacePath = _nextSourceRoot; - } - } } From d2071374eed122a199d9855071ae1547cd2512ab Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 14:18:43 +0200 Subject: [PATCH 19/25] [CI Visibility] Simplify CODEOWNERS matching internals --- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 560 +++++++--------------- 1 file changed, 184 insertions(+), 376 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 42912ca233fd..b93c8ab6d418 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -9,7 +9,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; -using System.Linq; using System.Security; using System.Text; using System.Text.RegularExpressions; @@ -18,13 +17,7 @@ namespace Datadog.Trace.Ci { /// - /// A CODEOWNERS parser that follows the GitHub and GitLab specifications: last matching rule wins, - /// rooted and unrooted (globstar-relative) paths, directory and wildcard patterns, globstars (**), - /// inline comments (GitHub), sections with default owners, optional sections, approval counts, - /// role owners (@@role) and exclusion patterns (GitLab). Matching is case-sensitive. - /// Usage: - /// var owners = new CodeOwners(pathToFile, CodeOwners.Platform.GitLab).Match("src/app/Program.cs"); - /// // owners is an IEnumerable{string} of unique owners that apply to that file. + /// Parses and matches GitHub and GitLab CODEOWNERS files. /// internal sealed class CodeOwners { @@ -168,7 +161,6 @@ private static List
Parse(IEnumerable lines, Platform platform, currentDefaultOwners = newSection.DefaultOwners; if (namedSections is not null && namedSections.TryGetValue(newSection.Name, out var existingSection)) { - existingSection.MergeMetadata(newSection); current = existingSection; } else @@ -265,7 +257,7 @@ private static bool TryParseSectionHeader( // after a malformed suffix (for example an extra ']') must never leak into defaults. var defaults = OwnerTokenizer.Tokenize(m.Groups["defaults"].Value, platform, out var allDefaultsValid); hasDiagnostics |= !allDefaultsValid; - section = new Section(name, required, approvals, defaults); + section = new Section(name, defaults); return true; } @@ -340,15 +332,6 @@ private static bool IsStrictSectionOwnerCharacter(char character) return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; } - /// - /// Compiles a CODEOWNERS-style glob into a deterministic matcher. - /// Supports **, *, ?, rooted paths, and trailing slash semantics. - /// Both platforms support escaped literals; GitLab additionally supports shell-style character classes. - /// Matching is deterministic and bounded by the pattern and path lengths, without regex backtracking. - /// - private static GlobPattern? CompileGlob(string pattern, Platform platform, bool includeDescendants) - => GlobPattern.Compile(pattern, platform, includeDescendants); - #pragma warning disable SA1201 public enum Platform #pragma warning restore SA1201 @@ -364,69 +347,6 @@ private enum CharacterClassParseResult Invalid } - private enum SegmentTokenType - { - Literal, - AnyCharacter, - Star, - CharacterClass - } - - private readonly struct SegmentToken - { - private readonly SegmentTokenType _type; - private readonly char _literal; - private readonly GlobCharacterClass? _characterClass; - - private SegmentToken(SegmentTokenType type, char literal = default, GlobCharacterClass? characterClass = null) - { - _type = type; - _literal = literal; - _characterClass = characterClass; - } - - public static SegmentToken Star { get; } = new(SegmentTokenType.Star); - - public static SegmentToken AnyCharacter { get; } = new(SegmentTokenType.AnyCharacter); - - public bool IsStar => _type == SegmentTokenType.Star; - - public static SegmentToken Literal(char value) => new(SegmentTokenType.Literal, literal: value); - - public static SegmentToken CharacterClass(GlobCharacterClass value) => new(SegmentTokenType.CharacterClass, characterClass: value); - - public bool Matches(char value) - => _type == SegmentTokenType.AnyCharacter || - (_type == SegmentTokenType.Literal && value == _literal) || - (_type == SegmentTokenType.CharacterClass && _characterClass!.Matches(value)); - } - - private readonly struct CharacterClassAtom - { - public CharacterClassAtom(char value, bool escaped) - { - Value = value; - Escaped = escaped; - } - - public char Value { get; } - - public bool Escaped { get; } - } - - private readonly struct CharacterRange - { - public CharacterRange(char start, char end) - { - Start = start; - End = end; - } - - public char Start { get; } - - public char End { get; } - } - private readonly struct GlobPathSegment { private readonly SegmentPattern? _segment; @@ -493,12 +413,48 @@ private static void AddUnique(List owners, HashSet uniqueOwners, } private static bool IsValidGitHubOwner(string token) - => IsValidGitHubNamespaceReference(token) || IsWholeEmailReference(token); + { + if (token.Length > 1 && token[0] == '@' && token[1] != '@') + { + var slash = token.IndexOf('/'); + return slash < 0 + ? IsValidGitHubIdentifier(token, 1, token.Length) + : token.IndexOf('/', slash + 1) < 0 && + IsValidGitHubIdentifier(token, 1, slash) && + IsValidGitHubIdentifier(token, slash + 1, token.Length); + } + + var at = token.IndexOf('@'); + if (at is < 1 or > 100 || + token.Length - at - 1 is < 1 or > 255 || + token.IndexOf('@', at + 1) >= 0) + { + return false; + } + + for (var i = 0; i < at; i++) + { + if (!IsEmailLocalCharacter(token[i])) + { + return false; + } + } + + for (var i = at + 1; i < token.Length; i++) + { + if (!IsAsciiLetterOrDigit(token[i]) && token[i] is not '.' and not '-' and not '_') + { + return false; + } + } + + return IsAsciiLetterOrDigit(token[token.Length - 1]) || token[token.Length - 1] == '_'; + } private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) { // Keep the overwhelmingly common canonical forms allocation-light. - if (IsValidNamespaceReference(token) || IsValidGitLabRole(token)) + if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) { AddUnique(owners, uniqueOwners, token); return true; @@ -508,9 +464,9 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS // the entire token verbatim (for example "(@team)" becomes "@team"). var foundReference = false; var searchStart = 0; - string? reference; - while (TryExtractNamespaceReference(token, searchStart, out reference, out searchStart)) + while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart)) { + var reference = token.Substring(referenceStart, referenceEnd - referenceStart); AddUnique(owners, uniqueOwners, reference); foundReference = true; } @@ -543,7 +499,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS } private static bool ContainsNamespaceReference(string value) - => TryExtractNamespaceReference(value, 0, out _, out _); + => TryFindNamespaceReference(value, 0, out _, out _, out _); private static bool IsValidGitLabRole(string token) { @@ -561,29 +517,8 @@ private static bool IsValidGitLabRole(string token) role.Equals("owners", StringComparison.OrdinalIgnoreCase); } - private static bool IsValidNamespaceReference(string token) - => token.Length > 1 && - token[0] == '@' && - token[1] != '@' && - IsValidNamespace(token, 1, token.Length); - - private static bool IsValidGitHubNamespaceReference(string token) - { - if (token.Length <= 1 || token[0] != '@' || token[1] == '@') - { - return false; - } - - var slash = token.IndexOf('/'); - if (slash < 0) - { - return IsValidGitHubIdentifier(token, 1, token.Length); - } - - return token.IndexOf('/', slash + 1) < 0 && - IsValidGitHubIdentifier(token, 1, slash) && - IsValidGitHubIdentifier(token, slash + 1, token.Length); - } + private static bool IsWholeNamespaceReference(string token) + => TryFindNamespaceReference(token, 0, out var start, out var end, out _) && start == 0 && end == token.Length; private static bool IsValidGitHubIdentifier(string value, int start, int end) { @@ -612,34 +547,11 @@ private static bool IsValidGitHubIdentifier(string value, int start, int end) return true; } - private static bool IsValidNamespace(string value, int start, int end) - { - var segmentStart = start; - for (var i = start; i < end; i++) - { - var character = value[i]; - if (character == '/') - { - if (i == segmentStart || !IsNamespaceEnd(value[i - 1])) - { - return false; - } - - segmentStart = i + 1; - } - else if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character)) - { - return false; - } - } - - return segmentStart < end && IsNamespaceEnd(value[end - 1]); - } - - private static bool TryExtractNamespaceReference( + private static bool TryFindNamespaceReference( string token, int searchStart, - [NotNullWhen(true)] out string? reference, + out int referenceStart, + out int referenceEnd, out int nextSearchStart) { for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) @@ -682,21 +594,19 @@ private static bool TryExtractNamespaceReference( if (lastValidEnd > atIndex + 1) { - reference = token.Substring(atIndex, lastValidEnd - atIndex); + referenceStart = atIndex; + referenceEnd = lastValidEnd; nextSearchStart = lastValidEnd; return true; } } - reference = null; + referenceStart = -1; + referenceEnd = -1; nextSearchStart = token.Length; return false; } - private static bool IsWholeEmailReference(string token) - => TryExtractEmailReference(token, out var reference) && - reference.Length == token.Length; - private static bool TryExtractGitLabEmailReference( string token, int searchStart, @@ -753,53 +663,6 @@ private static bool TryExtractGitLabEmailReference( return false; } - private static bool TryExtractEmailReference(string token, [NotNullWhen(true)] out string? reference) - { - for (var atIndex = token.IndexOf('@'); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) - { - if (atIndex == 0) - { - continue; - } - - var localStart = atIndex - 1; - while (localStart >= 0 && IsEmailLocalCharacter(token[localStart])) - { - localStart--; - } - - localStart++; - var localLength = atIndex - localStart; - if (localLength is < 1 or > 100) - { - continue; - } - - var domainEnd = atIndex + 1; - var lastValidDomainEnd = -1; - while (domainEnd < token.Length && IsEmailDomainCharacter(token[domainEnd])) - { - if (IsWordCharacter(token[domainEnd])) - { - lastValidDomainEnd = domainEnd + 1; - } - - domainEnd++; - } - - if (lastValidDomainEnd <= atIndex + 1 || lastValidDomainEnd - atIndex - 1 > 255) - { - continue; - } - - reference = token.Substring(localStart, lastValidDomainEnd - localStart); - return true; - } - - reference = null; - return false; - } - private static bool IsNamespaceStart(char character) => IsAsciiLetterOrDigit(character) || character is '_' or '.'; @@ -812,9 +675,6 @@ private static bool IsNamespaceEnd(char character) private static bool IsEmailLocalCharacter(char character) => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; - private static bool IsEmailDomainCharacter(char character) - => IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_'; - private static bool IsWordCharacter(char character) => char.IsLetterOrDigit(character) || character == '_'; @@ -1029,53 +889,39 @@ private sealed class SegmentPattern private const int MaximumPatternLength = 1_024; private const int MaximumMatchSteps = 65_536; - private readonly SegmentToken[] _tokens; + private readonly string _pattern; + private readonly Platform _platform; - private SegmentPattern(SegmentToken[] tokens) + private SegmentPattern(string pattern, Platform platform) { - _tokens = tokens; + _pattern = pattern; + _platform = platform; } public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(true)] out SegmentPattern? segment) { - // Repository path components are short, so larger segment patterns cannot provide - // useful ownership matches and can make wildcard retries disproportionately costly. if (pattern.Length > MaximumPatternLength) { segment = null; return false; } - var tokens = new List(pattern.Length); for (var i = 0; i < pattern.Length; i++) { var character = pattern[i]; if (character == '\\') { - // Both gitignore-style GitHub patterns and GitLab File.fnmatch patterns use - // a backslash to escape the following character. A trailing backslash is invalid. if (i + 1 >= pattern.Length) { segment = null; return false; } - tokens.Add(SegmentToken.Literal(pattern[++i])); - } - else if (character == '*') - { - if (tokens.Count == 0 || !tokens[tokens.Count - 1].IsStar) - { - tokens.Add(SegmentToken.Star); - } - } - else if (character == '?') - { - tokens.Add(SegmentToken.AnyCharacter); + i++; } else if (platform == Platform.GitLab && character == '[') { - var result = TryParseCharacterClass(pattern, i, out var closingBracket, out var characterClass); + var result = TryParseCharacterClass(pattern, i, default, evaluate: false, out var closingBracket, out _); if (result == CharacterClassParseResult.Invalid) { segment = null; @@ -1084,79 +930,25 @@ public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(tr if (result == CharacterClassParseResult.Success) { - tokens.Add(SegmentToken.CharacterClass(characterClass!)); i = closingBracket; } - else - { - tokens.Add(SegmentToken.Literal(character)); - } - } - else - { - tokens.Add(SegmentToken.Literal(character)); } } - segment = new SegmentPattern(tokens.ToArray()); + segment = new SegmentPattern(pattern, platform); return true; } - public bool IsMatch(string path, int start, int end) - { - var tokenIndex = 0; - var pathIndex = start; - var starTokenIndex = -1; - var starPathIndex = -1; - var remainingSteps = MaximumMatchSteps; - - while (pathIndex < end) - { - if (remainingSteps-- == 0) - { - // Bound adversarial star/suffix retries in the instrumented process. - return false; - } - - if (tokenIndex < _tokens.Length && _tokens[tokenIndex].IsStar) - { - starTokenIndex = tokenIndex++; - starPathIndex = pathIndex; - continue; - } - - if (tokenIndex < _tokens.Length && _tokens[tokenIndex].Matches(path[pathIndex])) - { - tokenIndex++; - pathIndex++; - continue; - } - - if (starTokenIndex < 0) - { - return false; - } - - tokenIndex = starTokenIndex + 1; - pathIndex = ++starPathIndex; - } - - while (tokenIndex < _tokens.Length && _tokens[tokenIndex].IsStar) - { - tokenIndex++; - } - - return tokenIndex == _tokens.Length; - } - private static CharacterClassParseResult TryParseCharacterClass( string pattern, int openingBracket, + char value, + bool evaluate, out int closingBracket, - [NotNullWhen(true)] out GlobCharacterClass? characterClass) + out bool matches) { closingBracket = -1; - characterClass = null; + matches = false; var contentStart = openingBracket + 1; var negated = contentStart < pattern.Length && pattern[contentStart] is '!' or '^'; var atomStart = negated ? contentStart + 1 : contentStart; @@ -1183,70 +975,139 @@ private static CharacterClassParseResult TryParseCharacterClass( return CharacterClassParseResult.NotAClass; } - var atoms = new List(); - for (var i = atomStart; i < closingBracket; i++) + if (atomStart == closingBracket) { - if (pattern[i] == '\\' && i + 1 < closingBracket) + return CharacterClassParseResult.Invalid; + } + + var atomIndex = atomStart; + while (atomIndex < closingBracket) + { + var rangeStart = ReadCharacterClassAtom(pattern, ref atomIndex, closingBracket, out _); + var lookahead = atomIndex; + var separatorEscaped = false; + var separator = lookahead < closingBracket + ? ReadCharacterClassAtom(pattern, ref lookahead, closingBracket, out separatorEscaped) + : default; + if (separator == '-' && !separatorEscaped && lookahead < closingBracket) { - atoms.Add(new CharacterClassAtom(pattern[++i], escaped: true)); + var rangeEnd = ReadCharacterClassAtom(pattern, ref lookahead, closingBracket, out _); + if (rangeStart > rangeEnd) + { + return CharacterClassParseResult.Invalid; + } + + if (evaluate && value >= rangeStart && value <= rangeEnd) + { + matches = true; + } + + atomIndex = lookahead; } - else + else if (evaluate && value == rangeStart) { - atoms.Add(new CharacterClassAtom(pattern[i], escaped: false)); + matches = true; } } - if (atoms.Count == 0) + matches = negated ? !matches : matches; + return CharacterClassParseResult.Success; + } + + private static char ReadCharacterClassAtom( + string pattern, + ref int index, + int closingBracket, + out bool escaped) + { + escaped = pattern[index] == '\\' && index + 1 < closingBracket; + if (escaped) { - return CharacterClassParseResult.Invalid; + index++; } - var ranges = new List(atoms.Count); - for (var i = 0; i < atoms.Count; i++) + return pattern[index++]; + } + + public bool IsMatch(string path, int start, int end) + { + var patternIndex = 0; + var pathIndex = start; + var starPatternIndex = -1; + var starPathIndex = -1; + var remainingSteps = MaximumMatchSteps; + + while (pathIndex < end) { - if (i + 2 < atoms.Count && atoms[i + 1].Value == '-' && !atoms[i + 1].Escaped) + if (remainingSteps-- == 0) { - if (atoms[i].Value > atoms[i + 2].Value) + return false; + } + + if (patternIndex < _pattern.Length && _pattern[patternIndex] == '*') + { + do { - return CharacterClassParseResult.Invalid; + patternIndex++; } + while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*'); - ranges.Add(new CharacterRange(atoms[i].Value, atoms[i + 2].Value)); - i += 2; + starPatternIndex = patternIndex; + starPathIndex = pathIndex; + continue; } - else + + if (TryMatchToken(patternIndex, path[pathIndex], out var nextPatternIndex)) { - ranges.Add(new CharacterRange(atoms[i].Value, atoms[i].Value)); + patternIndex = nextPatternIndex; + pathIndex++; + continue; } - } - characterClass = new GlobCharacterClass(negated, ranges.ToArray()); - return CharacterClassParseResult.Success; - } - } + if (starPatternIndex < 0) + { + return false; + } - private sealed class GlobCharacterClass - { - private readonly bool _negated; - private readonly CharacterRange[] _ranges; + patternIndex = starPatternIndex; + pathIndex = ++starPathIndex; + } - public GlobCharacterClass(bool negated, CharacterRange[] ranges) - { - _negated = negated; - _ranges = ranges; + while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*') + { + patternIndex++; + } + + return patternIndex == _pattern.Length; } - public bool Matches(char value) + private bool TryMatchToken(int patternIndex, char value, out int nextPatternIndex) { - foreach (var range in _ranges) + if (patternIndex >= _pattern.Length) + { + nextPatternIndex = patternIndex; + return false; + } + + var token = _pattern[patternIndex]; + if (token == '\\') + { + nextPatternIndex = patternIndex + 2; + return _pattern[patternIndex + 1] == value; + } + + if (_platform == Platform.GitLab && token == '[') { - if (value >= range.Start && value <= range.End) + var result = TryParseCharacterClass(_pattern, patternIndex, value, evaluate: true, out var closingBracket, out var matches); + if (result == CharacterClassParseResult.Success) { - return !_negated; + nextPatternIndex = closingBracket + 1; + return matches; } } - return _negated; + nextPatternIndex = patternIndex + 1; + return token == '?' || token == value; } } @@ -1256,23 +1117,17 @@ private sealed class Section private Entry[]? _cache; private bool _replaceDuplicatePatterns; - public Section(string name, bool required, int approvalCount, string[] defaultOwners) + public Section(string name, string[] defaultOwners) { Name = name; - Required = required; - ApprovalCount = approvalCount; DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; } public string Name { get; } - public bool Required { get; private set; } - - public int ApprovalCount { get; private set; } - public string[] DefaultOwners { get; } - public static Section CreateUnnamed() => new(string.Empty, required: true, approvalCount: 0, defaultOwners: []); + public static Section CreateUnnamed() => new(string.Empty, []); public void Add(Entry entry, bool replaceDuplicatePattern) { @@ -1280,46 +1135,23 @@ public void Add(Entry entry, bool replaceDuplicatePattern) _entries.Add(entry); } - public void MergeMetadata(Section other) - { - // Duplicate GitLab sections are combined case-insensitively. The most restrictive - // requirement wins; matching defaults remain attached to entries from each header. - Required |= other.Required; - ApprovalCount = Math.Max(ApprovalCount, other.ApprovalCount); - } - public void Seal() { - if (_replaceDuplicatePatterns) - { - // GitLab replaces duplicate normalized patterns and moves the replacement to - // the end. Build the reverse-order cache in one pass instead of repeatedly - // removing from the middle of the list. - var seenPatterns = new HashSet(StringComparer.Ordinal); - var cache = new List(_entries.Count); - for (var i = _entries.Count - 1; i >= 0; i--) + var seenPatterns = _replaceDuplicatePatterns ? new HashSet(StringComparer.Ordinal) : null; + var cache = new List(_entries.Count); + for (var i = _entries.Count - 1; i >= 0; i--) + { + var entry = _entries[i]; + if (seenPatterns is null || seenPatterns.Add(entry.PatternKey)) { - var entry = _entries[i]; - if (seenPatterns.Add(entry.PatternKey)) - { - cache.Add(entry); - } + cache.Add(entry); } - - _cache = cache.ToArray(); - } - else - { - _cache = _entries.AsEnumerable().Reverse().ToArray(); } + _cache = cache.ToArray(); _entries.Clear(); } - /// - /// GitHub evaluation: exclusion rules are unsupported and ignored, section default owners don't - /// exist, and the caller stops at the first (i.e. last in file order) matching rule. - /// public bool TryMatchGitHub(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; @@ -1339,23 +1171,13 @@ public bool TryMatchGitHub(string path, [NotNullWhen(true)] out string[]? owners return false; } - /// - /// GitLab evaluation: rules are evaluated in file order within the section; the last matching - /// entry wins, an exclusion exempts the path for the whole section (later rules cannot - /// re-include it), and entries without owners inherit the section default owners. - /// public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; string[]? matchedOwners = null; - var excluded = false; - // The cache holds entries in reverse file order, so iterating from the end evaluates - // rules in file order: each match overwrites the previous one, leaving the last - // matching rule's owners. - for (var i = rules.Length - 1; i >= 0; i--) + foreach (var rule in rules) { - var rule = rules[i]; if (!rule.Match(path)) { continue; @@ -1363,15 +1185,14 @@ public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners if (rule.IsExclusion) { - // Exclusions are terminal for the section: later rules cannot re-include the path. - excluded = true; - break; + owners = null; + return false; } - matchedOwners = rule.Owners; + matchedOwners ??= rule.Owners; } - if (excluded || matchedOwners is null || matchedOwners.Length == 0) + if (matchedOwners is null || matchedOwners.Length == 0) { owners = null; return false; @@ -1405,12 +1226,9 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne hasDiagnostics = false; if (platform == Platform.GitHub && raw.StartsWith("\\#")) { - // GitHub does not support escaping a leading #; the line is invalid, not a - // literal pattern beginning with #. return null; } - // Strip inline comments for GitHub. GitLab treats everything after # as data (inline comments unsupported). var idxHash = platform == Platform.GitHub ? FindUnescapedCharacter(raw, '#') : -1; var effective = idxHash >= 0 && platform == Platform.GitHub ? raw.Substring(0, idxHash).TrimEnd() : raw; if (string.IsNullOrWhiteSpace(effective)) @@ -1418,14 +1236,11 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne return null; } - // 2. Tokenise on unescaped whitespace. Both platforms support escaped literals - // in patterns; owner tokens themselves are not unescaped. string patternToken; string ownersSegment; bool hasExplicitOwners; SplitEscapedEntry(effective, out patternToken, out ownersSegment, out hasExplicitOwners); - // 3. Pattern & exclusion var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!"); if (isExclusion) { @@ -1434,8 +1249,6 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne if (platform == Platform.GitHub && IsUnsupportedGitHubPattern(patternToken)) { - // GitHub skips invalid CODEOWNERS lines instead of interpreting unsupported - // gitignore constructs as literal file names. return null; } @@ -1444,7 +1257,6 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne return null; } - // 4. Owners. GitLab exclusions deliberately ignore any trailing owner text. string[] owners; var allOwnersValid = true; if (isExclusion) @@ -1459,7 +1271,6 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne hasDiagnostics = !allOwnersValid; if (platform == Platform.GitHub && hasDiagnostics) { - // GitHub skips a whole rule containing a malformed owner token. return null; } @@ -1470,8 +1281,6 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne if (platform == Platform.GitLab && !isExclusion && owners.Length == 0) { - // GitLab keeps an ownerless rule because it can intentionally auto-approve a - // path, but reports the missing owner as a parsing diagnostic. hasDiagnostics = true; } @@ -1479,8 +1288,7 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne // `**/logs`); GitLab requires an explicit trailing slash for directory ownership. var isDirectoryPattern = platform == Platform.GitHub && IsDirectoryPattern(patternToken); - // 5. Compile the glob - var glob = CompileGlob(patternToken, platform, includeDescendants: isDirectoryPattern); + var glob = GlobPattern.Compile(patternToken, platform, includeDescendants: isDirectoryPattern); if (glob is null) { return null; From e7b40605ca22635fa0310bdc4e909c09931e0ca7 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 14:51:21 +0200 Subject: [PATCH 20/25] [CI Visibility] Separate CODEOWNERS platform handling --- .../Ci/CiEnvironment/CIEnvironmentValues.cs | 75 +-- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 452 ++++++++++-------- .../Ci/CodeOwnersFallbackTests.cs | 58 +-- .../Ci/CodeOwnersSpecTests.cs | 10 + 4 files changed, 293 insertions(+), 302 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 18c7e63683b8..6242b0eca205 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -712,62 +712,36 @@ private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwn var normalizedPath = sourceFilePath.Replace('\\', '/'); var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); - var start = -1; - - // Azure may build on one OS and run tests on another. Its checkout marker gives us an - // unambiguous repository-relative suffix even when the recorded path is absolute here. - if (string.Equals(Provider, "azurepipelines", StringComparison.Ordinal) && - !sourceFilePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) && - (!Uri.TryCreate(sourceFilePath, UriKind.Absolute, out var uri) || uri.IsFile)) + if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) { - for (var i = 2; i < segments.Length - 1; i++) - { - if (segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) && - int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) && - (segments[i - 2].Equals("a", StringComparison.OrdinalIgnoreCase) || - segments[i - 2].Equals("work", StringComparison.OrdinalIgnoreCase) || - segments[i - 2].Equals("_work", StringComparison.OrdinalIgnoreCase))) - { - start = i + 1; - break; - } - } + return false; } - var isAzureCheckoutPath = start >= 0; - if (!isAzureCheckoutPath) + var pathWithoutForeignPrefix = normalizedPath; + while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || + pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) { - if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) - { - return false; - } - - var pathWithoutForeignPrefix = normalizedPath; - while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || - pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) - { - var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; - pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); - } + var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; + pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); + } - if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) - { - // Reject absolute paths hidden after leading navigation segments. - return false; - } + if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) + { + // Reject absolute paths hidden after leading navigation segments. + return false; + } - if (segments.Length < 2) - { - // Never anchor bare file names: too easy to match an unrelated file. - return false; - } + if (segments.Length < 2) + { + // Never anchor bare file names: too easy to match an unrelated file. + return false; + } - // Leading navigation segments belong to the compiler's foreign base directory. - start = 0; - while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) - { - start++; - } + // Leading navigation segments belong to the compiler's foreign base directory. + var start = 0; + while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) + { + start++; } // Never anchor paths with interior navigation segments: their resolution depends on the @@ -780,8 +754,7 @@ private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwn } } - var lastStart = isAzureCheckoutPath ? start + 1 : segments.Length - 1; - for (var i = start; i < lastStart; i++) + for (var i = start; i < segments.Length - 1; i++) { var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath)) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index b93c8ab6d418..9a4a5cf39517 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -32,8 +32,7 @@ internal sealed class CodeOwners @"(? _sections; - private readonly Platform _platform; + private readonly Document _document; public CodeOwners(string filePath, Platform platform) { @@ -42,10 +41,9 @@ public CodeOwners(string filePath, Platform platform) throw new ArgumentNullException(nameof(filePath)); } - _platform = platform; if (platform == Platform.GitHub && new FileInfo(filePath).Length > GitHubMaximumFileSizeBytes) { - _sections = []; + _document = GitHubDocument.Empty; Log.Warning( "GitHub CODEOWNERS file exceeds the {MaximumSize} byte limit and will be ignored: {Path}", GitHubMaximumFileSizeBytes, @@ -53,7 +51,14 @@ public CodeOwners(string filePath, Platform platform) return; } - _sections = Parse(File.ReadLines(filePath), platform, out var parsingDiagnosticsCount); + int parsingDiagnosticsCount; + _document = platform switch + { + Platform.GitHub => GitHubDocument.Parse(File.ReadLines(filePath), out parsingDiagnosticsCount), + Platform.GitLab => GitLabDocument.Parse(File.ReadLines(filePath), out parsingDiagnosticsCount), + _ => throw new ArgumentOutOfRangeException(nameof(platform)), + }; + ParsingDiagnosticsCount = parsingDiagnosticsCount; if (parsingDiagnosticsCount > 0) { @@ -99,125 +104,12 @@ public IEnumerable Match(string path) // "", "/", and "//C:/file" all normalize to a single rooted form. normalizedPath = "/" + normalizedPath.TrimStart('/'); - if (_platform == Platform.GitHub) - { - // GitHub has no sections: the whole file is a single ordered rule set where - // the last matching pattern takes precedence over all previous ones. - for (var i = _sections.Count - 1; i >= 0; i--) - { - if (_sections[i].TryMatchGitHub(normalizedPath, out var sectionOwners)) - { - return sectionOwners; - } - } - - return []; - } - - // GitLab evaluates each section independently and combines their owners. - // The set is allocated lazily because most paths match at most one section. - HashSet? owners = null; - foreach (var section in _sections) - { - if (section.TryMatchGitLab(normalizedPath, out var sectionOwners)) - { - owners ??= new HashSet(StringComparer.Ordinal); - foreach (var o in sectionOwners) - { - owners.Add(o); - } - } - } - - return owners ?? []; + return _document.Match(normalizedPath); } - private static List
Parse(IEnumerable lines, Platform platform, out int parsingDiagnosticsCount) - { - parsingDiagnosticsCount = 0; - var sections = new List
(); - var current = Section.CreateUnnamed(); - var currentDefaultOwners = current.DefaultOwners; - Dictionary? namedSections = platform == Platform.GitLab - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - : null; - sections.Add(current); - - foreach (var line in lines) - { - var raw = line.Trim(); - if (raw.Length == 0) - { - continue; - } - - if (TryParseSectionHeader(raw, platform, out var newSection, out var sectionHasDiagnostics)) - { - if (sectionHasDiagnostics) - { - parsingDiagnosticsCount++; - } - - currentDefaultOwners = newSection.DefaultOwners; - if (namedSections is not null && namedSections.TryGetValue(newSection.Name, out var existingSection)) - { - current = existingSection; - } - else - { - current = newSection; - sections.Add(current); - namedSections?.Add(current.Name, current); - } - - continue; - } - - if (platform == Platform.GitLab && IsUnparsableSectionHeader(raw)) - { - // GitLab reports malformed header-like lines and skips them rather than - // reinterpreting them as path patterns. - parsingDiagnosticsCount++; - continue; - } - - if (raw[0] == '#') - { - // Comment line. GitLab parses owners found inside comments so they appear in MR widget, - // but those owners are not bound to any path pattern, so we ignore them for matching. - continue; - } - - var entry = Entry.Parse(raw, platform, currentDefaultOwners, out var entryHasDiagnostics); - if (entry is not null) - { - if (entryHasDiagnostics) - { - parsingDiagnosticsCount++; - } - - current.Add(entry, replaceDuplicatePattern: platform == Platform.GitLab); - } - else - { - parsingDiagnosticsCount++; - } - } - - // Reverse the entries of every section so the last rule in the file is evaluated first - // at match time, without additional copies. - foreach (var s in sections) - { - s.Seal(); - } - - return sections; - } - - private static bool TryParseSectionHeader( + private static bool TryParseGitLabSectionHeader( string raw, - Platform platform, - [NotNullWhen(true)] out Section? section, + [NotNullWhen(true)] out GitLabSection? section, out bool hasDiagnostics) { // Accepted forms: @@ -234,8 +126,7 @@ private static bool TryParseSectionHeader( var required = !m.Groups[1].Success; // ^ prefix => optional section var name = m.Groups["name"].Value.Trim(); - hasDiagnostics = platform == Platform.GitHub || - name.Length == 0 || + hasDiagnostics = name.Length == 0 || !IsStrictSectionHeader(raw); var approvals = 0; @@ -255,9 +146,9 @@ private static bool TryParseSectionHeader( // Only parse the owner span recognized by GitLab's permissive header grammar. Text // after a malformed suffix (for example an extra ']') must never leak into defaults. - var defaults = OwnerTokenizer.Tokenize(m.Groups["defaults"].Value, platform, out var allDefaultsValid); + var defaults = OwnerTokenizer.TokenizeGitLab(m.Groups["defaults"].Value, out var allDefaultsValid); hasDiagnostics |= !allDefaultsValid; - section = new Section(name, defaults); + section = new GitLabSection(name, defaults); return true; } @@ -371,7 +262,7 @@ private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresS private static class OwnerTokenizer { - public static string[] Tokenize(string segment, Platform platform, out bool allValid) + public static string[] TokenizeGitHub(string segment, out bool allValid) { if (string.IsNullOrWhiteSpace(segment)) { @@ -384,18 +275,33 @@ public static string[] Tokenize(string segment, Platform platform, out bool allV allValid = true; foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) { - if (platform == Platform.GitHub) + if (IsValidGitHubOwner(token)) { - if (IsValidGitHubOwner(token)) - { - AddUnique(owners, uniqueOwners, token); - } - else - { - allValid = false; - } + AddUnique(owners, uniqueOwners, token); } - else if (!ExtractGitLabOwners(token, owners, uniqueOwners)) + else + { + allValid = false; + } + } + + return owners.Count == 0 ? [] : owners.ToArray(); + } + + public static string[] TokenizeGitLab(string segment, out bool allValid) + { + if (string.IsNullOrWhiteSpace(segment)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + { + if (!ExtractGitLabOwners(token, owners, uniqueOwners)) { allValid = false; } @@ -702,7 +608,13 @@ private GlobPattern(GlobPathSegment[] segments) _segments = segments; } - public static GlobPattern? Compile(string pattern, Platform platform, bool includeDescendants) + public static GlobPattern? CompileGitHub(string pattern, bool includeDescendants) + => Compile(pattern, Platform.GitHub, includeDescendants); + + public static GlobPattern? CompileGitLab(string pattern) + => Compile(pattern, Platform.GitLab, includeDescendants: false); + + private static GlobPattern? Compile(string pattern, Platform platform, bool includeDescendants) { var rawSegments = SplitPattern(pattern, out var firstSeparator, out var hasTrailingSlash); var rooted = (rawSegments.Length > 0 && rawSegments[0].Length == 0) || @@ -1111,13 +1023,178 @@ private bool TryMatchToken(int patternIndex, char value, out int nextPatternInde } } - private sealed class Section + private abstract class Document + { + public abstract IEnumerable Match(string path); + } + + private sealed class GitHubDocument : Document + { + private readonly Entry[] _rules; + + private GitHubDocument(Entry[] rules) + { + _rules = rules; + } + + public static GitHubDocument Empty { get; } = new([]); + + public static GitHubDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) + { + parsingDiagnosticsCount = 0; + var rules = new List(); + + foreach (var line in lines) + { + var raw = line.Trim(); + if (raw.Length == 0 || raw[0] == '#') + { + continue; + } + + var entry = Entry.ParseGitHub(raw); + if (entry is null) + { + parsingDiagnosticsCount++; + } + else + { + rules.Add(entry); + } + } + + rules.Reverse(); + return new GitHubDocument(rules.ToArray()); + } + + public override IEnumerable Match(string path) + { + foreach (var rule in _rules) + { + if (rule.Match(path)) + { + return rule.Owners; + } + } + + return []; + } + } + + private sealed class GitLabDocument : Document + { + private readonly GitLabSection[] _sections; + + private GitLabDocument(GitLabSection[] sections) + { + _sections = sections; + } + + public static GitLabDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) + { + parsingDiagnosticsCount = 0; + var sections = new List(); + var current = GitLabSection.CreateUnnamed(); + var currentDefaultOwners = current.DefaultOwners; + var namedSections = new Dictionary(StringComparer.OrdinalIgnoreCase); + sections.Add(current); + + foreach (var line in lines) + { + var raw = line.Trim(); + if (raw.Length == 0) + { + continue; + } + + if (TryParseGitLabSectionHeader(raw, out var newSection, out var sectionHasDiagnostics)) + { + if (sectionHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + currentDefaultOwners = newSection.DefaultOwners; + if (namedSections.TryGetValue(newSection.Name, out var existingSection)) + { + current = existingSection; + } + else + { + current = newSection; + sections.Add(current); + namedSections.Add(current.Name, current); + } + + continue; + } + + if (IsUnparsableSectionHeader(raw)) + { + // GitLab reports malformed header-like lines and skips them rather than + // reinterpreting them as path patterns. + parsingDiagnosticsCount++; + continue; + } + + if (raw[0] == '#') + { + // GitLab parses owners inside comments for its MR widget, but comments do + // not bind those owners to a path and therefore do not affect matching. + continue; + } + + var entry = Entry.ParseGitLab(raw, currentDefaultOwners, out var entryHasDiagnostics); + if (entry is null) + { + parsingDiagnosticsCount++; + } + else + { + if (entryHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + current.Add(entry); + } + } + + foreach (var section in sections) + { + section.Seal(); + } + + return new GitLabDocument(sections.ToArray()); + } + + public override IEnumerable Match(string path) + { + // GitLab evaluates each section independently and combines their owners. + // The set is allocated lazily because most paths match at most one section. + HashSet? owners = null; + foreach (var section in _sections) + { + if (section.TryMatch(path, out var sectionOwners)) + { + owners ??= new HashSet(StringComparer.Ordinal); + foreach (var owner in sectionOwners) + { + owners.Add(owner); + } + } + } + + return owners ?? []; + } + } + + private sealed class GitLabSection { private readonly List _entries = new(); private Entry[]? _cache; - private bool _replaceDuplicatePatterns; - public Section(string name, string[] defaultOwners) + public GitLabSection(string name, string[] defaultOwners) { Name = name; DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; @@ -1127,22 +1204,18 @@ public Section(string name, string[] defaultOwners) public string[] DefaultOwners { get; } - public static Section CreateUnnamed() => new(string.Empty, []); + public static GitLabSection CreateUnnamed() => new(string.Empty, []); - public void Add(Entry entry, bool replaceDuplicatePattern) - { - _replaceDuplicatePatterns |= replaceDuplicatePattern; - _entries.Add(entry); - } + public void Add(Entry entry) => _entries.Add(entry); public void Seal() { - var seenPatterns = _replaceDuplicatePatterns ? new HashSet(StringComparer.Ordinal) : null; + var seenPatterns = new HashSet(StringComparer.Ordinal); var cache = new List(_entries.Count); for (var i = _entries.Count - 1; i >= 0; i--) { var entry = _entries[i]; - if (seenPatterns is null || seenPatterns.Add(entry.PatternKey)) + if (seenPatterns.Add(entry.PatternKey)) { cache.Add(entry); } @@ -1152,26 +1225,7 @@ public void Seal() _entries.Clear(); } - public bool TryMatchGitHub(string path, [NotNullWhen(true)] out string[]? owners) - { - var rules = _cache ?? []; - - foreach (var rule in rules) - { - if (!rule.Match(path)) - { - continue; - } - - owners = rule.Owners; - return true; - } - - owners = null; - return false; - } - - public bool TryMatchGitLab(string path, [NotNullWhen(true)] out string[]? owners) + public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; string[]? matchedOwners = null; @@ -1221,16 +1275,15 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne public string PatternKey { get; } - public static Entry? Parse(string raw, Platform platform, string[] defaultOwners, out bool hasDiagnostics) + public static Entry? ParseGitHub(string raw) { - hasDiagnostics = false; - if (platform == Platform.GitHub && raw.StartsWith("\\#")) + if (raw.StartsWith("\\#")) { return null; } - var idxHash = platform == Platform.GitHub ? FindUnescapedCharacter(raw, '#') : -1; - var effective = idxHash >= 0 && platform == Platform.GitHub ? raw.Substring(0, idxHash).TrimEnd() : raw; + var idxHash = FindUnescapedCharacter(raw, '#'); + var effective = idxHash >= 0 ? raw.Substring(0, idxHash).TrimEnd() : raw; if (string.IsNullOrWhiteSpace(effective)) { return null; @@ -1238,64 +1291,67 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne string patternToken; string ownersSegment; - bool hasExplicitOwners; - SplitEscapedEntry(effective, out patternToken, out ownersSegment, out hasExplicitOwners); + SplitEscapedEntry(effective, out patternToken, out ownersSegment, out _); - var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!"); - if (isExclusion) + if (patternToken.Length == 0 || IsUnsupportedGitHubPattern(patternToken)) { - patternToken = patternToken.Substring(1, patternToken.Length - 1); + return null; } - if (platform == Platform.GitHub && IsUnsupportedGitHubPattern(patternToken)) + var owners = OwnerTokenizer.TokenizeGitHub(ownersSegment, out var allOwnersValid); + if (!allOwnersValid) { return null; } - if (patternToken.Length == 0) + var glob = GlobPattern.CompileGitHub(patternToken, includeDescendants: IsDirectoryPattern(patternToken)); + if (glob is null) { return null; } - string[] owners; - var allOwnersValid = true; + return new Entry(glob, patternToken, exclusion: false, owners); + } + + public static Entry? ParseGitLab(string raw, string[] defaultOwners, out bool hasDiagnostics) + { + hasDiagnostics = false; + SplitEscapedEntry(raw, out var patternToken, out var ownersSegment, out var hasExplicitOwners); + + var isExclusion = patternToken.StartsWith("!"); if (isExclusion) { - owners = []; - } - else - { - owners = OwnerTokenizer.Tokenize(ownersSegment, platform, out allOwnersValid); + patternToken = patternToken.Substring(1, patternToken.Length - 1); } - hasDiagnostics = !allOwnersValid; - if (platform == Platform.GitHub && hasDiagnostics) + if (patternToken.Length == 0) { return null; } - if (platform == Platform.GitLab && !isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) + var allOwnersValid = true; + var owners = isExclusion + ? [] + : OwnerTokenizer.TokenizeGitLab(ownersSegment, out allOwnersValid); + hasDiagnostics = !isExclusion && !allOwnersValid; + + if (!isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) { owners = defaultOwners; } - if (platform == Platform.GitLab && !isExclusion && owners.Length == 0) + if (!isExclusion && owners.Length == 0) { hasDiagnostics = true; } - // GitHub owns the contents of directories matched by wildcard-free patterns (e.g. - // `**/logs`); GitLab requires an explicit trailing slash for directory ownership. - var isDirectoryPattern = platform == Platform.GitHub && IsDirectoryPattern(patternToken); - - var glob = GlobPattern.Compile(patternToken, platform, includeDescendants: isDirectoryPattern); + var glob = GlobPattern.CompileGitLab(patternToken); if (glob is null) { return null; } - var patternKey = platform == Platform.GitLab ? NormalizeGitLabPatternKey(patternToken) : patternToken; - return new Entry(glob, patternKey, isExclusion, owners); + return new Entry(glob, NormalizeGitLabPatternKey(patternToken), isExclusion, owners); } private static void SplitEscapedEntry(string entry, out string pattern, out string owners, out bool hasExplicitOwners) diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs index 27da08766609..4774e14108d0 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -491,61 +491,13 @@ public void AnchorsAzurePipelinesCompilerRecordedPaths() } [SkippableTheory] - [InlineData(@"D:\a\_work\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] - [InlineData(@"D:\a\1\s\tracer\test\Datadog.Trace.DuckTyping.Tests\ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] - [InlineData("/home/vsts/work/1/s/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", "tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs")] - [InlineData(@"D:\a\1\s\Program.cs", "Program.cs")] - public void AnchorsAzurePipelinesCompilerPathsFromAnotherOperatingSystem(string compilerPath, string expectedRelativePath) - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceFile = Path.Combine(repoRoot, expectedRelativePath.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!); - Directory.CreateDirectory(Path.Combine(repoRoot, ".github")); - File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), $"/{expectedRelativePath} @DataDog/tracing-dotnet\n"); - File.WriteAllText(sourceFile, "// test"); - - var env = new Dictionary - { - [PlatformKeys.Ci.Azure.TFBuild] = "True", - [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot, - [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha, - [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(compilerPath, false); - - Assert.Equal(expectedRelativePath, relative); - Assert.Equal(["@DataDog/tracing-dotnet"], ciValues.CodeOwners!.Match("/" + relative)); - } - - [SkippableFact] - public void DoesNotAnchorAzureStyleAbsolutePathsForOtherProviders() - { - using var tempDirectory = new TemporaryDirectory(); - var repoRoot = tempDirectory.RootPath; - var sourceDir = Path.Combine(repoRoot, "src"); - Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "/src/ @owner\n"); - File.WriteAllText(Path.Combine(sourceDir, "SpanBenchmark.cs"), "// test"); - - var env = new Dictionary - { - [PlatformKeys.Ci.GitHub.Sha] = CommitSha, - [PlatformKeys.Ci.GitHub.Workspace] = repoRoot, - [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet", - }; - - var ciValues = CIEnvironmentValues.Create(env); - - Assert.False(ciValues.TryGetCodeOwnersRelativePath(@"D:\a\_work\1\s\src\SpanBenchmark.cs", false, out _)); - } - - [SkippableTheory] + [InlineData(@"D:\a\_work\1\s\src\SpanBenchmark.cs")] + [InlineData(@"D:\a\1\s\src\SpanBenchmark.cs")] + [InlineData("/home/vsts/work/1/s/src/SpanBenchmark.cs")] + [InlineData("/tmp/work/1/s/src/SpanBenchmark.cs")] [InlineData("file:///D:/a/_work/1/s/src/SpanBenchmark.cs")] [InlineData("https://example.com/a/_work/1/s/src/SpanBenchmark.cs")] - public void DoesNotAnchorUrisThatResembleAzureCheckoutPaths(string sourcePath) + public void DoesNotAnchorAbsoluteAzurePipelinesPathsWithMatchingRepositorySuffix(string sourcePath) { using var tempDirectory = new TemporaryDirectory(); var repoRoot = tempDirectory.RootPath; diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs index fe9ff83e99ec..e06629a8ca17 100644 --- a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -166,6 +166,16 @@ public void LastMatchingRuleWinsGloballyForGitHub() Match(codeOwners, "/x/y.js").Should().Equal(["@second"]); } + [SkippableFact] + public void GitLabSectionSyntaxDoesNotScopeGitHubRules() + { + var codeOwners = Create("*.js @first\n[Docs] @ignored\n*.md @docs\n*.js @second\n", CodeOwners.Platform.GitHub); + + Match(codeOwners, "/x/y.js").Should().Equal(["@second"]); + Match(codeOwners, "/README.md").Should().Equal(["@docs"]); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + [SkippableFact] public void GlobstarMatchesZeroDirectories() { From db11c825335088314de6b0b8172baf2135aa41ed Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 15:32:54 +0200 Subject: [PATCH 21/25] [CI Visibility] Split CODEOWNERS responsibilities --- .../CIEnvironmentValues.CodeOwners.cs | 515 ++++++++++ .../Ci/CiEnvironment/CIEnvironmentValues.cs | 501 +--------- .../src/Datadog.Trace/Ci/CodeOwners.GitHub.cs | 285 ++++++ .../src/Datadog.Trace/Ci/CodeOwners.GitLab.cs | 645 +++++++++++++ tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 884 +----------------- 5 files changed, 1455 insertions(+), 1375 deletions(-) create mode 100644 tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs create mode 100644 tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs create mode 100644 tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs new file mode 100644 index 000000000000..ade404a07879 --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs @@ -0,0 +1,515 @@ +// +// 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace Datadog.Trace.Ci.CiEnvironment; + +internal abstract partial class CIEnvironmentValues +{ + private const int CodeOwnersSearchCacheLimit = 256; + + private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static readonly char[] ForwardSlashCharacters = { '/' }; + + private readonly object _codeOwnersLock = new(); + private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); + + public CodeOwners? CodeOwners { get; protected set; } + + internal string? CodeOwnersRoot { get; private set; } + + internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) + { + var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); + return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath) + ? codeOwnersRelativePath + : sourceRelativePath; + } + + internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) + { + codeOwnersRelativePath = null; + + if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) + { + return false; + } + + // Algorithm: load CODEOWNERS (with fallback), resolve roots, then normalize source file to repo-relative. + // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization. + EnsureCodeOwnersFromFallback(sourceFilePath); + + if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot)) + { + return false; + } + + var codeOwnersRoot = CodeOwnersRoot; + if (!Path.IsPathRooted(codeOwnersRoot)) + { + // If SourceRoot was relative, re-anchor to WorkspacePath before matching. + if (StringUtil.IsNullOrWhiteSpace(WorkspacePath) || + !TryResolvePathWithinBase(codeOwnersRoot, WorkspacePath, out var resolvedRoot)) + { + return false; + } + + // Require a CODEOWNERS file at the resolved root to avoid mismatched roots. + // Avoid mixing CODEOWNERS content from one root with a different resolved root. + if (!TryGetCodeOwnersPath(resolvedRoot, GetCodeOwnersPlatform(resolvedRoot), logLookup: false, out _)) + { + return false; + } + + codeOwnersRoot = resolvedRoot; + } + + if (TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath)) + { + return true; + } + + // Only match when the source file can be resolved under the CODEOWNERS root. + string absolutePath; + if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) + { + // Absolute paths are already resolved, no workspace anchoring needed. + absolutePath = sourceFilePath; + } + else + { + // For relative paths, enforce that they stay within the CODEOWNERS root. + // Relative paths must stay within the codeowners root; otherwise we try to anchor them. + if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) + { + return false; + } + + absolutePath = resolvedPath; + } + + // Normalize to a repo-relative path before matching the CODEOWNERS rules. + var relativePath = MakeRelativePath(codeOwnersRoot, absolutePath, useOSSeparator); + // Guard against paths that escape the root or remain absolute after normalization. + if (StringUtil.IsNullOrWhiteSpace(relativePath) || + Path.IsPathRooted(relativePath) || + Uri.TryCreate(relativePath, UriKind.Absolute, out _) || + relativePath.Equals("..", StringComparison.Ordinal) || + relativePath.StartsWith("../", StringComparison.Ordinal) || + relativePath.StartsWith("..\\", StringComparison.Ordinal)) + { + return false; + } + + codeOwnersRelativePath = relativePath; + return true; + } + + private static string? GetCodeOwnersSearchStart(string? path, string? basePath) + { + if (StringUtil.IsNullOrWhiteSpace(path)) + { + return null; + } + + string? resolvedPath = null; + try + { + if (Path.IsPathRooted(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) + { + resolvedPath = path; + } + else if (!StringUtil.IsNullOrWhiteSpace(basePath) && Path.IsPathRooted(basePath)) + { + // Keep relative paths anchored to a known workspace and reject escapes (no CWD fallback). + TryResolvePathWithinBase(path, basePath, out resolvedPath); + } + + if (StringUtil.IsNullOrWhiteSpace(resolvedPath)) + { + return null; + } + + // Start searching from the directory containing the candidate path. + if (Directory.Exists(resolvedPath)) + { + return resolvedPath; + } + + return Path.GetDirectoryName(resolvedPath); + } + catch (Exception ex) + { + Log.Debug(ex, "Error resolving CODEOWNERS search start for '{Path}'", resolvedPath ?? path); + return null; + } + } + + private static bool HasGitDirectory(string path) + { + var gitPath = Path.Combine(path, ".git"); + return Directory.Exists(gitPath) || File.Exists(gitPath); + } + + private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) + { + absolutePath = null; + + if (StringUtil.IsNullOrWhiteSpace(relativePath) || StringUtil.IsNullOrWhiteSpace(basePath)) + { + return false; + } + + try + { + // Only combine relative paths; rooted or absolute inputs bypass base anchoring. + if (Path.IsPathRooted(relativePath) || Uri.TryCreate(relativePath, UriKind.Absolute, out _)) + { + return false; + } + + if (!Path.IsPathRooted(basePath)) + { + return false; + } + + // Normalize to full paths and ensure the combined path stays within the base. + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var fullBasePath = Path.GetFullPath(basePath); + var fullBasePathWithSeparator = fullBasePath; + if (!fullBasePathWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), comparison) && + !fullBasePathWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), comparison)) + { + fullBasePathWithSeparator += Path.DirectorySeparatorChar; + } + + var combinedPath = Path.Combine(fullBasePath, relativePath); + var fullCombinedPath = Path.GetFullPath(combinedPath); + // Reject traversal that escapes the base directory. + if (!fullCombinedPath.StartsWith(fullBasePathWithSeparator, comparison) && + !string.Equals(fullCombinedPath, fullBasePath, comparison)) + { + return false; + } + + absolutePath = fullCombinedPath; + return true; + } + catch (Exception ex) + { + Log.Debug(ex, "Error resolving relative path '{Path}' within base '{BasePath}'", relativePath, basePath); + } + + return false; + } + + private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) + { + foreach (var path in GetCodeOwnersPaths(sourceRoot, platform)) + { + if (logLookup) + { + Log.Debug("Looking for CODEOWNERS file in: {Path}", path); + } + + if (File.Exists(path)) + { + codeOwnersPath = path; + return true; + } + } + + codeOwnersPath = null; + return false; + } + + private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform) + { + platform = default; + if (StringUtil.IsNullOrWhiteSpace(repository)) + { + return false; + } + + string? host = null; + if (Uri.TryCreate(repository, UriKind.Absolute, out var repositoryUri) && !StringUtil.IsNullOrEmpty(repositoryUri.Host)) + { + host = repositoryUri.Host; + } + else + { + // Handle SCP-style SSH URLs such as git@gitlab.com:group/project.git. + var hostStart = repository.IndexOf('@') + 1; + var hostEnd = repository.IndexOf(':', hostStart); + if (hostStart > 0 && hostEnd > hostStart) + { + host = repository.Substring(hostStart, hostEnd - hostStart); + } + } + + if (IsGitLabHost(host)) + { + platform = CodeOwners.Platform.GitLab; + return true; + } + + if (string.Equals(host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + platform = CodeOwners.Platform.GitHub; + return true; + } + + return false; + } + + private static bool IsGitLabHost(string? host) + => string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) || + (host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) || + (host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0); + + private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) + { + if (platform == CodeOwners.Platform.GitHub) + { + // GitHub searches .github first, then the repository root, then docs. + yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); + } + else if (platform == CodeOwners.Platform.GitLab) + { + // GitLab searches the repository root first, then docs, then .gitlab. + yield return Path.Combine(sourceRoot, "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS"); + } + else + { + // Unknown platform: search all known locations in a reasonable order. + yield return Path.Combine(sourceRoot, "CODEOWNERS"); + yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS"); + yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS"); + } + } + + private void ResetCodeOwners() + { + CodeOwners = null; + CodeOwnersRoot = null; + lock (_codeOwnersLock) + { + _codeOwnersSearchStarts.Clear(); + } + } + + private void LoadCodeOwners() + { + if (!StringUtil.IsNullOrEmpty(SourceRoot)) + { + var platform = GetCodeOwnersPlatform(SourceRoot); + if (TryGetCodeOwnersPath(SourceRoot, platform, logLookup: true, out var codeOwnersPath)) + { + Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); + if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) + { + CodeOwners = parser; + CodeOwnersRoot = SourceRoot; + } + } + } + } + + private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) + { + // Compiler-recorded paths can be relative to a different base directory than the current + // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict + // resolution fails, anchor the path by finding the longest suffix that exists under the + // CODEOWNERS root, independently of the CI provider layout that produced the prefix. + codeOwnersRelativePath = null; + if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) + { + return false; + } + + var normalizedPath = sourceFilePath.Replace('\\', '/'); + var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) + { + return false; + } + + var pathWithoutForeignPrefix = normalizedPath; + while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || + pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) + { + var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; + pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); + } + + if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) + { + // Reject absolute paths hidden after leading navigation segments. + return false; + } + + if (segments.Length < 2) + { + // Never anchor bare file names: too easy to match an unrelated file. + return false; + } + + // Leading navigation segments belong to the compiler's foreign base directory. + var start = 0; + while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) + { + start++; + } + + // Never anchor paths with interior navigation segments: their resolution depends on the + // unknown base directory and would produce malformed repository-relative paths. + for (var i = start; i < segments.Length; i++) + { + if (segments[i] == "." || segments[i] == "..") + { + return false; + } + } + + for (var i = start; i < segments.Length - 1; i++) + { + var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); + if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath)) + { + var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; + codeOwnersRelativePath = string.Join(separator, segments, i, segments.Length - i); + return true; + } + } + + return false; + } + + private void EnsureCodeOwnersFromFallback(string? sourceFilePath) + { + if (CodeOwners is not null) + { + return; + } + + lock (_codeOwnersLock) + { + if (CodeOwners is not null) + { + return; + } + + // Search order: source file path (most specific), then workspace root. + // Prefer a source-file-anchored search before falling back to the workspace root. + var platform = GetCodeOwnersPlatform(SourceRoot ?? WorkspacePath); + if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath)) + { + return; + } + + TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null); + } + } + + private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath) + { + var startDirectory = GetCodeOwnersSearchStart(startPath, basePath); + if (StringUtil.IsNullOrEmpty(startDirectory)) + { + return false; + } + + DirectoryInfo? directoryInfo; + try + { + directoryInfo = new DirectoryInfo(startDirectory); + } + catch (Exception ex) + { + Log.Debug(ex, "Error resolving CODEOWNERS search directory from '{Path}'", startDirectory); + return false; + } + + // Limit cache growth to avoid unbounded memory in large test suites. + if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit) + { + _codeOwnersSearchStarts.Clear(); + } + + // Skip repeated lookups for the same starting directory. + if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName)) + { + return false; + } + + // Walk parent directories until we find CODEOWNERS or hit a git boundary. + while (directoryInfo != null) + { + if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath)) + { + Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); + if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) + { + CodeOwners = parser; + CodeOwnersRoot = directoryInfo.FullName; + return true; + } + + return false; + } + + if (HasGitDirectory(directoryInfo.FullName)) + { + break; + } + + directoryInfo = directoryInfo.Parent; + } + + return false; + } + + private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) + { + if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) + { + return platform; + } + + if (string.Equals(Provider, "gitlab", StringComparison.Ordinal)) + { + return CodeOwners.Platform.GitLab; + } + + if (string.Equals(Provider, "github", StringComparison.Ordinal)) + { + return CodeOwners.Platform.GitHub; + } + + if (!StringUtil.IsNullOrEmpty(sourceRoot) && + File.Exists(Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS")) && + !File.Exists(Path.Combine(sourceRoot, ".github", "CODEOWNERS"))) + { + // A platform-specific location is the only reliable signal for self-managed GitLab + // instances whose host name does not identify the product. + return CodeOwners.Platform.GitLab; + } + + return CodeOwners.Platform.GitHub; + } +} diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs index 6242b0eca205..9724ca63a971 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Runtime.CompilerServices; @@ -20,22 +19,12 @@ namespace Datadog.Trace.Ci.CiEnvironment; // ReSharper disable once InconsistentNaming -internal abstract class CIEnvironmentValues +internal abstract partial class CIEnvironmentValues { - private const int CodeOwnersSearchCacheLimit = 256; internal const string RepositoryUrlPattern = @"((http|git|ssh|http(s)|file|\/?)|(git@[\w\.\-]+))(:(\/\/)?)([\w\.@\:/\-~]+)(\.git)?(\/)?"; protected static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(CIEnvironmentValues)); private static readonly Lazy LazyInstance = new(Create); private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled); - private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - private static readonly char[] ForwardSlashCharacters = { '/' }; - - private readonly object _codeOwnersLock = new(); - private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); - private string? _gitSearchFolder; public static CIEnvironmentValues Instance => LazyInstance.Value; @@ -126,10 +115,6 @@ public string? GitSearchFolder public string? HeadMessage { get; protected set; } - public CodeOwners? CodeOwners { get; protected set; } - - internal string? CodeOwnersRoot { get; private set; } - public Dictionary? VariablesToBypass { get; protected set; } public MetricTags.CIVisibilityTestSessionProvider MetricTag { get; protected set; } = MetricTags.CIVisibilityTestSessionProvider.Unsupported; @@ -261,186 +246,6 @@ protected static bool IsHex(IEnumerable chars) return Tuple.Create(branch, tag); } - private static string? GetCodeOwnersSearchStart(string? path, string? basePath) - { - if (StringUtil.IsNullOrWhiteSpace(path)) - { - return null; - } - - string? resolvedPath = null; - try - { - if (Path.IsPathRooted(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) - { - resolvedPath = path; - } - else if (!StringUtil.IsNullOrWhiteSpace(basePath) && Path.IsPathRooted(basePath)) - { - // Keep relative paths anchored to a known workspace and reject escapes (no CWD fallback). - TryResolvePathWithinBase(path, basePath, out resolvedPath); - } - - if (StringUtil.IsNullOrWhiteSpace(resolvedPath)) - { - return null; - } - - // Start searching from the directory containing the candidate path. - if (Directory.Exists(resolvedPath)) - { - return resolvedPath; - } - - return Path.GetDirectoryName(resolvedPath); - } - catch (Exception ex) - { - Log.Debug(ex, "Error resolving CODEOWNERS search start for '{Path}'", resolvedPath ?? path); - return null; - } - } - - private static bool HasGitDirectory(string path) - { - var gitPath = Path.Combine(path, ".git"); - return Directory.Exists(gitPath) || File.Exists(gitPath); - } - - private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) - { - absolutePath = null; - - if (StringUtil.IsNullOrWhiteSpace(relativePath) || StringUtil.IsNullOrWhiteSpace(basePath)) - { - return false; - } - - try - { - // Only combine relative paths; rooted or absolute inputs bypass base anchoring. - if (Path.IsPathRooted(relativePath) || Uri.TryCreate(relativePath, UriKind.Absolute, out _)) - { - return false; - } - - if (!Path.IsPathRooted(basePath)) - { - return false; - } - - // Normalize to full paths and ensure the combined path stays within the base. - var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var fullBasePath = Path.GetFullPath(basePath); - var fullBasePathWithSeparator = fullBasePath; - if (!fullBasePathWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), comparison) && - !fullBasePathWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), comparison)) - { - fullBasePathWithSeparator += Path.DirectorySeparatorChar; - } - - var combinedPath = Path.Combine(fullBasePath, relativePath); - var fullCombinedPath = Path.GetFullPath(combinedPath); - // Reject traversal that escapes the base directory. - if (!fullCombinedPath.StartsWith(fullBasePathWithSeparator, comparison) && - !string.Equals(fullCombinedPath, fullBasePath, comparison)) - { - return false; - } - - absolutePath = fullCombinedPath; - return true; - } - catch (Exception ex) - { - Log.Debug(ex, "Error resolving relative path '{Path}' within base '{BasePath}'", relativePath, basePath); - } - - return false; - } - - private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) - { - foreach (var path in GetCodeOwnersPaths(sourceRoot, platform)) - { - if (logLookup) - { - Log.Debug("Looking for CODEOWNERS file in: {Path}", path); - } - - if (File.Exists(path)) - { - codeOwnersPath = path; - return true; - } - } - - codeOwnersPath = null; - return false; - } - - private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform) - { - platform = default; - if (StringUtil.IsNullOrWhiteSpace(repository)) - { - return false; - } - - string? host = null; - if (Uri.TryCreate(repository, UriKind.Absolute, out var repositoryUri) && !StringUtil.IsNullOrEmpty(repositoryUri.Host)) - { - host = repositoryUri.Host; - } - else - { - // Handle SCP-style SSH URLs such as git@gitlab.com:group/project.git. - var hostStart = repository!.IndexOf('@') + 1; - var hostEnd = repository.IndexOf(':', hostStart); - if (hostStart > 0 && hostEnd > hostStart) - { - host = repository.Substring(hostStart, hostEnd - hostStart); - } - } - - if (IsGitLabHost(host)) - { - platform = CodeOwners.Platform.GitLab; - return true; - } - - if (string.Equals(host, "github.com", StringComparison.OrdinalIgnoreCase)) - { - platform = CodeOwners.Platform.GitHub; - return true; - } - - return false; - } - - private static bool IsGitLabHost(string? host) - => string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) || - (host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) || - (host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0); - - private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) - { - if (platform == CodeOwners.Platform.GitHub) - { - // GitHub searches .github first, then the repository root, then docs. - yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS"); - yield return Path.Combine(sourceRoot, "CODEOWNERS"); - yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); - } - else - { - // GitLab searches the repository root first, then docs, then .gitlab. - yield return Path.Combine(sourceRoot, "CODEOWNERS"); - yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS"); - yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS"); - } - } - public void DecorateSpan(Span span) { if (span == null) @@ -526,14 +331,9 @@ protected void ReloadEnvironmentData() CommitterDate = null; Message = null; SourceRoot = null; - CodeOwners = null; - CodeOwnersRoot = null; - lock (_codeOwnersLock) - { - _codeOwnersSearchStarts.Clear(); - } + ResetCodeOwners(); - Setup(string.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder!)); + Setup(StringUtil.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder)); // ********** // Remove sensitive info from repository url @@ -549,30 +349,15 @@ protected void ReloadEnvironmentData() // ********** // Sanitize Repository Url (Remove username:password info from the url) // ********** - if (!string.IsNullOrEmpty(Repository) && + if (!StringUtil.IsNullOrEmpty(Repository) && Uri.TryCreate(Repository, UriKind.Absolute, out var uriRepository) && - !string.IsNullOrEmpty(uriRepository.UserInfo)) + !StringUtil.IsNullOrEmpty(uriRepository.UserInfo)) { - Repository = Repository!.Replace(uriRepository.UserInfo + "@", string.Empty); + Repository = Repository.Replace(uriRepository.UserInfo + "@", string.Empty); Repository = Repository.Replace(uriRepository.UserInfo, string.Empty); } - // ********** - // Try load CodeOwners - // ********** - if (!string.IsNullOrEmpty(SourceRoot)) - { - var platform = GetCodeOwnersPlatform(SourceRoot); - if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath)) - { - Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); - if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) - { - CodeOwners = parser; - CodeOwnersRoot = SourceRoot; - } - } - } + LoadCodeOwners(); } protected abstract void Setup(IGitInfo gitInfo); @@ -611,163 +396,6 @@ public string MakeRelativePathFromSourceRoot(string absolutePath, bool useOSSepa return MakeRelativePath(SourceRoot, absolutePath, useOSSeparator); } - internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) - { - var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); - return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath) - ? codeOwnersRelativePath - : sourceRelativePath; - } - - internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) - { - codeOwnersRelativePath = null; - - if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) - { - return false; - } - - // Algorithm: load CODEOWNERS (with fallback), resolve roots, then normalize source file to repo-relative. - // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization. - EnsureCodeOwnersFromFallback(sourceFilePath); - - if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot)) - { - return false; - } - - var codeOwnersRoot = CodeOwnersRoot!; - if (!Path.IsPathRooted(codeOwnersRoot)) - { - // If SourceRoot was relative, re-anchor to WorkspacePath before matching. - if (StringUtil.IsNullOrWhiteSpace(WorkspacePath) || - !TryResolvePathWithinBase(codeOwnersRoot, WorkspacePath!, out var resolvedRoot)) - { - return false; - } - - // Require a CODEOWNERS file at the resolved root to avoid mismatched roots. - // Avoid mixing CODEOWNERS content from one root with a different resolved root. - if (!TryGetCodeOwnersPath(resolvedRoot, GetCodeOwnersPlatform(resolvedRoot), logLookup: false, out _)) - { - return false; - } - - codeOwnersRoot = resolvedRoot; - } - - if (TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath)) - { - return true; - } - - // Only match when the source file can be resolved under the CODEOWNERS root. - string absolutePath; - if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) - { - // Absolute paths are already resolved, no workspace anchoring needed. - absolutePath = sourceFilePath; - } - else - { - // For relative paths, enforce that they stay within the CODEOWNERS root. - // Relative paths must stay within the codeowners root; otherwise we try to anchor them. - if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath)) - { - return false; - } - - absolutePath = resolvedPath; - } - - // Normalize to a repo-relative path before matching the CODEOWNERS rules. - var relativePath = MakeRelativePath(codeOwnersRoot, absolutePath, useOSSeparator); - // Guard against paths that escape the root or remain absolute after normalization. - if (StringUtil.IsNullOrWhiteSpace(relativePath) || - Path.IsPathRooted(relativePath) || - Uri.TryCreate(relativePath, UriKind.Absolute, out _) || - relativePath.Equals("..", StringComparison.Ordinal) || - relativePath.StartsWith("../", StringComparison.Ordinal) || - relativePath.StartsWith("..\\", StringComparison.Ordinal)) - { - return false; - } - - codeOwnersRelativePath = relativePath; - return true; - } - - private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) - { - // Compiler-recorded paths can be relative to a different base directory than the current - // workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict - // resolution fails, anchor the path by finding the longest suffix that exists under the - // CODEOWNERS root, independently of the CI provider layout that produced the prefix. - codeOwnersRelativePath = null; - if (StringUtil.IsNullOrWhiteSpace(sourceFilePath)) - { - return false; - } - - var normalizedPath = sourceFilePath.Replace('\\', '/'); - var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries); - if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _)) - { - return false; - } - - var pathWithoutForeignPrefix = normalizedPath; - while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) || - pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal)) - { - var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2; - pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength); - } - - if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _)) - { - // Reject absolute paths hidden after leading navigation segments. - return false; - } - - if (segments.Length < 2) - { - // Never anchor bare file names: too easy to match an unrelated file. - return false; - } - - // Leading navigation segments belong to the compiler's foreign base directory. - var start = 0; - while (start < segments.Length && (segments[start] == "." || segments[start] == "..")) - { - start++; - } - - // Never anchor paths with interior navigation segments: their resolution depends on the - // unknown base directory and would produce malformed repository-relative paths. - for (var i = start; i < segments.Length; i++) - { - if (segments[i] == "." || segments[i] == "..") - { - return false; - } - } - - for (var i = start; i < segments.Length - 1; i++) - { - var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i); - if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath)) - { - var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/"; - codeOwnersRelativePath = string.Join(separator, segments, i, segments.Length - i); - return true; - } - } - - return false; - } - private string MakeRelativePath(string? basePath, string absolutePath, bool useOSSeparator) { var pivotFolder = basePath; @@ -778,7 +406,7 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO if (StringUtil.IsNullOrEmpty(absolutePath)) { - return pivotFolder!; + return pivotFolder; } try @@ -808,117 +436,4 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO return absolutePath; } - - private void EnsureCodeOwnersFromFallback(string? sourceFilePath) - { - if (CodeOwners is not null) - { - return; - } - - lock (_codeOwnersLock) - { - if (CodeOwners is not null) - { - return; - } - - // Search order: source file path (most specific), then workspace root. - // Prefer a source-file-anchored search before falling back to the workspace root. - var platform = GetCodeOwnersPlatform(SourceRoot ?? WorkspacePath); - if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath)) - { - return; - } - - TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null); - } - } - - private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath) - { - var startDirectory = GetCodeOwnersSearchStart(startPath, basePath); - if (StringUtil.IsNullOrEmpty(startDirectory)) - { - return false; - } - - DirectoryInfo? directoryInfo; - try - { - directoryInfo = new DirectoryInfo(startDirectory); - } - catch (Exception ex) - { - Log.Debug(ex, "Error resolving CODEOWNERS search directory from '{Path}'", startDirectory); - return false; - } - - // Limit cache growth to avoid unbounded memory in large test suites. - if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit) - { - _codeOwnersSearchStarts.Clear(); - } - - // Skip repeated lookups for the same starting directory. - if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName)) - { - return false; - } - - // Walk parent directories until we find CODEOWNERS or hit a git boundary. - while (directoryInfo != null) - { - if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath)) - { - Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath); - if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser)) - { - CodeOwners = parser; - CodeOwnersRoot = directoryInfo.FullName; - return true; - } - - return false; - } - - if (HasGitDirectory(directoryInfo.FullName)) - { - break; - } - - directoryInfo = directoryInfo.Parent; - } - - return false; - } - - private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) - { - if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) - { - return platform; - } - - if (string.Equals(Provider, "gitlab", StringComparison.Ordinal)) - { - return CodeOwners.Platform.GitLab; - } - - if (string.Equals(Provider, "github", StringComparison.Ordinal)) - { - return CodeOwners.Platform.GitHub; - } - - if (!StringUtil.IsNullOrEmpty(sourceRoot) && - File.Exists(Path.Combine(sourceRoot!, ".gitlab", "CODEOWNERS")) && - !File.Exists(Path.Combine(sourceRoot!, ".github", "CODEOWNERS"))) - { - // A platform-specific location is the only reliable signal for self-managed GitLab - // instances whose host name does not identify the product. - return CodeOwners.Platform.GitLab; - } - - return CodeOwners.Platform.GitHub; - } } diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs new file mode 100644 index 000000000000..63ca6ae9a1fb --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs @@ -0,0 +1,285 @@ +// +// 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.Collections.Generic; + +namespace Datadog.Trace.Ci +{ + internal sealed partial class CodeOwners + { + private static class GitHubOwnerTokenizer + { + public static string[] TokenizeGitHub(string segment, out bool allValid) + { + if (string.IsNullOrWhiteSpace(segment)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + { + if (IsValidGitHubOwner(token)) + { + AddUnique(owners, uniqueOwners, token); + } + else + { + allValid = false; + } + } + + return owners.Count == 0 ? [] : owners.ToArray(); + } + + private static void AddUnique(List owners, HashSet uniqueOwners, string owner) + { + if (uniqueOwners.Add(owner)) + { + owners.Add(owner); + } + } + + private static bool IsValidGitHubOwner(string token) + { + if (token.Length > 1 && token[0] == '@' && token[1] != '@') + { + var slash = token.IndexOf('/'); + return slash < 0 + ? IsValidGitHubIdentifier(token, 1, token.Length) + : token.IndexOf('/', slash + 1) < 0 && + IsValidGitHubIdentifier(token, 1, slash) && + IsValidGitHubIdentifier(token, slash + 1, token.Length); + } + + var at = token.IndexOf('@'); + if (at is < 1 or > 100 || + token.Length - at - 1 is < 1 or > 255 || + token.IndexOf('@', at + 1) >= 0) + { + return false; + } + + for (var i = 0; i < at; i++) + { + if (!IsEmailLocalCharacter(token[i])) + { + return false; + } + } + + for (var i = at + 1; i < token.Length; i++) + { + if (!IsAsciiLetterOrDigit(token[i]) && token[i] is not '.' and not '-' and not '_') + { + return false; + } + } + + return IsAsciiLetterOrDigit(token[token.Length - 1]) || token[token.Length - 1] == '_'; + } + + private static bool IsValidGitHubIdentifier(string value, int start, int end) + { + if (start >= end || !IsAsciiLetterOrDigit(value[start]) || !IsAsciiLetterOrDigit(value[end - 1])) + { + return false; + } + + var previousWasHyphen = false; + for (var i = start; i < end; i++) + { + var character = value[i]; + if (!IsAsciiLetterOrDigit(character) && character is not '-' and not '_') + { + return false; + } + + if (character == '-' && previousWasHyphen) + { + return false; + } + + previousWasHyphen = character == '-'; + } + + return true; + } + + private static bool IsEmailLocalCharacter(char character) + => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + } + + private sealed class GitHubDocument : Document + { + private readonly Entry[] _rules; + + private GitHubDocument(Entry[] rules) + { + _rules = rules; + } + + public static GitHubDocument Empty { get; } = new([]); + + public static GitHubDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) + { + parsingDiagnosticsCount = 0; + var rules = new List(); + + foreach (var line in lines) + { + var raw = line.Trim(); + if (raw.Length == 0 || raw[0] == '#') + { + continue; + } + + var entry = Entry.ParseGitHub(raw); + if (entry is null) + { + parsingDiagnosticsCount++; + } + else + { + rules.Add(entry); + } + } + + rules.Reverse(); + return new GitHubDocument(rules.ToArray()); + } + + public override IEnumerable Match(string path) + { + foreach (var rule in _rules) + { + if (rule.Match(path)) + { + return rule.Owners; + } + } + + return []; + } + } + + private sealed partial class Entry + { + public static Entry? ParseGitHub(string raw) + { + if (raw.StartsWith("\\#")) + { + return null; + } + + var idxHash = FindUnescapedCharacter(raw, '#'); + var effective = idxHash >= 0 ? raw.Substring(0, idxHash).TrimEnd() : raw; + if (string.IsNullOrWhiteSpace(effective)) + { + return null; + } + + string patternToken; + string ownersSegment; + SplitEscapedEntry(effective, out patternToken, out ownersSegment, out _); + + if (patternToken.Length == 0 || IsUnsupportedGitHubPattern(patternToken)) + { + return null; + } + + var owners = GitHubOwnerTokenizer.TokenizeGitHub(ownersSegment, out var allOwnersValid); + if (!allOwnersValid) + { + return null; + } + + var glob = GlobPattern.CompileGitHub(patternToken, includeDescendants: IsDirectoryPattern(patternToken)); + if (glob is null) + { + return null; + } + + return new Entry(glob, patternToken, exclusion: false, owners); + } + + private static int FindUnescapedCharacter(string value, char character) + { + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\\' && i + 1 < value.Length) + { + i++; + } + else if (value[i] == character) + { + return i; + } + } + + return -1; + } + + private static bool IsUnsupportedGitHubPattern(string patternToken) + { + if (patternToken.StartsWith("!")) + { + return true; + } + + var hasOpeningBracket = false; + for (var i = 0; i < patternToken.Length; i++) + { + if (patternToken[i] == '\\' && i + 1 < patternToken.Length) + { + i++; + } + else if (patternToken[i] == '[') + { + hasOpeningBracket = true; + } + else if (patternToken[i] == ']' && hasOpeningBracket) + { + return true; + } + } + + return false; + } + + private static bool IsDirectoryPattern(string patternToken) + { + var lastSegmentStart = patternToken.LastIndexOf('/'); + var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken; + if (lastSegment.Length == 0) + { + return false; + } + + for (var i = 0; i < lastSegment.Length; i++) + { + if (lastSegment[i] == '\\' && i + 1 < lastSegment.Length) + { + i++; + } + else if (lastSegment[i] is '*' or '?') + { + return false; + } + } + + return true; + } + } + } +} diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs new file mode 100644 index 000000000000..ee2d54c2883c --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs @@ -0,0 +1,645 @@ +// +// 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Datadog.Trace.Ci +{ + internal sealed partial class CodeOwners + { + private static readonly Regex SectionHeaderRegex = new( + @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex GitLabRoleReferenceRegex = new( + @"(? optional section + var name = m.Groups["name"].Value.Trim(); + hasDiagnostics = name.Length == 0 || + !IsStrictSectionHeader(raw); + + var approvals = 0; + if (m.Groups["cnt"].Success) + { + if (int.TryParse(m.Groups["cnt"].Value, out var val)) + { + approvals = val; + } + else + { + hasDiagnostics = true; + } + } + + hasDiagnostics |= !required && approvals > 0; + + // Only parse the owner span recognized by GitLab's permissive header grammar. Text + // after a malformed suffix (for example an extra ']') must never leak into defaults. + var defaults = GitLabOwnerTokenizer.TokenizeGitLab(m.Groups["defaults"].Value, out var allDefaultsValid); + hasDiagnostics |= !allDefaultsValid; + section = new GitLabSection(name, defaults); + return true; + } + + private static bool IsUnparsableSectionHeader(string raw) + => raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal); + + private static bool IsStrictSectionHeader(string raw) + { + var index = raw[0] == '^' ? 1 : 0; + if (index >= raw.Length || raw[index] != '[') + { + return false; + } + + var nameStart = ++index; + while (index < raw.Length && raw[index] != ']') + { + index++; + } + + if (index == nameStart || index >= raw.Length) + { + return false; + } + + index++; + if (index < raw.Length && raw[index] == '[') + { + var approvalStart = ++index; + while (index < raw.Length && char.IsDigit(raw[index])) + { + index++; + } + + if (index == approvalStart || index >= raw.Length || raw[index] != ']') + { + return false; + } + + index++; + } + + if (index == raw.Length) + { + return true; + } + + if (!char.IsWhiteSpace(raw[index])) + { + return false; + } + + for (; index < raw.Length; index++) + { + if (!IsStrictSectionOwnerCharacter(raw[index])) + { + return false; + } + } + + return true; + } + + private static bool IsStrictSectionOwnerCharacter(char character) + { + if (char.IsLetterOrDigit(character) || char.IsWhiteSpace(character) || character is '@' or '.' or '-' or '/') + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; + } + + private static class GitLabOwnerTokenizer + { + public static string[] TokenizeGitLab(string segment, out bool allValid) + { + if (string.IsNullOrWhiteSpace(segment)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + { + if (!ExtractGitLabOwners(token, owners, uniqueOwners)) + { + allValid = false; + } + } + + return owners.Count == 0 ? [] : owners.ToArray(); + } + + private static void AddUnique(List owners, HashSet uniqueOwners, string owner) + { + if (uniqueOwners.Add(owner)) + { + owners.Add(owner); + } + } + + private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) + { + // Keep the overwhelmingly common canonical forms allocation-light. + if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) + { + AddUnique(owners, uniqueOwners, token); + return true; + } + + // GitLab extracts references from surrounding punctuation instead of returning + // the entire token verbatim (for example "(@team)" becomes "@team"). + var foundReference = false; + var searchStart = 0; + while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart)) + { + var reference = token.Substring(referenceStart, referenceEnd - referenceStart); + AddUnique(owners, uniqueOwners, reference); + foundReference = true; + } + + var roleMatches = GitLabRoleReferenceRegex.Matches(token); + for (var i = 0; i < roleMatches.Count; i++) + { + var roleMatch = roleMatches[i]; + AddUnique(owners, uniqueOwners, roleMatch.Value); + foundReference = true; + } + + searchStart = 0; + while (TryExtractGitLabEmailReference(token, searchStart, out var emailStart, out var emailEnd, out searchStart)) + { + var email = emailStart == 0 && emailEnd == token.Length + ? token + : token.Substring(emailStart, emailEnd - emailStart); + // GitLab's permissive email expression can overlap a namespace reference + // (for example "(@team"). Such a value cannot resolve as an email, while + // the namespace extracted independently can resolve, so keep only the latter. + if (!ContainsNamespaceReference(email)) + { + AddUnique(owners, uniqueOwners, email); + foundReference = true; + } + } + + return foundReference; + } + + private static bool ContainsNamespaceReference(string value) + => TryFindNamespaceReference(value, 0, out _, out _, out _); + + private static bool IsValidGitLabRole(string token) + { + if (!token.StartsWith("@@", StringComparison.Ordinal) || token.Length <= 2) + { + return false; + } + + var role = token.Substring(2); + return role.Equals("developer", StringComparison.OrdinalIgnoreCase) || + role.Equals("developers", StringComparison.OrdinalIgnoreCase) || + role.Equals("maintainer", StringComparison.OrdinalIgnoreCase) || + role.Equals("maintainers", StringComparison.OrdinalIgnoreCase) || + role.Equals("owner", StringComparison.OrdinalIgnoreCase) || + role.Equals("owners", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsWholeNamespaceReference(string token) + => TryFindNamespaceReference(token, 0, out var start, out var end, out _) && start == 0 && end == token.Length; + + private static bool TryFindNamespaceReference( + string token, + int searchStart, + out int referenceStart, + out int referenceEnd, + out int nextSearchStart) + { + for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) + { + nextSearchStart = atIndex + 1; + if ((atIndex > 0 && (IsWordCharacter(token[atIndex - 1]) || token[atIndex - 1] == '@')) || + atIndex + 1 >= token.Length || + token[atIndex + 1] == '@' || + !IsNamespaceStart(token[atIndex + 1])) + { + continue; + } + + var segmentStart = atIndex + 1; + var lastValidEnd = -1; + for (var i = segmentStart; i < token.Length; i++) + { + var character = token[i]; + if (character == '/') + { + if (i == segmentStart || lastValidEnd != i) + { + break; + } + + segmentStart = i + 1; + continue; + } + + if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character)) + { + break; + } + + if (IsNamespaceEnd(character)) + { + lastValidEnd = i + 1; + } + } + + if (lastValidEnd > atIndex + 1) + { + referenceStart = atIndex; + referenceEnd = lastValidEnd; + nextSearchStart = lastValidEnd; + return true; + } + } + + referenceStart = -1; + referenceEnd = -1; + nextSearchStart = token.Length; + return false; + } + + private static bool TryExtractGitLabEmailReference( + string token, + int searchStart, + out int referenceStart, + out int referenceEnd, + out int nextSearchStart) + { + for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) + { + var localStart = atIndex - 1; + var localLength = 0; + while (localStart >= searchStart && + localLength < 100 && + token[localStart] != '@' && + !char.IsWhiteSpace(token[localStart])) + { + localStart--; + localLength++; + } + + localStart++; + if (localLength == 0) + { + continue; + } + + var domainEnd = atIndex + 1; + var domainLimit = Math.Min(token.Length, domainEnd + 255); + var lastWordEnd = -1; + while (domainEnd < domainLimit && token[domainEnd] != '@' && !char.IsWhiteSpace(token[domainEnd])) + { + if (IsRegexWordCharacter(token[domainEnd])) + { + lastWordEnd = domainEnd + 1; + } + + domainEnd++; + } + + if (lastWordEnd <= atIndex + 1) + { + continue; + } + + referenceStart = localStart; + referenceEnd = lastWordEnd; + nextSearchStart = lastWordEnd; + return true; + } + + referenceStart = -1; + referenceEnd = -1; + nextSearchStart = token.Length; + return false; + } + + private static bool IsNamespaceStart(char character) + => IsAsciiLetterOrDigit(character) || character is '_' or '.'; + + private static bool IsNamespaceCharacter(char character) + => IsNamespaceStart(character) || character == '-'; + + private static bool IsNamespaceEnd(char character) + => IsAsciiLetterOrDigit(character) || character is '_' or '-'; + + private static bool IsWordCharacter(char character) + => char.IsLetterOrDigit(character) || character == '_'; + + private static bool IsRegexWordCharacter(char character) + { + if (char.IsLetterOrDigit(character)) + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; + } + + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + } + + private sealed class GitLabDocument : Document + { + private readonly GitLabSection[] _sections; + + private GitLabDocument(GitLabSection[] sections) + { + _sections = sections; + } + + public static GitLabDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) + { + parsingDiagnosticsCount = 0; + var sections = new List(); + var current = GitLabSection.CreateUnnamed(); + var currentDefaultOwners = current.DefaultOwners; + var namedSections = new Dictionary(StringComparer.OrdinalIgnoreCase); + sections.Add(current); + + foreach (var line in lines) + { + var raw = line.Trim(); + if (raw.Length == 0) + { + continue; + } + + if (TryParseGitLabSectionHeader(raw, out var newSection, out var sectionHasDiagnostics)) + { + if (sectionHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + currentDefaultOwners = newSection.DefaultOwners; + if (namedSections.TryGetValue(newSection.Name, out var existingSection)) + { + current = existingSection; + } + else + { + current = newSection; + sections.Add(current); + namedSections.Add(current.Name, current); + } + + continue; + } + + if (IsUnparsableSectionHeader(raw)) + { + // GitLab reports malformed header-like lines and skips them rather than + // reinterpreting them as path patterns. + parsingDiagnosticsCount++; + continue; + } + + if (raw[0] == '#') + { + // GitLab parses owners inside comments for its MR widget, but comments do + // not bind those owners to a path and therefore do not affect matching. + continue; + } + + var entry = Entry.ParseGitLab(raw, currentDefaultOwners, out var entryHasDiagnostics); + if (entry is null) + { + parsingDiagnosticsCount++; + } + else + { + if (entryHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + current.Add(entry); + } + } + + foreach (var section in sections) + { + section.Seal(); + } + + return new GitLabDocument(sections.ToArray()); + } + + public override IEnumerable Match(string path) + { + // GitLab evaluates each section independently and combines their owners. + // The set is allocated lazily because most paths match at most one section. + HashSet? owners = null; + foreach (var section in _sections) + { + if (section.TryMatch(path, out var sectionOwners)) + { + owners ??= new HashSet(StringComparer.Ordinal); + foreach (var owner in sectionOwners) + { + owners.Add(owner); + } + } + } + + return owners ?? []; + } + } + + private sealed class GitLabSection + { + private readonly List _entries = new(); + private Entry[]? _cache; + + public GitLabSection(string name, string[] defaultOwners) + { + Name = name; + DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; + } + + public string Name { get; } + + public string[] DefaultOwners { get; } + + public static GitLabSection CreateUnnamed() => new(string.Empty, []); + + public void Add(Entry entry) => _entries.Add(entry); + + public void Seal() + { + var seenPatterns = new HashSet(StringComparer.Ordinal); + var cache = new List(_entries.Count); + for (var i = _entries.Count - 1; i >= 0; i--) + { + var entry = _entries[i]; + if (seenPatterns.Add(entry.PatternKey)) + { + cache.Add(entry); + } + } + + _cache = cache.ToArray(); + _entries.Clear(); + } + + public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) + { + var rules = _cache ?? []; + string[]? matchedOwners = null; + + foreach (var rule in rules) + { + if (!rule.Match(path)) + { + continue; + } + + if (rule.IsExclusion) + { + owners = null; + return false; + } + + matchedOwners ??= rule.Owners; + } + + if (matchedOwners is null || matchedOwners.Length == 0) + { + owners = null; + return false; + } + + owners = matchedOwners; + return true; + } + } + + private sealed partial class Entry + { + public static Entry? ParseGitLab(string raw, string[] defaultOwners, out bool hasDiagnostics) + { + hasDiagnostics = false; + SplitEscapedEntry(raw, out var patternToken, out var ownersSegment, out var hasExplicitOwners); + + var isExclusion = patternToken.StartsWith("!"); + if (isExclusion) + { + patternToken = patternToken.Substring(1, patternToken.Length - 1); + } + + if (patternToken.Length == 0) + { + return null; + } + + var allOwnersValid = true; + var owners = isExclusion + ? [] + : GitLabOwnerTokenizer.TokenizeGitLab(ownersSegment, out allOwnersValid); + hasDiagnostics = !isExclusion && !allOwnersValid; + + if (!isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) + { + owners = defaultOwners; + } + + if (!isExclusion && owners.Length == 0) + { + hasDiagnostics = true; + } + + var glob = GlobPattern.CompileGitLab(patternToken); + if (glob is null) + { + return null; + } + + return new Entry(glob, NormalizeGitLabPatternKey(patternToken), isExclusion, owners); + } + + private static string NormalizeGitLabPatternKey(string patternToken) + { + if (patternToken == "*") + { + return "/**/*"; + } + + var normalizedToken = NormalizeGitLabEscapes(patternToken); + var normalized = normalizedToken.StartsWith("/") ? normalizedToken : "/**/" + normalizedToken; + return normalized.EndsWith("/") ? normalized + "**/*" : normalized; + } + + private static string NormalizeGitLabEscapes(string patternToken) + { + StringBuilder? builder = null; + var copyStart = 0; + for (var i = 0; i + 1 < patternToken.Length; i++) + { + var unescape = i == 0 && patternToken[i] == '\\' && patternToken[i + 1] == '#'; + unescape |= patternToken[i] == '\\' && char.IsWhiteSpace(patternToken[i + 1]); + if (!unescape) + { + continue; + } + + builder ??= new StringBuilder(patternToken.Length); + builder.Append(patternToken, copyStart, i - copyStart); + copyStart = i + 1; + } + + if (builder is null) + { + return patternToken; + } + + builder.Append(patternToken, copyStart, patternToken.Length - copyStart); + return builder.ToString(); + } + } + } +} diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 9a4a5cf39517..8e8f427ed19b 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -7,11 +7,9 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.IO; using System.Security; using System.Text; -using System.Text.RegularExpressions; using Datadog.Trace.Logging; namespace Datadog.Trace.Ci @@ -19,19 +17,11 @@ namespace Datadog.Trace.Ci /// /// Parses and matches GitHub and GitLab CODEOWNERS files. /// - internal sealed class CodeOwners + internal sealed partial class CodeOwners { internal const long GitHubMaximumFileSizeBytes = 3 * 1024 * 1024; private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); - private static readonly Regex SectionHeaderRegex = new( - @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex GitLabRoleReferenceRegex = new( - @"(? Match(string path) return _document.Match(normalizedPath); } - private static bool TryParseGitLabSectionHeader( - string raw, - [NotNullWhen(true)] out GitLabSection? section, - out bool hasDiagnostics) - { - // Accepted forms: - // [Docs] - // ^[Go] - // [Backend][2] @team @another - var m = SectionHeaderRegex.Match(raw); - if (!m.Success) - { - section = null; - hasDiagnostics = false; - return false; - } - - var required = !m.Groups[1].Success; // ^ prefix => optional section - var name = m.Groups["name"].Value.Trim(); - hasDiagnostics = name.Length == 0 || - !IsStrictSectionHeader(raw); - - var approvals = 0; - if (m.Groups["cnt"].Success) - { - if (int.TryParse(m.Groups["cnt"].Value, out var val)) - { - approvals = val; - } - else - { - hasDiagnostics = true; - } - } - - hasDiagnostics |= !required && approvals > 0; - - // Only parse the owner span recognized by GitLab's permissive header grammar. Text - // after a malformed suffix (for example an extra ']') must never leak into defaults. - var defaults = OwnerTokenizer.TokenizeGitLab(m.Groups["defaults"].Value, out var allDefaultsValid); - hasDiagnostics |= !allDefaultsValid; - section = new GitLabSection(name, defaults); - return true; - } - - private static bool IsUnparsableSectionHeader(string raw) - => raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal); - - private static bool IsStrictSectionHeader(string raw) - { - var index = raw[0] == '^' ? 1 : 0; - if (index >= raw.Length || raw[index] != '[') - { - return false; - } - - var nameStart = ++index; - while (index < raw.Length && raw[index] != ']') - { - index++; - } - - if (index == nameStart || index >= raw.Length) - { - return false; - } - - index++; - if (index < raw.Length && raw[index] == '[') - { - var approvalStart = ++index; - while (index < raw.Length && char.IsDigit(raw[index])) - { - index++; - } - - if (index == approvalStart || index >= raw.Length || raw[index] != ']') - { - return false; - } - - index++; - } - - if (index == raw.Length) - { - return true; - } - - if (!char.IsWhiteSpace(raw[index])) - { - return false; - } - - for (; index < raw.Length; index++) - { - if (!IsStrictSectionOwnerCharacter(raw[index])) - { - return false; - } - } - - return true; - } - - private static bool IsStrictSectionOwnerCharacter(char character) - { - if (char.IsLetterOrDigit(character) || char.IsWhiteSpace(character) || character is '@' or '.' or '-' or '/') - { - return true; - } - - var category = CharUnicodeInfo.GetUnicodeCategory(character); - return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; - } - #pragma warning disable SA1201 public enum Platform #pragma warning restore SA1201 @@ -260,345 +134,6 @@ private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresS public bool Matches(string path, int start, int end) => _segment!.IsMatch(path, start, end); } - private static class OwnerTokenizer - { - public static string[] TokenizeGitHub(string segment, out bool allValid) - { - if (string.IsNullOrWhiteSpace(segment)) - { - allValid = true; - return []; - } - - var owners = new List(); - var uniqueOwners = new HashSet(StringComparer.Ordinal); - allValid = true; - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) - { - if (IsValidGitHubOwner(token)) - { - AddUnique(owners, uniqueOwners, token); - } - else - { - allValid = false; - } - } - - return owners.Count == 0 ? [] : owners.ToArray(); - } - - public static string[] TokenizeGitLab(string segment, out bool allValid) - { - if (string.IsNullOrWhiteSpace(segment)) - { - allValid = true; - return []; - } - - var owners = new List(); - var uniqueOwners = new HashSet(StringComparer.Ordinal); - allValid = true; - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) - { - if (!ExtractGitLabOwners(token, owners, uniqueOwners)) - { - allValid = false; - } - } - - return owners.Count == 0 ? [] : owners.ToArray(); - } - - private static void AddUnique(List owners, HashSet uniqueOwners, string owner) - { - if (uniqueOwners.Add(owner)) - { - owners.Add(owner); - } - } - - private static bool IsValidGitHubOwner(string token) - { - if (token.Length > 1 && token[0] == '@' && token[1] != '@') - { - var slash = token.IndexOf('/'); - return slash < 0 - ? IsValidGitHubIdentifier(token, 1, token.Length) - : token.IndexOf('/', slash + 1) < 0 && - IsValidGitHubIdentifier(token, 1, slash) && - IsValidGitHubIdentifier(token, slash + 1, token.Length); - } - - var at = token.IndexOf('@'); - if (at is < 1 or > 100 || - token.Length - at - 1 is < 1 or > 255 || - token.IndexOf('@', at + 1) >= 0) - { - return false; - } - - for (var i = 0; i < at; i++) - { - if (!IsEmailLocalCharacter(token[i])) - { - return false; - } - } - - for (var i = at + 1; i < token.Length; i++) - { - if (!IsAsciiLetterOrDigit(token[i]) && token[i] is not '.' and not '-' and not '_') - { - return false; - } - } - - return IsAsciiLetterOrDigit(token[token.Length - 1]) || token[token.Length - 1] == '_'; - } - - private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) - { - // Keep the overwhelmingly common canonical forms allocation-light. - if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) - { - AddUnique(owners, uniqueOwners, token); - return true; - } - - // GitLab extracts references from surrounding punctuation instead of returning - // the entire token verbatim (for example "(@team)" becomes "@team"). - var foundReference = false; - var searchStart = 0; - while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart)) - { - var reference = token.Substring(referenceStart, referenceEnd - referenceStart); - AddUnique(owners, uniqueOwners, reference); - foundReference = true; - } - - var roleMatches = GitLabRoleReferenceRegex.Matches(token); - for (var i = 0; i < roleMatches.Count; i++) - { - var roleMatch = roleMatches[i]; - AddUnique(owners, uniqueOwners, roleMatch.Value); - foundReference = true; - } - - searchStart = 0; - while (TryExtractGitLabEmailReference(token, searchStart, out var emailStart, out var emailEnd, out searchStart)) - { - var email = emailStart == 0 && emailEnd == token.Length - ? token - : token.Substring(emailStart, emailEnd - emailStart); - // GitLab's permissive email expression can overlap a namespace reference - // (for example "(@team"). Such a value cannot resolve as an email, while - // the namespace extracted independently can resolve, so keep only the latter. - if (!ContainsNamespaceReference(email)) - { - AddUnique(owners, uniqueOwners, email); - foundReference = true; - } - } - - return foundReference; - } - - private static bool ContainsNamespaceReference(string value) - => TryFindNamespaceReference(value, 0, out _, out _, out _); - - private static bool IsValidGitLabRole(string token) - { - if (!token.StartsWith("@@", StringComparison.Ordinal) || token.Length <= 2) - { - return false; - } - - var role = token.Substring(2); - return role.Equals("developer", StringComparison.OrdinalIgnoreCase) || - role.Equals("developers", StringComparison.OrdinalIgnoreCase) || - role.Equals("maintainer", StringComparison.OrdinalIgnoreCase) || - role.Equals("maintainers", StringComparison.OrdinalIgnoreCase) || - role.Equals("owner", StringComparison.OrdinalIgnoreCase) || - role.Equals("owners", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsWholeNamespaceReference(string token) - => TryFindNamespaceReference(token, 0, out var start, out var end, out _) && start == 0 && end == token.Length; - - private static bool IsValidGitHubIdentifier(string value, int start, int end) - { - if (start >= end || !IsAsciiLetterOrDigit(value[start]) || !IsAsciiLetterOrDigit(value[end - 1])) - { - return false; - } - - var previousWasHyphen = false; - for (var i = start; i < end; i++) - { - var character = value[i]; - if (!IsAsciiLetterOrDigit(character) && character is not '-' and not '_') - { - return false; - } - - if (character == '-' && previousWasHyphen) - { - return false; - } - - previousWasHyphen = character == '-'; - } - - return true; - } - - private static bool TryFindNamespaceReference( - string token, - int searchStart, - out int referenceStart, - out int referenceEnd, - out int nextSearchStart) - { - for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) - { - nextSearchStart = atIndex + 1; - if ((atIndex > 0 && (IsWordCharacter(token[atIndex - 1]) || token[atIndex - 1] == '@')) || - atIndex + 1 >= token.Length || - token[atIndex + 1] == '@' || - !IsNamespaceStart(token[atIndex + 1])) - { - continue; - } - - var segmentStart = atIndex + 1; - var lastValidEnd = -1; - for (var i = segmentStart; i < token.Length; i++) - { - var character = token[i]; - if (character == '/') - { - if (i == segmentStart || lastValidEnd != i) - { - break; - } - - segmentStart = i + 1; - continue; - } - - if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character)) - { - break; - } - - if (IsNamespaceEnd(character)) - { - lastValidEnd = i + 1; - } - } - - if (lastValidEnd > atIndex + 1) - { - referenceStart = atIndex; - referenceEnd = lastValidEnd; - nextSearchStart = lastValidEnd; - return true; - } - } - - referenceStart = -1; - referenceEnd = -1; - nextSearchStart = token.Length; - return false; - } - - private static bool TryExtractGitLabEmailReference( - string token, - int searchStart, - out int referenceStart, - out int referenceEnd, - out int nextSearchStart) - { - for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) - { - var localStart = atIndex - 1; - var localLength = 0; - while (localStart >= searchStart && - localLength < 100 && - token[localStart] != '@' && - !char.IsWhiteSpace(token[localStart])) - { - localStart--; - localLength++; - } - - localStart++; - if (localLength == 0) - { - continue; - } - - var domainEnd = atIndex + 1; - var domainLimit = Math.Min(token.Length, domainEnd + 255); - var lastWordEnd = -1; - while (domainEnd < domainLimit && token[domainEnd] != '@' && !char.IsWhiteSpace(token[domainEnd])) - { - if (IsRegexWordCharacter(token[domainEnd])) - { - lastWordEnd = domainEnd + 1; - } - - domainEnd++; - } - - if (lastWordEnd <= atIndex + 1) - { - continue; - } - - referenceStart = localStart; - referenceEnd = lastWordEnd; - nextSearchStart = lastWordEnd; - return true; - } - - referenceStart = -1; - referenceEnd = -1; - nextSearchStart = token.Length; - return false; - } - - private static bool IsNamespaceStart(char character) - => IsAsciiLetterOrDigit(character) || character is '_' or '.'; - - private static bool IsNamespaceCharacter(char character) - => IsNamespaceStart(character) || character == '-'; - - private static bool IsNamespaceEnd(char character) - => IsAsciiLetterOrDigit(character) || character is '_' or '-'; - - private static bool IsEmailLocalCharacter(char character) - => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; - - private static bool IsWordCharacter(char character) - => char.IsLetterOrDigit(character) || character == '_'; - - private static bool IsRegexWordCharacter(char character) - { - if (char.IsLetterOrDigit(character)) - { - return true; - } - - var category = CharUnicodeInfo.GetUnicodeCategory(character); - return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; - } - - private static bool IsAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; - } - private sealed class GlobPattern { private readonly GlobPathSegment[] _segments; @@ -1028,236 +563,7 @@ private abstract class Document public abstract IEnumerable Match(string path); } - private sealed class GitHubDocument : Document - { - private readonly Entry[] _rules; - - private GitHubDocument(Entry[] rules) - { - _rules = rules; - } - - public static GitHubDocument Empty { get; } = new([]); - - public static GitHubDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) - { - parsingDiagnosticsCount = 0; - var rules = new List(); - - foreach (var line in lines) - { - var raw = line.Trim(); - if (raw.Length == 0 || raw[0] == '#') - { - continue; - } - - var entry = Entry.ParseGitHub(raw); - if (entry is null) - { - parsingDiagnosticsCount++; - } - else - { - rules.Add(entry); - } - } - - rules.Reverse(); - return new GitHubDocument(rules.ToArray()); - } - - public override IEnumerable Match(string path) - { - foreach (var rule in _rules) - { - if (rule.Match(path)) - { - return rule.Owners; - } - } - - return []; - } - } - - private sealed class GitLabDocument : Document - { - private readonly GitLabSection[] _sections; - - private GitLabDocument(GitLabSection[] sections) - { - _sections = sections; - } - - public static GitLabDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) - { - parsingDiagnosticsCount = 0; - var sections = new List(); - var current = GitLabSection.CreateUnnamed(); - var currentDefaultOwners = current.DefaultOwners; - var namedSections = new Dictionary(StringComparer.OrdinalIgnoreCase); - sections.Add(current); - - foreach (var line in lines) - { - var raw = line.Trim(); - if (raw.Length == 0) - { - continue; - } - - if (TryParseGitLabSectionHeader(raw, out var newSection, out var sectionHasDiagnostics)) - { - if (sectionHasDiagnostics) - { - parsingDiagnosticsCount++; - } - - currentDefaultOwners = newSection.DefaultOwners; - if (namedSections.TryGetValue(newSection.Name, out var existingSection)) - { - current = existingSection; - } - else - { - current = newSection; - sections.Add(current); - namedSections.Add(current.Name, current); - } - - continue; - } - - if (IsUnparsableSectionHeader(raw)) - { - // GitLab reports malformed header-like lines and skips them rather than - // reinterpreting them as path patterns. - parsingDiagnosticsCount++; - continue; - } - - if (raw[0] == '#') - { - // GitLab parses owners inside comments for its MR widget, but comments do - // not bind those owners to a path and therefore do not affect matching. - continue; - } - - var entry = Entry.ParseGitLab(raw, currentDefaultOwners, out var entryHasDiagnostics); - if (entry is null) - { - parsingDiagnosticsCount++; - } - else - { - if (entryHasDiagnostics) - { - parsingDiagnosticsCount++; - } - - current.Add(entry); - } - } - - foreach (var section in sections) - { - section.Seal(); - } - - return new GitLabDocument(sections.ToArray()); - } - - public override IEnumerable Match(string path) - { - // GitLab evaluates each section independently and combines their owners. - // The set is allocated lazily because most paths match at most one section. - HashSet? owners = null; - foreach (var section in _sections) - { - if (section.TryMatch(path, out var sectionOwners)) - { - owners ??= new HashSet(StringComparer.Ordinal); - foreach (var owner in sectionOwners) - { - owners.Add(owner); - } - } - } - - return owners ?? []; - } - } - - private sealed class GitLabSection - { - private readonly List _entries = new(); - private Entry[]? _cache; - - public GitLabSection(string name, string[] defaultOwners) - { - Name = name; - DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; - } - - public string Name { get; } - - public string[] DefaultOwners { get; } - - public static GitLabSection CreateUnnamed() => new(string.Empty, []); - - public void Add(Entry entry) => _entries.Add(entry); - - public void Seal() - { - var seenPatterns = new HashSet(StringComparer.Ordinal); - var cache = new List(_entries.Count); - for (var i = _entries.Count - 1; i >= 0; i--) - { - var entry = _entries[i]; - if (seenPatterns.Add(entry.PatternKey)) - { - cache.Add(entry); - } - } - - _cache = cache.ToArray(); - _entries.Clear(); - } - - public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) - { - var rules = _cache ?? []; - string[]? matchedOwners = null; - - foreach (var rule in rules) - { - if (!rule.Match(path)) - { - continue; - } - - if (rule.IsExclusion) - { - owners = null; - return false; - } - - matchedOwners ??= rule.Owners; - } - - if (matchedOwners is null || matchedOwners.Length == 0) - { - owners = null; - return false; - } - - owners = matchedOwners; - return true; - } - } - - private sealed class Entry + private sealed partial class Entry { private readonly GlobPattern _glob; @@ -1275,85 +581,6 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne public string PatternKey { get; } - public static Entry? ParseGitHub(string raw) - { - if (raw.StartsWith("\\#")) - { - return null; - } - - var idxHash = FindUnescapedCharacter(raw, '#'); - var effective = idxHash >= 0 ? raw.Substring(0, idxHash).TrimEnd() : raw; - if (string.IsNullOrWhiteSpace(effective)) - { - return null; - } - - string patternToken; - string ownersSegment; - SplitEscapedEntry(effective, out patternToken, out ownersSegment, out _); - - if (patternToken.Length == 0 || IsUnsupportedGitHubPattern(patternToken)) - { - return null; - } - - var owners = OwnerTokenizer.TokenizeGitHub(ownersSegment, out var allOwnersValid); - if (!allOwnersValid) - { - return null; - } - - var glob = GlobPattern.CompileGitHub(patternToken, includeDescendants: IsDirectoryPattern(patternToken)); - if (glob is null) - { - return null; - } - - return new Entry(glob, patternToken, exclusion: false, owners); - } - - public static Entry? ParseGitLab(string raw, string[] defaultOwners, out bool hasDiagnostics) - { - hasDiagnostics = false; - SplitEscapedEntry(raw, out var patternToken, out var ownersSegment, out var hasExplicitOwners); - - var isExclusion = patternToken.StartsWith("!"); - if (isExclusion) - { - patternToken = patternToken.Substring(1, patternToken.Length - 1); - } - - if (patternToken.Length == 0) - { - return null; - } - - var allOwnersValid = true; - var owners = isExclusion - ? [] - : OwnerTokenizer.TokenizeGitLab(ownersSegment, out allOwnersValid); - hasDiagnostics = !isExclusion && !allOwnersValid; - - if (!isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) - { - owners = defaultOwners; - } - - if (!isExclusion && owners.Length == 0) - { - hasDiagnostics = true; - } - - var glob = GlobPattern.CompileGitLab(patternToken); - if (glob is null) - { - return null; - } - - return new Entry(glob, NormalizeGitLabPatternKey(patternToken), isExclusion, owners); - } - private static void SplitEscapedEntry(string entry, out string pattern, out string owners, out bool hasExplicitOwners) { var patternEnd = entry.Length; @@ -1375,113 +602,6 @@ private static void SplitEscapedEntry(string entry, out string pattern, out stri hasExplicitOwners = owners.Length > 0; } - private static int FindUnescapedCharacter(string value, char character) - { - for (var i = 0; i < value.Length; i++) - { - if (value[i] == '\\' && i + 1 < value.Length) - { - i++; - } - else if (value[i] == character) - { - return i; - } - } - - return -1; - } - - private static string NormalizeGitLabPatternKey(string patternToken) - { - if (patternToken == "*") - { - return "/**/*"; - } - - var normalizedToken = NormalizeGitLabEscapes(patternToken); - var normalized = normalizedToken.StartsWith("/") ? normalizedToken : "/**/" + normalizedToken; - return normalized.EndsWith("/") ? normalized + "**/*" : normalized; - } - - private static string NormalizeGitLabEscapes(string patternToken) - { - StringBuilder? builder = null; - var copyStart = 0; - for (var i = 0; i + 1 < patternToken.Length; i++) - { - var unescape = i == 0 && patternToken[i] == '\\' && patternToken[i + 1] == '#'; - unescape |= patternToken[i] == '\\' && char.IsWhiteSpace(patternToken[i + 1]); - if (!unescape) - { - continue; - } - - builder ??= new StringBuilder(patternToken.Length); - builder.Append(patternToken, copyStart, i - copyStart); - copyStart = i + 1; - } - - if (builder is null) - { - return patternToken; - } - - builder.Append(patternToken, copyStart, patternToken.Length - copyStart); - return builder.ToString(); - } - - private static bool IsUnsupportedGitHubPattern(string patternToken) - { - if (patternToken.StartsWith("!")) - { - return true; - } - - var hasOpeningBracket = false; - for (var i = 0; i < patternToken.Length; i++) - { - if (patternToken[i] == '\\' && i + 1 < patternToken.Length) - { - i++; - } - else if (patternToken[i] == '[') - { - hasOpeningBracket = true; - } - else if (patternToken[i] == ']' && hasOpeningBracket) - { - return true; - } - } - - return false; - } - - private static bool IsDirectoryPattern(string patternToken) - { - var lastSegmentStart = patternToken.LastIndexOf('/'); - var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken; - if (lastSegment.Length == 0) - { - return false; - } - - for (var i = 0; i < lastSegment.Length; i++) - { - if (lastSegment[i] == '\\' && i + 1 < lastSegment.Length) - { - i++; - } - else if (lastSegment[i] is '*' or '?') - { - return false; - } - } - - return true; - } - public bool Match(string path) => _glob.IsMatch(path); } } From e6617aa7cad44d18e08265c1402222460330ba0e Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 15:39:38 +0200 Subject: [PATCH 22/25] [CI Visibility] Document CODEOWNERS path resolution --- .../CIEnvironmentValues.CodeOwners.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs index ade404a07879..2cfcae890062 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs @@ -28,6 +28,8 @@ internal abstract partial class CIEnvironmentValues internal string? CodeOwnersRoot { get; private set; } + // Returns the source-root-relative path when possible, falling back to the CODEOWNERS root + // for compiler paths that were recorded relative to a different CI workspace. internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) { var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); @@ -36,6 +38,8 @@ internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath : sourceRelativePath; } + // Resolves a source path to a safe repository-relative path that can be matched against the + // loaded CODEOWNERS file, discovering the file lazily when necessary. internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { codeOwnersRelativePath = null; @@ -115,6 +119,8 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa return true; } + // Resolves a candidate source path and returns the directory from which an ancestor + // CODEOWNERS search should start, without falling back to the current working directory. private static string? GetCodeOwnersSearchStart(string? path, string? basePath) { if (StringUtil.IsNullOrWhiteSpace(path)) @@ -155,12 +161,15 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa } } + // Detects a repository boundary represented by either a .git directory or a worktree .git file. private static bool HasGitDirectory(string path) { var gitPath = Path.Combine(path, ".git"); return Directory.Exists(gitPath) || File.Exists(gitPath); } + // Anchors a relative path to an absolute base directory and rejects rooted inputs or traversal + // that would escape that base. private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) { absolutePath = null; @@ -213,6 +222,8 @@ private static bool TryResolvePathWithinBase(string relativePath, string basePat return false; } + // Probes the platform-specific CODEOWNERS locations in priority order and returns the first + // existing file. private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) { foreach (var path in GetCodeOwnersPaths(sourceRoot, platform)) @@ -233,6 +244,7 @@ private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform return false; } + // Infers the CODEOWNERS dialect from standard repository URLs and SCP-style SSH URLs. private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform) { platform = default; @@ -272,11 +284,14 @@ private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, o return false; } + // Recognizes gitlab.com and common self-managed GitLab host naming conventions. private static bool IsGitLabHost(string? host) => string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) || (host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) || (host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0); + // Enumerates the supported CODEOWNERS locations in the order defined by each platform, + // including all known locations when the platform value is unknown. private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) { if (platform == CodeOwners.Platform.GitHub) @@ -303,6 +318,7 @@ private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwn } } + // Clears the loaded parser, its repository root, and cached fallback search locations. private void ResetCodeOwners() { CodeOwners = null; @@ -313,6 +329,7 @@ private void ResetCodeOwners() } } + // Performs the initial CODEOWNERS lookup at SourceRoot using the detected platform semantics. private void LoadCodeOwners() { if (!StringUtil.IsNullOrEmpty(SourceRoot)) @@ -330,6 +347,8 @@ private void LoadCodeOwners() } } + // Re-anchors an unresolved relative compiler path by selecting the longest file suffix that + // exists below the CODEOWNERS root; absolute, ambiguous, and traversing paths are rejected. private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { // Compiler-recorded paths can be relative to a different base directory than the current @@ -400,6 +419,8 @@ private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwn return false; } + // Lazily discovers CODEOWNERS from the source path or workspace when the initial SourceRoot + // lookup did not load one, while serializing concurrent fallback attempts. private void EnsureCodeOwnersFromFallback(string? sourceFilePath) { if (CodeOwners is not null) @@ -426,6 +447,8 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) } } + // Walks ancestors from a resolved start directory, loading the first CODEOWNERS file and + // stopping at the nearest Git boundary; repeated start locations are cached. private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath) { var startDirectory = GetCodeOwnersSearchStart(startPath, basePath); @@ -484,6 +507,8 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } + // Selects the CODEOWNERS dialect from repository host, CI provider, or platform-specific file + // placement, defaulting to GitHub when no reliable GitLab signal exists. private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) { if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) From 8530d1a1ed18d62e2b4dc439fab6d3ec96502a7f Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 15:45:55 +0200 Subject: [PATCH 23/25] [CI Visibility] Use XML docs for CODEOWNERS helpers --- .../CIEnvironmentValues.CodeOwners.cs | 121 ++++++++++++++---- 1 file changed, 96 insertions(+), 25 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs index 2cfcae890062..cfd2d54ecf81 100644 --- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs @@ -28,8 +28,13 @@ internal abstract partial class CIEnvironmentValues internal string? CodeOwnersRoot { get; private set; } - // Returns the source-root-relative path when possible, falling back to the CODEOWNERS root - // for compiler paths that were recorded relative to a different CI workspace. + /// + /// Returns the source-root-relative path when possible, falling back to the CODEOWNERS root + /// for compiler paths that were recorded relative to a different CI workspace. + /// + /// The compiler-recorded source file path. + /// Whether to use the current operating system's directory separator. + /// The normalized path relative to the source root or CODEOWNERS root. internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true) { var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator); @@ -38,8 +43,14 @@ internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath : sourceRelativePath; } - // Resolves a source path to a safe repository-relative path that can be matched against the - // loaded CODEOWNERS file, discovering the file lazily when necessary. + /// + /// Resolves a source path to a safe repository-relative path that can be matched against the + /// loaded CODEOWNERS file, discovering the file lazily when necessary. + /// + /// The compiler-recorded source file path. + /// Whether to use the current operating system's directory separator. + /// The path relative to the root containing the loaded CODEOWNERS file. + /// true when the source path can be safely resolved; otherwise, false. internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { codeOwnersRelativePath = null; @@ -119,8 +130,13 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa return true; } - // Resolves a candidate source path and returns the directory from which an ancestor - // CODEOWNERS search should start, without falling back to the current working directory. + /// + /// Resolves a candidate source path and returns the directory from which an ancestor + /// CODEOWNERS search should start, without falling back to the current working directory. + /// + /// The source file or directory path used to start the search. + /// The absolute base used to resolve a relative . + /// The resolved search directory, or null when the path cannot be resolved safely. private static string? GetCodeOwnersSearchStart(string? path, string? basePath) { if (StringUtil.IsNullOrWhiteSpace(path)) @@ -161,15 +177,25 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa } } - // Detects a repository boundary represented by either a .git directory or a worktree .git file. + /// + /// Detects a repository boundary represented by either a .git directory or a worktree .git file. + /// + /// The directory in which to look for the Git marker. + /// true when the directory contains a Git marker; otherwise, false. private static bool HasGitDirectory(string path) { var gitPath = Path.Combine(path, ".git"); return Directory.Exists(gitPath) || File.Exists(gitPath); } - // Anchors a relative path to an absolute base directory and rejects rooted inputs or traversal - // that would escape that base. + /// + /// Anchors a relative path to an absolute base directory and rejects rooted inputs or traversal + /// that would escape that base. + /// + /// The relative path to resolve. + /// The absolute directory that must contain the resolved path. + /// The resolved absolute path when resolution succeeds. + /// true when the path resolves within the base directory; otherwise, false. private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath) { absolutePath = null; @@ -222,8 +248,15 @@ private static bool TryResolvePathWithinBase(string relativePath, string basePat return false; } - // Probes the platform-specific CODEOWNERS locations in priority order and returns the first - // existing file. + /// + /// Probes the platform-specific CODEOWNERS locations in priority order and returns the first + /// existing file. + /// + /// The repository root under which to search. + /// The platform whose CODEOWNERS lookup order should be used. + /// Whether each candidate path should be logged. + /// The first existing CODEOWNERS path. + /// true when a CODEOWNERS file is found; otherwise, false. private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) { foreach (var path in GetCodeOwnersPaths(sourceRoot, platform)) @@ -244,7 +277,12 @@ private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform return false; } - // Infers the CODEOWNERS dialect from standard repository URLs and SCP-style SSH URLs. + /// + /// Infers the CODEOWNERS dialect from standard repository URLs and SCP-style SSH URLs. + /// + /// The repository URL to inspect. + /// The platform inferred from the repository host. + /// true when the repository host identifies a supported platform; otherwise, false. private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform) { platform = default; @@ -284,14 +322,23 @@ private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, o return false; } - // Recognizes gitlab.com and common self-managed GitLab host naming conventions. + /// + /// Recognizes gitlab.com and common self-managed GitLab host naming conventions. + /// + /// The repository host name. + /// true when the host identifies GitLab; otherwise, false. private static bool IsGitLabHost(string? host) => string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) || (host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) || (host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0); - // Enumerates the supported CODEOWNERS locations in the order defined by each platform, - // including all known locations when the platform value is unknown. + /// + /// Enumerates the supported CODEOWNERS locations in the order defined by each platform, + /// including all known locations when the platform value is unknown. + /// + /// The repository root under which candidate paths are built. + /// The platform whose lookup order should be used. + /// The candidate CODEOWNERS paths in lookup order. private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform) { if (platform == CodeOwners.Platform.GitHub) @@ -318,7 +365,9 @@ private static IEnumerable GetCodeOwnersPaths(string sourceRoot, CodeOwn } } - // Clears the loaded parser, its repository root, and cached fallback search locations. + /// + /// Clears the loaded parser, its repository root, and cached fallback search locations. + /// private void ResetCodeOwners() { CodeOwners = null; @@ -329,7 +378,9 @@ private void ResetCodeOwners() } } - // Performs the initial CODEOWNERS lookup at SourceRoot using the detected platform semantics. + /// + /// Performs the initial CODEOWNERS lookup at SourceRoot using the detected platform semantics. + /// private void LoadCodeOwners() { if (!StringUtil.IsNullOrEmpty(SourceRoot)) @@ -347,8 +398,15 @@ private void LoadCodeOwners() } } - // Re-anchors an unresolved relative compiler path by selecting the longest file suffix that - // exists below the CODEOWNERS root; absolute, ambiguous, and traversing paths are rejected. + /// + /// Re-anchors an unresolved relative compiler path by selecting the longest file suffix that + /// exists below the CODEOWNERS root; absolute, ambiguous, and traversing paths are rejected. + /// + /// The unresolved compiler-recorded source path. + /// The repository root containing the loaded CODEOWNERS file. + /// Whether to use the current operating system's directory separator. + /// The existing suffix relative to the CODEOWNERS root. + /// true when an unambiguous existing suffix is found; otherwise, false. private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath) { // Compiler-recorded paths can be relative to a different base directory than the current @@ -419,8 +477,11 @@ private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwn return false; } - // Lazily discovers CODEOWNERS from the source path or workspace when the initial SourceRoot - // lookup did not load one, while serializing concurrent fallback attempts. + /// + /// Lazily discovers CODEOWNERS from the source path or workspace when the initial SourceRoot + /// lookup did not load one, while serializing concurrent fallback attempts. + /// + /// The source path from which to begin the most specific fallback search. private void EnsureCodeOwnersFromFallback(string? sourceFilePath) { if (CodeOwners is not null) @@ -447,8 +508,14 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath) } } - // Walks ancestors from a resolved start directory, loading the first CODEOWNERS file and - // stopping at the nearest Git boundary; repeated start locations are cached. + /// + /// Walks ancestors from a resolved start directory, loading the first CODEOWNERS file and + /// stopping at the nearest Git boundary; repeated start locations are cached. + /// + /// The source file or directory path from which to start. + /// The platform semantics used to locate and parse CODEOWNERS. + /// The absolute base used to resolve a relative . + /// true when a CODEOWNERS file is found and loaded; otherwise, false. private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath) { var startDirectory = GetCodeOwnersSearchStart(startPath, basePath); @@ -507,8 +574,12 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor return false; } - // Selects the CODEOWNERS dialect from repository host, CI provider, or platform-specific file - // placement, defaulting to GitHub when no reliable GitLab signal exists. + /// + /// Selects the CODEOWNERS dialect from repository host, CI provider, or platform-specific file + /// placement, defaulting to GitHub when no reliable GitLab signal exists. + /// + /// The repository root used to inspect platform-specific file locations. + /// The CODEOWNERS platform whose semantics should be used. private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot) { if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform)) From ec6057318832528d3fbde74cdffa24af9c494b56 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 15:58:59 +0200 Subject: [PATCH 24/25] [CI Visibility] Document CODEOWNERS matching algorithms --- .../src/Datadog.Trace/Ci/CodeOwners.GitHub.cs | 48 +++++++ .../src/Datadog.Trace/Ci/CodeOwners.GitLab.cs | 128 ++++++++++++++++-- tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 122 +++++++++++++++-- 3 files changed, 271 insertions(+), 27 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs index 63ca6ae9a1fb..46aa2cba1ea1 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs @@ -11,8 +11,14 @@ namespace Datadog.Trace.Ci { internal sealed partial class CodeOwners { + /// + /// Reads and validates the owner list from a GitHub rule. + /// private static class GitHubOwnerTokenizer { + /// + /// Splits the owner text, removes duplicates, and reports invalid owners. + /// public static string[] TokenizeGitHub(string segment, out bool allValid) { if (string.IsNullOrWhiteSpace(segment)) @@ -39,6 +45,9 @@ public static string[] TokenizeGitHub(string segment, out bool allValid) return owners.Count == 0 ? [] : owners.ToArray(); } + /// + /// Adds an owner once while keeping the original order. + /// private static void AddUnique(List owners, HashSet uniqueOwners, string owner) { if (uniqueOwners.Add(owner)) @@ -47,8 +56,12 @@ private static void AddUnique(List owners, HashSet uniqueOwners, } } + /// + /// Checks whether a token is a valid GitHub user, team, or email address. + /// private static bool IsValidGitHubOwner(string token) { + // GitHub users and teams use @user or @organization/team. if (token.Length > 1 && token[0] == '@' && token[1] != '@') { var slash = token.IndexOf('/'); @@ -59,6 +72,7 @@ private static bool IsValidGitHubOwner(string token) IsValidGitHubIdentifier(token, slash + 1, token.Length); } + // Any other owner must be a valid email address. var at = token.IndexOf('@'); if (at is < 1 or > 100 || token.Length - at - 1 is < 1 or > 255 || @@ -86,6 +100,9 @@ private static bool IsValidGitHubOwner(string token) return IsAsciiLetterOrDigit(token[token.Length - 1]) || token[token.Length - 1] == '_'; } + /// + /// Checks one GitHub user, organization, or team name inside a token. + /// private static bool IsValidGitHubIdentifier(string value, int start, int end) { if (start >= end || !IsAsciiLetterOrDigit(value[start]) || !IsAsciiLetterOrDigit(value[end - 1])) @@ -113,17 +130,29 @@ private static bool IsValidGitHubIdentifier(string value, int start, int end) return true; } + /// + /// Checks whether a character is allowed before the at sign in an email address. + /// private static bool IsEmailLocalCharacter(char character) => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; + /// + /// Checks whether a character is an ASCII letter or digit. + /// private static bool IsAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } + /// + /// Stores GitHub rules in last-match-first order. + /// private sealed class GitHubDocument : Document { private readonly Entry[] _rules; + /// + /// Initializes a new instance of the class from compiled rules. + /// private GitHubDocument(Entry[] rules) { _rules = rules; @@ -131,6 +160,9 @@ private GitHubDocument(Entry[] rules) public static GitHubDocument Empty { get; } = new([]); + /// + /// Parses valid GitHub rules and counts invalid lines. + /// public static GitHubDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) { parsingDiagnosticsCount = 0; @@ -155,10 +187,14 @@ public static GitHubDocument Parse(IEnumerable lines, out int parsingDia } } + // GitHub uses the last matching rule, so search the rules from bottom to top. rules.Reverse(); return new GitHubDocument(rules.ToArray()); } + /// + /// Returns the owners from the last GitHub rule that matches the path. + /// public override IEnumerable Match(string path) { foreach (var rule in _rules) @@ -175,6 +211,9 @@ public override IEnumerable Match(string path) private sealed partial class Entry { + /// + /// Parses one GitHub rule and compiles its path pattern. + /// public static Entry? ParseGitHub(string raw) { if (raw.StartsWith("\\#")) @@ -213,6 +252,9 @@ private sealed partial class Entry return new Entry(glob, patternToken, exclusion: false, owners); } + /// + /// Finds a character that is not escaped with a backslash. + /// private static int FindUnescapedCharacter(string value, char character) { for (var i = 0; i < value.Length; i++) @@ -230,6 +272,9 @@ private static int FindUnescapedCharacter(string value, char character) return -1; } + /// + /// Rejects pattern features that GitHub CODEOWNERS does not support. + /// private static bool IsUnsupportedGitHubPattern(string patternToken) { if (patternToken.StartsWith("!")) @@ -257,6 +302,9 @@ private static bool IsUnsupportedGitHubPattern(string patternToken) return false; } + /// + /// Checks whether the final path segment names a directory without wildcards. + /// private static bool IsDirectoryPattern(string patternToken) { var lastSegmentStart = patternToken.LastIndexOf('/'); diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs index ee2d54c2883c..727b792395e4 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs @@ -23,6 +23,9 @@ internal sealed partial class CodeOwners @"(? + /// Parses a GitLab section header, its name, and its default owners. + ///
private static bool TryParseGitLabSectionHeader( string raw, [NotNullWhen(true)] out GitLabSection? section, @@ -60,17 +63,22 @@ private static bool TryParseGitLabSectionHeader( hasDiagnostics |= !required && approvals > 0; - // Only parse the owner span recognized by GitLab's permissive header grammar. Text - // after a malformed suffix (for example an extra ']') must never leak into defaults. + // Use only the owner text matched by the header. Ignore text after a bad suffix. var defaults = GitLabOwnerTokenizer.TokenizeGitLab(m.Groups["defaults"].Value, out var allDefaultsValid); hasDiagnostics |= !allDefaultsValid; section = new GitLabSection(name, defaults); return true; } + /// + /// Checks whether a line looks like a section header but could not be parsed. + /// private static bool IsUnparsableSectionHeader(string raw) => raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal); + /// + /// Checks whether a section header follows GitLab's strict syntax. + /// private static bool IsStrictSectionHeader(string raw) { var index = raw[0] == '^' ? 1 : 0; @@ -128,6 +136,9 @@ private static bool IsStrictSectionHeader(string raw) return true; } + /// + /// Checks whether a character is allowed in the owner part of a section header. + /// private static bool IsStrictSectionOwnerCharacter(char character) { if (char.IsLetterOrDigit(character) || char.IsWhiteSpace(character) || character is '@' or '.' or '-' or '/') @@ -139,8 +150,14 @@ private static bool IsStrictSectionOwnerCharacter(char character) return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; } + /// + /// Finds GitLab users, groups, roles, and emails inside owner text. + /// private static class GitLabOwnerTokenizer { + /// + /// Extracts valid owners, removes duplicates, and reports text without a valid owner. + /// public static string[] TokenizeGitLab(string segment, out bool allValid) { if (string.IsNullOrWhiteSpace(segment)) @@ -163,6 +180,9 @@ public static string[] TokenizeGitLab(string segment, out bool allValid) return owners.Count == 0 ? [] : owners.ToArray(); } + /// + /// Adds an owner once while keeping the original order. + /// private static void AddUnique(List owners, HashSet uniqueOwners, string owner) { if (uniqueOwners.Add(owner)) @@ -171,17 +191,19 @@ private static void AddUnique(List owners, HashSet uniqueOwners, } } + /// + /// Finds every valid GitLab owner reference inside one token. + /// private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) { - // Keep the overwhelmingly common canonical forms allocation-light. + // Handle common complete references without extra parsing. if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) { AddUnique(owners, uniqueOwners, token); return true; } - // GitLab extracts references from surrounding punctuation instead of returning - // the entire token verbatim (for example "(@team)" becomes "@team"). + // GitLab accepts references inside punctuation, such as "(@team)". var foundReference = false; var searchStart = 0; while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart)) @@ -205,9 +227,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS var email = emailStart == 0 && emailEnd == token.Length ? token : token.Substring(emailStart, emailEnd - emailStart); - // GitLab's permissive email expression can overlap a namespace reference - // (for example "(@team"). Such a value cannot resolve as an email, while - // the namespace extracted independently can resolve, so keep only the latter. + // Do not add an email when the same text contains a valid group reference. if (!ContainsNamespaceReference(email)) { AddUnique(owners, uniqueOwners, email); @@ -218,9 +238,15 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS return foundReference; } + /// + /// Checks whether text contains a GitLab user or group reference. + /// private static bool ContainsNamespaceReference(string value) => TryFindNamespaceReference(value, 0, out _, out _, out _); + /// + /// Checks whether a token is one of GitLab's supported role references. + /// private static bool IsValidGitLabRole(string token) { if (!token.StartsWith("@@", StringComparison.Ordinal) || token.Length <= 2) @@ -237,9 +263,15 @@ private static bool IsValidGitLabRole(string token) role.Equals("owners", StringComparison.OrdinalIgnoreCase); } + /// + /// Checks whether the full token is one GitLab user or group reference. + /// private static bool IsWholeNamespaceReference(string token) => TryFindNamespaceReference(token, 0, out var start, out var end, out _) && start == 0 && end == token.Length; + /// + /// Finds the next GitLab user or group reference starting at the requested position. + /// private static bool TryFindNamespaceReference( string token, int searchStart, @@ -247,6 +279,7 @@ private static bool TryFindNamespaceReference( out int referenceEnd, out int nextSearchStart) { + // Find each @ and reject it when the characters around it make it part of another word. for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) { nextSearchStart = atIndex + 1; @@ -258,6 +291,7 @@ private static bool TryFindNamespaceReference( continue; } + // Read slash-separated namespace parts and remember the last valid end. var segmentStart = atIndex + 1; var lastValidEnd = -1; for (var i = segmentStart; i < token.Length; i++) @@ -300,6 +334,9 @@ private static bool TryFindNamespaceReference( return false; } + /// + /// Finds the next email-like owner reference while enforcing GitLab's length limits. + /// private static bool TryExtractGitLabEmailReference( string token, int searchStart, @@ -309,6 +346,7 @@ private static bool TryExtractGitLabEmailReference( { for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1)) { + // Read the local part backwards from @. var localStart = atIndex - 1; var localLength = 0; while (localStart >= searchStart && @@ -326,6 +364,7 @@ private static bool TryExtractGitLabEmailReference( continue; } + // Read the domain forwards and keep the last valid word character. var domainEnd = atIndex + 1; var domainLimit = Math.Min(token.Length, domainEnd + 255); var lastWordEnd = -1; @@ -356,18 +395,33 @@ private static bool TryExtractGitLabEmailReference( return false; } + /// + /// Checks whether a character can start a GitLab namespace segment. + /// private static bool IsNamespaceStart(char character) => IsAsciiLetterOrDigit(character) || character is '_' or '.'; + /// + /// Checks whether a character can appear inside a GitLab namespace segment. + /// private static bool IsNamespaceCharacter(char character) => IsNamespaceStart(character) || character == '-'; + /// + /// Checks whether a character can end a GitLab namespace segment. + /// private static bool IsNamespaceEnd(char character) => IsAsciiLetterOrDigit(character) || character is '_' or '-'; + /// + /// Checks whether a character is a Unicode word character used as a left boundary. + /// private static bool IsWordCharacter(char character) => char.IsLetterOrDigit(character) || character == '_'; + /// + /// Applies the Unicode word-character rules used by GitLab's email parser. + /// private static bool IsRegexWordCharacter(char character) { if (char.IsLetterOrDigit(character)) @@ -379,23 +433,36 @@ private static bool IsRegexWordCharacter(char character) return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; } + /// + /// Checks whether a character is an ASCII letter or digit. + /// private static bool IsAsciiLetterOrDigit(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } + /// + /// Stores GitLab rules grouped into independent sections. + /// private sealed class GitLabDocument : Document { private readonly GitLabSection[] _sections; + /// + /// Initializes a new instance of the class from parsed sections. + /// private GitLabDocument(GitLabSection[] sections) { _sections = sections; } + /// + /// Parses GitLab sections and rules and counts invalid input. + /// public static GitLabDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount) { parsingDiagnosticsCount = 0; var sections = new List(); + // Rules before the first header belong to the unnamed section. var current = GitLabSection.CreateUnnamed(); var currentDefaultOwners = current.DefaultOwners; var namedSections = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -419,6 +486,7 @@ public static GitLabDocument Parse(IEnumerable lines, out int parsingDia currentDefaultOwners = newSection.DefaultOwners; if (namedSections.TryGetValue(newSection.Name, out var existingSection)) { + // Repeated section names add rules to the first section with that name. current = existingSection; } else @@ -433,16 +501,14 @@ public static GitLabDocument Parse(IEnumerable lines, out int parsingDia if (IsUnparsableSectionHeader(raw)) { - // GitLab reports malformed header-like lines and skips them rather than - // reinterpreting them as path patterns. + // A malformed header is invalid and must not become a path rule. parsingDiagnosticsCount++; continue; } if (raw[0] == '#') { - // GitLab parses owners inside comments for its MR widget, but comments do - // not bind those owners to a path and therefore do not affect matching. + // Comments do not assign owners to paths. continue; } @@ -462,6 +528,7 @@ public static GitLabDocument Parse(IEnumerable lines, out int parsingDia } } + // Finish each section after all repeated definitions have been joined. foreach (var section in sections) { section.Seal(); @@ -470,10 +537,12 @@ public static GitLabDocument Parse(IEnumerable lines, out int parsingDia return new GitLabDocument(sections.ToArray()); } + /// + /// Matches each section separately and combines the owners from every matching section. + /// public override IEnumerable Match(string path) { - // GitLab evaluates each section independently and combines their owners. - // The set is allocated lazily because most paths match at most one section. + // Create the set only after the first section matches. HashSet? owners = null; foreach (var section in _sections) { @@ -491,11 +560,17 @@ public override IEnumerable Match(string path) } } + /// + /// Stores the rules and default owners for one GitLab section. + /// private sealed class GitLabSection { private readonly List _entries = new(); private Entry[]? _cache; + /// + /// Initializes a new instance of the class. + /// public GitLabSection(string name, string[] defaultOwners) { Name = name; @@ -506,14 +581,24 @@ public GitLabSection(string name, string[] defaultOwners) public string[] DefaultOwners { get; } + /// + /// Creates the section used for rules before the first named header. + /// public static GitLabSection CreateUnnamed() => new(string.Empty, []); + /// + /// Adds a parsed rule to this section. + /// public void Add(Entry entry) => _entries.Add(entry); + /// + /// Prepares the section for matching by keeping the last rule for each exact pattern. + /// public void Seal() { var seenPatterns = new HashSet(StringComparer.Ordinal); var cache = new List(_entries.Count); + // Walk backwards because later rules replace earlier rules with the same pattern. for (var i = _entries.Count - 1; i >= 0; i--) { var entry = _entries[i]; @@ -527,6 +612,9 @@ public void Seal() _entries.Clear(); } + /// + /// Returns the owners selected by this section, unless a matching exclusion removes the path. + /// public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; @@ -541,10 +629,12 @@ public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) if (rule.IsExclusion) { + // An exclusion applies only to this section. owners = null; return false; } + // Rules are stored last-first, so the first normal match wins. matchedOwners ??= rule.Owners; } @@ -561,6 +651,9 @@ public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) private sealed partial class Entry { + /// + /// Parses one GitLab rule, applies section defaults, and compiles its path pattern. + /// public static Entry? ParseGitLab(string raw, string[] defaultOwners, out bool hasDiagnostics) { hasDiagnostics = false; @@ -585,6 +678,7 @@ private sealed partial class Entry if (!isExclusion && !hasExplicitOwners && defaultOwners.Length > 0) { + // A rule without owners inherits the current section defaults. owners = defaultOwners; } @@ -602,6 +696,9 @@ private sealed partial class Entry return new Entry(glob, NormalizeGitLabPatternKey(patternToken), isExclusion, owners); } + /// + /// Normalizes equivalent GitLab patterns so later duplicates can replace earlier ones. + /// private static string NormalizeGitLabPatternKey(string patternToken) { if (patternToken == "*") @@ -614,6 +711,9 @@ private static string NormalizeGitLabPatternKey(string patternToken) return normalized.EndsWith("/") ? normalized + "**/*" : normalized; } + /// + /// Removes GitLab escapes for a leading hash and for whitespace. + /// private static string NormalizeGitLabEscapes(string patternToken) { StringBuilder? builder = null; diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index 8e8f427ed19b..cea3c1956347 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -24,6 +24,9 @@ internal sealed partial class CodeOwners private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); private readonly Document _document; + /// + /// Initializes a new instance of the class and loads the selected platform rules. + /// public CodeOwners(string filePath, Platform platform) { if (string.IsNullOrEmpty(filePath)) @@ -61,6 +64,9 @@ public CodeOwners(string filePath, Platform platform) internal int ParsingDiagnosticsCount { get; } + /// + /// Tries to load a CODEOWNERS file and handles file access errors. + /// internal static bool TryLoad(string filePath, Platform platform, [NotNullWhen(true)] out CodeOwners? codeOwners) { try @@ -84,7 +90,7 @@ public IEnumerable Match(string path) { if (path is null) { - // No callers pass null today, but normalizing here keeps the API safe to use. + // Returning no owners keeps this method safe if a caller passes null. return []; } @@ -98,6 +104,9 @@ public IEnumerable Match(string path) } #pragma warning disable SA1201 + /// + /// Identifies the CODEOWNERS syntax to use. + /// public enum Platform #pragma warning restore SA1201 { @@ -112,10 +121,16 @@ private enum CharacterClassParseResult Invalid } + /// + /// Represents either one path segment pattern or a globstar that can consume directories. + /// private readonly struct GlobPathSegment { private readonly SegmentPattern? _segment; + /// + /// Initializes a new instance of the struct. + /// private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresSegment) { _segment = segment; @@ -127,28 +142,52 @@ private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresS public bool RequiresSegment { get; } + /// + /// Creates a globstar that consumes zero or more path segments, or at least one when required. + /// public static GlobPathSegment GlobStar(bool requiresSegment) => new(null, isGlobStar: true, requiresSegment: requiresSegment); + /// + /// Wraps a normal compiled segment pattern. + /// public static GlobPathSegment Pattern(SegmentPattern segment) => new(segment, isGlobStar: false, requiresSegment: false); + /// + /// Checks whether this normal segment pattern matches the selected part of a path. + /// public bool Matches(string path, int start, int end) => _segment!.IsMatch(path, start, end); } + /// + /// Matches a full repository path by combining normal segment patterns and globstars. + /// private sealed class GlobPattern { private readonly GlobPathSegment[] _segments; + /// + /// Initializes a new instance of the class from compiled segments. + /// private GlobPattern(GlobPathSegment[] segments) { _segments = segments; } + /// + /// Compiles a pattern with GitHub rules. + /// public static GlobPattern? CompileGitHub(string pattern, bool includeDescendants) => Compile(pattern, Platform.GitHub, includeDescendants); + /// + /// Compiles a pattern with GitLab rules. + /// public static GlobPattern? CompileGitLab(string pattern) => Compile(pattern, Platform.GitLab, includeDescendants: false); + /// + /// Splits a pattern into path segments and compiles each segment with the selected platform rules. + /// private static GlobPattern? Compile(string pattern, Platform platform, bool includeDescendants) { var rawSegments = SplitPattern(pattern, out var firstSeparator, out var hasTrailingSlash); @@ -172,10 +211,9 @@ private GlobPattern(GlobPathSegment[] segments) if (rawSegments[i] == "**" && !(platform == Platform.GitLab && i == lastSegment - 1)) { - // A terminal /** means contents below the preceding directory and must - // consume at least one path segment on GitHub. GitLab delegates matching - // to File.fnmatch, where a terminal ** behaves like * within one segment. - // Middle globstars may consume none on both platforms. + // On GitHub, a final /** must match at least one child segment. + // On GitLab, a final ** works like * inside the last segment. + // A middle ** can match zero or more segments on both platforms. AddGlobStar(segments, requiresSegment: i == lastSegment - 1); } else if (SegmentPattern.TryCompile(rawSegments[i], platform, out var segment)) @@ -184,22 +222,24 @@ private GlobPattern(GlobPathSegment[] segments) } else { - // Invalid shell character classes invalidate only their own entry. + // Ignore the full rule when one segment is invalid. return null; } } if (hasTrailingSlash || includeDescendants) { - // A trailing slash denotes a directory, so it cannot match a same-named file. - // Descendant expansion inferred for GitHub patterns remains optional because - // the base pattern itself may denote either a file or a directory. + // A trailing slash means a directory and must match a child path. + // A plain GitHub directory name may also be a file, so its child match is optional. AddGlobStar(segments, requiresSegment: hasTrailingSlash); } return new GlobPattern(segments.ToArray()); } + /// + /// Splits a pattern on path separators while keeping escaped characters inside their segment. + /// private static string[] SplitPattern(string pattern, out int firstSeparator, out bool hasTrailingSlash) { var segments = new List(); @@ -215,7 +255,7 @@ private static string[] SplitPattern(string pattern, out int firstSeparator, out var escapedCharacter = pattern[i + 1]; if (escapedCharacter != '/') { - // Preserve non-separator escapes for SegmentPattern to compile. + // Keep this escape so SegmentPattern can process it. segment.Append(character); segment.Append(escapedCharacter); i++; @@ -223,8 +263,7 @@ private static string[] SplitPattern(string pattern, out int firstSeparator, out continue; } - // An escaped slash is still the path separator in gitignore-style globs; - // consume the escape before splitting so it cannot leave a trailing '\\'. + // An escaped slash is still a path separator. i++; } else if (character != '/') @@ -244,6 +283,9 @@ private static string[] SplitPattern(string pattern, out int firstSeparator, out return segments.ToArray(); } + /// + /// Matches path segments from left to right and lets the latest globstar consume more segments when needed. + /// public bool IsMatch(string path) { var patternIndex = 0; @@ -305,11 +347,14 @@ public bool IsMatch(string path) return patternIndex == _segments.Length; } + /// + /// Adds a globstar and merges it with the previous one when they are next to each other. + /// private static void AddGlobStar(List segments, bool requiresSegment) { if (segments.Count > 0 && segments[segments.Count - 1].IsGlobStar) { - // zero-or-more followed by one-or-more (or vice versa) is one-or-more. + // Adjacent globstars become one. If either needs a segment, the result does too. if (requiresSegment && !segments[segments.Count - 1].RequiresSegment) { segments[segments.Count - 1] = GlobPathSegment.GlobStar(requiresSegment: true); @@ -321,16 +366,25 @@ private static void AddGlobStar(List segments, bool requiresSeg segments.Add(GlobPathSegment.GlobStar(requiresSegment)); } + /// + /// Finds the end of the current path segment. + /// private static int GetSegmentEnd(string path, int segmentStart) { var separator = path.IndexOf('/', segmentStart); return separator >= 0 ? separator : path.Length; } + /// + /// Returns the start of the next path segment, or -1 when there is no next segment. + /// private static int GetNextSegmentStart(string path, int segmentEnd) => segmentEnd < path.Length - 1 ? segmentEnd + 1 : -1; } + /// + /// Matches one path segment using literals, escapes, wildcards, and GitLab character classes. + /// private sealed class SegmentPattern { private const int MaximumPatternLength = 1_024; @@ -339,12 +393,18 @@ private sealed class SegmentPattern private readonly string _pattern; private readonly Platform _platform; + /// + /// Initializes a new instance of the class. + /// private SegmentPattern(string pattern, Platform platform) { _pattern = pattern; _platform = platform; } + /// + /// Validates one segment and compiles it when its syntax and size are safe. + /// public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(true)] out SegmentPattern? segment) { if (pattern.Length > MaximumPatternLength) @@ -386,6 +446,9 @@ public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(tr return true; } + /// + /// Parses a GitLab character class and optionally checks whether it contains a character. + /// private static CharacterClassParseResult TryParseCharacterClass( string pattern, int openingBracket, @@ -397,6 +460,7 @@ private static CharacterClassParseResult TryParseCharacterClass( closingBracket = -1; matches = false; var contentStart = openingBracket + 1; + // GitLab allows ! or ^ after [ to negate the class. var negated = contentStart < pattern.Length && pattern[contentStart] is '!' or '^'; var atomStart = negated ? contentStart + 1 : contentStart; if (atomStart < pattern.Length && pattern[atomStart] == ']') @@ -404,6 +468,7 @@ private static CharacterClassParseResult TryParseCharacterClass( return CharacterClassParseResult.Invalid; } + // Find the first closing bracket that is not escaped. for (var i = atomStart; i < pattern.Length; i++) { if (pattern[i] == '\\' && i + 1 < pattern.Length) @@ -428,6 +493,7 @@ private static CharacterClassParseResult TryParseCharacterClass( } var atomIndex = atomStart; + // Each item is either one character or a start-end range. while (atomIndex < closingBracket) { var rangeStart = ReadCharacterClassAtom(pattern, ref atomIndex, closingBracket, out _); @@ -461,6 +527,9 @@ private static CharacterClassParseResult TryParseCharacterClass( return CharacterClassParseResult.Success; } + /// + /// Reads one literal or escaped character from a character class. + /// private static char ReadCharacterClassAtom( string pattern, ref int index, @@ -476,6 +545,9 @@ private static char ReadCharacterClassAtom( return pattern[index++]; } + /// + /// Matches one path segment and backtracks only to the latest star when a token fails. + /// public bool IsMatch(string path, int start, int end) { var patternIndex = 0; @@ -488,11 +560,13 @@ public bool IsMatch(string path, int start, int end) { if (remainingSteps-- == 0) { + // Stop malformed patterns from using too much CPU. return false; } if (patternIndex < _pattern.Length && _pattern[patternIndex] == '*') { + // First let the star match zero characters. do { patternIndex++; @@ -516,6 +590,7 @@ public bool IsMatch(string path, int start, int end) return false; } + // Retry the latest star after letting it consume one more character. patternIndex = starPatternIndex; pathIndex = ++starPathIndex; } @@ -528,6 +603,9 @@ public bool IsMatch(string path, int start, int end) return patternIndex == _pattern.Length; } + /// + /// Matches one pattern token against one path character. + /// private bool TryMatchToken(int patternIndex, char value, out int nextPatternIndex) { if (patternIndex >= _pattern.Length) @@ -558,15 +636,27 @@ private bool TryMatchToken(int patternIndex, char value, out int nextPatternInde } } + /// + /// Defines how one platform evaluates its parsed rules. + /// private abstract class Document { + /// + /// Returns the owners that apply to a normalized repository path. + /// public abstract IEnumerable Match(string path); } + /// + /// Stores one compiled CODEOWNERS rule. + /// private sealed partial class Entry { private readonly GlobPattern _glob; + /// + /// Initializes a new instance of the class with a compiled pattern and owners. + /// private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owners) { _glob = glob; @@ -581,6 +671,9 @@ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owne public string PatternKey { get; } + /// + /// Splits a rule at its first unescaped whitespace into the pattern and owner text. + /// private static void SplitEscapedEntry(string entry, out string pattern, out string owners, out bool hasExplicitOwners) { var patternEnd = entry.Length; @@ -602,6 +695,9 @@ private static void SplitEscapedEntry(string entry, out string pattern, out stri hasExplicitOwners = owners.Length > 0; } + /// + /// Checks whether this rule matches a normalized repository path. + /// public bool Match(string path) => _glob.IsMatch(path); } } From 371caaf49c513283334ce1e058ac6495cf3341e7 Mon Sep 17 00:00:00 2001 From: Tony Redondo Date: Mon, 24 Aug 2026 16:17:33 +0200 Subject: [PATCH 25/25] [CI Visibility] Deduplicate CODEOWNERS helpers --- .../src/Datadog.Trace/Ci/CodeOwners.GitHub.cs | 24 +++----------- .../src/Datadog.Trace/Ci/CodeOwners.GitLab.cs | 33 +++++++------------ tracer/src/Datadog.Trace/Ci/CodeOwners.cs | 26 +++++++++++++++ 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs index 46aa2cba1ea1..dba0f9adcc5c 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs @@ -30,11 +30,11 @@ public static string[] TokenizeGitHub(string segment, out bool allValid) var owners = new List(); var uniqueOwners = new HashSet(StringComparer.Ordinal); allValid = true; - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries)) { if (IsValidGitHubOwner(token)) { - AddUnique(owners, uniqueOwners, token); + AddUniqueOwner(owners, uniqueOwners, token); } else { @@ -45,17 +45,6 @@ public static string[] TokenizeGitHub(string segment, out bool allValid) return owners.Count == 0 ? [] : owners.ToArray(); } - /// - /// Adds an owner once while keeping the original order. - /// - private static void AddUnique(List owners, HashSet uniqueOwners, string owner) - { - if (uniqueOwners.Add(owner)) - { - owners.Add(owner); - } - } - /// /// Checks whether a token is a valid GitHub user, team, or email address. /// @@ -135,17 +124,14 @@ private static bool IsValidGitHubIdentifier(string value, int start, int end) ///
private static bool IsEmailLocalCharacter(char character) => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0; - - /// - /// Checks whether a character is an ASCII letter or digit. - /// - private static bool IsAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } /// /// Stores GitHub rules in last-match-first order. /// + /// + /// See GitHub CODEOWNERS syntax and precedence. + /// private sealed class GitHubDocument : Document { private readonly Entry[] _rules; diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs index 727b792395e4..2ebd87362549 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs @@ -169,7 +169,7 @@ public static string[] TokenizeGitLab(string segment, out bool allValid) var owners = new List(); var uniqueOwners = new HashSet(StringComparer.Ordinal); allValid = true; - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries)) { if (!ExtractGitLabOwners(token, owners, uniqueOwners)) { @@ -180,17 +180,6 @@ public static string[] TokenizeGitLab(string segment, out bool allValid) return owners.Count == 0 ? [] : owners.ToArray(); } - /// - /// Adds an owner once while keeping the original order. - /// - private static void AddUnique(List owners, HashSet uniqueOwners, string owner) - { - if (uniqueOwners.Add(owner)) - { - owners.Add(owner); - } - } - /// /// Finds every valid GitLab owner reference inside one token. /// @@ -199,7 +188,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS // Handle common complete references without extra parsing. if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) { - AddUnique(owners, uniqueOwners, token); + AddUniqueOwner(owners, uniqueOwners, token); return true; } @@ -209,7 +198,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart)) { var reference = token.Substring(referenceStart, referenceEnd - referenceStart); - AddUnique(owners, uniqueOwners, reference); + AddUniqueOwner(owners, uniqueOwners, reference); foundReference = true; } @@ -217,7 +206,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS for (var i = 0; i < roleMatches.Count; i++) { var roleMatch = roleMatches[i]; - AddUnique(owners, uniqueOwners, roleMatch.Value); + AddUniqueOwner(owners, uniqueOwners, roleMatch.Value); foundReference = true; } @@ -230,7 +219,7 @@ private static bool ExtractGitLabOwners(string token, List owners, HashS // Do not add an email when the same text contains a valid group reference. if (!ContainsNamespaceReference(email)) { - AddUnique(owners, uniqueOwners, email); + AddUniqueOwner(owners, uniqueOwners, email); foundReference = true; } } @@ -432,17 +421,14 @@ private static bool IsRegexWordCharacter(char character) var category = CharUnicodeInfo.GetUnicodeCategory(character); return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; } - - /// - /// Checks whether a character is an ASCII letter or digit. - /// - private static bool IsAsciiLetterOrDigit(char character) - => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; } /// /// Stores GitLab rules grouped into independent sections. /// + /// + /// See GitLab CODEOWNERS section rules. + /// private sealed class GitLabDocument : Document { private readonly GitLabSection[] _sections; @@ -615,6 +601,9 @@ public void Seal() /// /// Returns the owners selected by this section, unless a matching exclusion removes the path. /// + /// + /// See GitLab CODEOWNERS exclusion rules. + /// public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners) { var rules = _cache ?? []; diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs index cea3c1956347..c70e31cb35d5 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -21,6 +21,7 @@ internal sealed partial class CodeOwners { internal const long GitHubMaximumFileSizeBytes = 3 * 1024 * 1024; + private static readonly char[] OwnerSeparators = [' ', '\t']; private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); private readonly Document _document; @@ -34,6 +35,8 @@ public CodeOwners(string filePath, Platform platform) throw new ArgumentNullException(nameof(filePath)); } + // GitHub ignores CODEOWNERS files larger than 3 MB. + // https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-size if (platform == Platform.GitHub && new FileInfo(filePath).Length > GitHubMaximumFileSizeBytes) { _document = GitHubDocument.Empty; @@ -103,6 +106,23 @@ public IEnumerable Match(string path) return _document.Match(normalizedPath); } + /// + /// Adds an owner once while keeping the original order. + /// + private static void AddUniqueOwner(List owners, HashSet uniqueOwners, string owner) + { + if (uniqueOwners.Add(owner)) + { + owners.Add(owner); + } + } + + /// + /// Checks whether a character is an ASCII letter or digit. + /// + private static bool IsAsciiLetterOrDigit(char character) + => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + #pragma warning disable SA1201 /// /// Identifies the CODEOWNERS syntax to use. @@ -176,12 +196,18 @@ private GlobPattern(GlobPathSegment[] segments) /// /// Compiles a pattern with GitHub rules. /// + /// + /// See GitHub CODEOWNERS syntax. + /// public static GlobPattern? CompileGitHub(string pattern, bool includeDescendants) => Compile(pattern, Platform.GitHub, includeDescendants); /// /// Compiles a pattern with GitLab rules. /// + /// + /// See GitLab CODEOWNERS path matching. + /// public static GlobPattern? CompileGitLab(string pattern) => Compile(pattern, Platform.GitLab, includeDescendants: false);