diff --git a/actions/docs-verifier/MSDocsBuildVerifier.sln b/actions/docs-verifier/MSDocsBuildVerifier.sln
index ad8db2a0..7c0d8899 100644
--- a/actions/docs-verifier/MSDocsBuildVerifier.sln
+++ b/actions/docs-verifier/MSDocsBuildVerifier.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.5.33402.96
+# Visual Studio Version 18
+VisualStudioVersion = 18.9.12128.139 oobstable
MinimumVisualStudioVersion = 15.0.26124.0
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FB9E2FB6-DF28-4985-87D6-0334B8260365}"
ProjectSection(SolutionItems) = preProject
@@ -28,6 +28,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ActionRunner", "src\ActionR
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildVerifier.IO.Abstractions", "src\BuildVerifier.IO.Abstractions\BuildVerifier.IO.Abstractions.csproj", "{FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocfxVerifier", "src\DocfxVerifier\DocfxVerifier.csproj", "{3E0FB4E3-3080-D891-0CB6-382B700A7AA8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -122,6 +124,18 @@ Global
{FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x64.Build.0 = Release|Any CPU
{FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x86.ActiveCfg = Release|Any CPU
{FD67DE5D-D8C0-48AD-A7F9-E6F316821E10}.Release|x86.Build.0 = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x64.Build.0 = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Debug|x86.Build.0 = Debug|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x64.ActiveCfg = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x64.Build.0 = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x86.ActiveCfg = Release|Any CPU
+ {3E0FB4E3-3080-D891-0CB6-382B700A7AA8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj b/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj
index 551ad3db..935fa217 100644
--- a/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj
+++ b/actions/docs-verifier/src/ActionRunner/ActionRunner.csproj
@@ -8,6 +8,7 @@
+
diff --git a/actions/docs-verifier/src/ActionRunner/Program.cs b/actions/docs-verifier/src/ActionRunner/Program.cs
index bd966d0d..316e6763 100644
--- a/actions/docs-verifier/src/ActionRunner/Program.cs
+++ b/actions/docs-verifier/src/ActionRunner/Program.cs
@@ -106,6 +106,37 @@
IEnumerable matchers = await docfxConfigurationReader.MapConfigurationAsync();
IEnumerable pullRequestFiles = await GitHubPullRequest.GetPullRequestFilesAsync(pullRequestNumber);
+IEnumerable modifiedDocfxFiles = pullRequestFiles
+ .Where(file => !file.IsRemoved() && IsDocfxJsonPath(file.FileName))
+ .Select(file => file.FileName)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+foreach (string docfxFilePath in modifiedDocfxFiles)
+{
+ if (!await DocfxVerifier.PathVerifier.WriteResultsAsync(Console.Out, docfxFilePath))
+ {
+ returnCode++;
+ }
+}
+
+// Verify that all redirection URLs in modified
+// redirection files are valid and reachable.
+HashSet redirectionFileSet =
+ new(redirectionFiles.Select(NormalizePath), StringComparer.OrdinalIgnoreCase);
+
+IEnumerable modifiedRedirectionFiles = pullRequestFiles
+ .Where(file => !file.IsRemoved() && IsRegisteredRedirectionFile(file.FileName, redirectionFileSet))
+ .Select(file => file.FileName)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+foreach (string redirectionFilePath in modifiedRedirectionFiles)
+{
+ if (!await RedirectTargetVerifier.WriteResultsAsync(Console.Out, redirectionFilePath))
+ {
+ returnCode++;
+ }
+}
+
List files =
[.. pullRequestFiles.Where(f => IsRedirectableFile(f, matchers))];
@@ -132,8 +163,7 @@
return returnCode;
-static bool IsRedirectableFile(
- PullRequestFile file, IEnumerable matchers)
+static bool IsRedirectableFile(PullRequestFile file, IEnumerable matchers)
{
string? deletedFileName = file.IsRenamed()
? file.PreviousFileName
@@ -144,15 +174,32 @@ static bool IsRedirectableFile(
// A deleted toc.yml doesn't need redirection.
// Also, don't require a redirection for file patterns specified as "exclude"s in docfx config file.
- return !isDeletedToc && IsYmlOrMarkdownFile(deletedFileName)
+ return !isDeletedToc
+ && IsYmlOrMarkdownFile(deletedFileName)
&& matchers.Any(m => m.Match(deletedFileName).HasMatches);
}
static bool IsYmlOrMarkdownFile([NotNullWhen(true)] string? fileName) =>
Path.GetExtension(fileName) is ".yml" or ".md";
+static bool IsDocfxJsonPath(string? path)
+{
+ if (path is null)
+ {
+ return false;
+ }
+
+ string normalized = path.Replace('\\', '/');
+ return normalized.Equals("docfx.json", StringComparison.OrdinalIgnoreCase)
+ || normalized.EndsWith("/docfx.json", StringComparison.OrdinalIgnoreCase);
+}
+
+static bool IsRegisteredRedirectionFile(string? path, HashSet redirectionFilesSet) =>
+ path is not null && redirectionFilesSet.Contains(NormalizePath(path));
+
+static string NormalizePath(string path) => path.Replace('\\', '/');
+
static bool IsExtensionChangeOnly(string file1, string file2) =>
RemoveExtension(file1).Equals(RemoveExtension(file2), StringComparison.OrdinalIgnoreCase);
-static string RemoveExtension(string file) =>
- file.Substring(0, file.Length - Path.GetExtension(file).Length);
+static string RemoveExtension(string file) => file[..^Path.GetExtension(file).Length];
diff --git a/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj b/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj
new file mode 100644
index 00000000..b7601447
--- /dev/null
+++ b/actions/docs-verifier/src/DocfxVerifier/DocfxVerifier.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs b/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs
new file mode 100644
index 00000000..7d0ace4d
--- /dev/null
+++ b/actions/docs-verifier/src/DocfxVerifier/PathVerifier.cs
@@ -0,0 +1,208 @@
+using System.Text.Json;
+
+namespace DocfxVerifier
+{
+ ///
+ /// Validates file path entries declared
+ /// under build.fileMetadata in a docfx.json file.
+ ///
+ public static class PathVerifier
+ {
+ private static readonly JsonDocumentOptions s_jsonDocumentOptions = new()
+ {
+ AllowTrailingCommas = true
+ };
+
+ ///
+ /// Verifies that file paths in the docfx.json file are valid.
+ ///
+ public static Task WriteResultsAsync(TextWriter writer) =>
+ WriteResultsAsync(writer, configurationPath: null);
+
+ ///
+ /// Verifies that file paths in a specific docfx.json file are valid.
+ ///
+ public static async Task WriteResultsAsync(TextWriter writer, string? configurationPath)
+ {
+ ArgumentNullException.ThrowIfNull(writer, nameof(writer));
+
+ configurationPath ??= FindDocfxConfigurationPath();
+ if (configurationPath is null)
+ {
+ await writer.WriteLineAsync("::error::Unable to find docfx.json in the repository root or its immediate subdirectories.");
+ return false;
+ }
+
+ if (!File.Exists(configurationPath))
+ {
+ await writer.WriteLineAsync($"::error::docfx.json file '{configurationPath}' does not exist.");
+ return false;
+ }
+
+ using FileStream stream = File.OpenRead(configurationPath);
+ using JsonDocument json = await JsonDocument.ParseAsync(stream, s_jsonDocumentOptions);
+
+ string repositoryRoot = Directory.GetCurrentDirectory();
+ string configurationDirectory = Path.GetDirectoryName(Path.GetFullPath(configurationPath)) ?? repositoryRoot;
+ string configurationPathForLog = configurationPath.Replace('\\', '/');
+
+ var errors = new List();
+ ValidateFileMetadataPaths(json.RootElement, repositoryRoot, configurationDirectory, errors);
+
+ foreach (string error in errors)
+ {
+ await writer.WriteLineAsync($"::error file={configurationPathForLog}::{error}");
+ }
+
+ return errors.Count == 0;
+ }
+
+ private static void ValidateFileMetadataPaths(
+ JsonElement element,
+ string repositoryRoot,
+ string configurationDirectory,
+ List errors)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ if (element.TryGetProperty("build", out JsonElement buildSection)
+ && buildSection.ValueKind == JsonValueKind.Object)
+ {
+ ValidateBuildFileMetadataSection(buildSection, "$.build", repositoryRoot, configurationDirectory, errors);
+ }
+ }
+
+ private static void ValidateBuildFileMetadataSection(
+ JsonElement buildSection,
+ string jsonPath,
+ string repositoryRoot,
+ string configurationDirectory,
+ List errors)
+ {
+ if (buildSection.TryGetProperty("fileMetadata", out JsonElement fileMetadata)
+ && fileMetadata.ValueKind == JsonValueKind.Object)
+ {
+ foreach (JsonProperty metadataProperty in fileMetadata.EnumerateObject())
+ {
+ if (metadataProperty.Value.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ foreach (JsonProperty pathProperty in metadataProperty.Value.EnumerateObject())
+ {
+ ValidatePath(
+ pathProperty.Name,
+ $"{jsonPath}.fileMetadata.{metadataProperty.Name}.{pathProperty.Name}",
+ repositoryRoot,
+ configurationDirectory,
+ errors);
+ }
+ }
+ }
+ }
+
+ private static void ValidatePath(
+ string? path,
+ string jsonPath,
+ string repositoryRoot,
+ string resolutionBaseDirectory,
+ List errors)
+ {
+ if (string.IsNullOrWhiteSpace(path) || path is ".")
+ {
+ return;
+ }
+
+ if (Uri.TryCreate(path, UriKind.Absolute, out Uri? uri)
+ && uri is not null
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return;
+ }
+
+ string normalizedPath = path.Replace('\\', '/');
+ string nonWildcardPrefix = GetNonWildcardPrefix(normalizedPath);
+ if (string.IsNullOrEmpty(nonWildcardPrefix))
+ {
+ return;
+ }
+
+ if (!ExistsInRepository(nonWildcardPrefix, repositoryRoot, resolutionBaseDirectory))
+ {
+ errors.Add($"{jsonPath}: Path '{path}' is invalid.");
+ }
+ }
+
+ private static bool ExistsInRepository(string path, string repositoryRoot, string resolutionBaseDirectory)
+ {
+ string? combinedPath = TryResolvePathWithinRepository(path, repositoryRoot, resolutionBaseDirectory);
+ return combinedPath is not null && (File.Exists(combinedPath) || Directory.Exists(combinedPath));
+ }
+
+ private static string? TryResolvePathWithinRepository(
+ string? path,
+ string repositoryRoot,
+ string resolutionBaseDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ string combinedPath = Path.GetFullPath(Path.Combine(resolutionBaseDirectory, path));
+ string relative = Path.GetRelativePath(Path.GetFullPath(repositoryRoot), combinedPath);
+
+ if (relative.Equals("..", StringComparison.Ordinal)
+ || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)
+ || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ return combinedPath;
+ }
+
+ private static string GetNonWildcardPrefix(string path)
+ {
+ ReadOnlySpan wildcardChars = ['*', '?', '[', ']', '{', '}'];
+ string[] segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
+
+ var prefixSegments = new List();
+ foreach (string segment in segments)
+ {
+ if (segment.AsSpan().IndexOfAny(wildcardChars) >= 0)
+ {
+ break;
+ }
+
+ prefixSegments.Add(segment);
+ }
+
+ return string.Join('/', prefixSegments);
+ }
+
+ private static string? FindDocfxConfigurationPath()
+ {
+ const string fileName = "docfx.json";
+ if (File.Exists(fileName))
+ {
+ return fileName;
+ }
+
+ foreach (string directory in Directory.GetDirectories(".", "*", SearchOption.TopDirectoryOnly))
+ {
+ string candidate = Path.Combine(directory, fileName);
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs b/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..542c99c6
--- /dev/null
+++ b/actions/docs-verifier/src/RedirectionVerifier/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GitHub.UnitTests")]
diff --git a/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs b/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs
new file mode 100644
index 00000000..5d542787
--- /dev/null
+++ b/actions/docs-verifier/src/RedirectionVerifier/RedirectTargetVerifier.cs
@@ -0,0 +1,115 @@
+using System.Collections.Immutable;
+using System.Net;
+
+namespace RedirectionVerifier;
+
+public static class RedirectTargetVerifier
+{
+ private const string LearnMicrosoftCom = "https://learn.microsoft.com";
+ private static readonly HttpClient s_httpClient = new()
+ {
+ Timeout = TimeSpan.FromSeconds(15)
+ };
+
+ ///
+ /// Verifies that redirect targets in an entire redirection file are valid.
+ ///
+ public static async Task WriteResultsAsync(
+ TextWriter writer,
+ string redirectionFilePath)
+ => await WriteResultsAsync(writer, redirectionFilePath, GetStatusCodeAsync);
+
+ internal static async Task WriteResultsAsync(
+ TextWriter writer,
+ string redirectionFilePath,
+ Func> statusCodeProvider)
+ {
+ ArgumentNullException.ThrowIfNull(writer, nameof(writer));
+ ArgumentNullException.ThrowIfNull(redirectionFilePath, nameof(redirectionFilePath));
+ ArgumentNullException.ThrowIfNull(statusCodeProvider, nameof(statusCodeProvider));
+
+ if (!File.Exists(redirectionFilePath))
+ {
+ await writer.WriteLineAsync($"::error::Redirection file '{redirectionFilePath}' does not exist.");
+ return false;
+ }
+
+ OpenPublishingRedirectionReader reader = new(redirectionFilePath);
+ ImmutableArray redirections = await reader.MapConfigurationAsync();
+ if (redirections.IsDefaultOrEmpty)
+ {
+ return true;
+ }
+
+ bool isValid = true;
+ for (int i = 0; i < redirections.Length; i++)
+ {
+ string? redirectUrl = redirections[i].RedirectUrl;
+ if (string.IsNullOrWhiteSpace(redirectUrl))
+ {
+ await writer.WriteLineAsync($"::error file={redirectionFilePath}::Redirection at index {i} has an empty 'redirect_url'.");
+ isValid = false;
+ continue;
+ }
+
+ if (redirectUrl[0] != '/')
+ {
+ continue;
+ }
+
+ string redirectTarget = $"{LearnMicrosoftCom}{redirectUrl}";
+
+ bool hasValidUri = Uri.TryCreate(redirectTarget, UriKind.Absolute, out Uri? uri)
+ && uri is not null
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+
+ if (!hasValidUri)
+ {
+ await writer.WriteLineAsync($"::error file={redirectionFilePath}::Invalid 'redirect_url' at index {i}: '{redirectUrl}'.");
+ isValid = false;
+ continue;
+ }
+
+ HttpStatusCode? statusCode = await statusCodeProvider(uri!);
+ if (statusCode is null)
+ {
+ await writer.WriteLineAsync($"::error file={redirectionFilePath}::Unable to verify 'redirect_url' at index {i}: '{redirectUrl}'.");
+ isValid = false;
+ continue;
+ }
+
+ if (statusCode == HttpStatusCode.NotFound)
+ {
+ await writer.WriteLineAsync($"::error file={redirectionFilePath}::Redirect target returns 404 at index {i}: '{redirectUrl}'.");
+ isValid = false;
+ }
+ }
+
+ return isValid;
+ }
+
+ private static async Task GetStatusCodeAsync(Uri uri)
+ {
+ try
+ {
+ using HttpRequestMessage headRequest = new(HttpMethod.Head, uri);
+ using HttpResponseMessage headResponse = await s_httpClient.SendAsync(headRequest);
+ if (headResponse.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented or HttpStatusCode.NotFound)
+ {
+ using HttpRequestMessage getRequest = new(HttpMethod.Get, uri);
+ using HttpResponseMessage getResponse = await s_httpClient.SendAsync(getRequest, HttpCompletionOption.ResponseHeadersRead);
+ return getResponse.StatusCode;
+ }
+
+ return headResponse.StatusCode;
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ catch (TaskCanceledException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj b/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj
index 14fc2bce..74c7e150 100644
--- a/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj
+++ b/actions/docs-verifier/tests/GitHub.UnitTests/GitHub.UnitTests.csproj
@@ -19,6 +19,8 @@
+
+
-
\ No newline at end of file
+
diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs b/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs
new file mode 100644
index 00000000..3baaa173
--- /dev/null
+++ b/actions/docs-verifier/tests/GitHub.UnitTests/PathVerifierTests.cs
@@ -0,0 +1,262 @@
+using DocfxVerifier;
+using Xunit;
+
+namespace GitHub.UnitTests;
+
+public class PathVerifierTests
+{
+ private static readonly SemaphoreSlim s_currentDirectoryLock = new(1, 1);
+
+ [Fact]
+ public async Task WriteResultsAsyncReturnsTrueForValidFileMetadataPaths()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+ Directory.CreateDirectory(Path.Combine("docs", "valid"));
+ await File.WriteAllTextAsync("docfx.json", """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.author": {
+ "docs/valid/**/**.{md,yml}": "someone"
+ }
+ }
+ }
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer);
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncReturnsFalseForInvalidFileMetadataPaths()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+ await File.WriteAllTextAsync("docfx.json", """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.author": {
+ "missing/path/**/**.{md,yml}": "someone"
+ }
+ }
+ }
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer);
+ string output = writer.ToString();
+
+ Assert.False(result);
+ Assert.Contains("Path 'missing/path/**/**.{md,yml}' is invalid", output, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncIgnoresNonFileMetadataPathEntries()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+ await File.WriteAllTextAsync("docfx.json", """
+ {
+ "build": {
+ "content": [
+ {
+ "files": ["**/*.md"],
+ "src": "missing-folder"
+ }
+ ],
+ "template": ["missing-template"],
+ "fileMetadata": {
+ "ms.author": {
+ "**/*.md": "someone"
+ }
+ }
+ },
+ "globalMetadata": {
+ "src": "not-a-docfx-path"
+ }
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer);
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncFindsDocfxInSubdirectory()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+ Directory.CreateDirectory(Path.Combine("docs", "content", "guides"));
+ await File.WriteAllTextAsync(Path.Combine("docs", "docfx.json"), """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.topic": {
+ "content/**": "conceptual"
+ }
+ }
+ }
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer);
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncUsesSpecifiedDocfxPath()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+
+ await File.WriteAllTextAsync("docfx.json", """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.author": {
+ "missing-root-folder/**": "someone"
+ }
+ }
+ }
+ }
+ """);
+
+ Directory.CreateDirectory(Path.Combine("valid-docs", "valid"));
+ string modifiedDocfxPath = Path.Combine("valid-docs", "docfx.json");
+ await File.WriteAllTextAsync(modifiedDocfxPath, """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.author": {
+ "valid/**": "someone"
+ }
+ }
+ }
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer, modifiedDocfxPath);
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncAllowsTrailingCommas()
+ {
+ await s_currentDirectoryLock.WaitAsync();
+ string testRoot = CreateTempDirectory();
+ string originalDirectory = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(testRoot);
+ Directory.CreateDirectory("docs");
+
+ await File.WriteAllTextAsync("docfx.json", """
+ {
+ "build": {
+ "fileMetadata": {
+ "ms.author": {
+ "docs/**": "someone",
+ },
+ },
+ },
+ }
+ """);
+
+ using var writer = new StringWriter();
+ bool result = await PathVerifier.WriteResultsAsync(writer);
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ Directory.Delete(testRoot, recursive: true);
+ s_currentDirectoryLock.Release();
+ }
+ }
+
+ private static string CreateTempDirectory()
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"path-verifier-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(path);
+ return path;
+ }
+}
diff --git a/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs b/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs
new file mode 100644
index 00000000..99a611ef
--- /dev/null
+++ b/actions/docs-verifier/tests/GitHub.UnitTests/RedirectTargetVerifierTests.cs
@@ -0,0 +1,119 @@
+using System.Net;
+using RedirectionVerifier;
+using Xunit;
+
+namespace GitHub.UnitTests;
+
+public class RedirectTargetVerifierTests
+{
+ [Fact]
+ public async Task WriteResultsAsyncReturnsTrueForValidLearnUrlPath()
+ {
+ string redirectionFilePath = await CreateRedirectionFileAsync("/dotnet");
+ try
+ {
+ using var writer = new StringWriter();
+ bool result = await RedirectTargetVerifier.WriteResultsAsync(
+ writer,
+ redirectionFilePath,
+ _ => Task.FromResult(HttpStatusCode.OK));
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ }
+ finally
+ {
+ File.Delete(redirectionFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncSkipsNonLearnUrlTargets()
+ {
+ string redirectionFilePath = await CreateRedirectionFileAsync("not-a-valid-url");
+ bool statusProviderCalled = false;
+
+ try
+ {
+ using var writer = new StringWriter();
+ bool result = await RedirectTargetVerifier.WriteResultsAsync(
+ writer,
+ redirectionFilePath,
+ _ =>
+ {
+ statusProviderCalled = true;
+ return Task.FromResult(HttpStatusCode.OK);
+ });
+
+ Assert.True(result);
+ Assert.Equal(string.Empty, writer.ToString());
+ Assert.False(statusProviderCalled);
+ }
+ finally
+ {
+ File.Delete(redirectionFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncReturnsFalseFor404Url()
+ {
+ string redirectionFilePath = await CreateRedirectionFileAsync("/missing");
+ try
+ {
+ using var writer = new StringWriter();
+ bool result = await RedirectTargetVerifier.WriteResultsAsync(
+ writer,
+ redirectionFilePath,
+ _ => Task.FromResult(HttpStatusCode.NotFound));
+
+ Assert.False(result);
+ Assert.Contains("returns 404", writer.ToString(), StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(redirectionFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task WriteResultsAsyncReturnsFalseWhenLearnUrlCannotBeVerified()
+ {
+ string redirectionFilePath = await CreateRedirectionFileAsync("/dotnet");
+
+ try
+ {
+ using var writer = new StringWriter();
+ bool result = await RedirectTargetVerifier.WriteResultsAsync(
+ writer,
+ redirectionFilePath,
+ _ => Task.FromResult(null));
+
+ Assert.False(result);
+ Assert.Contains("Unable to verify 'redirect_url'", writer.ToString(), StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(redirectionFilePath);
+ }
+ }
+
+ private static async Task CreateRedirectionFileAsync(string redirectUrl)
+ {
+ string filePath = Path.Combine(Path.GetTempPath(), $"redirect-{Guid.NewGuid():N}.json");
+ string content = $$"""
+ {
+ "redirections": [
+ {
+ "source_path": "docs/old.md",
+ "redirect_url": "{{redirectUrl}}"
+ }
+ ]
+ }
+ """;
+
+ await File.WriteAllTextAsync(filePath, content);
+ return filePath;
+ }
+
+}