From 2ae8090bd54236b37df595c1bd355b2a1011ee10 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 21 Nov 2025 12:00:20 +0100 Subject: [PATCH 1/6] code qls --- .../Controllers/JobsController.cs | 18 +++++++++++++++++- src/Microsoft.Crank.Agent/ProcessUtil.cs | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs index 0afe2e13f..e95ae000d 100644 --- a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs +++ b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs @@ -801,7 +801,23 @@ public async Task Invoke(int id, string path) try { var job = _jobs.Find(id); - var response = await _httpClient.GetStringAsync(new Uri(new Uri(job.Url), path)); + + if (!Uri.TryCreate(job.Url, UriKind.Absolute, out var baseUri)) + { + Log.Error($"Invalid job URL: '{job.Url}'"); + return BadRequest("Invalid job configuration."); + } + var combinedUri = new Uri(baseUri, path); + + // Ensure the resulting URL is still under the same host/port as the base + if (combinedUri.Host != baseUri.Host || combinedUri.Port != baseUri.Port) + { + Log.Error($"Trying to access different host. Base: {baseUri.Host}:{baseUri.Port}, Target: {combinedUri.Host}:{combinedUri.Port}"); + return BadRequest("Cannot invoke requests to different hosts."); + } + + Log.Info($"Invoking: {combinedUri}"); + var response = await _httpClient.GetStringAsync(combinedUri); return Content(response); } catch (Exception e) diff --git a/src/Microsoft.Crank.Agent/ProcessUtil.cs b/src/Microsoft.Crank.Agent/ProcessUtil.cs index 1b06fbac4..cb3c85a35 100644 --- a/src/Microsoft.Crank.Agent/ProcessUtil.cs +++ b/src/Microsoft.Crank.Agent/ProcessUtil.cs @@ -102,7 +102,7 @@ public static Task RunAsync( CancellationToken cancellationToken = default ) { - var startInfo = new ProcessStartInfo(filename, arguments); + var startInfo = new ProcessStartInfo(filename, arguments); // CodeQL [SM05274] justification: Arguments are passed explicitly from the caller return RunAsync(startInfo, timeout, workingDirectory, throwOnError, environmentVariables, outputDataReceived, log, onStart, onStop, captureOutput, captureError, runAsRoot, cancellationToken); } From 7b38530516be29b24277cf84b9008e346ca03e73 Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 27 Nov 2025 11:45:26 +0100 Subject: [PATCH 2/6] add validation on path --- .../Controllers/JobsController.cs | 27 ++++++++++++++++--- src/Microsoft.Crank.Agent/ProcessUtil.cs | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs index e95ae000d..8c60f4986 100644 --- a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs +++ b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs @@ -665,12 +665,21 @@ public async Task Download(int id, string path) try { var job = _jobs.Find(id); - if (job == null) { return NotFound(); } + if (string.IsNullOrWhiteSpace(path)) + { + return BadRequest("Path parameter is required."); + } + if (PathValidator.ContainsDangerousCharacters(path)) + { + Log.Error($"Path contains dangerous characters: '{path}'"); + return BadRequest("Path contains invalid characters."); + } + // Downloads can't get out of this path var rootPath = Directory.GetParent(job.BasePath).FullName; @@ -685,9 +694,7 @@ public async Task Download(int id, string path) // Resolve dot notation in path var fullPath = Path.GetFullPath(path, job.BasePath); - Log.Info($"Download requested: '{path}'"); - if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { Log.Error($"Client is not allowed to download '{fullPath}'"); @@ -1044,5 +1051,19 @@ private IActionResult ObjectOrNotFoundResult(object obj) return new ObjectResult(obj); } + + private class PathValidator + { + private static readonly char[] _dangerousChars + = { ';', '|', '&', '$', '`', '\n', '\r', '<', '>', '"', '\'' }; + + /// + /// Returns true if detects shell metacharacters and control characters. False otherwise + /// + public static bool ContainsDangerousCharacters(string path) + { + return path.IndexOfAny(_dangerousChars) >= 0; + } + } } } diff --git a/src/Microsoft.Crank.Agent/ProcessUtil.cs b/src/Microsoft.Crank.Agent/ProcessUtil.cs index cb3c85a35..1b06fbac4 100644 --- a/src/Microsoft.Crank.Agent/ProcessUtil.cs +++ b/src/Microsoft.Crank.Agent/ProcessUtil.cs @@ -102,7 +102,7 @@ public static Task RunAsync( CancellationToken cancellationToken = default ) { - var startInfo = new ProcessStartInfo(filename, arguments); // CodeQL [SM05274] justification: Arguments are passed explicitly from the caller + var startInfo = new ProcessStartInfo(filename, arguments); return RunAsync(startInfo, timeout, workingDirectory, throwOnError, environmentVariables, outputDataReceived, log, onStart, onStop, captureOutput, captureError, runAsRoot, cancellationToken); } From 583ced08e01b190e00ce04fd45d5c9b04b02ba43 Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 27 Nov 2025 12:29:55 +0100 Subject: [PATCH 3/6] add tests for download path --- Microsoft.Crank.sln | 11 +- .../Controllers/JobsController.cs | 6 +- .../JobsControllerTests.cs | 148 ++++++++++++++++++ .../Microsoft.Crank.UnitTests.csproj | 24 +++ 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 test/Microsoft.Crank.UnitTests/JobsControllerTests.cs create mode 100644 test/Microsoft.Crank.UnitTests/Microsoft.Crank.UnitTests.csproj diff --git a/Microsoft.Crank.sln b/Microsoft.Crank.sln index f7cdd3043..6ae2f6a41 100644 --- a/Microsoft.Crank.sln +++ b/Microsoft.Crank.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.31911.260 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11111.16 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{995FCFF9-E5F6-4BDD-8E5B-FBDEA21145F9}" EndProject @@ -55,6 +55,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Crank.JobObjectWr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Crank.Jobs.K6", "src\Microsoft.Crank.Jobs.K6\Microsoft.Crank.Jobs.K6.csproj", "{D8DD4222-6929-46F3-A3F2-F38394AA1C72}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Crank.UnitTests", "test\Microsoft.Crank.UnitTests\Microsoft.Crank.UnitTests.csproj", "{E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +131,10 @@ Global {D8DD4222-6929-46F3-A3F2-F38394AA1C72}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8DD4222-6929-46F3-A3F2-F38394AA1C72}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8DD4222-6929-46F3-A3F2-F38394AA1C72}.Release|Any CPU.Build.0 = Release|Any CPU + {E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -151,6 +157,7 @@ Global {A2B9140B-46E5-451D-9246-ECA019487F5C} = {995FCFF9-E5F6-4BDD-8E5B-FBDEA21145F9} {D02CC5A5-A6EA-42CF-9EA5-E3D1CE0FFBFE} = {995FCFF9-E5F6-4BDD-8E5B-FBDEA21145F9} {D8DD4222-6929-46F3-A3F2-F38394AA1C72} = {995FCFF9-E5F6-4BDD-8E5B-FBDEA21145F9} + {E735E8D7-2CF4-4C89-8D47-21E8B6AA26E6} = {07A30A34-2DDA-45EE-B767-28021086B235} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C48AD7EE-82B1-4307-A869-3FC14AC9B21F} diff --git a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs index 8c60f4986..d11752ddf 100644 --- a/src/Microsoft.Crank.Agent/Controllers/JobsController.cs +++ b/src/Microsoft.Crank.Agent/Controllers/JobsController.cs @@ -817,9 +817,11 @@ public async Task Invoke(int id, string path) var combinedUri = new Uri(baseUri, path); // Ensure the resulting URL is still under the same host/port as the base - if (combinedUri.Host != baseUri.Host || combinedUri.Port != baseUri.Port) + var combinedUriComponents = combinedUri.GetComponents(UriComponents.SchemeAndServer | UriComponents.StrongAuthority, UriFormat.Unescaped); + var baseUriComponents = baseUri.GetComponents(UriComponents.SchemeAndServer | UriComponents.StrongAuthority, UriFormat.Unescaped); + if (!combinedUriComponents.Equals(baseUriComponents, StringComparison.InvariantCulture)) { - Log.Error($"Trying to access different host. Base: {baseUri.Host}:{baseUri.Port}, Target: {combinedUri.Host}:{combinedUri.Port}"); + Log.Error($"Trying to access different address. Base: {baseUri.Host}:{baseUri.Port}, Target: {combinedUri.Host}:{combinedUri.Port}"); return BadRequest("Cannot invoke requests to different hosts."); } diff --git a/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs new file mode 100644 index 000000000..558e65304 --- /dev/null +++ b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Crank.Agent.Controllers; +using Microsoft.Crank.Models; +using Repository; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Crank.UnitTests +{ + public class JobsControllerTests + { + private readonly ITestOutputHelper _output; + + public JobsControllerTests(ITestOutputHelper output) + { + _output = output; + } + + [Theory] + [InlineData("$&\n\r.\\execute")] // Dangerous characters + [InlineData("../../../../../../etc/passwd")] // Unix-style traversal + [InlineData("..\\..\\..\\Windows\\System32\\config\\SAM")] // Windows system files + [InlineData("../../../../../../../var/log/syslog")] // Absolute path traversal + [InlineData("..\\\\..\\\\..\\\\sensitive.txt")] // Double backslash + [InlineData("foo/../../../etc/passwd")] // Mixed valid and traversal + [InlineData("/etc/passwd")] // Absolute Unix path + [InlineData("\\\\network\\share\\file.txt")] // UNC path + [InlineData("..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\file.txt")] // Excessive traversal + [InlineData("./../../../../../../etc/shadow")] // Dot slash mixed + [InlineData("test/../../../../../../etc/hosts")] // Valid then traversal + public async Task Download_PathTraversalAttempts_ReturnsBadRequest(string path) + { + // Use absolute path for testing + var tempDir = Path.GetTempPath(); + var jobDir = Path.Combine(tempDir, "crank_test_jobs", "1"); + Directory.CreateDirectory(jobDir); + + try + { + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = jobDir + }); + + var jobsController = new JobsController(jobRepo); + + var result = await jobsController.Download(1, path); + + // Log diagnostic information + _output.WriteLine($"Testing path: {path}"); + _output.WriteLine($"BasePath: {jobDir}"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + + Assert.IsType(result); + } + finally + { + // Cleanup + try + { + Directory.Delete(Path.Combine(tempDir, "crank_test_jobs"), true); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Fact] + public async Task Download_DiagnosticTest_ShowsPathResolution() + { + var tempDir = Path.GetTempPath(); + var jobDir = Path.Combine(tempDir, "crank_test_jobs", "1"); + Directory.CreateDirectory(jobDir); + + try + { + var path = "..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\file.txt"; + var rootPath = Directory.GetParent(jobDir).FullName; + var fullPath = Path.GetFullPath(path, jobDir); + + _output.WriteLine($"Input path: {path}"); + _output.WriteLine($"BasePath: {jobDir}"); + _output.WriteLine($"Root path: {rootPath}"); + _output.WriteLine($"Resolved full path: {fullPath}"); + _output.WriteLine($"FullPath starts with RootPath: {fullPath.StartsWith(rootPath, System.StringComparison.OrdinalIgnoreCase)}"); + + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = jobDir + }); + + var jobsController = new JobsController(jobRepo); + var result = await jobsController.Download(1, path); + + _output.WriteLine($"Result type: {result.GetType().Name}"); + if (result is BadRequestObjectResult badRequest) + { + _output.WriteLine($"Error message: {badRequest.Value}"); + } + } + finally + { + try + { + Directory.Delete(Path.Combine(tempDir, "crank_test_jobs"), true); + } + catch + { + // Ignore cleanup errors + } + } + } + + private class JobsRepository : IJobRepository + { + private readonly Dictionary _jobs = new(); + + public Job Add(Job item) + { + _jobs.Add(item.Id, item); + return item; + } + + public Job Find(int id) + => _jobs.TryGetValue(id, out var job) ? job : null; + + public IEnumerable GetAll() + => _jobs.Values; + + public Job Remove(int id) + => _jobs.Remove(id, out var job) ? job : null; + + public void Update(Job item) + => _jobs[item.Id] = item; + } + } +} diff --git a/test/Microsoft.Crank.UnitTests/Microsoft.Crank.UnitTests.csproj b/test/Microsoft.Crank.UnitTests/Microsoft.Crank.UnitTests.csproj new file mode 100644 index 000000000..09157a256 --- /dev/null +++ b/test/Microsoft.Crank.UnitTests/Microsoft.Crank.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + Microsoft.Crank.UnitTests + true + true + XUnit + false + + + + + + + + + + + + + + + From d65a8931e9cff104328860d7dc8748c037b6912a Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 27 Nov 2025 12:42:38 +0100 Subject: [PATCH 4/6] tests for invoke --- .../JobsControllerTests.cs | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs index 558e65304..3edc24ad6 100644 --- a/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs +++ b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs @@ -20,7 +20,7 @@ public JobsControllerTests(ITestOutputHelper output) } [Theory] - [InlineData("$&\n\r.\\execute")] // Dangerous characters + [InlineData("$&\n\r.\\execute")] [InlineData("../../../../../../etc/passwd")] // Unix-style traversal [InlineData("..\\..\\..\\Windows\\System32\\config\\SAM")] // Windows system files [InlineData("../../../../../../../var/log/syslog")] // Absolute path traversal @@ -122,6 +122,96 @@ public async Task Download_DiagnosticTest_ShowsPathResolution() } } + [Theory] + [InlineData("http://localhost:5000/api", "http://evil.com/steal")] // Different domain + [InlineData("http://localhost:5000", "http://localhost:8080/api")] // Different port + [InlineData("http://localhost:5000", "https://localhost:5000/api")] // Different scheme + [InlineData("http://example.com", "http://attacker.com")] // Completely different host + [InlineData("http://api.example.com", "http://evil.example.com")] // Subdomain manipulation + [InlineData("http://localhost:5000", "//evil.com/steal")] // Protocol-relative URL + [InlineData("http://localhost:5000", "http://127.0.0.1:5000/api")] // IP vs hostname + public async Task Invoke_DifferentHostRequests_ReturnsBadRequest(string jobUrl, string path) + { + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = Path.GetTempPath(), + Url = jobUrl + }); + + var jobsController = new JobsController(jobRepo); + var result = await jobsController.Invoke(1, path); + + _output.WriteLine($"Job URL: {jobUrl}"); + _output.WriteLine($"Request path: {path}"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + + var badRequestResult = Assert.IsType(result); + } + + [Fact] + public async Task Invoke_InvalidJobUrl_ReturnsBadRequest() + { + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = Path.GetTempPath(), + Url = "not-a-valid-url" // Invalid URL + }); + + var jobsController = new JobsController(jobRepo); + var result = await jobsController.Invoke(1, "/api/test"); + + _output.WriteLine($"Result type: {result.GetType().Name}"); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid job configuration.", badRequestResult.Value); + } + + [Fact] + public async Task Invoke_NonExistentJob_ReturnsStatusCode500() + { + var jobRepo = new JobsRepository(); + var jobsController = new JobsController(jobRepo); + + var result = await jobsController.Invoke(999, "/api/test"); + + _output.WriteLine($"Result type: {result.GetType().Name}"); + + // When job is null, job.Url will throw NullReferenceException, caught by the catch block + Assert.IsType(result); + var objectResult = result as ObjectResult; + Assert.Equal(500, objectResult.StatusCode); + } + + [Theory] + [InlineData("http://localhost:5000", "http://localhost@evil.com/steal")] // Username in URL trick + [InlineData("http://localhost:5000", "http://evil.com#localhost:5000")] // Fragment manipulation + public async Task Invoke_AdvancedSSRFAttempts_ReturnsBadRequest(string jobUrl, string path) + { + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = Path.GetTempPath(), + Url = jobUrl + }); + + var jobsController = new JobsController(jobRepo); + var result = await jobsController.Invoke(1, path); + + _output.WriteLine($"Job URL: {jobUrl}"); + _output.WriteLine($"Request path: {path}"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + + Assert.IsType(result); + } + private class JobsRepository : IJobRepository { private readonly Dictionary _jobs = new(); From 38d953172a453aa17642fa958012482856c43a96 Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 27 Nov 2025 12:43:59 +0100 Subject: [PATCH 5/6] sln rollback --- Microsoft.Crank.sln | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Microsoft.Crank.sln b/Microsoft.Crank.sln index 6ae2f6a41..33e1d4114 100644 --- a/Microsoft.Crank.sln +++ b/Microsoft.Crank.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.0.11111.16 d18.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.31911.260 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{995FCFF9-E5F6-4BDD-8E5B-FBDEA21145F9}" EndProject From fe44fa291e4152f928a09bed84264fb0729e6519 Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 27 Nov 2025 14:28:37 +0100 Subject: [PATCH 6/6] split up windows only paths --- .../JobsControllerTests.cs | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs index 3edc24ad6..ffb4c3e66 100644 --- a/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs +++ b/test/Microsoft.Crank.UnitTests/JobsControllerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Crank.Agent.Controllers; @@ -22,13 +23,9 @@ public JobsControllerTests(ITestOutputHelper output) [Theory] [InlineData("$&\n\r.\\execute")] [InlineData("../../../../../../etc/passwd")] // Unix-style traversal - [InlineData("..\\..\\..\\Windows\\System32\\config\\SAM")] // Windows system files [InlineData("../../../../../../../var/log/syslog")] // Absolute path traversal - [InlineData("..\\\\..\\\\..\\\\sensitive.txt")] // Double backslash [InlineData("foo/../../../etc/passwd")] // Mixed valid and traversal [InlineData("/etc/passwd")] // Absolute Unix path - [InlineData("\\\\network\\share\\file.txt")] // UNC path - [InlineData("..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\file.txt")] // Excessive traversal [InlineData("./../../../../../../etc/shadow")] // Dot slash mixed [InlineData("test/../../../../../../etc/hosts")] // Valid then traversal public async Task Download_PathTraversalAttempts_ReturnsBadRequest(string path) @@ -73,6 +70,60 @@ public async Task Download_PathTraversalAttempts_ReturnsBadRequest(string path) } } + [Theory] + [InlineData("..\\..\\..\\Windows\\System32\\config\\SAM")] // Windows system files + [InlineData("..\\\\..\\\\..\\\\sensitive.txt")] // Double backslash + [InlineData("\\\\network\\share\\file.txt")] // UNC path + [InlineData("..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\file.txt")] // Excessive traversal + public async Task Download_PathTraversalAttempts_Windows_ReturnsBadRequest(string path) + { + // Skip this test on non-Windows platforms since backslash paths are only relevant on Windows + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _output.WriteLine("Skipping Windows-specific path test on non-Windows platform"); + return; + } + + // Use absolute path for testing + var tempDir = Path.GetTempPath(); + var jobDir = Path.Combine(tempDir, "crank_test_jobs", "1"); + Directory.CreateDirectory(jobDir); + + try + { + var jobRepo = new JobsRepository(); + jobRepo.Add(new() + { + Id = 1, + State = JobState.Running, + BasePath = jobDir + }); + + var jobsController = new JobsController(jobRepo); + + var result = await jobsController.Download(1, path); + + // Log diagnostic information + _output.WriteLine($"Testing path: {path}"); + _output.WriteLine($"BasePath: {jobDir}"); + _output.WriteLine($"Result type: {result.GetType().Name}"); + + Assert.IsType(result); + } + finally + { + // Cleanup + try + { + Directory.Delete(Path.Combine(tempDir, "crank_test_jobs"), true); + } + catch + { + // Ignore cleanup errors + } + } + } + [Fact] public async Task Download_DiagnosticTest_ShowsPathResolution() {