Skip to content
Closed
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
9d3fe04
feat: stabilize local dev paths with ContentRoot-based resolution
joirunner Jan 11, 2026
af3fc33
Initial plan
Copilot Jan 19, 2026
912efcb
Initial plan
Copilot Jan 19, 2026
a29a83e
Initial plan
Copilot Jan 19, 2026
91d7c4f
Update Motely.API/MotelyPaths.cs
joirunner Jan 19, 2026
65a8708
Initial plan
Copilot Jan 19, 2026
6bea872
Initial plan
Copilot Jan 19, 2026
3c1680d
Merge pull request #4 from OptimusPi/copilot/sub-pr-1
joirunner Jan 19, 2026
1843263
Merge pull request #5 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 19, 2026
bd585e9
Reuse motelyRoot variable instead of calling FindMotelyRoot twice
Copilot Jan 19, 2026
74cc0c1
Merge pull request #6 from OptimusPi/copilot/sub-pr-1-another-one
joirunner Jan 19, 2026
95c5442
Merge pull request #7 from OptimusPi/copilot/sub-pr-1-yet-again
joirunner Jan 19, 2026
848dc83
Add path validation to FilterService.GetFilterJaml for security
Copilot Jan 19, 2026
72f6193
Use normalized path consistently in File operations
Copilot Jan 19, 2026
0267720
Initial plan
Copilot Jan 19, 2026
f06d6f3
Use Path.GetRelativePath for more robust path validation
Copilot Jan 19, 2026
0992ba3
Initial plan
Copilot Jan 19, 2026
11f115b
Merge pull request #8 from OptimusPi/copilot/sub-pr-1-one-more-time
joirunner Jan 19, 2026
6fa1224
Add path traversal protection to SaveFilter endpoint
Copilot Jan 19, 2026
7f7ef90
Add path traversal protection to SaveFilter endpoint
Copilot Jan 19, 2026
dac829e
Merge pull request #11 from OptimusPi/copilot/sub-pr-1-yet-again
joirunner Jan 19, 2026
1bd22d0
Apply same sanitization to DeleteFilter for consistency
Copilot Jan 19, 2026
4c0414e
Extract sanitization logic to shared helper method
Copilot Jan 19, 2026
5152d4f
Remove redundant null checks after TrySanitizeFilterName
Copilot Jan 19, 2026
0466221
Merge branch 'feat/stabilize-local-dev-paths' into copilot/sub-pr-1-a…
joirunner Jan 19, 2026
19c8e2f
Merge pull request #9 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 19, 2026
2001330
Initial plan
Copilot Jan 19, 2026
8da1706
Initial plan
Copilot Jan 19, 2026
6c73796
Add path validation to SaveFilter and DeleteFilter endpoints
Copilot Jan 19, 2026
8628f3a
Add initialization guard to MotelyPaths to prevent race conditions
Copilot Jan 19, 2026
1d696bd
Move initialization flag to end of Initialize method
Copilot Jan 19, 2026
4946f8b
Add volatile keyword for thread-safe initialization
Copilot Jan 19, 2026
4c793b6
Add thread-safety documentation to EnsureInitialized method
Copilot Jan 19, 2026
f854f7f
Merge pull request #14 from OptimusPi/copilot/sub-pr-1-another-one
joirunner Jan 19, 2026
a52919f
Merge pull request #13 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 19, 2026
a0d2aee
Initial plan
Copilot Jan 19, 2026
65bf53c
Add defense-in-depth path validation with StartsWith check
Copilot Jan 19, 2026
4cb9208
Extract path validation to helper method and use Ordinal comparison
Copilot Jan 19, 2026
60aa2a9
Use normalized paths consistently for file operations
Copilot Jan 19, 2026
8a4aff4
Merge pull request #15 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 19, 2026
11484c9
Update Motely.API/appsettings.example.json
joirunner Jan 19, 2026
ed9a68a
Initial plan
Copilot Jan 19, 2026
8154071
Update Motely.API/Endpoints.cs
joirunner Jan 19, 2026
4224d0c
Make all MotelyPaths static fields volatile to fix memory ordering ra…
Copilot Jan 19, 2026
b10f984
Merge pull request #16 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 19, 2026
22b2bd9
Initial plan
Copilot Jan 19, 2026
7874dd2
Initial plan
Copilot Jan 19, 2026
e69e56e
Add thread-safe double-checked locking to Initialize method
Copilot Jan 19, 2026
dc22ba0
Improve documentation and clarify atomic initialization design
Copilot Jan 19, 2026
c2b11cf
Clarify EnsureInitialized documentation to indicate it verifies and t…
Copilot Jan 19, 2026
a276850
Add comprehensive security tests for FilterService path validation
Copilot Jan 19, 2026
587a40e
Merge pull request #19 from OptimusPi/copilot/sub-pr-1-one-more-time
joirunner Jan 20, 2026
0e7ae98
Merge pull request #17 from OptimusPi/copilot/sub-pr-1-again
joirunner Jan 20, 2026
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
46 changes: 34 additions & 12 deletions Motely.API/Endpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ public static IResult GetFilters()
{
try
{
var fullPath = @"X:\BalatroSeedOracle\external\Motely\JamlFilters";
var filters = FilterService.LoadFiltersFromDisk(fullPath, cfg => false);
var filters = FilterService.LoadFiltersFromDisk(MotelyPaths.JamlFiltersDir, cfg => false);
return Results.Ok(filters);
}
catch (Exception ex)
Expand All @@ -31,9 +30,10 @@ public static IResult GetSeedSources()
new { key = "all", label = "All Seeds", kind = "builtin" }
};

