using allstarr.Core.ManagedFiles; using System.Security.Cryptography; using System.Text; namespace allstarr.Tests; public sealed class FilePlacementServiceTests : IDisposable { private readonly string testRoot = Path.Combine(Path.GetTempPath(), $"allstarr-placement-{Guid.NewGuid():N}"); [Fact] public async Task PlaceAsync_RendersSafeTemplateAndCopiesUnownedSource() { var source = CreateSource("source/song.flac", "audio-payload"); var targetRoot = Path.Combine(testRoot, "managed"); var store = new MemoryOwnershipStore(); var operations = new RecordingOperations(hardLinkResult: true, reflinkResult: false); var service = new FilePlacementService(store, operations); var result = await service.PlaceAsync(Request(source, targetRoot, sourceManaged: false)); Assert.Equal(ManagedFilePlacementMethod.Copy, result.File.PlacementMethod); Assert.Equal(Path.Combine(targetRoot, "Artist", "Album", "03 - A_B.flac"), result.File.CanonicalPath); Assert.Equal("audio-payload", await File.ReadAllTextAsync(result.File.CanonicalPath)); Assert.False(result.Reused); Assert.Equal(1, operations.CopyCalls); Assert.Equal(0, operations.HardLinkCalls); Assert.True(result.File.IsManaged); } [Theory] [InlineData("../{artist}/{title}")] [InlineData("/{artist}/{title}")] [InlineData("{artist}/../../{title}")] [InlineData("{unknown}/{title}")] public async Task PlaceAsync_RejectsUnsafeTemplates(string template) { var source = CreateSource("source/song.flac", "untouched"); var service = new FilePlacementService(new MemoryOwnershipStore(), new RecordingOperations(false, false)); var request = Request(source, Path.Combine(testRoot, "managed"), false) with { PathTemplate = template }; await Assert.ThrowsAnyAsync(() => service.PlaceAsync(request)); Assert.Equal("untouched", await File.ReadAllTextAsync(source)); } [Fact] public async Task PlaceAsync_RejectsSymlinkEscapeAndLeavesSourceUntouched() { if (OperatingSystem.IsWindows()) return; var source = CreateSource("source/song.flac", "untouched"); var targetRoot = Path.Combine(testRoot, "managed"); var outside = Path.Combine(testRoot, "outside"); Directory.CreateDirectory(targetRoot); Directory.CreateDirectory(outside); Directory.CreateSymbolicLink(Path.Combine(targetRoot, "Artist"), outside); var service = new FilePlacementService(new MemoryOwnershipStore(), new RecordingOperations(false, false)); await Assert.ThrowsAsync(() => service.PlaceAsync(Request(source, targetRoot, false))); Assert.Empty(Directory.EnumerateFiles(outside)); Assert.Equal("untouched", await File.ReadAllTextAsync(source)); } [Fact] public async Task PlaceAsync_UsesHardLinkOnlyForImmutableManagedSourceAndFallsBackAcrossVolumes() { var source = CreateSource("source/song.flac", "audio"); var operations = new RecordingOperations(hardLinkResult: false, reflinkResult: false); var service = new FilePlacementService(new MemoryOwnershipStore(), operations); var result = await service.PlaceAsync(Request(source, Path.Combine(testRoot, "managed"), true)); Assert.Equal(1, operations.HardLinkCalls); Assert.Equal(1, operations.ReflinkCalls); Assert.Equal(1, operations.CopyCalls); Assert.Equal(ManagedFilePlacementMethod.Copy, result.File.PlacementMethod); Assert.Equal("audio", await File.ReadAllTextAsync(source)); } [Fact] public async Task PlaceAsync_RejectsSourceThatNoLongerMatchesVerifiedArtifact() { var source = CreateSource("source/song.flac", "modified-audio"); var expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("verified-audio"))) .ToLowerInvariant(); var operations = new RecordingOperations(hardLinkResult: true, reflinkResult: false); var service = new FilePlacementService(new MemoryOwnershipStore(), operations); var request = Request(source, Path.Combine(testRoot, "managed"), true) with { SourceIsImmutable = false, ExpectedContentSha256 = expected, ExpectedLength = Encoding.UTF8.GetByteCount("verified-audio") }; await Assert.ThrowsAsync(() => service.PlaceAsync(request)); Assert.Equal(0, operations.HardLinkCalls); Assert.False(Directory.Exists(Path.Combine(testRoot, "managed"))); } [Fact] public async Task PlaceAsync_DoesNotHardLinkProviderWritableSource() { var source = CreateSource("source/song.flac", "verified-audio"); var expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("verified-audio"))) .ToLowerInvariant(); var operations = new RecordingOperations(hardLinkResult: true, reflinkResult: false); var service = new FilePlacementService(new MemoryOwnershipStore(), operations); var request = Request(source, Path.Combine(testRoot, "managed"), true) with { SourceIsImmutable = false, ExpectedContentSha256 = expected, ExpectedLength = Encoding.UTF8.GetByteCount("verified-audio") }; var result = await service.PlaceAsync(request); Assert.Equal(0, operations.HardLinkCalls); Assert.Equal(1, operations.ReflinkCalls); Assert.Equal(ManagedFilePlacementMethod.Copy, result.File.PlacementMethod); } [Fact] public async Task PlaceAsync_RepeatedRequestReusesOwnedContentAndIncrementsReference() { var source = CreateSource("source/song.flac", "same-audio"); var store = new MemoryOwnershipStore(); var operations = new RecordingOperations(false, false); var service = new FilePlacementService(store, operations); var first = await service.PlaceAsync(Request(source, Path.Combine(testRoot, "managed"), false)); var second = await service.PlaceAsync(Request(source, Path.Combine(testRoot, "managed"), false)); Assert.False(first.Reused); Assert.True(second.Reused); Assert.Equal(first.File.Id, second.File.Id); Assert.Equal(2, second.File.ReferenceCount); Assert.Equal(1, operations.CopyCalls); } [Fact] public async Task PlaceAsync_RejectsMutatedManagedFileInsteadOfAddingAReference() { var source = CreateSource("source/song.flac", "same-audio"); var store = new MemoryOwnershipStore(); var service = new FilePlacementService(store, new RecordingOperations(false, false)); var request = Request(source, Path.Combine(testRoot, "managed"), false); var first = await service.PlaceAsync(request); await File.WriteAllTextAsync(first.File.CanonicalPath, "evil-audio"); await Assert.ThrowsAsync(() => service.PlaceAsync(request)); Assert.Equal(1, first.File.ReferenceCount); } [Fact] public async Task PlaceAsync_UnrelatedCollisionGetsDeterministicFingerprintSuffix() { var source = CreateSource("source/song.flac", "new-audio"); var targetRoot = Path.Combine(testRoot, "managed"); var occupied = Path.Combine(targetRoot, "Artist", "Album", "03 - A_B.flac"); Directory.CreateDirectory(Path.GetDirectoryName(occupied)!); await File.WriteAllTextAsync(occupied, "unrelated"); var service = new FilePlacementService(new MemoryOwnershipStore(), new RecordingOperations(false, false)); var result = await service.PlaceAsync(Request(source, targetRoot, false)); Assert.Matches(@"03 - A_B \[[0-9a-f]{12}\]\.flac$", result.File.CanonicalPath); Assert.Equal("unrelated", await File.ReadAllTextAsync(occupied)); } [Fact] public async Task PlaceAsync_OwnershipFailureRemovesPartialOutputButNeverSource() { var source = CreateSource("source/song.flac", "source-safe"); var store = new MemoryOwnershipStore { FailAdd = true }; var targetRoot = Path.Combine(testRoot, "managed"); var service = new FilePlacementService(store, new RecordingOperations(false, false)); await Assert.ThrowsAsync(() => service.PlaceAsync(Request(source, targetRoot, false))); Assert.Equal("source-safe", await File.ReadAllTextAsync(source)); Assert.Empty(Directory.EnumerateFiles(targetRoot, "*.flac", SearchOption.AllDirectories)); Assert.Empty(Directory.EnumerateFiles(Path.Combine(targetRoot, ".allstarr-staging"))); } [Fact] public async Task RemoveAsync_RequiresExplicitOwnershipAndSingleReference() { var path = CreateSource("managed/song.flac", "managed"); var record = new ManagedFileRecord(Guid.NewGuid(), Guid.NewGuid(), path, new string('a', 64), 7, ManagedFilePlacementMethod.Copy, Guid.NewGuid(), Guid.NewGuid(), "library", Guid.NewGuid(), "owned-scope", 2, true, DateTimeOffset.UtcNow) { TargetRootPath = Path.GetDirectoryName(path)! }; var store = new MemoryRemovalStore(record); var service = new ManagedFileRemovalService(store); await Assert.ThrowsAsync(() => service.RemoveAsync(record.Id, "owned-scope", false)); await Assert.ThrowsAsync(() => service.RemoveAsync(record.Id, "other-scope", true)); await Assert.ThrowsAsync(() => service.RemoveAsync(record.Id, "owned-scope", true)); Assert.True(File.Exists(path)); Assert.False(store.Removed); } [Fact] public async Task RemoveAsync_DeletesOnlyExplicitlySelectedOwnedOutput() { var path = CreateSource("managed/song.flac", "managed"); var record = new ManagedFileRecord(Guid.NewGuid(), Guid.NewGuid(), path, new string('a', 64), 7, ManagedFilePlacementMethod.Copy, Guid.NewGuid(), Guid.NewGuid(), "library", Guid.NewGuid(), "owned-scope", 1, true, DateTimeOffset.UtcNow) { TargetRootPath = Path.GetDirectoryName(path)! }; var store = new MemoryRemovalStore(record); await new ManagedFileRemovalService(store).RemoveAsync(record.Id, "owned-scope", true); Assert.False(File.Exists(path)); Assert.True(store.Removed); } [Fact] public async Task RemoveAsync_RejectsAncestorSymlinkSwapOutsideRecordedRoot() { if (OperatingSystem.IsWindows()) return; var managedRoot = Path.Combine(testRoot, "managed"); var artistDirectory = Path.Combine(managedRoot, "Artist"); var original = CreateSource("managed/Artist/song.flac", "managed"); var outside = CreateSource("outside/song.flac", "outside-safe"); Directory.Delete(artistDirectory, recursive: true); Directory.CreateSymbolicLink(artistDirectory, Path.GetDirectoryName(outside)!); var record = new ManagedFileRecord(Guid.NewGuid(), Guid.NewGuid(), original, new string('a', 64), 7, ManagedFilePlacementMethod.Copy, Guid.NewGuid(), Guid.NewGuid(), "library", Guid.NewGuid(), "owned-scope", 1, true, DateTimeOffset.UtcNow) { TargetRootPath = managedRoot }; var store = new MemoryRemovalStore(record); await Assert.ThrowsAsync(() => new ManagedFileRemovalService(store).RemoveAsync(record.Id, "owned-scope", true)); Assert.Equal("outside-safe", await File.ReadAllTextAsync(outside)); Assert.False(store.Removed); } private ManagedFilePlacementRequest Request(string source, string root, bool sourceManaged) => new( new(Guid.NewGuid(), Path.GetFullPath(root), Guid.NewGuid(), Guid.NewGuid(), "library-1"), source, "{albumArtist}/{album}/{track:00} - {title}", new("A/B", "Artist", "Album", Track: 3, Extension: ".flac"), Guid.NewGuid(), "tenant/user/library", sourceManaged); private string CreateSource(string relative, string content) { var path = Path.Combine(testRoot, relative); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, content); return path; } public void Dispose() { if (Directory.Exists(testRoot)) Directory.Delete(testRoot, recursive: true); } private sealed class RecordingOperations(bool hardLinkResult, bool reflinkResult) : IManagedFileOperations { public int HardLinkCalls { get; private set; } public int ReflinkCalls { get; private set; } public int CopyCalls { get; private set; } public bool TryCreateHardLink(string linkPath, string existingPath) { HardLinkCalls++; if (hardLinkResult) File.Copy(existingPath, linkPath); return hardLinkResult; } public bool TryCreateReflink(string destinationPath, string sourcePath) { ReflinkCalls++; if (reflinkResult) File.Copy(sourcePath, destinationPath); return reflinkResult; } public async Task CopyAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) { CopyCalls++; await using var source = File.OpenRead(sourcePath); await using var destination = new FileStream(destinationPath, FileMode.CreateNew); await source.CopyToAsync(destination, cancellationToken); } public void MoveNoReplace(string sourcePath, string destinationPath) => File.Move(sourcePath, destinationPath, false); } private sealed class MemoryOwnershipStore : IManagedFileOwnershipStore { private readonly Dictionary records = []; public bool FailAdd { get; init; } public Task FindByPathAsync(string canonicalPath, CancellationToken cancellationToken) => Task.FromResult(records.Values.SingleOrDefault(item => item.CanonicalPath == canonicalPath)); public Task FindCompatibleAsync(Guid rootId, string contentSha256, string scopeKey, CancellationToken cancellationToken) => Task.FromResult(records.Values.SingleOrDefault(item => item.RootId == rootId && item.ContentSha256 == contentSha256 && item.ScopeKey == scopeKey)); public Task AddAsync(ManagedFileRecord record, CancellationToken cancellationToken) { if (FailAdd) throw new InvalidOperationException("simulated ownership failure"); records.Add(record.Id, record); return Task.FromResult(record); } public Task AddReferenceAsync(Guid id, CancellationToken cancellationToken) { var record = records[id] with { ReferenceCount = records[id].ReferenceCount + 1 }; records[id] = record; return Task.FromResult(record); } } private sealed class MemoryRemovalStore(ManagedFileRecord record) : IManagedFileRemovalStore { public bool Removed { get; private set; } public Task GetAsync(Guid id, CancellationToken cancellationToken) => Task.FromResult(id == record.Id ? record : null); public Task MarkRemovedAsync(Guid id, CancellationToken cancellationToken) { Removed = true; return Task.CompletedTask; } } }