diff --git a/src/AquaSynth.Core/StoreFileName.cs b/src/AquaSynth.Core/StoreFileName.cs
index 0de02d1..9dcac70 100644
--- a/src/AquaSynth.Core/StoreFileName.cs
+++ b/src/AquaSynth.Core/StoreFileName.cs
@@ -13,6 +13,11 @@ namespace AquaSynth.Dsl;
/// 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 "..".
///
+///
+/// Not handled: symlinks and junctions already inside a store are followed, and Windows 8.3 short names
+/// were not verified. An id with an uppercase letter is rewritten, so a batch directory named for such
+/// an id gains a hash suffix.
+///
public static class StoreFileName
{
private const int MaxStem = 100;
diff --git a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
index b083f6f..8da1f52 100644
--- a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
@@ -160,6 +160,33 @@ public sealed class StoreFileNameTests
Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(full));
}
+ [Theory]
+ [InlineData("\\\\host\\share\\x")]
+ [InlineData("C:x")]
+ [InlineData("a:b")]
+ [InlineData("x.")]
+ [InlineData("x ")]
+ [InlineData("x. ")]
+ public void WindowsAbsoluteAndTrailingDotOrSpaceIdsStayContainedAndPlain(string id)
+ {
+ var root = Path.Combine(Path.GetTempPath(), "artifact-root-probe", "artifacts");
+ var name = StoreFileName.From(id);
+ Assert.Matches("^[A-Za-z0-9._-]+$", name);
+ Assert.False(name.EndsWith('.') || name.EndsWith(' '));
+ Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(Path.GetFullPath(StoreFileName.Combine(root, id))));
+ }
+
+ [Theory]
+ [InlineData("../x")]
+ [InlineData("a/b")]
+ [InlineData("name:with:colons")]
+ public void LoweredDspFileNameStaysInTheOutputDirectory(string name)
+ {
+ var outDir = Path.Combine(Path.GetTempPath(), "aqualower-probe", "out");
+ var file = StoreFileName.From(name) + ".dsp";
+ Assert.Equal(Path.GetFullPath(outDir), Path.GetDirectoryName(Path.GetFullPath(Path.Combine(outDir, file))));
+ }
+
[Fact]
public void BareDotDotBatchIdIsRefused() =>
Assert.Throws(() => StoreFileName.Combine(Path.GetTempPath(), ".."));
diff --git a/tools/AquaLower/Program.cs b/tools/AquaLower/Program.cs
index e2d4948..8ee3fd7 100644
--- a/tools/AquaLower/Program.cs
+++ b/tools/AquaLower/Program.cs
@@ -47,8 +47,9 @@ foreach (var spec in args.Skip(2))
warnings.AddRange(export.Warnings);
}
else source = text;
- File.WriteAllText(Path.Combine(outDir, name + ".dsp"), source);
- entries.Add(new { name, origin = rel.Replace('\\', '/'), kind = path.EndsWith(".aqua", StringComparison.Ordinal) ? "aqua" : "faust", dsp = name + ".dsp", warnings });
+ var dspFile = StoreFileName.From(name) + ".dsp";
+ File.WriteAllText(Path.Combine(outDir, dspFile), source);
+ entries.Add(new { name, origin = rel.Replace('\\', '/'), kind = path.EndsWith(".aqua", StringComparison.Ordinal) ? "aqua" : "faust", dsp = dspFile, warnings });
Console.WriteLine($"{name}: {source.Length} chars from {rel}, {warnings.Count} warnings");
}
File.WriteAllText(Path.Combine(outDir, "catalog.json"), JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true }));