One StoreFileName in Core: no id collisions, no '..' escape #11

Merged
aqua merged 5 commits from aqua/safe-filename-collide-dotdot into main 2026-11-07 10:39:10 +00:00
7 changed files with 249 additions and 46 deletions

View file

@ -0,0 +1,90 @@
using System.Security.Cryptography;
using System.Text;
namespace AquaSynth.Dsl;
/// <summary>
/// The one mapping from a caller-chosen id to a single file-name component in a store.
/// An id passes through unchanged only if it is already a fixed point: ASCII [A-Za-z0-9._-],
/// all lowercase (stores live on case-insensitive filesystems), at most <see cref="MaxStem"/> long,
/// no trailing dot, not a Windows device stem (CON, NUL, COM1, ...), and not ending in a
/// "-" plus 16 hex digits. Every other id is sanitised and suffixed with "-" plus the first
/// 16 hex of SHA-256 of the original id. Passed-through names never end in the hash shape and
/// rewritten names always do, so the two sets cannot meet; distinct ids stay distinct up to a 64-bit
/// hash. The result is never a path: no separators, never "." or "..".
/// </summary>
public static class StoreFileName
{
private const int MaxStem = 100;
private const int HashLength = 16;
public static string From(string id)
{
ArgumentNullException.ThrowIfNull(id);
if (id.Length == 0 || id.All(c => c == '.'))
{
throw new ArgumentException($"Id '{id}' cannot name a store file.", nameof(id));
}
var builder = new StringBuilder(Math.Min(id.Length, MaxStem));
foreach (var c in id.Take(MaxStem))
{
builder.Append(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-' or '_' or '.' ? c : '_');

Case-insensitive filesystems (Medium, reproduced). A and a both pass through unchanged (From("A")=A, From("a")=a), so on NTFS/APFS defaults they are the same file. The test compares with OrdinalIgnoreCase but never feeds two ids differing only by case, so it cannot see this. Fix: hash when the id has any uppercase, or lowercase-fold into the hash input.

**Case-insensitive filesystems (Medium, reproduced).** `A` and `a` both pass through unchanged (`From("A")=A`, `From("a")=a`), so on NTFS/APFS defaults they are the same file. The test compares with `OrdinalIgnoreCase` but never feeds two ids differing only by case, so it cannot see this. Fix: hash when the id has any uppercase, or lowercase-fold into the hash input.
}
var safe = builder.ToString();
if (IsFixedPoint(id, safe))
{
return safe;

Windows reserved device names pass through (Medium on Windows, unproven on Linux). CON, nul, com1.x return unchanged (reproduced), and the daemon appends .cc/.f32/.dsp. On Windows CON.cc, NUL.cc, COM1.f32 still resolve to the device (extension is ignored), so a receipt is lost or errors. Treat the reserved stems (CON PRN AUX NUL COM0-9 LPT0-9, case-insensitive, any extension) as a rewrite. Not run on Windows here.

**Windows reserved device names pass through (Medium on Windows, unproven on Linux).** `CON`, `nul`, `com1.x` return unchanged (reproduced), and the daemon appends `.cc`/`.f32`/`.dsp`. On Windows `CON.cc`, `NUL.cc`, `COM1.f32` still resolve to the device (extension is ignored), so a receipt is lost or errors. Treat the reserved stems (CON PRN AUX NUL COM0-9 LPT0-9, case-insensitive, any extension) as a rewrite. Not run on Windows here.
}
// "nul.x-<hash>" still has the device stem "nul"; the underscore moves the whole name off it.
if (IsDeviceStem(safe))
{
safe = "_" + safe;
}
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id)))[..HashLength].ToLowerInvariant();
return $"{safe}-{hash}";

No length bound (Low). A 400-char clean id returns a 400-char name (reproduced); with .cc that exceeds the 255-byte component limit and throws IOException mid-command after earlier receipts were written. Not an escape. Cap the stem and put the hash in the tail. Also the refusal of ./../"" surfaces as an unhandled ArgumentException from SampleAsync; fine if the protocol layer maps it to a failed receipt, but I did not see that mapping.

