Skip to content

Commit 17eea57

Browse files
Mpdreamzclaude
andauthored
Wrap all IFileSystem usage in ScopedFileSystem (#3001)
* Wrap all IFileSystem usage in ScopedFileSystem Introduces `Nullean.ScopedFileSystem` (v0.2.0) as a security boundary around every file system operation in the codebase. `ScopedFileSystem` is a `System.IO.Abstractions.IFileSystem` decorator that enforces at runtime that no file read or write can escape a set of configured root directories. Any path outside the roots — including paths reached via symlink, `..` traversal, or hidden directories — throws a `ScopedFileSystemException` (extends `SecurityException`) before the underlying OS call is ever made. `File.Exists` / `Directory.Exists` return `false` for out-of-scope paths instead of throwing, so probing code stays safe. The library supports multiple disjoint roots, an allowlist for specific hidden names (e.g. `.git`), and opt-in OS special-folder access (e.g. `Temp`). Changes: - New `FileSystemFactory` in `Elastic.Documentation.Configuration` with: - `FileSystemFactory.Real` — pre-allocated singleton scoped to the working-directory root and `Paths.ApplicationData` (`LocalApplicationData/elastic/docs-builder`), with `.git` folder/file allowlisted and `Temp` special-folder enabled - `FileSystemFactory.InMemory()` — wraps a fresh `MockFileSystem` in the same scope options; each call returns a new independent instance - `FileSystemFactory.CreateScoped(IFileSystem inner, ...)` — for wrapping an existing FS with optional extra roots from extensions - `FileSystemFactory.CreateForUserData()` — user-profile scope with `.docs-builder` allowlisted, used by `GitLinkIndexReader` - All 33+ `new FileSystem()` call sites replaced with factory methods - `CrossLinkFetcher` and `CheckForUpdatesFilter` converted from direct `System.IO.File` / `Directory` static calls to `IFileSystem` injection - `IDocsBuilderExtension` gains `ExternalScopeRoots` (default `[]`); `DetectionRulesDocsBuilderExtension` implements it to expose the detection-rules folders so builds can widen the scope when needed - `IFileSystem` registered as DI singleton (`FileSystemFactory.Real`) in `DocumentationTooling` - Service constructors that previously required the concrete `FileSystem` type widened to `IFileSystem` Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Tighten ScopedFileSystem allowlists and consolidate app data paths Fix a critical bug where real disk builds would throw ScopedFileSystemException when writing to the default output directory (.artifacts/docs/html), because .artifacts was missing from AllowedHiddenFolderNames. Changes: - FileSystemFactory.Real: remove speculative hidden-file allowances (.gitignore, .gitmodules, .gitattributes, .editorconfig, .nojekyll — none accessed via IFileSystem), remove AllowedSpecialFolders.Temp, add .artifacts (folder) and .doc.state (file) which are confirmed accessed - FileSystemFactory.AppData: new pre-allocated FS scoped to only the elastic /docs-builder app data directory; used by components that never touch workspace files (CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader) - ConfigurationFileProvider: move temp staging directory from OS temp to {ApplicationData}/config-runtime/, eliminating the only Temp usage - GitLinkIndexReader: normalize CloneDirectory from ~/.docs-builder/codex-link-index to {LocalAppData}/elastic/docs-builder/codex-link-index so all app data lives under one root; switch FS fallback from custom user-profile scope to FileSystemFactory.AppData - CrossLinkFetcher: default FS changed to FileSystemFactory.AppData - CheckForUpdatesFilter: use FileSystemFactory.AppData directly; remove IFileSystem DI injection (AppData is the correct and only scope needed) Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Split FileSystemFactory.Real into RealRead and RealWrite Builds that write HTML output never need .git access. Introducing RealWrite enforces this at the ScopedFileSystem level: any accidental write into .git would throw ScopedFileSystemException rather than silently succeeding. RealRead retains .git access (needed by GitCheckoutInformation for branch/remote metadata and worktree resolution), plus .artifacts and .doc.state for incremental build state reads. RealWrite has the same scope roots but omits .git from both AllowedHiddenFolderNames and AllowedHiddenFileNames. IsolatedBuildCommand now passes FileSystemFactory.RealWrite explicitly as the writeFileSystem argument for non-in-memory builds. In-memory builds pass null so IsolatedBuildService falls back to the same MockFileSystem used for reads. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Tighten GitLinkIndexReader scope and rename CreateScoped Pass FileSystemFactory.AppData to GitLinkIndexReader at the three call sites that previously passed context.ReadFileSystem or the build fileSystem parameter. GitLinkIndexReader only ever accesses {AppData}/codex-link-index so the workspace scope was unnecessarily broad. The IFileSystem? parameter is retained for test injection. Rename CreateScoped -> WrapToRead (read options, .git allowed) and add WrapToWrite (write options, .git not allowed) to match the RealRead/RealWrite naming convention. The extension-roots overload WrapToRead(inner, extensionRoots) is the hook for DetectionRules external scope wiring. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Enforce ScopedFileSystem at compile time across the codebase Addresses the CodeRabbit concern: services accepted IFileSystem? with ?? defaults, allowing any unscoped instance to bypass the security boundary silently. The fix is a compile-time contract — no runtime is/as checks. Changes: - FileSystemFactory: return types changed from IFileSystem to ScopedFileSystem for all factory members (RealRead, RealWrite, AppData, InMemory, WrapToRead, WrapToWrite). Logic unchanged; this is annotation only. - IDocumentationContext: ReadFileSystem/WriteFileSystem typed ScopedFileSystem - BuildContext: properties and constructor params typed ScopedFileSystem - 12 optional IFileSystem? service parameters → ScopedFileSystem? (GitLinkIndexReader, CrossLinkFetcher, CsvReader, all changelog services, IsolatedBuildService.writeFileSystem) - DocumentationSetFile.LoadAndResolve: ScopedFileSystem? with cast guard on the IFileInfo/IDirectoryInfo.FileSystem fallback - AssembleContext, CodexContext, authoring services: updated to ScopedFileSystem - All IDocumentationContext mock implementations in tests updated - Tests: new MockFileSystem() → FileSystemFactory.WrapToRead(new MockFileSystem()) new FileSystem() → FileSystemFactory.RealRead The compiler now rejects any call site that tries to pass an unscoped new FileSystem() or bare MockFileSystem to a service boundary. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Scope file systems to the path argument rather than CWD RealRead/RealWrite are pre-allocated singletons scoped to Paths.WorkingDirectoryRoot (the CWD git root). Commands that accept an explicit --path or --output argument may target a repo outside that root, which would cause ScopedFileSystemException. Add FileSystemFactory.ForPath(string? path) and ForPathWrite(string? path, string? output) that derive the scope root dynamically by walking up from the given path to find its .git boundary. Both fall back to RealRead/RealWrite when no path is provided (CWD-relative operation). ForPathWrite also adds the output directory as an extra scope root when it falls outside the git root. Update all commands that accept --path/--output to use these: IndexCommand, IsolatedBuildCommand, ServeCommand, FormatCommand, MoveCommand, DiffCommands, InMemoryBuildState, StaticWebHost. Commands that always operate relative to CWD (assembler, codex, changelog) continue using RealRead since their scope is determined by assembler config or git context, not an arbitrary user-provided path. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Consolidate git root discovery and remove Paths.GitCommonRoot static Three changes: 1. FileSystemFactory.FindGitRoot removed — ForPath/ForPathWrite now call Paths.FindGitRoot, which is the single canonical git-root walker. 2. Paths.DetermineSourceDirectoryRoot simplified — the loop body had a dead branch (checked GetDirectories.Length == 0 in the while condition then re-checked inside). Rewritten to a clean while-loop matching FindGitRoot's structure; semantics unchanged. 3. Paths.GitCommonRoot static field and InitGitCommonRoot removed — the static ran complex logic (ScopedFileSystem creation + file read) at class init time and was only used by AssembleContext and CodexContext. Both now call Paths.ResolveGitCommonRoot(readFileSystem, workingRoot) directly, which is already public and takes explicit dependencies. Paths.cs no longer references Nullean.ScopedFileSystem. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix scope gaps, depth-protect git root discovery, consolidate API Move checkout directories to ApplicationData: - AssembleContext: .artifacts/checkouts/{env} → ApplicationData/checkouts/{env} - CodexContext: .artifacts/codex/clone → ApplicationData/codex/clone Both now resolve within the existing ApplicationData scope root; no worktree detection or scope expansion needed. Paths.ResolveGitCommonRoot (IFileSystem) had no remaining callers and is removed along with its tests. Depth protection on git root discovery: - DetermineWorkingDirectoryRoot: only adopts a .git anchor ≤ 1 directory above CWD in release builds. Debug builds allow deeper traversal when a *.slnx is adjacent (developer running from IDE output directory). - FindGitRoot(string) gets the same depth limit — documentation is not expected to live deep inside a repo tree. Consolidate FindGitRoot / DetermineSourceDirectoryRoot: - DetermineSourceDirectoryRoot removed; FindGitRoot(IDirectoryInfo) overload added with the same semantics (IFileSystem-abstracted, same depth protection, returns IDirectoryInfo?). - Callers updated: BuildContext, ChangelogCommand, LocalChangesService. Rename ForPath/ForPathWrite → RealForPath/RealForPathWrite: - Signals these always create a real FileSystem. - Doc comment notes suitability for command layer; service layer is tested via InMemory() at unit test level. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix CI failures: restore Temp in WriteOptions, fix import ordering WriteOptions had AllowedSpecialFolders.Temp removed on the assumption that only ConfigurationFileProvider used it. AwsS3SyncApplyStrategy also uses temp to stage files before S3 upload — restore Temp to WriteOptions to fix the DocsSyncTests.TestApply integration failure. Fix IMPORTS lint errors across all files that gained new Nullean.ScopedFileSystem using directives during the ScopedFileSystem migration; dotnet format reorders them into the expected alphabetical/grouped order. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix remaining CI failures Three issues: 1. Changelog tests used FileSystem.Path.GetTempPath() to generate unique paths within MockFileSystem. Since MockFS is in-memory, the paths can be anything in scope — replace with Paths.WorkingDirectoryRoot.FullName as the base so they stay within the ScopedFileSystem scope bounds. No real filesystem is touched; all operations remain in-memory. 2. DocsSyncTests.TestApply passed the same WrapToRead FS for both read and write in AssembleContext. AwsS3SyncApplyStrategy uses context.WriteFileSystem and needs AllowedSpecialFolders.Temp (present in WriteOptions). Fix by using WrapToWrite for the write FS. 3. IsolatedBuildService CI fallback defaulted the output path to Paths.WorkingDirectoryRoot/.artifacts/docs/html. When --path points to a different repo the write FS is scoped to that repo's root, not WorkingDirectoryRoot. Derive the default output from `path` instead so it stays within the write FS scope (same logic as BuildContext uses for the normal build path). FileSystemFactory.RealForPathWrite: replace StartsWith string check with IDirectoryInfo.IsSubPathOf from Nullean.ScopedFileSystem which does a proper directory-tree walk, preventing sibling-prefix false positives. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix last import ordering lint error in CrossLinkRegistryTests Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Address CodeRabbit review comments CsvReader: remove GetType().Name == "MockFileSystem" type check — the check never matched after ScopedFileSystem wrapping was introduced, causing Sep to read from the real filesystem via FromFile() instead of going through the scoped IFileSystem. Always use IFileSystem.File.ReadAllText + Sep.FromText so all filesystem access (real, scoped, or mock) goes through the abstraction. ChangelogCommand: four ChangelogConfigurationLoader instantiations were still passing new System.IO.Abstractions.FileSystem() directly, bypassing the scoped _fileSystem field. Replace with _fileSystem. ChangelogRenderingService: service writes output files (CreateDirectory, WriteAllTextAsync) so its default should be FileSystemFactory.RealWrite not RealRead. RealWrite has the same scope but correctly excludes .git write access. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix changelog test paths: replace out-of-scope absolute paths with WorkingDirectoryRoot-relative Tests that extend ChangelogTestBase use a ScopedFileSystem wrapping MockFileSystem, bounded to [WorkingDirectoryRoot, ApplicationData]. Several tests used absolute paths like /tmp/config, /docs/changelog, /test-root, and relative paths like docs/changelog that resolved outside the scope root on CI runners. - ChangelogPrEvaluationServiceTests: /tmp/config/changelog.yml → Path.Join(WorkingDirectoryRoot, "config/changelog.yml"); "docs/changelog" → Path.Join(WorkingDirectoryRoot, "docs/changelog") - ChangelogCreationServiceTests: /tmp/config → Path.Join(WorkingDirectoryRoot, "config"); /tmp/output → Path.Join(WorkingDirectoryRoot, "output") - BundleChangelogsTests: /test-root → WorkingDirectoryRoot; use MockFileSystemOptions { CurrentDirectory = root } so relative paths within service code resolve correctly; switch YAML template config strings to $$""" raw literals so {version}/{lifecycle} stay as literal text while {{Path.Join(...)}} is interpolated - ChangelogTestBase: set MockFileSystem CurrentDirectory to WorkingDirectoryRoot so paths within the test base resolve within scope BundleLoaderTests (standalone, unscoped MockFileSystem) does not extend ChangelogTestBase and is not affected. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * remove RFC data * Rename WrapToRead/WrapToWrite to explicit Scope* methods; fix navigation test scope WrapToRead/WrapToWrite implicitly injected Paths.WorkingDirectoryRoot into every scope, which was a wrong assumption when the inner FS contained files outside the working directory (e.g. navigation tests with MockFS at /checkouts/...). New explicit API in FileSystemFactory: - ScopeCurrentWorkingDirectory(inner) — scopes to WorkingDirectoryRoot + ApplicationData - ScopeCurrentWorkingDirectory(inner, extensionRoots) — adds detection-rules roots - ScopeCurrentWorkingDirectoryForWrite(inner) — write variant (no .git) - ScopeSourceDirectory(inner, sourceRoot) — scopes to an explicit root + ApplicationData - ScopeSourceDirectoryForWrite(inner, sourceRoot) — write variant All production options fields renamed: ReadOptions → WorkingDirectoryReadOptions, WriteOptions → WorkingDirectoryWriteOptions (makes scope intent visible in code). DocumentationSetFile.LoadAndResolve: replace the InvalidOperationException cast guard with FileSystemFactory.ScopeSourceDirectory(fs, directory.FullName) so callers with unscoped IFileSystem/IDirectoryInfo (e.g. from a raw MockFileSystem) are automatically given the tightest correct scope. Navigation.Tests/Assembler: change ScopeCurrentWorkingDirectory(fileSystem) → ScopeSourceDirectory(fileSystem, "/checkouts") for all tests that use SiteNavigationTestFixture (MockFS rooted at /checkouts/current/...). TestDocumentationSetContext: use ScopeSourceDirectory(fileSystem, sourceDirectory.FullName) and ScopeSourceDirectoryForWrite(fileSystem, outputDirectory.FullName) so each test gets a scope precisely matching its mock FS tree. Fix 3 semantic bugs where WriteFileSystem was assigned ScopeCurrentWorkingDirectory instead of ScopeCurrentWorkingDirectoryForWrite: - CodexNavigationTestBase, GroupNavigationTests, CrossLinkRegistryTests Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Rename RealForPath* to RealGitRootForPath* for clarity RealForPath → RealGitRootForPath RealForPathWrite → RealGitRootForPathWrite The Git prefix makes explicit that these factory methods resolve the scope root by walking up to the nearest .git boundary from the given path. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix ConfigurationFileProvider file lock and GitCheckoutInformation type check ConfigurationFileProvider: use a unique subdirectory per instance under ApplicationData/config-runtime/{guid} to avoid Windows file-locking collisions when multiple tests run in parallel writing the same versions.yml/products.yml files. Original CreateTempSubdirectory gave unique dirs; the fixed path broke parallel test isolation on Windows. GitCheckoutInformation.Create: the guard `fileSystem is not FileSystem` returned hardcoded test data for any ScopedFileSystem, including RealRead wrapping a real FileSystem. RealRead is used in GitCheckoutInformationTests which expects real git metadata. Change to check the type name for "Mock" so only MockFileSystem-backed instances return test data. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix MockFileSystem temp path mismatch in write-scope methods MockFileSystem on non-Windows returns a hardcoded Unix-ified path ('/temp/') for GetTempPath() — it unixifies 'C:\temp' rather than calling System.IO.Path.GetTempPath(). This diverges from the real API which reads TMPDIR. AllowedSpecialFolder.Temp in the ValidationContext uses the real System.IO.Path.GetTempPath() (resolves to '/tmp/' on standard Linux), creating a mismatch: the scope allows '/tmp/' but the mock FS creates staging paths at '/temp/zzyysk35.wbk'. Fix: ScopeCurrentWorkingDirectoryForWrite and ScopeSourceDirectoryForWrite now call inner.Path.GetTempPath() on non-Windows and add the result as an explicit scope root alongside AllowedSpecialFolders.Temp. This covers both the real OS temp ('/tmp/' via AllowedSpecialFolders) and the mock's hardcoded path ('/temp/' via the explicit root), without relaxing permissions on Windows where MockFileSystem temp is consistent with the real API. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Bump Nullean.ScopedFileSystem to 0.4.0; use InnerType for mock detection 0.4.0 exposes ScopedFileSystem.InnerType, removing the need to check the outer wrapper's type name when inspecting the underlying filesystem. FileSystemFactory.BuildWriteOptions: use sf.InnerType where the inner FS is available through a ScopedFileSystem to check for MockFileSystem, with a note linking to the upstream fix PR (TestableIO/System.IO.Abstractions#1454). GitCheckoutInformation.Create: replace fileSystem.GetType().Name.Contains("Mock") with (fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType()) so the check correctly sees through the ScopedFileSystem wrapper. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix double-wrapping bug in PhysicalDocsetCanBeNavigated PhysicalDocsetCanBeNavigated was the only test in PhysicalDocsetTests.cs that passed FileSystemFactory.RealRead (already a ScopedFileSystem) to TestDocumentationSetContext, which then tried to scope it again via ScopeSourceDirectory — causing 'Cannot wrap a ScopedFileSystem inside another ScopedFileSystem' with Nullean.ScopedFileSystem 0.4.0's new validation. All other tests in the same file correctly pass new FileSystem() (raw). This was an over-eager substitution during the new FileSystem() → RealRead migration. Fix: use new FileSystem() and derive the ScopedFileSystem via context.ReadFileSystem for the DocumentationSetFile.LoadAndResolve call. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix CodeRabbit: use WriteFileSystem for OutputDirectory; fix File.Exists bypass AssembleContext and CodexContext both materialised OutputDirectory from ReadFileSystem. Since OutputDirectory is the write target (HTML files, state files, redirects) it must be resolved via WriteFileSystem so that scope enforcement applies to writes. CheckoutDirectory remains on ReadFileSystem as it is a read-only source. AssemblerBuildService had a direct System.IO.File.Exists(redirectsPath) call that bypassed the scoped filesystem boundary; replaced with fs.File.Exists using the ScopedFileSystem parameter already in scope. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * Fix SiteNavigationTests.DisposeAsync collection-modified race RecordedLogs was being iterated in DisposeAsync while Aspire was still writing to it concurrently, causing 'Collection was modified; enumeration operation may not execute.' Snapshot with .ToList() before iterating. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <[email protected]>
1 parent d5a73d9 commit 17eea57

130 files changed

Lines changed: 1110 additions & 823 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
4343
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
4444
<PackageVersion Include="Microsoft.Extensions.Telemetry.Abstractions" Version="10.0.0" />
45+
<PackageVersion Include="Nullean.ScopedFileSystem" Version="0.4.0" />
4546
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
4647
<PackageVersion Include="Generator.Equals" Version="3.3.0" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
4748
<PackageVersion Include="KubernetesClient" Version="18.0.5" />

src/Elastic.Codex/Building/CodexBuildService.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
using Elastic.Markdown.Exporters;
2626
using Elastic.Markdown.IO;
2727
using Microsoft.Extensions.Logging;
28+
using Nullean.ScopedFileSystem;
2829

2930
namespace Elastic.Codex.Building;
3031

@@ -46,7 +47,7 @@ public class CodexBuildService(
4647
public async Task<CodexBuildResult> BuildAll(
4748
CodexContext context,
4849
CodexCloneResult cloneResult,
49-
IFileSystem fileSystem,
50+
ScopedFileSystem fileSystem,
5051
Cancel ctx,
5152
IReadOnlySet<Exporter>? exporters = null)
5253
{
@@ -66,7 +67,7 @@ public async Task<CodexBuildResult> BuildAll(
6667
var buildContexts = new List<CodexDocumentationSetBuildContext>();
6768

6869
var environment = context.Configuration.Environment ?? "internal";
69-
using var codexLinkIndexReader = new GitLinkIndexReader(environment, context.ReadFileSystem, skipFetch: true);
70+
using var codexLinkIndexReader = new GitLinkIndexReader(environment, FileSystemFactory.AppData, skipFetch: true);
7071

7172
// Phase 1: Load and parse all documentation sets
7273
foreach (var checkout in cloneResult.Checkouts)
@@ -136,7 +137,7 @@ public async Task<CodexBuildResult> BuildAll(
136137
private async Task<CodexDocumentationSetBuildContext?> LoadDocumentationSet(
137138
CodexContext context,
138139
CodexCheckout checkout,
139-
IFileSystem fileSystem,
140+
ScopedFileSystem fileSystem,
140141
ILinkIndexReader codexLinkIndexReader,
141142
Cancel ctx)
142143
{
@@ -401,10 +402,10 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo
401402
public IDiagnosticsCollector Collector => codexContext.Collector;
402403

403404
/// <inheritdoc />
404-
public IFileSystem ReadFileSystem => codexContext.ReadFileSystem;
405+
public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem;
405406

406407
/// <inheritdoc />
407-
public IFileSystem WriteFileSystem => codexContext.WriteFileSystem;
408+
public ScopedFileSystem WriteFileSystem => codexContext.WriteFileSystem;
408409

409410
/// <inheritdoc />
410411
public IDirectoryInfo OutputDirectory => codexContext.OutputDirectory;

src/Elastic.Codex/CodexContext.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Elastic.Documentation.Configuration;
77
using Elastic.Documentation.Configuration.Codex;
88
using Elastic.Documentation.Diagnostics;
9+
using Nullean.ScopedFileSystem;
910

1011
namespace Elastic.Codex;
1112

@@ -14,8 +15,8 @@ namespace Elastic.Codex;
1415
/// </summary>
1516
public class CodexContext
1617
{
17-
public IFileSystem ReadFileSystem { get; }
18-
public IFileSystem WriteFileSystem { get; }
18+
public ScopedFileSystem ReadFileSystem { get; }
19+
public ScopedFileSystem WriteFileSystem { get; }
1920
public IDiagnosticsCollector Collector { get; }
2021
public CodexConfiguration Configuration { get; }
2122
public IFileInfo ConfigurationPath { get; }
@@ -34,8 +35,8 @@ public CodexContext(
3435
CodexConfiguration configuration,
3536
IFileInfo configurationPath,
3637
IDiagnosticsCollector collector,
37-
IFileSystem readFileSystem,
38-
IFileSystem writeFileSystem,
38+
ScopedFileSystem readFileSystem,
39+
ScopedFileSystem writeFileSystem,
3940
string? checkoutDirectory,
4041
string? outputDirectory)
4142
{
@@ -45,10 +46,10 @@ public CodexContext(
4546
ReadFileSystem = readFileSystem;
4647
WriteFileSystem = writeFileSystem;
4748

48-
var defaultCheckoutDirectory = Path.Join(Paths.GitCommonRoot.FullName, ".artifacts", "codex", "clone");
49+
var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "codex", "clone");
4950
CheckoutDirectory = ReadFileSystem.DirectoryInfo.New(checkoutDirectory ?? defaultCheckoutDirectory);
5051

5152
var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs");
52-
OutputDirectory = ReadFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory);
53+
OutputDirectory = WriteFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory);
5354
}
5455
}

src/Elastic.Codex/Indexing/CodexIndexService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Elastic.Documentation.Isolated;
1111
using Elastic.Documentation.Services;
1212
using Microsoft.Extensions.Logging;
13+
using Nullean.ScopedFileSystem;
1314

1415
namespace Elastic.Codex.Indexing;
1516

@@ -30,7 +31,7 @@ IsolatedBuildService isolatedBuildService
3031
public async Task<bool> Index(
3132
CodexContext codexContext,
3233
CodexCloneResult cloneResult,
33-
FileSystem fileSystem,
34+
ScopedFileSystem fileSystem,
3435
ElasticsearchIndexOptions esOptions,
3536
Cancel ctx = default)
3637
{

src/Elastic.Documentation.Configuration/BuildContext.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Elastic.Documentation.Configuration.Toc;
1414
using Elastic.Documentation.Configuration.Versions;
1515
using Elastic.Documentation.Diagnostics;
16+
using Nullean.ScopedFileSystem;
1617

1718
namespace Elastic.Documentation.Configuration;
1819

@@ -21,8 +22,8 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati
2122
public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes<AssemblyInformationalVersionAttribute>()
2223
.FirstOrDefault()?.InformationalVersion ?? "0.0.0";
2324

24-
public IFileSystem ReadFileSystem { get; }
25-
public IFileSystem WriteFileSystem { get; }
25+
public ScopedFileSystem ReadFileSystem { get; }
26+
public ScopedFileSystem WriteFileSystem { get; }
2627
public IReadOnlySet<Exporter> AvailableExporters { get; }
2728

2829
public IDirectoryInfo? DocumentationCheckoutDirectory { get; }
@@ -72,7 +73,7 @@ public string? UrlPathPrefix
7273

7374
public BuildContext(
7475
IDiagnosticsCollector collector,
75-
IFileSystem fileSystem,
76+
ScopedFileSystem fileSystem,
7677
IConfigurationContext configurationContext
7778
)
7879
: this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null)
@@ -81,8 +82,8 @@ IConfigurationContext configurationContext
8182

8283
public BuildContext(
8384
IDiagnosticsCollector collector,
84-
IFileSystem readFileSystem,
85-
IFileSystem writeFileSystem,
85+
ScopedFileSystem readFileSystem,
86+
ScopedFileSystem writeFileSystem,
8687
IConfigurationContext configurationContext,
8788
IReadOnlySet<Exporter> availableExporters,
8889
string? source = null,
@@ -107,7 +108,7 @@ public BuildContext(
107108

108109
(DocumentationSourceDirectory, ConfigurationPath) = Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder);
109110

110-
DocumentationCheckoutDirectory = Paths.DetermineSourceDirectoryRoot(DocumentationSourceDirectory);
111+
DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory);
111112

112113
OutputDirectory = !string.IsNullOrWhiteSpace(output)
113114
? WriteFileSystem.DirectoryInfo.New(output)

src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ public ConfigurationFileProvider(
4545
_fileSystem = fileSystem;
4646
_assemblyName = typeof(ConfigurationFileProvider).Assembly.GetName().Name!;
4747
SkipPrivateRepositories = skipPrivateRepositories;
48-
TemporaryDirectory = fileSystem.Directory.CreateTempSubdirectory("docs-builder-config");
48+
// Use a unique subdirectory per instance to avoid file-locking collisions when
49+
// multiple processes or parallel tests share the same ApplicationData path.
50+
var configRuntimeDir = Path.Join(Paths.ApplicationData.FullName, "config-runtime", Guid.NewGuid().ToString("N"));
51+
TemporaryDirectory = fileSystem.DirectoryInfo.New(configRuntimeDir);
52+
TemporaryDirectory.Create();
4953

5054
// TODO: This doesn't work as expected if a github actions consumer repo has a `config` directory.
5155
// ConfigurationSource = configurationSource ?? (
@@ -267,7 +271,7 @@ public static IServiceCollection AddConfigurationFileProvider(this IServiceColle
267271
{
268272
using var sp = services.BuildServiceProvider();
269273
var logFactory = sp.GetRequiredService<ILoggerFactory>();
270-
var provider = new ConfigurationFileProvider(logFactory, new FileSystem(), skipPrivateRepositories, configurationSource);
274+
var provider = new ConfigurationFileProvider(logFactory, FileSystemFactory.RealRead, skipPrivateRepositories, configurationSource);
271275
_ = services.AddSingleton(provider);
272276
configure(services, provider);
273277
return services;

src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<ProjectReference Include="..\Elastic.Documentation\Elastic.Documentation.csproj"/>
12+
<ProjectReference Include="..\Elastic.Documentation\Elastic.Documentation.csproj" />
1313
</ItemGroup>
1414

1515
<ItemGroup>
1616
<PackageReference Include="DotNet.Glob" />
1717
<PackageReference Include="NetEscapades.EnumGenerators" />
18+
<PackageReference Include="Nullean.ScopedFileSystem" />
19+
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
1820
<PackageReference Include="Samboy063.Tomlet" />
19-
<PackageReference Include="Vecc.YamlDotNet.Analyzers.StaticGenerator"/>
20-
<PackageReference Include="YamlDotNet"/>
21+
<PackageReference Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" />
22+
<PackageReference Include="YamlDotNet" />
2123
</ItemGroup>
2224

2325
<ItemGroup>

0 commit comments

Comments
 (0)