Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 8 additions & 7 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 @@ -122,7 +123,7 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
}

var fileName = $"{name}.jaml";
var fullPath = Path.Combine("JamlFilters", fileName);
var fullPath = Path.Combine(jamlFiltersDir, fileName);
File.WriteAllText(fullPath, request.FilterJaml);

return Results.Ok(new { filePath = fileName });
Expand All @@ -131,7 +132,7 @@ public static async Task<IResult> SaveFilter(string id, HttpRequest req)
public static IResult DeleteFilter(string id)
{
var safeName = Path.GetFileName(id);
var fullPath = Path.Combine("JamlFilters", safeName);
var fullPath = Path.Combine(MotelyPaths.JamlFiltersDir, safeName);

if (File.Exists(fullPath))
{
Expand Down
19 changes: 14 additions & 5 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,12 +103,14 @@ 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();
if (!string.IsNullOrEmpty(motelyRoot))
// Initialize MotelyPaths with ContentRoot and configuration
MotelyPaths.Initialize(app.Environment, app.Configuration);

// Initialize SearchManager with motely root path (for SaveFilterToEcosystem compatibility)
var motelyRootForSearchManager = FindMotelyRoot();
if (!string.IsNullOrEmpty(motelyRootForSearchManager))
{
SearchManager.Instance.SetMotelyRoot(motelyRoot);
SearchManager.Instance.SetMotelyRoot(motelyRootForSearchManager);
}

// Wire up SearchBroadcaster to SearchManager
Expand Down
82 changes: 82 additions & 0 deletions Motely.API/MotelyPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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.
/// </summary>
public static class MotelyPaths
{
private static string _contentRoot = Directory.GetCurrentDirectory();
private static string? _jamlFiltersOverride;
private static string? _seedSourcesOverride;
private static string? _searchResultsOverride;

/// <summary>
/// Gets the content root path (typically the repo root).
/// </summary>
public static string ContentRoot => _contentRoot;

/// <summary>
/// Gets the directory for JAML filter files.
/// Defaults to &lt;ContentRoot&gt;/JamlFilters, can be overridden via config.
/// </summary>
public static string JamlFiltersDir => ResolvePath(_jamlFiltersOverride, "JamlFilters");

/// <summary>
/// Gets the directory for seed source files (txt, csv, db).
/// Defaults to &lt;ContentRoot&gt;/SeedSources, can be overridden via config.
/// </summary>
public static string SeedSourcesDir => ResolvePath(_seedSourcesOverride, "SeedSources");

/// <summary>
/// Gets the directory for search result databases and metadata.
/// Defaults to &lt;ContentRoot&gt;/SearchResults, can be overridden via config.
/// </summary>
public static string SearchResultsDir => 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
Directory.CreateDirectory(JamlFiltersDir);
Directory.CreateDirectory(SeedSourcesDir);
Directory.CreateDirectory(SearchResultsDir);
}

/// <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
18 changes: 16 additions & 2 deletions Motely.API/Services/FilterService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using Motely;
using Motely.API;

namespace Motely.API.Services;

Expand All @@ -9,8 +10,21 @@ public static string GetFilterJaml(string? filterId)
{
if (string.IsNullOrEmpty(filterId))
return string.Empty;

var filterPath = Path.Combine("Filters", $"{filterId}.jaml");

// Sanitize filterId to prevent path traversal attacks
// Extract just the filename stem (no path separators, no extension)
var safeName = Path.GetFileNameWithoutExtension(filterId);
if (string.IsNullOrWhiteSpace(safeName))
return string.Empty;

// Remove any remaining path separators or invalid characters
var invalidChars = Path.GetInvalidFileNameChars();
foreach (var c in invalidChars)
{
safeName = safeName.Replace(c, '_');
}

var filterPath = Path.Combine(MotelyPaths.JamlFiltersDir, $"{safeName}.jaml");
if (!File.Exists(filterPath))
return string.Empty;

Expand Down
7 changes: 7 additions & 0 deletions Motely.API/appsettings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,12 @@
"Bucket": "balatro-seed-sources",
"Enabled": true
}
},
"Motely": {
"Paths": {
"JamlFiltersDir": null,
"SeedSourcesDir": null,
"SearchResultsDir": null
}
}
}