One StoreFileName in Core: no id collisions, no '..' escape
The daemon and native-runtime SafeFileName copies sanitised differently: the daemon mapped a/b, a b, a:b and a-b to the same name (a later patch overwrote an earlier artifact) and returned ".." unchanged, so a sessionId or runId of ".." resolved one directory above the store. Replace both with AquaSynth.Dsl.StoreFileName.From. Ids within [A-Za-z0-9._-] pass through; any other id is sanitised and suffixed with the first 8 hex of SHA-256 of the original. Empty, "." and all-dot ids are refused (ArgumentException). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
df21ea7bea
commit
396da106cd
4 changed files with 114 additions and 27 deletions
38
src/AquaSynth.Core/StoreFileName.cs
Normal file
38
src/AquaSynth.Core/StoreFileName.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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.
|
||||
/// 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 "..".
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 +918,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 +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('\\', '/')}";
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
Normal file
68
tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
Normal file
|
|
@ -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<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 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")));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue