Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
11 changes: 10 additions & 1 deletion src/FlowTime.API/Endpoints/RunOrchestrationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -145,7 +146,15 @@ private static async Task<IResult> 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)
Expand Down
58 changes: 40 additions & 18 deletions src/FlowTime.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
{
/// <summary>
/// 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 <paramref name="runPath"/> and the
/// method returns null.
/// </summary>
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;
}

/// <summary>
/// Get the artifacts directory with proper precedence: Environment Variable > Configuration > Solution Root Default
/// </summary>
Expand Down
12 changes: 11 additions & 1 deletion src/FlowTime.API/Services/GraphService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,7 +44,16 @@ public async Task<GraphResponse> 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.");
Expand Down
12 changes: 11 additions & 1 deletion src/FlowTime.API/Services/MetricsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,7 +43,16 @@ public async Task<MetricsResponse> 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.");
Expand Down
11 changes: 10 additions & 1 deletion src/FlowTime.API/Services/StateQueryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -286,7 +287,15 @@ private async Task<StateRunContext> 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))
{
Expand Down
67 changes: 67 additions & 0 deletions src/FlowTime.Contracts/Storage/RunPathResolver.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
22 changes: 20 additions & 2 deletions src/FlowTime.TimeMachine/Orchestration/RunOrchestrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,16 @@ private async Task<string> WriteTemporaryProvenanceAsync(string provenanceJson,

private async Task<RunOrchestrationOutcome?> 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;
Expand Down Expand Up @@ -745,7 +754,16 @@ private async Task<RunOrchestrationOutcome> 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)
Expand Down
11 changes: 10 additions & 1 deletion src/FlowTime.TimeMachine/TelemetryBundleBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +55,15 @@ public async Task<TelemetryBundleResult> 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)
Expand Down
12 changes: 11 additions & 1 deletion src/FlowTime.TimeMachine/TelemetryGenerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +55,16 @@ public async Task<TelemetryGenerationResult> 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.");
Expand Down
Loading
Loading