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.CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs new file mode 100644 index 000000000000..cfd2d54ecf81 --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs @@ -0,0 +1,611 @@ +// +// 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; } + + /// + /// 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); + return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath) + ? codeOwnersRelativePath + : 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. + /// + /// 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; + + 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; + } + + /// + /// 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)) + { + 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; + } + } + + /// + /// 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. + /// + /// 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; + + 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; + } + + /// + /// 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)) + { + if (logLookup) + { + Log.Debug("Looking for CODEOWNERS file in: {Path}", path); + } + + if (File.Exists(path)) + { + codeOwnersPath = path; + return true; + } + } + + codeOwnersPath = null; + return false; + } + + /// + /// 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; + 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; + } + + /// + /// 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. + /// + /// 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) + { + // 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"); + } + } + + /// + /// Clears the loaded parser, its repository root, and cached fallback search locations. + /// + private void ResetCodeOwners() + { + CodeOwners = null; + CodeOwnersRoot = null; + lock (_codeOwnersLock) + { + _codeOwnersSearchStarts.Clear(); + } + } + + /// + /// Performs the initial CODEOWNERS lookup at SourceRoot using the detected platform semantics. + /// + 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; + } + } + } + } + + /// + /// 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 + // 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; + } + + /// + /// 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) + { + 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); + } + } + + /// + /// 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); + 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; + } + + /// + /// 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)) + { + 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 a35e4de1d3f3..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,20 +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 readonly object _codeOwnersLock = new(); - private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer); - private string? _gitSearchFolder; public static CIEnvironmentValues Instance => LazyInstance.Value; @@ -124,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; @@ -259,132 +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, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath) - { - foreach (var path in GetCodeOwnersPaths(sourceRoot)) - { - 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 IEnumerable GetCodeOwnersPaths(string sourceRoot) - { - 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"); - } - public void DecorateSpan(Span span) { if (span == null) @@ -470,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 @@ -493,26 +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)) - { - if (TryGetCodeOwnersPath(SourceRoot!, logLookup: true, out var codeOwnersPath)) - { - Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath); - CodeOwners = new CodeOwners(codeOwnersPath, GetCodeOwnersPlatform()); - CodeOwnersRoot = SourceRoot; - } - } + LoadCodeOwners(); } protected abstract void Setup(IGitInfo gitInfo); @@ -551,92 +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); - // If CODEOWNERS is rooted elsewhere, normalize SourceFile to that root for consistent matching. - if (TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath)) - { - return codeOwnersRelativePath; - } - - return 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, logLookup: false, out _)) - { - return false; - } - - codeOwnersRoot = resolvedRoot; - } - - // 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 skip. - 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 string MakeRelativePath(string? basePath, string absolutePath, bool useOSSeparator) { var pivotFolder = basePath; @@ -647,7 +406,7 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO if (StringUtil.IsNullOrEmpty(absolutePath)) { - return pivotFolder!; + return pivotFolder; } try @@ -677,87 +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(); - 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, 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; - } - - // Stop walking when we hit a git boundary. - if (HasGitDirectory(directoryInfo.FullName)) - { - break; - } - - directoryInfo = directoryInfo.Parent; - } - - return false; - } - - private CodeOwners.Platform GetCodeOwnersPlatform() - => GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : 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..dba0f9adcc5c --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs @@ -0,0 +1,319 @@ +// +// 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 + { + /// + /// 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)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries)) + { + if (IsValidGitHubOwner(token)) + { + AddUniqueOwner(owners, uniqueOwners, token); + } + else + { + allValid = false; + } + } + + return owners.Count == 0 ? [] : owners.ToArray(); + } + + /// + /// 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('/'); + return slash < 0 + ? IsValidGitHubIdentifier(token, 1, token.Length) + : token.IndexOf('/', slash + 1) < 0 && + IsValidGitHubIdentifier(token, 1, slash) && + 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 || + 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] == '_'; + } + + /// + /// 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])) + { + 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; + } + + /// + /// 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; + } + + /// + /// Stores GitHub rules in last-match-first order. + /// + /// + /// See GitHub CODEOWNERS syntax and precedence. + /// + 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; + } + + 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; + 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); + } + } + + // 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) + { + if (rule.Match(path)) + { + return rule.Owners; + } + } + + return []; + } + } + + private sealed partial class Entry + { + /// + /// Parses one GitHub rule and compiles its path pattern. + /// + 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); + } + + /// + /// 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++) + { + if (value[i] == '\\' && i + 1 < value.Length) + { + i++; + } + else if (value[i] == character) + { + return i; + } + } + + return -1; + } + + /// + /// Rejects pattern features that GitHub CODEOWNERS does not support. + /// + 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; + } + + /// + /// Checks whether the final path segment names a directory without wildcards. + /// + 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..2ebd87362549 --- /dev/null +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs @@ -0,0 +1,734 @@ +// +// 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( + @"(? + /// Parses a GitLab section header, its name, and its default owners. + /// + 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; + + // 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; + 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; + } + + /// + /// 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 '/') + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(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)) + { + allValid = true; + return []; + } + + var owners = new List(); + var uniqueOwners = new HashSet(StringComparer.Ordinal); + allValid = true; + foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries)) + { + if (!ExtractGitLabOwners(token, owners, uniqueOwners)) + { + allValid = false; + } + } + + return owners.Count == 0 ? [] : owners.ToArray(); + } + + /// + /// Finds every valid GitLab owner reference inside one token. + /// + private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners) + { + // Handle common complete references without extra parsing. + if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token)) + { + AddUniqueOwner(owners, uniqueOwners, token); + return true; + } + + // 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)) + { + var reference = token.Substring(referenceStart, referenceEnd - referenceStart); + AddUniqueOwner(owners, uniqueOwners, reference); + foundReference = true; + } + + var roleMatches = GitLabRoleReferenceRegex.Matches(token); + for (var i = 0; i < roleMatches.Count; i++) + { + var roleMatch = roleMatches[i]; + AddUniqueOwner(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); + // Do not add an email when the same text contains a valid group reference. + if (!ContainsNamespaceReference(email)) + { + AddUniqueOwner(owners, uniqueOwners, email); + foundReference = true; + } + } + + 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) + { + 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); + } + + /// + /// 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, + out int referenceStart, + 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; + if ((atIndex > 0 && (IsWordCharacter(token[atIndex - 1]) || token[atIndex - 1] == '@')) || + atIndex + 1 >= token.Length || + token[atIndex + 1] == '@' || + !IsNamespaceStart(token[atIndex + 1])) + { + 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++) + { + 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; + } + + /// + /// Finds the next email-like owner reference while enforcing GitLab's length limits. + /// + 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)) + { + // Read the local part backwards from @. + 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; + } + + // 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; + 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; + } + + /// + /// 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)) + { + return true; + } + + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; + } + } + + /// + /// Stores GitLab rules grouped into independent sections. + /// + /// + /// See GitLab CODEOWNERS section rules. + /// + 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); + 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)) + { + // Repeated section names add rules to the first section with that name. + current = existingSection; + } + else + { + current = newSection; + sections.Add(current); + namedSections.Add(current.Name, current); + } + + continue; + } + + if (IsUnparsableSectionHeader(raw)) + { + // A malformed header is invalid and must not become a path rule. + parsingDiagnosticsCount++; + continue; + } + + if (raw[0] == '#') + { + // Comments do not assign owners to paths. + continue; + } + + var entry = Entry.ParseGitLab(raw, currentDefaultOwners, out var entryHasDiagnostics); + if (entry is null) + { + parsingDiagnosticsCount++; + } + else + { + if (entryHasDiagnostics) + { + parsingDiagnosticsCount++; + } + + current.Add(entry); + } + } + + // Finish each section after all repeated definitions have been joined. + foreach (var section in sections) + { + section.Seal(); + } + + return new GitLabDocument(sections.ToArray()); + } + + /// + /// Matches each section separately and combines the owners from every matching section. + /// + public override IEnumerable Match(string path) + { + // Create the set only after the first section matches. + 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 ?? []; + } + } + + /// + /// 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; + DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; + } + + public string Name { get; } + + 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]; + if (seenPatterns.Add(entry.PatternKey)) + { + cache.Add(entry); + } + } + + _cache = cache.ToArray(); + _entries.Clear(); + } + + /// + /// 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 ?? []; + string[]? matchedOwners = null; + + foreach (var rule in rules) + { + if (!rule.Match(path)) + { + continue; + } + + 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; + } + + if (matchedOwners is null || matchedOwners.Length == 0) + { + owners = null; + return false; + } + + owners = matchedOwners; + return true; + } + } + + 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; + 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) + { + // A rule without owners inherits the current section defaults. + 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); + } + + /// + /// Normalizes equivalent GitLab patterns so later duplicates can replace earlier ones. + /// + private static string NormalizeGitLabPatternKey(string patternToken) + { + if (patternToken == "*") + { + return "/**/*"; + } + + var normalizedToken = NormalizeGitLabEscapes(patternToken); + var normalized = normalizedToken.StartsWith("/") ? normalizedToken : "/**/" + normalizedToken; + return normalized.EndsWith("/") ? normalized + "**/*" : normalized; + } + + /// + /// Removes GitLab escapes for a leading hash and for whitespace. + /// + 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 ef1d69d2bd54..c70e31cb35d5 100644 --- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs +++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs @@ -8,23 +8,26 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; -using System.Text.RegularExpressions; +using System.Security; +using System.Text; +using Datadog.Trace.Logging; 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.). - /// 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 + internal sealed partial class CodeOwners { - private readonly IReadOnlyList
_sections; - private readonly Platform _platform; + internal const long GitHubMaximumFileSizeBytes = 3 * 1024 * 1024; + private static readonly char[] OwnerSeparators = [' ', '\t']; + 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)) @@ -32,8 +35,54 @@ public CodeOwners(string filePath, Platform platform) throw new ArgumentNullException(nameof(filePath)); } - _platform = platform; - _sections = Parse(File.ReadLines(filePath), platform); + // 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; + Log.Warning( + "GitHub CODEOWNERS file exceeds the {MaximumSize} byte limit and will be ignored: {Path}", + GitHubMaximumFileSizeBytes, + filePath); + return; + } + + 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) + { + Log.Warning( + "CODEOWNERS file contains {Count} invalid lines. Invalid rules were ignored or parsed with errors: {Path}", + parsingDiagnosticsCount, + filePath); + } + } + + 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 + { + 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; + } } /// @@ -42,229 +91,602 @@ public CodeOwners(string filePath, Platform platform) /// public IEnumerable Match(string path) { - var owners = new HashSet(StringComparer.Ordinal); + if (path is null) + { + // Returning no owners keeps this method safe if a caller passes null. + return []; + } + var normalizedPath = path.IndexOf('\\') >= 0 ? path.Replace('\\', '/') : path; - foreach (var section in _sections) + // 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('/'); + + 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)) { - if (section.TryMatch(normalizedPath, _platform, out var sectionOwners)) - { - foreach (var o in sectionOwners) - { - owners.Add(o); - } - } + 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. + /// + public enum Platform +#pragma warning restore SA1201 + { + GitHub, + GitLab + } + + private enum CharacterClassParseResult + { + NotAClass, + Success, + 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; + IsGlobStar = isGlobStar; + RequiresSegment = requiresSegment; } - return owners; + public bool IsGlobStar { get; } + + 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); } - private static List
Parse(IEnumerable lines, Platform platform) + /// + /// Matches a full repository path by combining normal segment patterns and globstars. + /// + private sealed class GlobPattern { - var sections = new List
(); - var current = Section.CreateUnnamed(); - sections.Add(current); + private readonly GlobPathSegment[] _segments; - var lineNo = 0; - foreach (var line in lines) + /// + /// Initializes a new instance of the class from compiled segments. + /// + private GlobPattern(GlobPathSegment[] segments) { - lineNo++; - var raw = line.TrimEnd(); - if (raw.Length == 0) + _segments = 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); + + /// + /// 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); + 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) { - continue; + lastSegment--; } - if (TryParseSectionHeader(raw, out var newSection)) + var segments = new List(rawSegments.Length + 2); + if (!rooted) { - current = newSection; - sections.Add(current); - continue; + AddGlobStar(segments, requiresSegment: false); } - if (raw[0] == '#') + for (var i = firstSegment; i < lastSegment; i++) { - // 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; + if (rawSegments[i] == "**" && + !(platform == Platform.GitLab && i == lastSegment - 1)) + { + // 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)) + { + segments.Add(GlobPathSegment.Pattern(segment)); + } + else + { + // Ignore the full rule when one segment is invalid. + return null; + } } - var entry = Entry.Parse(raw, platform, lineNo); - if (entry is not null) + if (hasTrailingSlash || includeDescendants) { - current.Add(entry); + // 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()); } - // Last‑rule precedence: iterate rules in reverse order at run‑time without additional copies. - foreach (var s in sections) + /// + /// 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) { - s.Seal(); - } + var segments = new List(); + var segment = new StringBuilder(pattern.Length); + firstSeparator = -1; + hasTrailingSlash = false; - return sections; - } + 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 != '/') + { + // Keep this escape so SegmentPattern can process it. + segment.Append(character); + segment.Append(escapedCharacter); + i++; + hasTrailingSlash = false; + continue; + } + + // An escaped slash is still a path separator. + i++; + } + else if (character != '/') + { + segment.Append(character); + hasTrailingSlash = false; + continue; + } - private static bool TryParseSectionHeader(string raw, [NotNullWhen(true)] out Section? section) - { - // Accepted forms: - // [Docs] - // ^[Go] - // [Backend][2] @team @another - var m = Regex.Match(raw, @"^\s*(\^)?\[(?[^\]]+)\](?:\[(?\d+)\])?(?.*)$"); - if (!m.Success) - { - section = null; - return false; + firstSeparator = firstSeparator < 0 ? i : firstSeparator; + segments.Add(segment.ToString()); + segment.Clear(); + hasTrailingSlash = i == pattern.Length - 1; + } + + segments.Add(segment.ToString()); + return segments.ToArray(); } - var required = !m.Groups[1].Success; // ^ prefix => optional section - var name = m.Groups["name"].Value.Trim(); - var approvals = 0; - if (m.Groups["cnt"].Success && int.TryParse(m.Groups["cnt"].Value, out var val)) + /// + /// Matches path segments from left to right and lets the latest globstar consume more segments when needed. + /// + public bool IsMatch(string path) { - approvals = val; - } + var patternIndex = 0; + var pathSegmentStart = path.Length > 1 ? 1 : -1; + var globStarIndex = -1; + var globStarPathStart = -1; - var defaults = OwnerTokenizer.Tokenize(m.Groups["rest"].Value).ToArray(); - section = new Section(name, required, approvals, defaults); - return true; - } + 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; + } - /// - /// Converts a CODEOWNERS ‑style glob into a Regex. - /// Supports **, *, ?, /‑rooted, and trailing / semantics. - /// - private static Regex CompileGlob(string pattern) - { - // Escape regex metachars first. - var rx = Regex.Escape(pattern); + continue; + } - // Temporary sentinel for ** that we restore after dealing with single *. - rx = rx.Replace("\\*\\*", "§§DOUBLESTAR§§"); - rx = rx.Replace("\\*", "[^/]*"); // single‑level wildcard - rx = rx.Replace("§§DOUBLESTAR§§", ".*"); // multi‑level wildcard - rx = rx.Replace("\\?", "."); // single char + 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 (pattern.EndsWith("/")) - { - rx += ".*"; // directory pattern matches everything underneath + 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; } - if (pattern.StartsWith("/")) + /// + /// 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) { - // keep the escaped leading slash so paths like "/foo/bar" match - rx = "^" + rx; + if (segments.Count > 0 && segments[segments.Count - 1].IsGlobStar) + { + // 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); + } + + return; + } + + segments.Add(GlobPathSegment.GlobStar(requiresSegment)); } - else + + /// + /// Finds the end of the current path segment. + /// + private static int GetSegmentEnd(string path, int segmentStart) { - // Allowed anywhere in repo tree; use non‑capturing look‑behind to avoid double counting. - rx = "(^|.*/)" + rx; + var separator = path.IndexOf('/', segmentStart); + return separator >= 0 ? separator : path.Length; } - rx += "$"; - return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant); + /// + /// 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; } -#pragma warning disable SA1201 - public enum Platform -#pragma warning restore SA1201 + /// + /// Matches one path segment using literals, escapes, wildcards, and GitLab character classes. + /// + private sealed class SegmentPattern { - GitHub, - GitLab - } + private const int MaximumPatternLength = 1_024; + private const int MaximumMatchSteps = 65_536; - private static class OwnerTokenizer - { - public static IEnumerable Tokenize(string segment) + private readonly string _pattern; + private readonly Platform _platform; + + /// + /// Initializes a new instance of the class. + /// + private SegmentPattern(string pattern, Platform platform) { - if (string.IsNullOrWhiteSpace(segment)) + _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) { - yield break; + segment = null; + return false; } - foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + for (var i = 0; i < pattern.Length; i++) { - if (token.StartsWith("@@") || token.StartsWith("@") || token.Contains("@")) + var character = pattern[i]; + if (character == '\\') + { + if (i + 1 >= pattern.Length) + { + segment = null; + return false; + } + + i++; + } + else if (platform == Platform.GitLab && character == '[') { - yield return token; + var result = TryParseCharacterClass(pattern, i, default, evaluate: false, out var closingBracket, out _); + if (result == CharacterClassParseResult.Invalid) + { + segment = null; + return false; + } + + if (result == CharacterClassParseResult.Success) + { + i = closingBracket; + } } } - } - } - private sealed class Section - { - private readonly List _entries = new(); - private Entry[]? _cache; + segment = new SegmentPattern(pattern, platform); + return true; + } - public Section(string name, bool required, int approvalCount, string[] defaultOwners) + /// + /// Parses a GitLab character class and optionally checks whether it contains a character. + /// + private static CharacterClassParseResult TryParseCharacterClass( + string pattern, + int openingBracket, + char value, + bool evaluate, + out int closingBracket, + out bool matches) { - Name = name; - Required = required; - ApprovalCount = approvalCount; - DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners; - } + 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] == ']') + { + return CharacterClassParseResult.Invalid; + } - public string Name { get; } + // Find the first closing bracket that is not escaped. + for (var i = atomStart; i < pattern.Length; i++) + { + if (pattern[i] == '\\' && i + 1 < pattern.Length) + { + i++; + } + else if (pattern[i] == ']') + { + closingBracket = i; + break; + } + } - public bool Required { get; } + if (closingBracket < 0) + { + return CharacterClassParseResult.NotAClass; + } - public int ApprovalCount { get; } + if (atomStart == closingBracket) + { + return CharacterClassParseResult.Invalid; + } - public string[] DefaultOwners { get; } + 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 _); + var lookahead = atomIndex; + var separatorEscaped = false; + var separator = lookahead < closingBracket + ? ReadCharacterClassAtom(pattern, ref lookahead, closingBracket, out separatorEscaped) + : default; + if (separator == '-' && !separatorEscaped && lookahead < closingBracket) + { + 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 if (evaluate && value == rangeStart) + { + matches = true; + } + } - public static Section CreateUnnamed() => new(string.Empty, required: true, approvalCount: 0, defaultOwners: []); + matches = negated ? !matches : matches; + return CharacterClassParseResult.Success; + } - public void Add(Entry entry) => _entries.Add(entry); + /// + /// Reads one literal or escaped character from a character class. + /// + private static char ReadCharacterClassAtom( + string pattern, + ref int index, + int closingBracket, + out bool escaped) + { + escaped = pattern[index] == '\\' && index + 1 < closingBracket; + if (escaped) + { + index++; + } - public void Seal() => _cache = _entries.AsEnumerable().Reverse().ToArray(); + return pattern[index++]; + } - public bool TryMatch(string path, Platform platform, out IEnumerable owners) + /// + /// Matches one path segment and backtracks only to the latest star when a token fails. + /// + public bool IsMatch(string path, int start, int end) { - owners = []; - var rules = _cache ?? []; + var patternIndex = 0; + var pathIndex = start; + var starPatternIndex = -1; + var starPathIndex = -1; + var remainingSteps = MaximumMatchSteps; - foreach (var rule in rules) + while (pathIndex < end) { - // GitHub doesn’t support exclusion rules. Keep them parse‑able but ignore when evaluating. - if (rule.IsExclusion && platform == Platform.GitHub) + 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++; + } + while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*'); + + starPatternIndex = patternIndex; + starPathIndex = pathIndex; continue; } - if (!rule.Match(path)) + if (TryMatchToken(patternIndex, path[pathIndex], out var nextPatternIndex)) { + patternIndex = nextPatternIndex; + pathIndex++; continue; } - if (rule.IsExclusion) + if (starPatternIndex < 0) { - // Excluded for this section; stop evaluating this section. return false; } - owners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners; - return owners.Any(); + // Retry the latest star after letting it consume one more character. + patternIndex = starPatternIndex; + pathIndex = ++starPathIndex; } - if (DefaultOwners.Length > 0) + while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*') { - owners = DefaultOwners; - return true; + patternIndex++; } - return false; + 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) + { + nextPatternIndex = patternIndex; + return false; + } + + var token = _pattern[patternIndex]; + if (token == '\\') + { + nextPatternIndex = patternIndex + 2; + return _pattern[patternIndex + 1] == value; + } + + if (_platform == Platform.GitLab && token == '[') + { + var result = TryParseCharacterClass(_pattern, patternIndex, value, evaluate: true, out var closingBracket, out var matches); + if (result == CharacterClassParseResult.Success) + { + nextPatternIndex = closingBracket + 1; + return matches; + } + } + + nextPatternIndex = patternIndex + 1; + return token == '?' || token == value; } } - private sealed class Entry + /// + /// 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 Regex _regex; + private readonly GlobPattern _glob; - private Entry(Regex regex, bool exclusion, string[] owners) + /// + /// Initializes a new instance of the class with a compiled pattern and owners. + /// + private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owners) { - _regex = regex; + _glob = glob; + PatternKey = patternKey; IsExclusion = exclusion; Owners = owners; } @@ -273,58 +695,36 @@ private Entry(Regex regex, bool exclusion, string[] owners) public string[] Owners { get; } - public static Entry? Parse(string raw, Platform platform, int lineNo) - { - // Strip inline comments for GitHub. GitLab treats everything after # as data (inline comments unsupported). - var idxHash = raw.IndexOf('#'); - var effective = idxHash >= 0 && platform == Platform.GitHub ? raw.Substring(0, idxHash).TrimEnd() : raw; - if (string.IsNullOrWhiteSpace(effective)) - { - return null; - } + public string PatternKey { get; } - // 2. Tokenise - // * GitHub: simple whitespace split - // * GitLab: split on whitespace NOT escaped with back-slash - string[] tokens; - - if (platform == Platform.GitLab) - { - // Split on space / tab that are **not** escaped: (? t.Length > 0) - // Undo the escaping: "\ " → " ", "\#" → "#", "\\" → "\" - .Select(t => Regex.Replace(t, @"\\([ #\\])", "$1")) - .ToArray(); - } - else - { - tokens = effective.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); - } - - if (tokens.Length == 0) - { - return null; - } - - // 3. Pattern & exclusion - var patternToken = tokens[0]; - var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!"); - if (isExclusion) + /// + /// 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; + for (var i = 0; i < entry.Length; i++) { - patternToken = patternToken.Substring(1, patternToken.Length - 1); + if (entry[i] == '\\' && i + 1 < entry.Length) + { + i++; + } + else if (entry[i] is ' ' or '\t') + { + patternEnd = i; + break; + } } - // 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(); - - // 5. Compile the glob - var rx = CompileGlob(patternToken); - return new Entry(rx, isExclusion, owners); + pattern = entry.Substring(0, patternEnd); + owners = patternEnd < entry.Length ? entry.Substring(patternEnd).Trim() : string.Empty; + hasExplicitOwners = owners.Length > 0; } - public bool Match(string path) => _regex.IsMatch(path); + /// + /// Checks whether this rule matches a normalized repository path. + /// + public bool Match(string path) => _glob.IsMatch(path); } } } diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs deleted file mode 100644 index 02a9bb77351d..000000000000 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs +++ /dev/null @@ -1,293 +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. -// -#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); - } - - 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.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs deleted file mode 100644 index c13f2001fd95..000000000000 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs +++ /dev/null @@ -1,95 +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", "[\"docs@example.com\"]")] - [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 - 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 new file mode 100644 index 000000000000..4774e14108d0 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs @@ -0,0 +1,633 @@ +// +// 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; + +[Collection(nameof(EnvironmentVariablesTestCollection))] +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(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"); + 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 GitHubProviderUsesOfficialCodeOwnersLocationPriority() + { + 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 ReloadingEnvironmentValues(repoRoot, "github"); + + 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 GitLabProviderUsesOfficialCodeOwnersLocationPriority() + { + 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 ReloadingEnvironmentValues(repoRoot, "gitlab"); + + 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 ReloadingEnvironmentValues(githubDirectory.RootPath, "github"); + + 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 ReloadingEnvironmentValues(gitlabDirectory.RootPath, "gitlab"); + + gitlabValues.Reload(); + Assert.Null(gitlabValues.CodeOwners); + } + + [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(); + 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 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() + { + 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 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 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); + } + + [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 DoesNotAnchorAbsoluteAzurePipelinesPathsWithMatchingRepositorySuffix(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() + { + 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 _)); + } + + [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 _)); + } + + 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, string? provider = null) + { + SourceRoot = sourceRoot; + WorkspacePath = workspacePath; + Provider = provider; + } + + protected override void Setup(IGitInfo gitInfo) + { + } + } + + private sealed class ReloadingEnvironmentValues : CIEnvironmentValues + { + private readonly string _sourceRoot; + private readonly string _provider; + + public ReloadingEnvironmentValues(string sourceRoot, string provider) + { + _sourceRoot = sourceRoot; + _provider = provider; + } + + public void Reload() => ReloadEnvironmentData(); + + protected override void Setup(IGitInfo gitInfo) + { + SourceRoot = _sourceRoot; + WorkspacePath = _sourceRoot; + Provider = _provider; + } + } +} 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..03d18e366212 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs @@ -0,0 +1,179 @@ +// +// 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 +{ + private static readonly HashSet BuildOutputDirectories = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj" }; + + [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 EnumerateRepositoryFiles(fullRoot)) + { + 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"); + } + + [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* + [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; + } + + 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 new file mode 100644 index 000000000000..e06629a8ca17 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs @@ -0,0 +1,973 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// +#nullable enable + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +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 + """; + + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + + [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 GithubDirectoryAndTerminalGlobstarPatternsRequireDescendants() + { + 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"]); + 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 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() + { + // 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 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() + { + // `**/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 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() + { + 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 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 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() + { + 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 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() + { + 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 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() + { + 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 LongMalformedGitLabOwnerTokensHaveBoundedParsingCost() + { + var malformedOwner = new string('x', 4_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(TestTimeout, "the broad timeout guards against hangs without acting as a microbenchmark"); + Match(codeOwners, "/file.cs").Should().BeEmpty(); + codeOwners.ParsingDiagnosticsCount.Should().Be(1); + } + finally + { + File.Delete(path); + } + } + + [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() + { + 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 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() + { + 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() + { + // 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); + } + + [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 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 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 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(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] + public void DescendantMatchingIsStableAcrossRepeatedCalls() + { + // 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++) + { + 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 = WriteTemporaryCodeOwners(content); + + try + { + return new CodeOwners(path, platform); + } + finally + { + File.Delete(path); + } + } + + 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 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(); +} 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..02efd5d9a41e --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs @@ -0,0 +1,101 @@ +// +// 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", "[\"@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\"]")] + [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(@"\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)] + [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); + } +}