**No length bound (Low).** A 400-char clean id returns a 400-char name (reproduced); with `.cc` that exceeds the 255-byte component limit and throws `IOException` mid-command after earlier receipts were written. Not an escape. Cap the stem and put the hash in the tail. Also the refusal of `.`/`..`/`""` surfaces as an unhandled `ArgumentException` from `SampleAsync`; fine if the protocol layer maps it to a failed receipt, but I did not see that mapping.
}
/// <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))
{
return false;
}
return true;
}
// Windows resolves CON, PRN, AUX, NUL, COM0-9, LPT0-9 as devices whatever the extension.
private static bool IsDeviceStem(string id)
{
var dot = id.IndexOf('.');
var stem = (dot < 0 ? id : id[..dot]).TrimEnd(' ');
return stem.Length switch
{
3 => stem.Equals("con", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("prn", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("aux", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("nul", StringComparison.OrdinalIgnoreCase),
4 => char.IsAsciiDigit(stem[3])
&& (stem.StartsWith("com", StringComparison.OrdinalIgnoreCase) || stem.StartsWith("lpt", StringComparison.OrdinalIgnoreCase)),
_ => false,
};
}
private static bool EndsWithHashShape(string id)
{
if (id.Length <= HashLength || id[^(HashLength + 1)] != '-')
{
return false;
}
return id[^HashLength..].All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
}
}

View file

@ -556,9 +556,9 @@ public sealed class AquaSynthDaemonService : IDisposable
session.Stream.ProcessBlock([], outputs, command.FrameCount, session.Controls);
var mono = MixMono(outputs, command.FrameCount, command.Gain * session.Gain);
var blockId = BlockId(command.CommandId);
var blockDir = Path.Combine(options.StoreRoot, "live", SafeFileName(command.SessionId));
var blockDir = Path.Combine(options.StoreRoot, "live", StoreFileName.From(command.SessionId));
Directory.CreateDirectory(blockDir);
var floatPath = Path.Combine(blockDir, $"{SafeFileName(blockId)}.f32");
var floatPath = Path.Combine(blockDir, $"{StoreFileName.From(blockId)}.f32");
await WriteFloat32Async(floatPath, mono).ConfigureAwait(false);
var sequence = session.NextSequence();
@ -661,8 +661,9 @@ public sealed class AquaSynthDaemonService : IDisposable
var renderId = RenderId(command.CommandId);
var sampleDir = Path.Combine(options.StoreRoot, "samples");

Escape still open (High, confirmed by code; success path not executed here: no libfaust on the review host). renderId = RenderId(command.CommandId) is $"{commandId}-render" (line 1128) and is used raw at 664-665: samples/{renderId}.f32 and .wav. A commandId of ../../x on a patch that compiles writes store/samples/../../x-render.f32|wav, i.e. outside the store. The PR migrated the receipt and live/stream sites but not this one. Fix: StoreFileName.From(renderId) here. The PR's daemon test cannot see it: it uses invalid syntax, so the compile-failed branch returns before this code.

**Escape still open (High, confirmed by code; success path not executed here: no libfaust on the review host).** `renderId = RenderId(command.CommandId)` is `$"{commandId}-render"` (line 1128) and is used raw at 664-665: `samples/{renderId}.f32` and `.wav`. A commandId of `../../x` on a patch that compiles writes `store/samples/../../x-render.f32|wav`, i.e. outside the store. The PR migrated the receipt and live/stream sites but not this one. Fix: `StoreFileName.From(renderId)` here. The PR's daemon test cannot see it: it uses invalid syntax, so the compile-failed branch returns before this code.
Directory.CreateDirectory(sampleDir);
var floatPath = Path.Combine(sampleDir, $"{renderId}.f32");
var wavPath = Path.Combine(sampleDir, $"{renderId}.wav");
var sampleStem = StoreFileName.From(renderId);
var floatPath = Path.Combine(sampleDir, $"{sampleStem}.f32");
var wavPath = Path.Combine(sampleDir, $"{sampleStem}.wav");
await WriteFloat32Async(floatPath, samples).ConfigureAwait(false);
await WriteWavAsync(wavPath, samples, patch.Manifest.SampleRate).ConfigureAwait(false);
@ -703,8 +704,8 @@ public sealed class AquaSynthDaemonService : IDisposable
var runId = $"{command.CommandId}-stream";
var verseId = "aquasynth.instrument";
var audioStreamId = $"aquasynth.audio.{SafeFileName(command.CommandId)}";
var controlStreamId = $"aquasynth.controls.{SafeFileName(command.CommandId)}";
var audioStreamId = $"aquasynth.audio.{StoreFileName.From(command.CommandId)}";
var controlStreamId = $"aquasynth.controls.{StoreFileName.From(command.CommandId)}";
if (!string.Equals(compile.Status, "succeeded", StringComparison.Ordinal))
{
@ -741,7 +742,7 @@ public sealed class AquaSynthDaemonService : IDisposable
.GroupBy(frame => frame.Block)
.ToDictionary(group => group.Key, group => NormalizeControls(group.Last().Controls), EqualityComparer<int>.Default);
var streamDir = Path.Combine(options.StoreRoot, "streams", SafeFileName(runId));
var streamDir = Path.Combine(options.StoreRoot, "streams", StoreFileName.From(runId));
Directory.CreateDirectory(streamDir);
var packets = new List<AquaSynthAutomationPacketReceipt>();
using (var stream = patch.CreateStreamingPatch())
@ -918,7 +919,7 @@ public sealed class AquaSynthDaemonService : IDisposable
private async Task WriteReceiptAsync<T>(string family, string id, T document, bool global = false)
where T : class
{
var filePath = Path.Combine(options.StoreRoot, family, $"{SafeFileName(id)}.cc");
var filePath = Path.Combine(options.StoreRoot, family, $"{StoreFileName.From(id)}.cc");
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
// Opening hydrates; a receipt file holds exactly one document, so everything else goes before the write.
using var cache = CultCacheMessagePack.Create(filePath);
@ -1129,18 +1130,7 @@ public sealed class AquaSynthDaemonService : IDisposable
private static string BlockId(string commandId) => $"{commandId}-block";
private static string SessionId(string patchId, string compileKey) => $"{SafeFileName(patchId)}-{compileKey[..Math.Min(12, compileKey.Length)]}";
private static string SafeFileName(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var c in value)
{
builder.Append(char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '-');
}
return builder.ToString().Trim('-');
}
private static string SessionId(string patchId, string compileKey) => $"{StoreFileName.From(patchId)}-{compileKey[..Math.Min(12, compileKey.Length)]}";
private static string ToArtifactUri(string path) => $"file:///{Path.GetFullPath(path).Replace('\\', '/')}";

View file

@ -222,12 +222,8 @@ public static class FaustCompiler
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null)
};
private static string SafeArtifactName(string value)
{
var sanitized = new string(value.Select(character =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '_').ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "aquasynth_patch" : sanitized;
}
private static string SafeArtifactName(string value) =>
string.IsNullOrWhiteSpace(value) ? "aquasynth_patch" : StoreFileName.From(value);
private static async Task<string?> FaustArchDirAsync(string faustPath, CancellationToken cancellationToken)
{

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, 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(SafeName(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, 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);
@ -298,7 +298,7 @@ public static class IpaTrialOrchestrator
var startedAt = DateTimeOffset.UtcNow;
var startedStamp = Stopwatch.GetTimestamp();
var safeCandidateId = SafeName(candidate.CandidateId);
var safeCandidateId = StoreFileName.From(candidate.CandidateId);
var candidateDir = Path.Combine(candidateRoot, safeCandidateId);
Directory.CreateDirectory(candidateDir);
var audioDir = Path.Combine(candidateDir, "audio");
@ -721,9 +721,6 @@ public static class IpaTrialOrchestrator
return samples;
}
private static string SafeName(string value) =>
new(value.Select(character => char.IsLetterOrDigit(character) ? character : '_').ToArray());
private static string Escape(string value) =>
value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')
? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""

View file

@ -633,18 +633,10 @@ public sealed class AquaSynthNativeCompiler : IDisposable
}
Directory.CreateDirectory(outputRoot);
var path = Path.Combine(outputRoot, $"{SafeFileName(patchId)}.dsp");
var path = Path.Combine(outputRoot, $"{StoreFileName.From(patchId)}.dsp");
File.WriteAllText(path, source);
return path;
}
private static string SafeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var name = new string(chars).Trim();
return string.IsNullOrWhiteSpace(name) ? "aquasynth_patch" : name;
}
}
public static class AquaSynthFaustToolchain

