diff --git a/src/AquaSynth.Core/StoreFileName.cs b/src/AquaSynth.Core/StoreFileName.cs new file mode 100644 index 0000000..c18bda1 --- /dev/null +++ b/src/AquaSynth.Core/StoreFileName.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AquaSynth.Dsl; + +/// +/// The one mapping from a caller-chosen id to a single file-name component in a store. +/// Ids already within [A-Za-z0-9._-] pass through unchanged. Anything else is sanitised and +/// suffixed with the first 8 hex of SHA-256 of the original id, so distinct ids stay distinct. +/// The result is never a path: no separators, never "." or "..". +/// +public static class StoreFileName +{ + 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(id.Length); + foreach (var c in id) + { + builder.Append(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-' or '_' or '.' ? c : '_'); + } + + var safe = builder.ToString(); + // A trailing dot is dropped by Windows, so "a." would alias "a"; hash it like any other rewrite. + if (safe == id && !id.EndsWith('.')) + { + return safe; + } + + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id)))[..8].ToLowerInvariant(); + return $"{safe}-{hash}"; + } +} diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs index 28ae62d..74f6830 100644 --- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs +++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs @@ -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(); @@ -703,8 +703,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 +741,7 @@ public sealed class AquaSynthDaemonService : IDisposable .GroupBy(frame => frame.Block) .ToDictionary(group => group.Key, group => NormalizeControls(group.Last().Controls), EqualityComparer.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(); using (var stream = patch.CreateStreamingPatch()) @@ -918,7 +918,7 @@ public sealed class AquaSynthDaemonService : IDisposable private async Task WriteReceiptAsync(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 +1129,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('\\', '/')}"; diff --git a/src/AquaSynth.Faust/NativeFaustRuntime.cs b/src/AquaSynth.Faust/NativeFaustRuntime.cs index e6b44c4..99bc288 100644 --- a/src/AquaSynth.Faust/NativeFaustRuntime.cs +++ b/src/AquaSynth.Faust/NativeFaustRuntime.cs @@ -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 diff --git a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs new file mode 100644 index 0000000..24ad104 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs @@ -0,0 +1,68 @@ +using AquaSynth.Faust; + +namespace AquaSynth.Dsl.Tests; + +public sealed class StoreFileNameTests +{ + [Fact] + public void DistinctIdsGetDistinctNames() + { + 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")] + public void CleanIdsPassThroughUnchanged(string id) => Assert.Equal(id, StoreFileName.From(id)); + + [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()))[..8].ToLowerInvariant(); + Assert.Equal($"a_b-{expected}", StoreFileName.From("a b")); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("")] + public void DotsAndEmptyAreRefused(string id) => Assert.Throws(() => 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 DaemonReceiptForTraversalCommandIdStaysInsideStore() + { + 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)); + + await service.SampleAsync(new AquaSynthInstrumentSampleCommand( + "../../escape", "test.invalid", "test_invalid", "this is not aqua syntax", DurationSeconds: 0.1f)); + + Assert.Empty(Directory.GetFiles(parent)); + Assert.Empty(Directory.GetDirectories(parent).Where(d => Path.GetFileName(d) != "store")); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(storeRoot, "compile"))); + } +}