AquaSynth/src/AquaSynth.Core/StoreFileName.cs
Aqua 396da106cd 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>
2026-11-06 18:29:25 +00:00

38 lines
1.3 KiB
C#

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}";
}
}