View file

@ -0,0 +1,138 @@
using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
public sealed class StoreFileNameTests
{
[Fact]
public void DistinctIdsGetDistinctNames()

Distinctness test is real but narrow. The seven names are genuinely distinct (hashes differ), so it would fail on the old collapse. It does not cover the collision above (a rewritten id's output as an id), case-only variants, or reserved names, which is where the implementation is weak. Add those as the failing cases.

**Distinctness test is real but narrow.** The seven names are genuinely distinct (hashes differ), so it would fail on the old collapse. It does not cover the collision above (a rewritten id's output as an id), case-only variants, or reserved names, which is where the implementation is weak. Add those as the failing cases.
{
var ids = new[] { "a/b", "a b", "a:b", "a-b", "a_b", "a\\b", "a?b" };
var names = ids.Select(StoreFileName.From).ToArray();
Assert.Equal(ids.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
[Theory]
[InlineData("dings.warm-bell")]
[InlineData("demo-stream-block")]
[InlineData("a_b.c-1")]
[InlineData("conx")]
[InlineData("com10")]
public void CleanIdsPassThroughUnchanged(string id) => Assert.Equal(id, StoreFileName.From(id));
[Fact]
public void RewrittenIdOutputAsAnIdDoesNotCollideWithTheOriginal()
{
var rewritten = StoreFileName.From("a b");
Assert.NotEqual(rewritten, StoreFileName.From(rewritten));
Assert.NotEqual(StoreFileName.From("a_b"), rewritten);
}
[Fact]
public void IdsDifferingOnlyByCaseGetDistinctNames()
{
var names = new[] { "A", "a", "Ab", "aB", "ab" }.Select(StoreFileName.From).ToArray();
Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
[Theory]
[InlineData("CON")]
[InlineData("nul")]
[InlineData("Aux.txt")]
[InlineData("com1")]
[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(' ');
Assert.DoesNotMatch("^(?i:con|prn|aux|nul|com[0-9]|lpt[0-9])$", stem);
}
[Fact]
public void OverlongIdsAreBoundedAndStayDistinct()
{
var a = StoreFileName.From(new string('x', 400));
var b = StoreFileName.From(new string('x', 399) + "y");
Assert.True(a.Length + ".cc".Length <= 255);
Assert.NotEqual(a, b);
}
[Fact]
public void RewrittenIdsCarryHashOfTheOriginal()
{
// sha256("a b") begins c2a5..., checked independently of the implementation.
var expected = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData("a b"u8.ToArray()))[..16].ToLowerInvariant();
Assert.Equal($"a_b-{expected}", StoreFileName.From("a b"));
}
[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("")]
public void DotsAndEmptyAreRefused(string id) => Assert.Throws<ArgumentException>(() => StoreFileName.From(id));
[Theory]
[InlineData("../..")]
[InlineData("../../etc/passwd")]
[InlineData("..\\..\\x")]
[InlineData("/abs/path")]
[InlineData("C:\\abs")]
[InlineData("a.")]
[InlineData("a/..")]
public void NoIdCanMakePathCombineEscapeTheRoot(string id)
{
var root = Path.Combine(Path.GetTempPath(), "store-root-probe", "store");
var name = StoreFileName.From(id);
Assert.Equal(name, Path.GetFileName(name));
Assert.NotEqual(".", name);
Assert.NotEqual("..", name);
var full = Path.GetFullPath(Path.Combine(root, name));
Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(full));
}
[Fact]
public async Task DaemonSampleFilesForDotDotCommandIdStayInsideStore()
{
var parent = Path.Combine(Path.GetTempPath(), "aquasynth-sfn-" + Guid.NewGuid().ToString("N"));
var storeRoot = Path.Combine(parent, "store");
using var service = new AquaSynthDaemonService(new AquaSynthDaemonOptions(storeRoot));
// A patch that compiles reaches the samples/ write; without native Faust only the compile-failed branch runs.
var result = await service.SampleAsync(new AquaSynthInstrumentSampleCommand(
"..", "test.sine", "test_sine",
"voice wave=sine freq=440 gain=.2 attack=.001 sustain=.05 decay=.05", DurationSeconds: 0.1f));
Assert.Empty(Directory.GetFiles(parent));
Assert.Empty(Directory.GetDirectories(parent).Where(d => Path.GetFileName(d) != "store"));
if (string.Equals(result.RenderReceipt.Status, "succeeded", StringComparison.Ordinal))
{
Assert.Equal(storeRoot, Path.GetDirectoryName(Path.GetDirectoryName(UriPath(result.RenderReceipt.Float32Uri))));
Assert.Equal(storeRoot, Path.GetDirectoryName(Path.GetDirectoryName(UriPath(result.RenderReceipt.WavUri))));
}
}
[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);
@ -228,7 +228,7 @@ static async Task SongScoreAsync(Dictionary<string, string> options)
{
var startedAt = DateTimeOffset.UtcNow;
var startedStamp = Stopwatch.GetTimestamp();
var safeCandidateId = SafeName(candidate.CandidateId);
var safeCandidateId = StoreFileName.From(candidate.CandidateId);
var candidateDir = Path.Combine(candidateRoot, safeCandidateId);
var audioDir = Path.Combine(candidateDir, "audio");
Directory.CreateDirectory(audioDir);
@ -2658,10 +2658,10 @@ static bool IsOfficialMusicArtifactPath(string path)
}
static string CandidatePatchPath(SongSummaryRow row) =>
Path.Combine(Path.GetDirectoryName(row.SummaryPath)!, "song-candidates", SafeName(row.CandidateId), "candidate.aqua");
Path.Combine(Path.GetDirectoryName(row.SummaryPath)!, "song-candidates", StoreFileName.From(row.CandidateId), "candidate.aqua");
static string CandidateAnalysisPath(SongSummaryRow row) =>
Path.Combine(Path.GetDirectoryName(row.SummaryPath)!, "song-candidates", SafeName(row.CandidateId), "candidate-analysis.md");
Path.Combine(Path.GetDirectoryName(row.SummaryPath)!, "song-candidates", StoreFileName.From(row.CandidateId), "candidate-analysis.md");
static string EvaluatorReportPath(SongSummaryRow row) =>
Path.Combine(Path.GetDirectoryName(row.SummaryPath)!, "evaluator-report.md");