if (Directory.Exists("WordLists"))
var seedSourcesDir = MotelyPaths.SeedSourcesDir;
if (Directory.Exists(seedSourcesDir))
{
foreach (var file in Directory.GetFiles("WordLists", "*.*")
foreach (var file in Directory.GetFiles(seedSourcesDir, "*.*")
.Where(f => f.EndsWith(".db") || f.EndsWith(".txt") || f.EndsWith(".csv"))
.Select(Path.GetFileName)
.Where(f => f != null)
Expand Down Expand Up @@ -112,7 +112,8 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
var request = await req.ReadFromJsonAsync<FilterSaveRequest>();
if (request?.FilterJaml == null) return Results.BadRequest();

Directory.CreateDirectory("JamlFilters");
var jamlFiltersDir = MotelyPaths.JamlFiltersDir;
Directory.CreateDirectory(jamlFiltersDir);

// Use id from route, or extract name from JAML
string? name = id;
Expand All @@ -121,21 +122,42 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
name = cfg.Name ?? id;
}

var fileName = $"{name}.jaml";
var fullPath = Path.Combine("JamlFilters", fileName);
File.WriteAllText(fullPath, request.FilterJaml);
// Sanitize name to prevent path traversal attacks
if (!FilterService.TrySanitizeFilterName(name, out var safeName))
return Results.BadRequest("Invalid filter name");

var fileName = $"{safeName}.jaml";
var filePath = Path.Combine(jamlFiltersDir, fileName);

// Validate that the resolved path is within the expected directory
if (!FilterService.IsPathWithinDirectory(filePath, jamlFiltersDir, out var fullFilePath))
{
return Results.BadRequest("Invalid filter path");
}

File.WriteAllText(fullFilePath, request.FilterJaml);

return Results.Ok(new { filePath = fileName });
}

public static IResult DeleteFilter(string id)
{
var safeName = Path.GetFileName(id);
var fullPath = Path.Combine("JamlFilters", safeName);
// Sanitize id to prevent path traversal attacks
if (!FilterService.TrySanitizeFilterName(id, out var safeName))
return Results.BadRequest("Invalid filter name");

var fileName = $"{safeName}.jaml";
var filePath = Path.Combine(MotelyPaths.JamlFiltersDir, fileName);

// Validate that the resolved path is within the expected directory
if (!FilterService.IsPathWithinDirectory(filePath, MotelyPaths.JamlFiltersDir, out var fullFilePath))
{
return Results.BadRequest("Invalid filter path");
}

if (File.Exists(fullPath))
if (File.Exists(fullFilePath))
{
File.Delete(fullPath);
File.Delete(fullFilePath);
return Results.Ok();
}

Expand Down
14 changes: 11 additions & 3 deletions Motely.API/MotelyApiHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public static WebApplication CreateHost(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

// Set ContentRoot to repo root for consistent path resolution
var motelyRoot = FindMotelyRoot();
if (!string.IsNullOrEmpty(motelyRoot))
{
builder.Host.UseContentRoot(motelyRoot);
}

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddCors(options =>
{
Expand Down Expand Up @@ -96,9 +103,10 @@ public static WebApplication CreateHost(string[] args)

var app = builder.Build();

// Initialize SearchManager with motely root path
// Find the root directory by looking for JamlFilters folder
var motelyRoot = FindMotelyRoot();
// Initialize MotelyPaths with ContentRoot and configuration
MotelyPaths.Initialize(app.Environment, app.Configuration);

// Initialize SearchManager with motely root path (for SaveFilterToEcosystem compatibility)
if (!string.IsNullOrEmpty(motelyRoot))
{
SearchManager.Instance.SetMotelyRoot(motelyRoot);
Expand Down
131 changes: 131 additions & 0 deletions Motely.API/MotelyPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;

namespace Motely.API;

/// <summary>
/// Centralized path resolver for Motely directories.
/// Uses ASP.NET Core ContentRootPath as the base, with optional config overrides.
/// IMPORTANT: Must call Initialize(IWebHostEnvironment, IConfiguration?) at application startup
/// before accessing any path properties. Accessing paths before initialization will throw InvalidOperationException.
/// </summary>
public static class MotelyPaths
{
private static volatile string _contentRoot = Directory.GetCurrentDirectory();
private static string? _jamlFiltersOverride;
private static string? _seedSourcesOverride;
private static string? _searchResultsOverride;
private static volatile bool _isInitialized = false;

/// <summary>
/// Gets the content root path (typically the repo root).
/// </summary>
public static string ContentRoot
{
get
{
EnsureInitialized();
return _contentRoot;
}
}

/// <summary>
/// Gets the directory for JAML filter files.
/// Defaults to ContentRoot/JamlFilters, can be overridden via config.
/// </summary>
public static string JamlFiltersDir
{
get
{
EnsureInitialized();
return ResolvePath(_jamlFiltersOverride, "JamlFilters");
}
}

/// <summary>
/// Gets the directory for seed source files (txt, csv, db).
/// Defaults to ContentRoot/SeedSources, can be overridden via config.
/// </summary>
public static string SeedSourcesDir
{
get
{
EnsureInitialized();
return ResolvePath(_seedSourcesOverride, "SeedSources");
}
}

/// <summary>
/// Gets the directory for search result databases and metadata.
/// Defaults to ContentRoot/SearchResults, can be overridden via config.
/// </summary>
public static string SearchResultsDir
{
get
{
EnsureInitialized();
return ResolvePath(_searchResultsOverride, "SearchResults");
}
}

/// <summary>
/// Initializes MotelyPaths with the web host environment and configuration.
/// Should be called once at application startup.
/// </summary>
public static void Initialize(IWebHostEnvironment env, IConfiguration? config = null)
{
_contentRoot = env.ContentRootPath;

if (config != null)
{
_jamlFiltersOverride = config["Motely:Paths:JamlFiltersDir"];
_seedSourcesOverride = config["Motely:Paths:SeedSourcesDir"];
_searchResultsOverride = config["Motely:Paths:SearchResultsDir"];
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path configuration allows arbitrary absolute paths via config overrides, which could be exploited to point to sensitive system directories. Consider adding validation in Initialize to ensure configured paths are within safe boundaries or at least document this security consideration in appsettings.example.json. This is especially important since directory creation happens automatically for configured paths.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


// Ensure directories exist (using ResolvePath directly to avoid EnsureInitialized check)
Directory.CreateDirectory(ResolvePath(_jamlFiltersOverride, "JamlFilters"));
Directory.CreateDirectory(ResolvePath(_seedSourcesOverride, "SeedSources"));
Directory.CreateDirectory(ResolvePath(_searchResultsOverride, "SearchResults"));

// Mark as initialized after all setup is complete
_isInitialized = true;
}
Comment on lines +78 to +113
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The volatile keyword alone is insufficient for thread safety in the Initialize method. Multiple threads could potentially call Initialize concurrently, leading to race conditions where directories might be created multiple times or _isInitialized could be set true before all initialization is complete. Consider using a lock or Interlocked operations to ensure thread-safe initialization, or document that Initialize must only be called once from a single thread during application startup.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


/// <summary>
/// Ensures that Initialize has been called before accessing path properties.
/// Thread-safe: uses volatile _isInitialized field for proper memory ordering.
/// Initialization should complete before any concurrent path access begins.
/// </summary>
private static void EnsureInitialized()
{
if (!_isInitialized)
{
throw new InvalidOperationException(
"MotelyPaths.Initialize must be called before accessing path properties. " +
"Call MotelyPaths.Initialize(IWebHostEnvironment, IConfiguration?) at application startup.");
}
}

/// <summary>
/// Resolves a path: if override is provided and is absolute, use it;
/// if override is relative, combine with ContentRoot;
/// otherwise use default relative to ContentRoot.
/// </summary>
private static string ResolvePath(string? overridePath, string defaultSubDir)
{
if (!string.IsNullOrWhiteSpace(overridePath))
{
// If override is an absolute path, use it as-is
if (Path.IsPathRooted(overridePath))
{
return overridePath;
}
// If override is relative, combine with ContentRoot
return Path.Combine(_contentRoot, overridePath);
}

// Default: combine ContentRoot with default subdirectory
return Path.Combine(_contentRoot, defaultSubDir);
}
}
21 changes: 10 additions & 11 deletions Motely.API/SearchManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ public class SearchManager

private readonly ConcurrentDictionary<string, ActiveSearch> _activeSearches = new();
private readonly ConcurrentDictionary<string, string> _lastErrors = new(StringComparer.OrdinalIgnoreCase);
private static readonly string _searchResultsDir = "SearchResults";

public string GetSearchResultsDir() => _searchResultsDir;
public string GetSearchResultsDir() => MotelyPaths.SearchResultsDir;

private string? _motelyRoot;

Expand Down Expand Up @@ -115,7 +114,7 @@ public class ActiveSearch
await _lifecycleGate.WaitAsync();
try
{
Directory.CreateDirectory(_searchResultsDir);
Directory.CreateDirectory(MotelyPaths.SearchResultsDir);

if (!JamlConfigLoader.TryLoadFromJamlString(filterJaml, out var config, out var error) || config == null)
throw new ArgumentException(error ?? "Invalid filter");
Expand All @@ -126,7 +125,7 @@ public class ActiveSearch
var filterName = GetFilterName(filterJaml);
var sanitizedName = SanitizeFilterFileStem(filterName);
var searchId = $"{sanitizedName}_{deck}_{stake}";
var dbPath = Path.Combine(_searchResultsDir, $"{searchId}.db");
var dbPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.db");

_lastErrors.TryRemove(searchId, out _);

Expand Down Expand Up @@ -175,7 +174,7 @@ public class ActiveSearch
search.Database = new MotelySearchDatabase(dbPath, columnNames);

// Save JAML metadata file so it can be retrieved even after search stops
var jamlPath = Path.Combine(_searchResultsDir, $"{searchId}.jaml");
var jamlPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.jaml");
File.WriteAllText(jamlPath, filterJaml);

// Automatically save filter to JamlFilters ecosystem
Expand Down Expand Up @@ -772,7 +771,7 @@ public async Task<List<SearchResult>> StopSearchAsync(string searchId)
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
_broadcaster?.Broadcast(JsonSerializer.Serialize(new { type = "filters_changed" }, options));

var dbPath = Path.Combine(_searchResultsDir, $"{searchId}.db");
var dbPath = Path.Combine(MotelyPaths.SearchResultsDir, $"{searchId}.db");
return GetTopResultsFromDb(dbPath, 1000);
}
finally
Expand Down Expand Up @@ -820,9 +819,9 @@ public async Task ClearAllSearchesAsync()
}

// Clear all stored results by deleting all database files
if (Directory.Exists(_searchResultsDir))
if (Directory.Exists(MotelyPaths.SearchResultsDir))
{
var dbFiles = Directory.GetFiles(_searchResultsDir, "*.db");
var dbFiles = Directory.GetFiles(MotelyPaths.SearchResultsDir, "*.db");
foreach (var file in dbFiles)
{
try
Expand Down Expand Up @@ -901,7 +900,7 @@ private async Task<bool> StopSearchInternalAsync(ActiveSearch search, string rea
try
{
var dbPath = search.Database?.DatabasePath
?? Path.Combine(_searchResultsDir, $"{search.SearchId}.db");
?? Path.Combine(MotelyPaths.SearchResultsDir, $"{search.SearchId}.db");
await ExportTopResultsToFertilizerAsync(dbPath, limit: 1000);
}
catch (Exception ex)
Expand Down Expand Up @@ -1028,7 +1027,7 @@ private void ApplySeedSource(JsonSearchParams searchParams, string? seedSource)
else
{
// Relative path - look in SeedSources folder
var relativePath = Path.Combine("SeedSources", safeName);
var relativePath = Path.Combine(MotelyPaths.SeedSourcesDir, safeName);
if (File.Exists(relativePath))
{
csvPath = relativePath;
Expand Down Expand Up @@ -1065,7 +1064,7 @@ private void ApplySeedSource(JsonSearchParams searchParams, string? seedSource)
else
{
// Relative path - look in SeedSources folder
var relativePath = Path.Combine("SeedSources", safeName);
var relativePath = Path.Combine(MotelyPaths.SeedSourcesDir, safeName);
if (File.Exists(relativePath))
{
searchParams.SeedSources = relativePath;
Expand Down
Loading
Loading