First half of replacing the stub text domain with a media collector. Nothing references this yet - the store is standalone and fully tested before anything depends on it. Blobs are addressed by SHA-256 and sharded two levels deep, so the same picture re-uploaded at a dozen addresses costs one file. Downloads stage in a sibling temp directory on the same volume and are promoted by rename, which is what keeps blobs/ free of truncated files: a crash leaves a stray .part that the next startup sweeps, never a half-image indistinguishable from a real one. The SQLite index holds provenance separately from content, so purging one source leaves blobs another source still references - that is what ref_count buys, and it is recomputed rather than incremented because the item upsert can replace a row pointing at a different blob. The seen_url journal deliberately outlives a purge: without that, the next run downloads again exactly what the user just deleted. Terminal outcomes are split from retryable ones so a flaky network does not permanently lose content. The showcase gives every item a dated, named path via hard links - a second name for one file, not a second file. Hard links are a filesystem privilege rather than a guarantee, so it degrades to copying and records which it achieved; the UI has to be able to admit that. Names suggested by the origin are treated as hostile: only the last path segment survives, Windows device names are pushed aside, and the extension comes from the sniffed kind, never from the remote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
2.4 KiB
C#
77 lines
2.4 KiB
C#
using AvParser.Infrastructure.Media;
|
|
|
|
namespace AvParser.Infrastructure.Tests.Media;
|
|
|
|
public class ShowcaseNameTests
|
|
{
|
|
private const string Hash = "a3f19b7c5d2e8f0142536475869708192a3b4c5d6e7f8091a2b3c4d5e6f70819";
|
|
|
|
[Fact]
|
|
public void A_plain_name_is_kept()
|
|
{
|
|
ShowcaseLinker.SanitiseName("kitten", Hash).ShouldBe("kitten");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_absent_name_falls_back_to_the_hash()
|
|
{
|
|
ShowcaseLinker.SanitiseName(null, Hash).ShouldBe(Hash[..12]);
|
|
ShowcaseLinker.SanitiseName(" ", Hash).ShouldBe(Hash[..12]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../../etc/passwd")]
|
|
[InlineData("..\\..\\windows\\system32\\config")]
|
|
[InlineData("/absolute/path/name")]
|
|
public void A_name_cannot_walk_out_of_its_directory(string suggested)
|
|
{
|
|
// The suggestion comes from a remote server, so it is treated as hostile rather than
|
|
// merely untidy: only the last segment survives, and separators never do.
|
|
var name = ShowcaseLinker.SanitiseName(suggested, Hash);
|
|
|
|
name.ShouldNotContain("/");
|
|
name.ShouldNotContain("\\");
|
|
name.ShouldNotContain("..");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_extension_the_origin_suggested_is_dropped()
|
|
{
|
|
// The extension is derived from the sniffed kind, never from what the origin claimed.
|
|
ShowcaseLinker.SanitiseName("photo.exe", Hash).ShouldBe("photo");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("CON")]
|
|
[InlineData("nul")]
|
|
[InlineData("COM1")]
|
|
[InlineData("lpt9")]
|
|
public void Windows_device_names_are_pushed_out_of_the_way(string reserved)
|
|
{
|
|
ShowcaseLinker.SanitiseName(reserved, Hash).ShouldBe($"_{reserved}");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_over_long_name_is_trimmed()
|
|
{
|
|
var name = ShowcaseLinker.SanitiseName(new string('x', 400), Hash);
|
|
|
|
name.Length.ShouldBeLessThanOrEqualTo(80);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_name_of_nothing_but_junk_falls_back_to_the_hash()
|
|
{
|
|
// Dots rather than, say, '?': the set of invalid filename characters differs by platform,
|
|
// but a name that trims away to nothing does so everywhere.
|
|
ShowcaseLinker.SanitiseName("...", Hash).ShouldBe(Hash[..12]);
|
|
ShowcaseLinker.SanitiseName("\t\t", Hash).ShouldBe(Hash[..12]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Control_characters_are_replaced_but_spaces_survive()
|
|
{
|
|
ShowcaseLinker.SanitiseName("a b\tc", Hash).ShouldBe("a b_c");
|
|
}
|
|
}
|