AquaSynth/src/AquaSynth.Core/StoreFileName.cs
Aqua afd0cc9ec3 StoreFileName: device stems no longer survive as the stem; IpaTrialWorker paths use it
"nul.x" was rewritten to "nul.x-<hash>", whose stem is still NUL on Windows;
rewritten device names now get a leading underscore, and the test asserts the
stem is not reserved. IpaTrialWorker candidate directories go through
StoreFileName.From; its remaining SafeName only slugs uuid-suffixed record ids.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 09:54:00 +00:00

87 lines
3.2 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.
/// 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 : '_');
}
var safe = builder.ToString();
if (IsFixedPoint(id, safe))
{
return safe;
}
// "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}";
}
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');
}
}