From df098d25afaba91a4ff6a570781b1c1278ce0d9d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 24 Jul 2026 12:03:39 +0200 Subject: [PATCH] Fix overlapping scope roots for nested Codex config paths docs-builder 1.29.0 added extension roots from FindGitRoot for Codex commands. When config.yml lives under environments/internal/, the resolved root is nested inside WorkingDirectoryRoot and ScopedFileSystem rejects the overlapping roots. Normalize extension roots before constructing the scoped filesystem so redundant nested paths are dropped. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../FileSystemFactory.cs | 53 +++++++++++++++++-- .../FileSystemFactoryTests.cs | 44 +++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs index 09cb7411ee..de4e5ffa35 100644 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs @@ -109,11 +109,14 @@ public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, I if (extensionRoots is null) return ScopeCurrentWorkingDirectory(inner); - var roots = new[] { Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName } - .Concat(extensionRoots) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (roots.Length == 2) + var roots = NormalizeDisjointRoots([ + Paths.WorkingDirectoryRoot.FullName, + Paths.ApplicationData.FullName, + .. extensionRoots, + ]); + if (roots.Length == 2 + && roots.Contains(NormalizeRootPath(Paths.WorkingDirectoryRoot.FullName), StringComparer.OrdinalIgnoreCase) + && roots.Contains(NormalizeRootPath(Paths.ApplicationData.FullName), StringComparer.OrdinalIgnoreCase)) return ScopeCurrentWorkingDirectory(inner); return new ScopedFileSystem(inner, new ScopedFileSystemOptions(roots) @@ -123,6 +126,46 @@ public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, I }); } + private static string NormalizeRootPath(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static bool IsSameOrSubPathOf(string path, string possibleAncestor) + { + path = NormalizeRootPath(path); + possibleAncestor = NormalizeRootPath(possibleAncestor); + if (path.Equals(possibleAncestor, StringComparison.OrdinalIgnoreCase)) + return true; + + return path.StartsWith(possibleAncestor + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(possibleAncestor + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static string[] NormalizeDisjointRoots(IEnumerable roots) + { + var normalized = roots + .Select(NormalizeRootPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static root => root.Length) + .ToList(); + + var kept = new List(); + foreach (var root in normalized) + { + if (kept.Exists(existing => IsSameOrSubPathOf(root, existing))) + continue; + + for (var i = kept.Count - 1; i >= 0; i--) + { + if (IsSameOrSubPathOf(kept[i], root)) + kept.RemoveAt(i); + } + + kept.Add(root); + } + + return [.. kept]; + } + // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. // diff --git a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs new file mode 100644 index 0000000000..77f72e6148 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs @@ -0,0 +1,44 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class FileSystemFactoryTests +{ + [Fact] + public void ScopeCurrentWorkingDirectory_NestedExtensionRoot_DoesNotThrow() + { + var workingRoot = Paths.WorkingDirectoryRoot.FullName; + var nestedConfigDir = Path.Join(workingRoot, "environments", "internal"); + var configPath = Path.Join(nestedConfigDir, "config.yml"); + var mockFs = new MockFileSystem(new Dictionary + { + { configPath, new MockFileData("environment: internal") } + }); + + var act = () => FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + + act.Should().NotThrow(); + var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + scoped.File.Exists(configPath).Should().BeTrue(); + } + + [Fact] + public void ScopeCurrentWorkingDirectory_ExternalExtensionRoot_AllowsReadingExternalConfig() + { + var externalRoot = Path.Join(Path.GetTempPath(), $"external-codex-{Guid.NewGuid():N}"); + var configPath = Path.Join(externalRoot, "codex.yml"); + var mockFs = new MockFileSystem(new Dictionary + { + { configPath, new MockFileData("environment: internal") } + }); + + var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [externalRoot]); + + scoped.File.Exists(configPath).Should().BeTrue(); + } +}