From c65ccc3206f3ba5f2e3d016144bf924973a94d2c Mon Sep 17 00:00:00 2001 From: Peter Bruinsma Date: Mon, 20 Apr 2026 22:16:09 +0000 Subject: [PATCH] chore(engine): close path-traversal class on run-artifact endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultrareview finding 1 (work/gaps.md 2026-04-20) called for a GetRunDirectorySafe helper applied to the whole class of Path.Combine(artifactsRoot, runId) call sites, not a single endpoint. Initial patch covered 5 API sites; this extends the fix to all 9 sites across API + TimeMachine. New helper: - src/FlowTime.Contracts/Storage/RunPathResolver.cs — static GetSafeRunDirectory(artifactsDirectory, runId). Validates non-null/non-whitespace, rejects path separators and "."/"..", canonicalizes via Path.GetFullPath, enforces candidate strictly under root via trailing-separator-aware StartsWith check. Throws ArgumentException on any violation. Located in Contracts so both API and TimeMachine consume it. HTTP-layer helper: - Program.TryResolveRunDirectory(artifactsDirectory, runId, out runPath) → IResult? — returns 404 IResult on invalid or missing runId, null on success. Collapses the endpoint try/catch/exists pattern from ~10 lines per site to ~4. Sites patched: - API/Program.cs — /runs/{runId}/index, /series, POST /export, GET /export/{format} - API/Services/StateQueryService.cs — state, state_window - API/Services/GraphService.cs — /runs/{runId}/graph - API/Services/MetricsService.cs — /runs/{runId}/metrics - API/Endpoints/RunOrchestrationEndpoints.cs — /runs/{runId} - TimeMachine/Orchestration/RunOrchestrationService.cs — run- reuse check + explicit-runId Directory.Move write path (this one prevents a write-side traversal: without validation a user-supplied RunId could relocate the just-written run outside outputRoot) - TimeMachine/TelemetryBundleBuilder.cs — explicit run directory delete/write path Tests: - RunPathResolverTests.cs — 26 unit tests: valid, invalid input, traversal attempts (.., ../other, ../../etc/passwd, absolute paths), separator rejection, sibling-prefix false-positive guard (root vs root-sibling), trailing-separator branch, non-existence allowed. - TryResolveRunDirectoryTests.cs — 8 tests on the HTTP helper: existing dir → null, nonexistent → IResult, invalid runId → IResult + empty path, invalid artifactsDirectory → IResult. An earlier iteration added RunPathTraversalEndpointTests that sent .. as a URL segment; ASP.NET's routing normalises those before parameter binding so the tests would have passed even without the fix. Deleted. The unit tests on the helper are the real security assurance; endpoint wiring is a single helper call at each site. Also: - .gitignore: add .claude/worktrees/ - RunPathResolver: static readonly char[] for path separators (avoid per-call allocation) Full solution: 1736 passed / 9 skipped / 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .../Endpoints/RunOrchestrationEndpoints.cs | 11 +- src/FlowTime.API/Program.cs | 58 +++++-- src/FlowTime.API/Services/GraphService.cs | 12 +- src/FlowTime.API/Services/MetricsService.cs | 12 +- .../Services/StateQueryService.cs | 11 +- .../Storage/RunPathResolver.cs | 67 ++++++++ .../Orchestration/RunOrchestrationService.cs | 22 ++- .../TelemetryBundleBuilder.cs | 11 +- .../TelemetryGenerationService.cs | 12 +- .../RunPathResolverTests.cs | 162 ++++++++++++++++++ .../TryResolveRunDirectoryTests.cs | 79 +++++++++ 12 files changed, 432 insertions(+), 26 deletions(-) create mode 100644 src/FlowTime.Contracts/Storage/RunPathResolver.cs create mode 100644 tests/FlowTime.Api.Tests/RunPathResolverTests.cs create mode 100644 tests/FlowTime.Api.Tests/TryResolveRunDirectoryTests.cs diff --git a/.gitignore b/.gitignore index cecaa389..0271639b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ apis/ *.userprefs .claude/settings.local.json .claude/*.lock +.claude/worktrees/ # AI framework sync-generated (rebuilt by .ai/sync.sh) .claude/agents/ diff --git a/src/FlowTime.API/Endpoints/RunOrchestrationEndpoints.cs b/src/FlowTime.API/Endpoints/RunOrchestrationEndpoints.cs index fef5a369..7b00440e 100644 --- a/src/FlowTime.API/Endpoints/RunOrchestrationEndpoints.cs +++ b/src/FlowTime.API/Endpoints/RunOrchestrationEndpoints.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using FlowTime.Contracts.Storage; using FlowTime.Contracts.TimeTravel; using FlowTime.TimeMachine.Orchestration; @@ -145,7 +146,15 @@ private static async Task HandleGetRunAsync( } var runsRoot = Program.ServiceHelpers.RunsRoot(configuration); - var runDirectory = Path.Combine(runsRoot, runId); + string runDirectory; + try + { + runDirectory = RunPathResolver.GetSafeRunDirectory(runsRoot, runId); + } + catch (ArgumentException) + { + return Results.NotFound(new { error = $"Run '{runId}' not found." }); + } var result = await orchestration.TryLoadRunAsync(runDirectory, cancellationToken).ConfigureAwait(false); if (result is null) diff --git a/src/FlowTime.API/Program.cs b/src/FlowTime.API/Program.cs index a7d3e901..29cfbd16 100644 --- a/src/FlowTime.API/Program.cs +++ b/src/FlowTime.API/Program.cs @@ -1068,11 +1068,10 @@ static bool IsSupportedDependencyField(string value) { var artifactsDirectory = Program.GetArtifactsDirectory(builder.Configuration); var reader = new Synthetic.FileSeriesReader(); - var runPath = Path.Combine(artifactsDirectory, runId); - - if (!Directory.Exists(runPath)) + var notFound = Program.TryResolveRunDirectory(artifactsDirectory, runId, out var runPath); + if (notFound is not null) { - return Results.NotFound(new { error = $"Run {runId} not found" }); + return notFound; } var adapter = new Synthetic.RunArtifactAdapter(reader, runPath); @@ -1093,11 +1092,10 @@ static bool IsSupportedDependencyField(string value) { var artifactsDirectory = Program.GetArtifactsDirectory(builder.Configuration); var reader = new Synthetic.FileSeriesReader(); - var runPath = Path.Combine(artifactsDirectory, runId); - - if (!Directory.Exists(runPath)) + var notFound = Program.TryResolveRunDirectory(artifactsDirectory, runId, out var runPath); + if (notFound is not null) { - return Results.NotFound(new { error = $"Run {runId} not found" }); + return notFound; } // First try exact match @@ -1155,11 +1153,10 @@ static bool IsSupportedDependencyField(string value) try { var artifactsDirectory = Program.GetArtifactsDirectory(builder.Configuration); - var runPath = Path.Combine(artifactsDirectory, runId); - - if (!Directory.Exists(runPath)) + var notFound = Program.TryResolveRunDirectory(artifactsDirectory, runId, out var runPath); + if (notFound is not null) { - return Results.NotFound(new { error = $"Run {runId} not found" }); + return notFound; } // Save all available export formats to disk @@ -1191,11 +1188,10 @@ static bool IsSupportedDependencyField(string value) try { var artifactsDirectory = Program.GetArtifactsDirectory(builder.Configuration); - var runPath = Path.Combine(artifactsDirectory, runId); - - if (!Directory.Exists(runPath)) + var notFound = Program.TryResolveRunDirectory(artifactsDirectory, runId, out var runPath); + if (notFound is not null) { - return Results.NotFound(new { error = $"Run {runId} not found" }); + return notFound; } // Return the requested format (no side effects) @@ -1292,8 +1288,34 @@ static async Task SaveAllExportFormatsAsync(string runPath, ILogger logger) app.Run(); // Allow WebApplicationFactory to reference the entry point for integration tests -public partial class Program -{ +public partial class Program +{ + /// + /// Resolves a safe run directory under the given root, returning a 404 IResult + /// when the runId is invalid or the directory does not exist. On success the + /// resolved absolute path is written to and the + /// method returns null. + /// + internal static IResult? TryResolveRunDirectory(string artifactsDirectory, string runId, out string runPath) + { + try + { + runPath = FlowTime.Contracts.Storage.RunPathResolver.GetSafeRunDirectory(artifactsDirectory, runId); + } + catch (ArgumentException) + { + runPath = string.Empty; + return Results.NotFound(new { error = $"Run {runId} not found" }); + } + + if (!Directory.Exists(runPath)) + { + return Results.NotFound(new { error = $"Run {runId} not found" }); + } + + return null; + } + /// /// Get the artifacts directory with proper precedence: Environment Variable > Configuration > Solution Root Default /// diff --git a/src/FlowTime.API/Services/GraphService.cs b/src/FlowTime.API/Services/GraphService.cs index 5209fffe..d78320e8 100644 --- a/src/FlowTime.API/Services/GraphService.cs +++ b/src/FlowTime.API/Services/GraphService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using FlowTime.Contracts.Services; +using FlowTime.Contracts.Storage; using FlowTime.Contracts.TimeTravel; using FlowTime.Core.Dispatching; using FlowTime.Core.Metrics; @@ -43,7 +44,16 @@ public async Task GetGraphAsync(string runId, GraphQueryOptions? } var runsRoot = Program.ServiceHelpers.RunsRoot(configuration); - var runDirectory = Path.Combine(runsRoot, runId); + string runDirectory; + try + { + runDirectory = RunPathResolver.GetSafeRunDirectory(runsRoot, runId); + } + catch (ArgumentException) + { + throw new GraphQueryException(404, $"Run '{runId}' not found."); + } + if (!Directory.Exists(runDirectory)) { throw new GraphQueryException(404, $"Run '{runId}' not found."); diff --git a/src/FlowTime.API/Services/MetricsService.cs b/src/FlowTime.API/Services/MetricsService.cs index 03748430..4e27a593 100644 --- a/src/FlowTime.API/Services/MetricsService.cs +++ b/src/FlowTime.API/Services/MetricsService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using FlowTime.Adapters.Synthetic; using FlowTime.Contracts.Services; +using FlowTime.Contracts.Storage; using FlowTime.Contracts.TimeTravel; using FlowTime.Core.DataSources; using FlowTime.Core.TimeTravel; @@ -42,7 +43,16 @@ public async Task GetMetricsAsync(string runId, int? requestedS } var runsRoot = Program.ServiceHelpers.RunsRoot(configuration); - var runDirectory = Path.Combine(runsRoot, runId); + string runDirectory; + try + { + runDirectory = RunPathResolver.GetSafeRunDirectory(runsRoot, runId); + } + catch (ArgumentException) + { + throw new MetricsQueryException(404, $"Run '{runId}' not found."); + } + if (!Directory.Exists(runDirectory)) { throw new MetricsQueryException(404, $"Run '{runId}' not found."); diff --git a/src/FlowTime.API/Services/StateQueryService.cs b/src/FlowTime.API/Services/StateQueryService.cs index 1d312f3e..b8174467 100644 --- a/src/FlowTime.API/Services/StateQueryService.cs +++ b/src/FlowTime.API/Services/StateQueryService.cs @@ -5,6 +5,7 @@ using System.Linq; using FlowTime.Adapters.Synthetic; using FlowTime.Contracts.Services; +using FlowTime.Contracts.Storage; using FlowTime.Contracts.TimeTravel; using FlowTime.Core.Constraints; using FlowTime.Core.Dispatching; @@ -286,7 +287,15 @@ private async Task LoadContextAsync(string runId, bool loadComp } var artifactsDirectory = Program.GetArtifactsDirectory(configuration); - var runDirectory = Path.Combine(artifactsDirectory, runId); + string runDirectory; + try + { + runDirectory = RunPathResolver.GetSafeRunDirectory(artifactsDirectory, runId); + } + catch (ArgumentException) + { + throw new StateQueryException(404, $"Run '{runId}' not found."); + } if (!Directory.Exists(runDirectory)) { diff --git a/src/FlowTime.Contracts/Storage/RunPathResolver.cs b/src/FlowTime.Contracts/Storage/RunPathResolver.cs new file mode 100644 index 00000000..22983c93 --- /dev/null +++ b/src/FlowTime.Contracts/Storage/RunPathResolver.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; + +namespace FlowTime.Contracts.Storage; + +public static class RunPathResolver +{ + private static readonly char[] PathSeparators = { '/', '\\' }; + + public static string GetSafeRunDirectory(string artifactsDirectory, string runId) + { + if (string.IsNullOrWhiteSpace(artifactsDirectory)) + { + throw new ArgumentException("artifactsDirectory must be provided.", nameof(artifactsDirectory)); + } + + if (string.IsNullOrWhiteSpace(runId)) + { + throw new ArgumentException("runId must be provided.", nameof(runId)); + } + + if (runId.IndexOfAny(PathSeparators) >= 0) + { + throw new ArgumentException($"runId '{runId}' must not contain path separators.", nameof(runId)); + } + + if (runId == "." || runId == "..") + { + throw new ArgumentException($"runId '{runId}' is not a valid run identifier.", nameof(runId)); + } + + string rootFull; + try + { + rootFull = Path.GetFullPath(artifactsDirectory); + } + catch (Exception ex) when (ex is ArgumentException || ex is PathTooLongException || ex is NotSupportedException) + { + throw new ArgumentException($"artifactsDirectory '{artifactsDirectory}' is not a valid path.", nameof(artifactsDirectory), ex); + } + + var rootWithSeparator = rootFull.EndsWith(Path.DirectorySeparatorChar) + ? rootFull + : rootFull + Path.DirectorySeparatorChar; + + string candidate; + try + { + candidate = Path.GetFullPath(Path.Combine(rootWithSeparator, runId)); + } + catch (Exception ex) when (ex is ArgumentException || ex is PathTooLongException || ex is NotSupportedException) + { + throw new ArgumentException($"runId '{runId}' is not a valid path segment.", nameof(runId), ex); + } + + var candidateWithSeparator = candidate.EndsWith(Path.DirectorySeparatorChar) + ? candidate + : candidate + Path.DirectorySeparatorChar; + + if (!candidateWithSeparator.StartsWith(rootWithSeparator, StringComparison.Ordinal)) + { + throw new ArgumentException($"runId '{runId}' resolves outside the artifacts directory.", nameof(runId)); + } + + return candidate.TrimEnd(Path.DirectorySeparatorChar); + } +} diff --git a/src/FlowTime.TimeMachine/Orchestration/RunOrchestrationService.cs b/src/FlowTime.TimeMachine/Orchestration/RunOrchestrationService.cs index 8b4fb20a..71aab6b7 100644 --- a/src/FlowTime.TimeMachine/Orchestration/RunOrchestrationService.cs +++ b/src/FlowTime.TimeMachine/Orchestration/RunOrchestrationService.cs @@ -324,7 +324,16 @@ private async Task WriteTemporaryProvenanceAsync(string provenanceJson, private async Task TryReuseExistingRunAsync(string runId, string outputRoot, bool overwriteExisting, CancellationToken cancellationToken) { - var runDirectory = Path.Combine(outputRoot, runId); + string runDirectory; + try + { + runDirectory = FlowTime.Contracts.Storage.RunPathResolver.GetSafeRunDirectory(outputRoot, runId); + } + catch (ArgumentException) + { + return null; + } + if (!Directory.Exists(runDirectory)) { return null; @@ -745,7 +754,16 @@ private async Task CreateSimulationRunAsync( if (!string.IsNullOrWhiteSpace(request.RunId)) { - var explicitDirectory = Path.Combine(outputRoot, request.RunId); + string explicitDirectory; + try + { + explicitDirectory = FlowTime.Contracts.Storage.RunPathResolver.GetSafeRunDirectory(outputRoot, request.RunId); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Requested runId '{request.RunId}' is not a valid run identifier.", ex); + } + if (Directory.Exists(explicitDirectory)) { if (!request.OverwriteExisting) diff --git a/src/FlowTime.TimeMachine/TelemetryBundleBuilder.cs b/src/FlowTime.TimeMachine/TelemetryBundleBuilder.cs index d1354623..59db6297 100644 --- a/src/FlowTime.TimeMachine/TelemetryBundleBuilder.cs +++ b/src/FlowTime.TimeMachine/TelemetryBundleBuilder.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using FlowTime.Contracts.Services; +using FlowTime.Contracts.Storage; using FlowTime.Core.Artifacts; using FlowTime.Core.Models; using FlowTime.Core.Nodes; @@ -54,7 +55,15 @@ public async Task BuildAsync(TelemetryBundleOptions optio string? explicitRunDirectory = null; if (!string.IsNullOrWhiteSpace(options.RunId)) { - explicitRunDirectory = Path.Combine(outputRoot, options.RunId); + try + { + explicitRunDirectory = RunPathResolver.GetSafeRunDirectory(outputRoot, options.RunId); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Requested runId '{options.RunId}' is not a valid run identifier.", ex); + } + if (Directory.Exists(explicitRunDirectory)) { if (!options.Overwrite) diff --git a/src/FlowTime.TimeMachine/TelemetryGenerationService.cs b/src/FlowTime.TimeMachine/TelemetryGenerationService.cs index 8ce393e1..73a83eca 100644 --- a/src/FlowTime.TimeMachine/TelemetryGenerationService.cs +++ b/src/FlowTime.TimeMachine/TelemetryGenerationService.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using FlowTime.Contracts.Storage; using FlowTime.Core.TimeTravel; using FlowTime.TimeMachine.Artifacts; using FlowTime.TimeMachine.Models; @@ -54,7 +55,16 @@ public async Task GenerateAsync( throw new ArgumentNullException(nameof(output)); } - var runDirectory = Path.Combine(runsRoot, runId); + string runDirectory; + try + { + runDirectory = RunPathResolver.GetSafeRunDirectory(runsRoot, runId); + } + catch (ArgumentException) + { + throw new DirectoryNotFoundException($"Run directory for '{runId}' was not found."); + } + if (!Directory.Exists(runDirectory)) { throw new DirectoryNotFoundException($"Run directory '{runDirectory}' was not found."); diff --git a/tests/FlowTime.Api.Tests/RunPathResolverTests.cs b/tests/FlowTime.Api.Tests/RunPathResolverTests.cs new file mode 100644 index 00000000..4937c690 --- /dev/null +++ b/tests/FlowTime.Api.Tests/RunPathResolverTests.cs @@ -0,0 +1,162 @@ +using System; +using System.IO; +using FlowTime.Contracts.Storage; +using Xunit; + +namespace FlowTime.Api.Tests; + +public class RunPathResolverTests : IDisposable +{ + private readonly string tempRoot; + + public RunPathResolverTests() + { + tempRoot = Path.Combine(Path.GetTempPath(), "flowtime-runpath-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + } + + public void Dispose() + { + try + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + catch + { + // best-effort cleanup; ignore + } + } + + [Fact] + public void GetSafeRunDirectory_ValidRunId_ReturnsCanonicalPath() + { + var runId = "run_abc_123"; + var result = RunPathResolver.GetSafeRunDirectory(tempRoot, runId); + var expected = Path.GetFullPath(Path.Combine(tempRoot, runId)); + Assert.Equal(expected, result); + } + + [Fact] + public void GetSafeRunDirectory_ResultIsAlwaysAbsolute() + { + var relativeRoot = Path.GetRelativePath(Directory.GetCurrentDirectory(), tempRoot); + var result = RunPathResolver.GetSafeRunDirectory(relativeRoot, "run_xyz"); + Assert.True(Path.IsPathRooted(result)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void GetSafeRunDirectory_InvalidArtifactsDirectory_Throws(string? artifactsDirectory) + { + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(artifactsDirectory!, "run_abc")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void GetSafeRunDirectory_InvalidRunId_Throws(string? runId) + { + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(tempRoot, runId!)); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("../other")] + [InlineData("..\\other")] + [InlineData("../../etc/passwd")] + [InlineData("/absolute/path")] + [InlineData("\\windows\\absolute")] + public void GetSafeRunDirectory_PathTraversalAttempt_Throws(string runId) + { + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(tempRoot, runId)); + } + + [Theory] + [InlineData("run/nested")] + [InlineData("run\\nested")] + [InlineData("run/.")] + [InlineData("run\\..")] + public void GetSafeRunDirectory_RunIdContainsSeparator_Throws(string runId) + { + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(tempRoot, runId)); + } + + [Fact] + public void GetSafeRunDirectory_RunIdIsSiblingAttempt_Throws() + { + // Attempt to escape by resolving to sibling directory: root/../other + // Even if the ASP.NET route allowed it (it doesn't), canonicalization + // must reject anything not strictly under the root. + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(tempRoot, ".." + Path.DirectorySeparatorChar + "sibling")); + } + + [Fact] + public void GetSafeRunDirectory_ResultRootedUnderArtifactsDirectory() + { + var result = RunPathResolver.GetSafeRunDirectory(tempRoot, "run_inside"); + var rootFull = Path.GetFullPath(tempRoot) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootFull, result + Path.DirectorySeparatorChar, StringComparison.Ordinal); + } + + [Fact] + public void GetSafeRunDirectory_SiblingPrefixDoesNotSatisfyRootCheck() + { + // Guard against StartsWith false positives: root "/tmp/flowtime-a" must + // not accept candidate "/tmp/flowtime-abc". GetFullPath + trailing + // separator check prevents this. + var siblingRoot = tempRoot + "-sibling"; + Directory.CreateDirectory(siblingRoot); + try + { + // Resolve "../" relative to tempRoot would land in + // the sibling. Must be rejected. + var siblingName = Path.GetFileName(siblingRoot); + var escaping = ".." + Path.DirectorySeparatorChar + siblingName; + Assert.Throws(() => + RunPathResolver.GetSafeRunDirectory(tempRoot, escaping)); + } + finally + { + Directory.Delete(siblingRoot); + } + } + + [Fact] + public void GetSafeRunDirectory_RootWithTrailingSeparator_DoesNotDuplicateSeparator() + { + // Covers the branch where rootFull already ends with a directory + // separator (e.g., filesystem root "/"). Exercises the ternary + // that skips appending. + var rootWithTrailingSep = tempRoot + Path.DirectorySeparatorChar; + var result = RunPathResolver.GetSafeRunDirectory(rootWithTrailingSep, "run_inside"); + var expected = Path.GetFullPath(Path.Combine(tempRoot, "run_inside")); + Assert.Equal(expected, result); + Assert.DoesNotContain( + string.Concat(Path.DirectorySeparatorChar, Path.DirectorySeparatorChar), + result); + } + + [Fact] + public void GetSafeRunDirectory_DoesNotRequireDirectoryToExist() + { + // Canonicalization is the security contract. Existence is a separate + // caller concern (→ 404). The helper must not error on missing dirs. + var result = RunPathResolver.GetSafeRunDirectory(tempRoot, "run_does_not_exist_yet"); + Assert.False(Directory.Exists(result)); + Assert.StartsWith(Path.GetFullPath(tempRoot), result, StringComparison.Ordinal); + } +} diff --git a/tests/FlowTime.Api.Tests/TryResolveRunDirectoryTests.cs b/tests/FlowTime.Api.Tests/TryResolveRunDirectoryTests.cs new file mode 100644 index 00000000..25900966 --- /dev/null +++ b/tests/FlowTime.Api.Tests/TryResolveRunDirectoryTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Xunit; + +namespace FlowTime.Api.Tests; + +public class TryResolveRunDirectoryTests : IDisposable +{ + private readonly string tempRoot; + + public TryResolveRunDirectoryTests() + { + tempRoot = Path.Combine(Path.GetTempPath(), "flowtime-tryresolve-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + } + + public void Dispose() + { + try + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + catch + { + // best-effort cleanup + } + } + + [Fact] + public void ExistingRunDirectory_ReturnsNullAndSetsPath() + { + var runId = "run_valid"; + var expectedPath = Path.Combine(tempRoot, runId); + Directory.CreateDirectory(expectedPath); + + var result = Program.TryResolveRunDirectory(tempRoot, runId, out var runPath); + + Assert.Null(result); + Assert.Equal(Path.GetFullPath(expectedPath), runPath); + } + + [Fact] + public void NonexistentRunDirectory_ReturnsNotFoundResult() + { + var result = Program.TryResolveRunDirectory(tempRoot, "run_not_created", out var runPath); + + Assert.NotNull(result); + // runPath is populated on the resolve (canonical), then the existence + // check fails. The helper returns a 404 IResult — we can't easily + // introspect the status code without executing against an HttpContext, + // so we assert the non-null contract shape. + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("../escape")] + [InlineData("nested/path")] + [InlineData("")] + public void InvalidRunId_ReturnsNotFoundAndSetsEmptyPath(string runId) + { + var result = Program.TryResolveRunDirectory(tempRoot, runId, out var runPath); + + Assert.NotNull(result); + Assert.Equal(string.Empty, runPath); + } + + [Fact] + public void InvalidArtifactsDirectory_ReturnsNotFoundAndSetsEmptyPath() + { + var result = Program.TryResolveRunDirectory("", "run_abc", out var runPath); + + Assert.NotNull(result); + Assert.Equal(string.Empty, runPath); + } +}