StoreFileName.Combine: batch ids from the command line cannot leave the artifact root

IpaTrialWorker song-score took --batch-id raw into Path.Combine; the orchestrator
sites share the new Combine. Tests cover traversal and absolute batch ids and the
underscore on device stems (nul.x, LPT1.x); drop the double From on the emitter name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-07 10:33:47 +00:00
commit 80ebf39033
4 changed files with 26 additions and 4 deletions

View file

@ -48,6 +48,9 @@ public static class StoreFileName
return $"{safe}-{hash}";
}
/// <summary>A child of <paramref name="root"/> named for an external id; the result never leaves the root.</summary>
public static string Combine(string root, string id) => Path.Combine(root, From(id));
private static bool IsFixedPoint(string id, string safe)
{
if (safe != id || id.EndsWith('.') || id.Any(char.IsAsciiLetterUpper) || IsDeviceStem(id) || EndsWithHashShape(id))

View file

@ -124,7 +124,7 @@ public static class IpaTrialOrchestrator
throw new ArgumentException("At least one IPA trial target set is required.", nameof(trialSets));
}
var batchDirectory = Path.Combine(artifactRoot, StoreFileName.From(options.BatchId));
var batchDirectory = StoreFileName.Combine(artifactRoot, options.BatchId);
Directory.CreateDirectory(batchDirectory);
var storePath = Path.Combine(batchDirectory, options.TrialResultStoreFileName);
var referenceRenderer = new PinkTromboneReferenceRenderer();
@ -156,7 +156,7 @@ public static class IpaTrialOrchestrator
var candidateDir = Path.Combine(round.RoundDirectory, "audio", candidate.Id);
Directory.CreateDirectory(candidateDir);
var script = await File.ReadAllTextAsync(candidate.ScriptPath, cancellationToken).ConfigureAwait(false);
var source = FaustEmitter.EmitScript(script, new FaustExportOptions(StoreFileName.From(candidate.Id))).Source;
var source = FaustEmitter.EmitScript(script, new FaustExportOptions(candidate.Id)).Source;
var dspPath = Path.Combine(candidateDir, "candidate.dsp");
await File.WriteAllTextAsync(dspPath, source, cancellationToken).ConfigureAwait(false);
@ -274,7 +274,7 @@ public static class IpaTrialOrchestrator
throw new ArgumentException("At least one IPA script candidate is required.", nameof(candidates));
}
var batchDirectory = Path.Combine(artifactRoot, StoreFileName.From(options.BatchId));
var batchDirectory = StoreFileName.Combine(artifactRoot, options.BatchId);
var candidateRoot = Path.Combine(batchDirectory, "agent-candidates");
var timelineRoot = Path.Combine(batchDirectory, "agent-timelines");
Directory.CreateDirectory(candidateRoot);

View file

@ -43,9 +43,12 @@ public sealed class StoreFileNameTests
[InlineData("COM9.f32")]
[InlineData("lpt0")]
[InlineData("prn.cc")]
[InlineData("nul.x")]
[InlineData("LPT1.x")]
public void WindowsDeviceStemsAreRewritten(string id)
{
var name = StoreFileName.From(id);
Assert.StartsWith("_", name);
Assert.NotEqual(id.ToLowerInvariant(), name.ToLowerInvariant());
Assert.Matches("-[0-9a-f]{16}$", name);
var stem = name.Split('.')[0].TrimEnd(' ');
@ -115,5 +118,21 @@ public sealed class StoreFileNameTests
}
}
[Theory]
[InlineData("../x")]
[InlineData("..\\x")]
[InlineData("/etc/x")]
[InlineData("C:\\x")]
public void BatchIdsFromTheCommandLineCannotLeaveTheArtifactRoot(string batchId)
{
var root = Path.Combine(Path.GetTempPath(), "artifact-root-probe", "artifacts");
var full = Path.GetFullPath(StoreFileName.Combine(root, batchId));
Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(full));
}
[Fact]
public void BareDotDotBatchIdIsRefused() =>
Assert.Throws<ArgumentException>(() => StoreFileName.Combine(Path.GetTempPath(), ".."));
private static string UriPath(string uri) => new Uri(uri).LocalPath;
}

View file

@ -214,7 +214,7 @@ static async Task SongScoreAsync(Dictionary<string, string> options)
throw new InvalidDataException($"No .aqua song candidates found under `{patchRoot}`.");
}
var batchDirectory = Path.Combine(artifactRoot, batchId);
var batchDirectory = StoreFileName.Combine(artifactRoot, batchId);
var candidateRoot = Path.Combine(batchDirectory, "song-candidates");
var timelineRoot = Path.Combine(batchDirectory, "song-timelines");
Directory.CreateDirectory(candidateRoot);