diff --git a/ModernFormsNext.Tests/ReleaseVersionConsistencyTests.cs b/ModernFormsNext.Tests/ReleaseVersionConsistencyTests.cs index c3a4d3a..097ee23 100644 --- a/ModernFormsNext.Tests/ReleaseVersionConsistencyTests.cs +++ b/ModernFormsNext.Tests/ReleaseVersionConsistencyTests.cs @@ -31,16 +31,18 @@ public void CentralPackageAndVsixVersionsAreCoordinated() [Fact] public void EveryPackableProjectUsesTheCentralPackageVersion() { - string root = FindRepositoryRoot(); - string[] actualProjects = Directory - .EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories) - .Where(path => !ContainsGeneratedDirectory(path)) - .Where(path => string.Equals(ElementValue(XDocument.Load(path), "IsPackable"), "true", StringComparison.OrdinalIgnoreCase)) - .Select(path => IOPath.GetRelativePath(root, path).Replace('\\', '/')) - .Order(StringComparer.Ordinal) + string root = RepositoryFileEnumerator.FindRepositoryRoot(); + RepositoryFileEnumeration enumeration = RepositoryFileEnumerator.EnumerateFiles( + root, + path => path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)); + string[] actualProjects = enumeration.Files + .Where(path => string.Equals(ElementValue(LoadXml(root, path), "IsPackable"), "true", StringComparison.OrdinalIgnoreCase)) .ToArray(); + string[] expectedProjects = PackableProjects.Order(StringComparer.Ordinal).ToArray(); - Assert.Equal(PackableProjects.Order(StringComparer.Ordinal), actualProjects); + Assert.True( + expectedProjects.SequenceEqual(actualProjects, StringComparer.Ordinal), + enumeration.FormatDiagnostics(expectedProjects)); foreach (string projectPath in PackableProjects) { @@ -113,34 +115,19 @@ private static Func HasPackedFile(string fileName) private static void AssertRegistrationVersion(string relativePath) { - string text = File.ReadAllText(IOPath.Combine(FindRepositoryRoot(), relativePath.Replace('/', IOPath.DirectorySeparatorChar))); + string text = File.ReadAllText(IOPath.Combine(RepositoryFileEnumerator.FindRepositoryRoot(), relativePath.Replace('/', IOPath.DirectorySeparatorChar))); Assert.Contains($"InstalledProductRegistration", text, StringComparison.Ordinal); Assert.Contains($"\"{ExpectedVersion}\"", text, StringComparison.Ordinal); Assert.DoesNotContain("\"1.8.0\"", text, StringComparison.Ordinal); } private static XDocument LoadXml(string relativePath) - => XDocument.Load(IOPath.Combine(FindRepositoryRoot(), relativePath.Replace('/', IOPath.DirectorySeparatorChar))); + => LoadXml(RepositoryFileEnumerator.FindRepositoryRoot(), relativePath); + + private static XDocument LoadXml(string root, string relativePath) + => XDocument.Load(IOPath.Combine(root, relativePath.Replace('/', IOPath.DirectorySeparatorChar))); private static string? ElementValue(XDocument document, string name) => document.Descendants().FirstOrDefault(element => element.Name.LocalName == name)?.Value.Trim(); - private static bool ContainsGeneratedDirectory(string path) - { - string normalized = path.Replace('\\', '/'); - return normalized.Contains("/bin/", StringComparison.OrdinalIgnoreCase) - || normalized.Contains("/obj/", StringComparison.OrdinalIgnoreCase); - } - - private static string FindRepositoryRoot() - { - for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) - { - if (File.Exists(IOPath.Combine(directory.FullName, "Directory.Build.props")) - && File.Exists(IOPath.Combine(directory.FullName, "ModernFormsNext.slnx"))) - return directory.FullName; - } - - throw new DirectoryNotFoundException("Could not locate the ModernFormsNext repository root from the test output directory."); - } } diff --git a/ModernFormsNext.Tests/RepositoryFileEnumerator.cs b/ModernFormsNext.Tests/RepositoryFileEnumerator.cs new file mode 100644 index 0000000..04aec3a --- /dev/null +++ b/ModernFormsNext.Tests/RepositoryFileEnumerator.cs @@ -0,0 +1,383 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace ModernFormsNext.Tests; + +internal enum RepositoryFileSource +{ + GitTrackedFiles, + FileSystemFallback +} + +internal sealed record RepositoryPathExclusion(string Path, string Reason); + +internal sealed record RepositoryFileEnumeration( + RepositoryFileSource Source, + IReadOnlyList Files, + IReadOnlyList Exclusions) +{ + public string FormatDiagnostics(IEnumerable? expectedFiles = null) + { + var builder = new StringBuilder() + .AppendLine($"Repository input source: {Source}") + .AppendLine("Included repository-relative files:"); + + foreach (string file in Files) + builder.Append(" + ").AppendLine(file); + + if (expectedFiles is not null) + { + builder.AppendLine("Expected repository-relative files:"); + foreach (string file in expectedFiles.Order(StringComparer.Ordinal)) + builder.Append(" = ").AppendLine(file); + } + + builder.AppendLine("Excluded repository-relative paths:"); + foreach (RepositoryPathExclusion exclusion in Exclusions) + builder.Append(" - ").Append(exclusion.Path).Append(" (").Append(exclusion.Reason).AppendLine(")"); + + return builder.ToString(); + } +} + +/// +/// Enumerates repository-owned files without treating generated output or nested checkouts as source. +/// +/// +/// Git-tracked files are authoritative when repository metadata and Git are available. Source archives +/// use a filesystem fallback that prunes generated directories, nested Git roots, and reparse points. +/// All returned paths and diagnostics are repository-relative and use forward slashes. +/// +internal static class RepositoryFileEnumerator +{ + private static readonly HashSet GeneratedDirectoryNames = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + ".vs", + ".idea", + ".codex", + ".nuget", + ".cache", + "artifacts", + "bin", + "obj", + "TestResult", + "TestResults", + "packages", + "node_modules", + "BenchmarkDotNet.Artifacts", + "Generated Files", + "GeneratedArtifacts", + "AppPackages", + "BundleArtifacts", + ".codex-build", + ".codex-pack", + ".codex-pack-api", + "_site" + }; + + internal static string FindRepositoryRoot(string? startPath = null) + { + string candidate = IOPath.GetFullPath(startPath ?? AppContext.BaseDirectory); + if (File.Exists(candidate)) + candidate = IOPath.GetDirectoryName(candidate) + ?? throw new DirectoryNotFoundException($"Could not resolve a directory from '{startPath}'."); + + for (DirectoryInfo? directory = new(candidate); directory is not null; directory = directory.Parent) + { + if (File.Exists(IOPath.Combine(directory.FullName, "Directory.Build.props")) + && File.Exists(IOPath.Combine(directory.FullName, "ModernFormsNext.slnx"))) + { + return IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(directory.FullName)); + } + } + + throw new DirectoryNotFoundException( + "Could not locate the ModernFormsNext repository root from the supplied start path."); + } + + internal static RepositoryFileEnumeration EnumerateFiles( + string repositoryRoot, + Func includeFile) + => EnumerateFiles(repositoryRoot, includeFile, TryEnumerateGitTrackedFiles); + + internal static RepositoryFileEnumeration EnumerateFiles( + string repositoryRoot, + Func includeFile, + Func?> trackedFileProvider) + { + ArgumentNullException.ThrowIfNull(includeFile); + ArgumentNullException.ThrowIfNull(trackedFileProvider); + + string root = NormalizeRoot(repositoryRoot); + IReadOnlyList? trackedFiles = trackedFileProvider(root); + return trackedFiles is null + ? EnumerateFileSystem(root, includeFile) + : EnumerateTrackedFiles(root, includeFile, trackedFiles); + } + + internal static bool ContainsGeneratedDirectorySegment(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return NormalizeSeparators(path) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(GeneratedDirectoryNames.Contains); + } + + internal static bool IsReparsePoint(FileAttributes attributes) + => (attributes & FileAttributes.ReparsePoint) != 0; + + private static RepositoryFileEnumeration EnumerateTrackedFiles( + string root, + Func includeFile, + IReadOnlyList trackedFiles) + { + var files = new List(); + var exclusions = new List(); + + foreach (string candidate in trackedFiles) + { + string relative = NormalizeRepositoryRelativePath(root, candidate); + if (!includeFile(relative)) + continue; + + if (ContainsGeneratedDirectorySegment(relative)) + { + exclusions.Add(new(relative, "generated directory segment")); + continue; + } + + string fullPath = IOPath.GetFullPath(IOPath.Combine(root, relative.Replace('/', IOPath.DirectorySeparatorChar))); + if (ContainsExistingReparsePoint(root, fullPath)) + { + exclusions.Add(new(relative, "reparse point")); + continue; + } + + files.Add(relative); + } + + return CreateResult(RepositoryFileSource.GitTrackedFiles, files, exclusions); + } + + private static RepositoryFileEnumeration EnumerateFileSystem( + string root, + Func includeFile) + { + var files = new List(); + var exclusions = new List(); + var pending = new Stack(); + pending.Push(root); + + while (pending.Count > 0) + { + string directory = pending.Pop(); + string[] childFiles; + string[] childDirectories; + + try + { + childFiles = Directory.GetFiles(directory, "*", SearchOption.TopDirectoryOnly); + childDirectories = Directory.GetDirectories(directory, "*", SearchOption.TopDirectoryOnly); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + exclusions.Add(new(ToRelativePath(root, directory), exception.GetType().Name)); + continue; + } + + foreach (string file in childFiles.Order(StringComparer.Ordinal)) + { + string relative = ToRelativePath(root, file); + if (!includeFile(relative)) + continue; + + if (IsExistingReparsePoint(file)) + { + exclusions.Add(new(relative, "reparse point")); + continue; + } + + files.Add(relative); + } + + foreach (string childDirectory in childDirectories.OrderDescending(StringComparer.Ordinal)) + { + string relative = ToRelativePath(root, childDirectory); + string name = IOPath.GetFileName(childDirectory); + + if (GeneratedDirectoryNames.Contains(name)) + { + exclusions.Add(new(relative, "generated directory")); + continue; + } + + if (IsExistingReparsePoint(childDirectory)) + { + exclusions.Add(new(relative, "reparse point")); + continue; + } + + if (File.Exists(IOPath.Combine(childDirectory, ".git")) + || Directory.Exists(IOPath.Combine(childDirectory, ".git"))) + { + exclusions.Add(new(relative, "nested Git worktree/repository")); + continue; + } + + pending.Push(childDirectory); + } + } + + return CreateResult(RepositoryFileSource.FileSystemFallback, files, exclusions); + } + + private static RepositoryFileEnumeration CreateResult( + RepositoryFileSource source, + List files, + List exclusions) + { + files.Sort(StringComparer.Ordinal); + exclusions.Sort((left, right) => StringComparer.Ordinal.Compare(left.Path, right.Path)); + return new(source, files, exclusions); + } + + private static IReadOnlyList? TryEnumerateGitTrackedFiles(string root) + { + string gitEntry = IOPath.Combine(root, ".git"); + if (!File.Exists(gitEntry) && !Directory.Exists(gitEntry)) + return null; + + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + } + }; + process.StartInfo.ArgumentList.Add("-C"); + process.StartInfo.ArgumentList.Add(root); + process.StartInfo.ArgumentList.Add("ls-files"); + process.StartInfo.ArgumentList.Add("-z"); + process.StartInfo.ArgumentList.Add("--cached"); + process.StartInfo.ArgumentList.Add("--"); + + if (!process.Start()) + return null; + + Task standardOutput = process.StandardOutput.ReadToEndAsync(); + Task standardError = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(milliseconds: 10_000)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + Task.WaitAll(standardOutput, standardError); + return null; + } + + Task.WaitAll(standardOutput, standardError); + if (process.ExitCode != 0) + return null; + + return standardOutput.Result.Split('\0', StringSplitOptions.RemoveEmptyEntries); + } + catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException) + { + return null; + } + } + + private static string NormalizeRoot(string repositoryRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + string root = IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(repositoryRoot)); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException("Repository root does not exist."); + + return root; + } + + private static string NormalizeRepositoryRelativePath(string root, string candidate) + { + ArgumentException.ThrowIfNullOrWhiteSpace(candidate); + string normalized = NormalizeSeparators(candidate); + bool hasDriveRoot = normalized.Length >= 3 + && char.IsAsciiLetter(normalized[0]) + && normalized[1] == ':' + && normalized[2] == '/'; + if (normalized.StartsWith("/", StringComparison.Ordinal) + || hasDriveRoot + || IOPath.IsPathRooted(candidate) + || normalized.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(segment => segment is "." or "..")) + { + throw new InvalidDataException($"Repository traversal returned an unsafe relative path: '{candidate}'."); + } + + string fullPath = IOPath.GetFullPath(IOPath.Combine(root, normalized.Replace('/', IOPath.DirectorySeparatorChar))); + if (!IsWithinRoot(root, fullPath)) + throw new InvalidDataException($"Repository traversal escaped the repository root: '{candidate}'."); + + return NormalizeSeparators(IOPath.GetRelativePath(root, fullPath)); + } + + private static string ToRelativePath(string root, string path) + { + string fullPath = IOPath.GetFullPath(path); + if (!IsWithinRoot(root, fullPath)) + throw new InvalidDataException("Filesystem traversal escaped the repository root."); + + return NormalizeSeparators(IOPath.GetRelativePath(root, fullPath)); + } + + private static bool IsWithinRoot(string root, string path) + { + string rootPrefix = IOPath.EndsInDirectorySeparator(root) + ? root + : root + IOPath.DirectorySeparatorChar; + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return path.StartsWith(rootPrefix, comparison); + } + + private static bool ContainsExistingReparsePoint(string root, string filePath) + { + string? directory = IOPath.GetDirectoryName(filePath); + while (!string.IsNullOrWhiteSpace(directory) && !string.Equals(directory, root, PathComparison)) + { + if (IsExistingReparsePoint(directory)) + return true; + + directory = IOPath.GetDirectoryName(directory); + } + + return IsExistingReparsePoint(filePath); + } + + private static bool IsExistingReparsePoint(string path) + { + try + { + return (File.Exists(path) || Directory.Exists(path)) && IsReparsePoint(File.GetAttributes(path)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return true; + } + } + + private static StringComparison PathComparison => OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private static string NormalizeSeparators(string path) => path.Replace('\\', '/'); +} diff --git a/ModernFormsNext.Tests/RepositoryFileEnumeratorTests.cs b/ModernFormsNext.Tests/RepositoryFileEnumeratorTests.cs new file mode 100644 index 0000000..03f301e --- /dev/null +++ b/ModernFormsNext.Tests/RepositoryFileEnumeratorTests.cs @@ -0,0 +1,279 @@ +using System.Xml.Linq; +using Xunit; + +namespace ModernFormsNext.Tests; + +public sealed class RepositoryFileEnumeratorTests +{ + [Fact] + public void FindsRepositoryRootFromNestedTestOutputWithoutUsingCurrentDirectory() + { + using var fixture = new RepositoryFixture(); + string nested = fixture.CreateDirectory("tests/bin/Debug/net10.0"); + + Assert.Equal(fixture.Root, RepositoryFileEnumerator.FindRepositoryRoot(nested)); + } + + [Theory] + [InlineData("artifacts/old/App.csproj")] + [InlineData("ARTIFACTS\\old\\App.csproj")] + [InlineData("src/bin/App.csproj")] + [InlineData("src\\OBJ\\generated.props")] + [InlineData("src/TestResults/result.trx")] + [InlineData("src/packages/cache.nupkg")] + [InlineData("src/.nuget/packages/cache.nupkg")] + [InlineData("src/.cache/generated/App.csproj")] + [InlineData("src/.codex/local/App.csproj")] + [InlineData("src/node_modules/package.json")] + [InlineData("external/.git/config")] + public void GeneratedDirectoryMatchingUsesExactCaseInsensitiveSegments(string path) + => Assert.True(RepositoryFileEnumerator.ContainsGeneratedDirectorySegment(path)); + + [Theory] + [InlineData("ArtifactsDocumentation/App.csproj")] + [InlineData("my-artifacts-source/App.csproj")] + [InlineData("BinaryTools/App.csproj")] + [InlineData("ObjectModel/App.csproj")] + public void SimilarDirectoryNamesRemainRepositorySource(string path) + => Assert.False(RepositoryFileEnumerator.ContainsGeneratedDirectorySegment(path)); + + [Fact] + public void FilesystemFallbackExcludesGeneratedTreesAndKeepsStableSourceOrder() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/Zeta/Zeta.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + fixture.WriteProject("src/Alpha/Alpha.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + fixture.WriteProject("ArtifactsDocumentation/Docs.csproj", isPackable: false, version: "1.0.0"); + fixture.WriteProject("my-artifacts-source/Source.csproj", isPackable: false, version: "1.0.0"); + fixture.WriteProject("artifacts/old/Old.csproj", isPackable: true, version: "1.9.0"); + fixture.WriteProject("bin/Binary.csproj", isPackable: true, version: "1.9.0"); + fixture.WriteFile("obj/metadata.props", ""); + + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + + Assert.Equal(RepositoryFileSource.FileSystemFallback, result.Source); + Assert.Equal( + [ + "ArtifactsDocumentation/Docs.csproj", + "my-artifacts-source/Source.csproj", + "src/Alpha/Alpha.csproj", + "src/Zeta/Zeta.csproj" + ], + result.Files); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "artifacts"); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "bin"); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "obj"); + } + + [Fact] + public void SourceArchiveWithoutGitUsesFilesystemFallback() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/App.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + + RepositoryFileEnumeration result = RepositoryFileEnumerator.EnumerateFiles(fixture.Root, IsProject); + + Assert.Equal(RepositoryFileSource.FileSystemFallback, result.Source); + Assert.Equal(["src/App.csproj"], result.Files); + } + + [Fact] + public void UnusableWorktreeMetadataFallsBackWithoutMakingGitMandatory() + { + using var fixture = new RepositoryFixture(); + fixture.WriteFile(".git", "gitdir: missing-worktree-metadata"); + fixture.WriteProject("src/App.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + + RepositoryFileEnumeration result = RepositoryFileEnumerator.EnumerateFiles(fixture.Root, IsProject); + + Assert.Equal(RepositoryFileSource.FileSystemFallback, result.Source); + Assert.Equal(["src/App.csproj"], result.Files); + } + + [Fact] + public void FilesystemFallbackPrunesArtifactsContainingACompleteNestedWorktree() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/App.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + fixture.WriteFile("artifacts/issue-83/.git", "gitdir: ../../.git/worktrees/issue-83"); + fixture.WriteFile("artifacts/issue-83/Directory.Build.props", "1.9.0"); + fixture.WriteFile("artifacts/issue-83/ModernFormsNext.slnx", ""); + fixture.WriteProject("artifacts/issue-83/Old.csproj", isPackable: true, version: "1.9.0"); + + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + + Assert.Equal(["src/App.csproj"], result.Files); + } + + [Fact] + public void FilesystemFallbackPrunesNestedGitDirectoryAndGitFile() + { + using var fixture = new RepositoryFixture(); + fixture.WriteFile("external-repository/.git/config", "[core]"); + fixture.WriteProject("external-repository/External.csproj", isPackable: true, version: "1.9.0"); + fixture.WriteFile("linked-worktree/.git", "gitdir: ../.git/worktrees/linked"); + fixture.WriteProject("linked-worktree/Linked.csproj", isPackable: true, version: "1.9.0"); + fixture.WriteProject("source/Source.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + + Assert.Equal(["source/Source.csproj"], result.Files); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "external-repository" && exclusion.Reason.Contains("nested Git", StringComparison.Ordinal)); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "linked-worktree" && exclusion.Reason.Contains("nested Git", StringComparison.Ordinal)); + } + + [Fact] + public void GitTrackedFilesArePreferredAndGeneratedSegmentsRemainExcluded() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/App.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + fixture.WriteProject("artifacts/Old.csproj", isPackable: true, version: "1.9.0"); + + RepositoryFileEnumeration result = RepositoryFileEnumerator.EnumerateFiles( + fixture.Root, + IsProject, + _ => ["src/App.csproj", "artifacts/Old.csproj"]); + + Assert.Equal(RepositoryFileSource.GitTrackedFiles, result.Source); + Assert.Equal(["src/App.csproj"], result.Files); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "artifacts/Old.csproj"); + } + + [Fact] + public void GitTrackedInputNormalizesWindowsAndUnixSeparators() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/Unix.csproj", isPackable: false, version: "1.0.0"); + fixture.WriteProject("src/Windows.csproj", isPackable: false, version: "1.0.0"); + + RepositoryFileEnumeration result = RepositoryFileEnumerator.EnumerateFiles( + fixture.Root, + IsProject, + _ => ["src/Unix.csproj", "src\\Windows.csproj"]); + + Assert.Equal(["src/Unix.csproj", "src/Windows.csproj"], result.Files); + } + + [Fact] + public void UnsafeTrackedPathCannotEscapeRepositoryRoot() + { + using var fixture = new RepositoryFixture(); + + Assert.Throws(() => RepositoryFileEnumerator.EnumerateFiles( + fixture.Root, + IsProject, + _ => ["../outside.csproj"])); + } + + [Fact] + public void FilesystemFallbackStillIncludesARealSourceProjectWithAnInvalidVersion() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/Broken.csproj", isPackable: true, version: "1.9.0"); + + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + string projectPath = Assert.Single(result.Files); + XDocument project = XDocument.Load(IOPath.Combine(fixture.Root, projectPath.Replace('/', IOPath.DirectorySeparatorChar))); + + Assert.Equal("1.9.0", project.Descendants("Version").Single().Value); + Assert.NotEqual("$(ModernFormsNextPackageVersion)", project.Descendants("Version").Single().Value); + } + + [Fact] + public void ReparsePointAttributeIsRecognizedWithoutFollowingTheTarget() + => Assert.True(RepositoryFileEnumerator.IsReparsePoint(FileAttributes.Directory | FileAttributes.ReparsePoint)); + + [Fact] + public void FilesystemFallbackDoesNotFollowDirectorySymlinksWhenSupported() + { + using var fixture = new RepositoryFixture(); + string outside = IOPath.Combine(IOPath.GetTempPath(), $"ModernFormsNext.RepositoryOutside.{Guid.NewGuid():N}"); + string link = IOPath.Combine(fixture.Root, "linked-source"); + Directory.CreateDirectory(outside); + File.WriteAllText(IOPath.Combine(outside, "Outside.csproj"), "true"); + + try + { + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + + Assert.Empty(result.Files); + Assert.Contains(result.Exclusions, exclusion => exclusion.Path == "linked-source" && exclusion.Reason == "reparse point"); + } + finally + { + if (Directory.Exists(link)) + Directory.Delete(link); + if (Directory.Exists(outside)) + Directory.Delete(outside, recursive: true); + } + } + + [Fact] + public void DiagnosticsContainOnlyRepositoryRelativePaths() + { + using var fixture = new RepositoryFixture(); + fixture.WriteProject("src/App.csproj", isPackable: true, version: "$(ModernFormsNextPackageVersion)"); + RepositoryFileEnumeration result = EnumerateFallback(fixture.Root); + + string diagnostics = result.FormatDiagnostics(["src/Expected.csproj"]); + + Assert.Contains("src/App.csproj", diagnostics, StringComparison.Ordinal); + Assert.DoesNotContain(fixture.Root, diagnostics, StringComparison.OrdinalIgnoreCase); + } + + private static RepositoryFileEnumeration EnumerateFallback(string root) + => RepositoryFileEnumerator.EnumerateFiles(root, IsProject, _ => null); + + private static bool IsProject(string path) + => path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase); + + private sealed class RepositoryFixture : IDisposable + { + public RepositoryFixture() + { + Root = IOPath.Combine(IOPath.GetTempPath(), $"ModernFormsNext.RepositoryTests.{Guid.NewGuid():N}"); + Directory.CreateDirectory(Root); + WriteFile("Directory.Build.props", ""); + WriteFile("ModernFormsNext.slnx", ""); + } + + public string Root { get; } + + public string CreateDirectory(string relativePath) + { + string path = GetPath(relativePath); + Directory.CreateDirectory(path); + return path; + } + + public void WriteProject(string relativePath, bool isPackable, string version) + => WriteFile( + relativePath, + $"{isPackable.ToString().ToLowerInvariant()}{version}"); + + public void WriteFile(string relativePath, string content) + { + string path = GetPath(relativePath); + Directory.CreateDirectory(IOPath.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + public void Dispose() + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + + private string GetPath(string relativePath) + => IOPath.Combine(Root, relativePath.Replace('/', IOPath.DirectorySeparatorChar)); + } +} diff --git a/RELEASING.md b/RELEASING.md index df01b2c..f331aa7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -88,6 +88,13 @@ dotnet build .\ModernFormsNext.slnx --configuration Release --no-restore --verbo dotnet test .\ModernFormsNext.slnx --configuration Debug --no-restore ``` +Repository-level structure and version tests use Git-tracked files when available and a bounded +filesystem fallback for source archives. Generated directories and nested worktrees are not release +inputs; see [Repository validation inputs](docs/repository-validation.md) before adding a new +repository-wide traversal. A clean external exact-SHA worktree remains recommended for provenance, +while `-m:1 /p:UseSharedCompilation=false` remains necessary to avoid genuine concurrent writes to +shared MicroCom intermediate outputs. + For 1.10.0, additionally validate: - `net10.0-windows` framework and samples; diff --git a/docs/known-limitations.md b/docs/known-limitations.md index c3b4a8b..77c2229 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -66,7 +66,7 @@ not active limitations. | SER-01 | `.mfdesign` / code generation | `.mfdesign` is the source of truth. Reverse parsing accepts the generator's conservative subset and reports unsupported arbitrary expressions rather than evaluating or merging them. | Compatibility limitation | Medium | Safe fidelity improvements [tracked #81](https://github.com/ProGraMajster/ModernFormsNext/issues/81); arbitrary code execution remains permanently out of scope | [Designer reverse sync](designer-architecture.md#current-designer-limitations) | | TPL-01 | Templates/compatibility | The packaged starter template is Windows-only and the published libraries target .NET 10; Android needs an explicit activity/surface host. | Compatibility limitation | Medium | Older .NET [#44](https://github.com/ProGraMajster/ModernFormsNext/issues/44); Android host [#72](https://github.com/ProGraMajster/ModernFormsNext/issues/72) | [Installation](installation.md#android) | | REL-01 | Documentation/release | Browser rendering, interactive VS Designer, Marketplace publication, and Android device observation remain manual gates outside deterministic DocFX/package tests. | Validation gap | Low | Automation and explicit handoffs [tracked #82](https://github.com/ProGraMajster/ModernFormsNext/issues/82) | [Versioned documentation](releasing/versioned-documentation-artifacts.md#current-validation-boundaries) | -| REL-02 | Repository validation | Nested worktrees under `artifacts/` can contaminate project-enumeration tests; exact-SHA validation needs a clean external worktree and sequential build flags. | Tooling limitation | Low | [Tracked #83](https://github.com/ProGraMajster/ModernFormsNext/issues/83) | [Versioned documentation](releasing/versioned-documentation-artifacts.md#current-validation-boundaries) | +| REL-02 | Repository validation | Released 1.10.0 validation can see nested worktrees under `artifacts/`; 1.11.0 source selects tracked projects with a safe source-archive fallback. | Resolved after 1.10.0 | Low | Fix and regression coverage [tracked #83](https://github.com/ProGraMajster/ModernFormsNext/issues/83); clean exact-SHA worktrees remain a provenance practice | [Repository validation](repository-validation.md) | | RES-01 | Dynamic resources | Reflection-based property references need a trimming/AOT metadata strategy; merged dictionaries and factories are not implemented. | Compatibility limitation | Medium | [Tracked #84](https://github.com/ProGraMajster/ModernFormsNext/issues/84) | [Dynamic resources](dynamic-resources.md#current-limits) | | BND-01 | Data binding | No ModernFormsNext-native `BindingNavigator`; WinForms designer serialization hooks are intentionally not ported. | Missing feature | Medium | Native control [tracked #85](https://github.com/ProGraMajster/ModernFormsNext/issues/85); WinForms serialization hooks remain permanently out of scope | [Data binding](data-binding.md#current-limitations) | | TXT-01 | RichTextBox | The portable RTF/editor subset omits OLE, protected ranges, URL activation, bullets/paragraph indentation rendering, custom tab stops, and native IME language-option behavior. | Compatibility limitation | Medium | Compatibility surface [tracked #86](https://github.com/ProGraMajster/ModernFormsNext/issues/86); shared IME [#62](https://github.com/ProGraMajster/ModernFormsNext/issues/62) | [RichTextBox](richtextbox.md#compatibility-notes) | diff --git a/docs/releasing/versioned-documentation-artifacts.md b/docs/releasing/versioned-documentation-artifacts.md index 0d489a1..77c4bee 100644 --- a/docs/releasing/versioned-documentation-artifacts.md +++ b/docs/releasing/versioned-documentation-artifacts.md @@ -157,11 +157,14 @@ content, and internal offline links. Browser rendering, interactive Visual Studi behavior, Marketplace publication, and Android emulator or physical-device behavior remain manual gates and must be reported separately. -Repository-wide project/version enumeration can see nested Git worktrees placed below `artifacts/`. -For an exact-SHA release check, use a clean external worktree and run builds/tests sequentially with -`-m:1 /p:UseSharedCompilation=false`; do not weaken consistency tests to accommodate a contaminated -checkout. The [central known-limitations index](../known-limitations.md) records this tooling -boundary. +Repository-wide project/version validation selects Git-tracked inputs when available and uses a +bounded, generated-directory-aware filesystem fallback for source archives. Nested worktrees below +`artifacts/` therefore cannot become duplicate release inputs. The full rules and maintainer +guidance are documented in [Repository validation inputs](../repository-validation.md). + +For an exact-SHA release check, still prefer a clean external worktree for provenance. Run builds +sequentially with `-m:1 /p:UseSharedCompilation=false` where MicroCom projects share intermediate +outputs; this prevents genuine concurrent writers and is independent of repository enumeration. ## Security and exclusions diff --git a/docs/repository-validation.md b/docs/repository-validation.md new file mode 100644 index 0000000..cd520d8 --- /dev/null +++ b/docs/repository-validation.md @@ -0,0 +1,76 @@ +# Repository validation inputs + +Repository-level validation must describe the tracked ModernFormsNext source tree, not every file +that happens to exist beneath a checkout. Local `artifacts/` content can contain complete Git +worktrees, release copies, package output, and files created by unrelated tasks; treating those +paths as source produces duplicate projects and false version failures. + +## Source selection + +`ModernFormsNext.Tests` resolves the repository root by walking upward from the test output +directory until both `Directory.Build.props` and `ModernFormsNext.slnx` are present. It never uses +the process working directory as the root. + +When the root contains `.git` as either a directory or a worktree file, repository project +enumeration asks `git ls-files` for tracked inputs. Git does not require network access for this +operation. If repository metadata or the Git executable is unavailable, as in a source archive, +validation falls back to a bounded filesystem traversal. + +The fallback excludes these exact directory names, case-insensitively: + +- `.git`, `.vs`, `.idea`, `.codex`, `.nuget`, and `.cache`; +- `artifacts`, `bin`, and `obj`; +- `TestResult`, `TestResults`, `packages`, and `node_modules`; +- `BenchmarkDotNet.Artifacts`, `Generated Files`, `GeneratedArtifacts`, `AppPackages`, and + `BundleArtifacts`; +- local validation outputs `.codex-build`, `.codex-pack`, and `.codex-pack-api`; +- DocFX output `_site`. + +Names are compared as complete path segments. A source directory such as +`ArtifactsDocumentation`, `my-artifacts-source`, `BinaryTools`, or `ObjectModel` is not excluded. +Add a directory to the central list only when it is generated or user-specific in every location +where that segment can occur. Prefer a narrower caller-specific scope when that is not true. + +## Nested repositories and path safety + +Filesystem fallback prunes a child directory when it contains `.git` as either a directory or a +file. The latter is the normal shape of a linked Git worktree. Generated directories are pruned +before nested-worktree heuristics, so an `artifacts/` tree is never recursively inspected. + +Directory and file reparse points are not followed. This prevents symlink or Windows junction +cycles and prevents repository validation from reading outside the normalized root. Git output is +also normalized and rejected if it is absolute, contains `.` or `..` segments, or resolves outside +the root. + +Results and diagnostics use repository-relative forward-slash paths, are ordered with ordinal +comparison, and never embed a local checkout path. Validation failures identify whether Git tracked +files or the filesystem fallback supplied the inputs and list included and explicitly excluded +paths. + +## Choosing traversal semantics + +Use tracked Git files for repository structure, version, package metadata, and release-input tests. +Use the safe fallback only when the same test is required to work from a source archive without +Git. Do not use either mechanism for a caller that intentionally validates an explicit artifact +directory, package archive, generated documentation staging tree, or user-selected filesystem +location. + +The release documentation scripts already select tracked documentation and samples with +`git ls-files`; their later recursive operations are limited to explicit staging or archive +directories. Package and Android scripts likewise enumerate explicit output directories. These +callers intentionally do not use repository source traversal. + +## Writing repository-level tests + +1. Resolve the root through `RepositoryFileEnumerator.FindRepositoryRoot`. +2. Enumerate the narrowest file type needed and keep allowlists explicit. +3. Test both Git-tracked input and the no-Git fallback. +4. Add fixtures for generated paths, nested `.git` directories and files, similar non-generated + names, separator normalization, and unsafe paths. +5. Confirm a real source-tree inconsistency is still included and fails validation. +6. Keep output deterministic and repository-relative; never print user-specific absolute paths. + +Clean external exact-SHA worktrees remain recommended for final release provenance. They are a +release-safety measure, not a workaround required for correctness of repository enumeration. +Sequential `-m:1 /p:UseSharedCompilation=false` builds remain required where MicroCom projects share +intermediate output paths; repository filtering does not hide or solve genuine concurrent writers.