mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 06:05:02 -04:00
606 lines
28 KiB
C#
606 lines
28 KiB
C#
using allstarr.Core.ManagedFiles;
|
|
using allstarr.Core.Enrichment;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class FilePlacementServiceTests : IDisposable
|
|
{
|
|
private readonly string testRoot = Path.Combine(Path.GetTempPath(), $"allstarr-placement-{Guid.NewGuid():N}");
|
|
|
|
[Fact]
|
|
public void ManagedScopeKey_IsStablePerOwnerRootAndLibraryButNotPerAction()
|
|
{
|
|
var tenant = Guid.CreateVersion7();
|
|
var owner = Guid.CreateVersion7();
|
|
var root = Guid.CreateVersion7();
|
|
|
|
var first = ManagedFileScopeKey.Create(tenant, owner, root, "music");
|
|
var retry = ManagedFileScopeKey.Create(tenant, owner, root, " music ");
|
|
|
|
Assert.Equal(first, retry);
|
|
Assert.NotEqual(first, ManagedFileScopeKey.Create(tenant, owner, root, "audiobooks"));
|
|
Assert.NotEqual(first, ManagedFileScopeKey.Create(tenant, owner, Guid.CreateVersion7(), "music"));
|
|
}
|
|
|
|
[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<Exception>(() => 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<UnauthorizedAccessException>(() => service.PlaceAsync(Request(source, targetRoot, false)));
|
|
|
|
Assert.Empty(Directory.EnumerateFiles(outside));
|
|
Assert.Equal("untouched", await File.ReadAllTextAsync(source));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlaceAsync_DoesNotTrustAnUnrecordedManagedSourceClaim()
|
|
{
|
|
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(0, 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_UsesIndependentCopyUntilDurableImmutabilityLeasesExist()
|
|
{
|
|
var source = CreateSource("source/song.flac", "audio");
|
|
var operations = new RecordingOperations(hardLinkResult: true, reflinkResult: false);
|
|
var store = new MemoryOwnershipStore();
|
|
var service = new FilePlacementService(store, operations);
|
|
var request = Request(source, Path.Combine(testRoot, "managed"), true) with
|
|
{
|
|
DestinationIsImmutable = true,
|
|
ReferenceKey = "destination-reference"
|
|
};
|
|
Assert.True(operations.TryGetFileIdentity(source, out var identity));
|
|
store.Seed(new ManagedFileRecord(Guid.NewGuid(), Guid.NewGuid(), source,
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("audio"))).ToLowerInvariant(), 5,
|
|
ManagedFilePlacementMethod.Copy, request.Root.TenantId, request.Root.OwnerUserId,
|
|
request.Root.LibraryScopeId, Guid.NewGuid(), request.ScopeKey, 1, true, DateTimeOffset.UtcNow)
|
|
{
|
|
TargetRootPath = Path.GetDirectoryName(source)!,
|
|
FileSystemDeviceId = identity.DeviceId,
|
|
FileSystemFileId = identity.FileId,
|
|
FileSystemLinkCount = identity.LinkCount
|
|
});
|
|
|
|
var result = await service.PlaceAsync(request);
|
|
|
|
Assert.Equal(ManagedFilePlacementMethod.Copy, result.File.PlacementMethod);
|
|
Assert.Equal(0, operations.HardLinkCalls);
|
|
Assert.Equal(1, operations.ReflinkCalls);
|
|
Assert.Equal(1, operations.CopyCalls);
|
|
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<IOException>(() => 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_PhysicalOperationsNeverShareSourceLibraryInode()
|
|
{
|
|
if (OperatingSystem.IsWindows()) return;
|
|
var source = CreateSource("library/song.flac", "original-library-audio");
|
|
var service = new FilePlacementService(new MemoryOwnershipStore(), new PhysicalManagedFileOperations());
|
|
var request = Request(source, Path.Combine(testRoot, "managed"), sourceManaged: true) with
|
|
{
|
|
DestinationIsImmutable = true,
|
|
ReferenceKey = "library-safety"
|
|
};
|
|
|
|
var result = await service.PlaceAsync(request);
|
|
await File.WriteAllTextAsync(result.File.CanonicalPath, "changed-managed-audio");
|
|
|
|
Assert.NotEqual(ManagedFilePlacementMethod.HardLink, result.File.PlacementMethod);
|
|
Assert.Equal("original-library-audio", await File.ReadAllTextAsync(source));
|
|
Assert.NotNull(result.File.FileSystemDeviceId);
|
|
Assert.NotNull(result.File.FileSystemFileId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlaceAsync_AdoptsExactFinalizedFileAfterProcessCrash()
|
|
{
|
|
var source = CreateSource("source/recover.flac", "recoverable-audio");
|
|
var root = Path.Combine(testRoot, "managed");
|
|
var request = Request(source, root, false) with { ReferenceKey = "favorite:recover" };
|
|
var target = Path.Combine(root, "Artist", "Album", "03 - A_B.flac");
|
|
var stagingDirectory = Path.Combine(root, ".allstarr-staging");
|
|
var staging = Path.Combine(stagingDirectory, "interrupted.partial");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
|
Directory.CreateDirectory(stagingDirectory);
|
|
File.Copy(source, target);
|
|
var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("recoverable-audio")))
|
|
.ToLowerInvariant();
|
|
var material = $"{request.Root.TenantId:N}|{request.Root.OwnerUserId:N}|{request.Root.Id:N}|{request.ScopeKey}|{request.ReferenceKey}";
|
|
var journalName = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))).ToLowerInvariant();
|
|
var journal = Path.Combine(stagingDirectory, $"{journalName}.placement.json");
|
|
await File.WriteAllTextAsync(journal, JsonSerializer.Serialize(new
|
|
{
|
|
Version = 1,
|
|
RootId = request.Root.Id,
|
|
TenantId = request.Root.TenantId!.Value,
|
|
request.Root.OwnerUserId,
|
|
request.Root.LibraryScopeId,
|
|
request.ScopeKey,
|
|
ReferenceKey = request.ReferenceKey!,
|
|
RootPath = Path.GetFullPath(root),
|
|
TargetPath = target,
|
|
StagingPath = staging,
|
|
ContentSha256 = fingerprint,
|
|
Length = new FileInfo(source).Length,
|
|
request.SourceJobId,
|
|
PlacementMethod = ManagedFilePlacementMethod.Copy,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
}));
|
|
var operations = new RecordingOperations(false, false);
|
|
|
|
var result = await new FilePlacementService(new MemoryOwnershipStore(), operations).PlaceAsync(request);
|
|
|
|
Assert.True(result.Reused);
|
|
Assert.Equal(target, result.File.CanonicalPath);
|
|
Assert.Equal("recoverable-audio", await File.ReadAllTextAsync(target));
|
|
Assert.Equal(0, operations.CopyCalls);
|
|
Assert.False(File.Exists(journal));
|
|
}
|
|
|
|
[Fact]
|
|
public void PhysicalOperations_ReturnStableFilesystemIdentityWhereSupported()
|
|
{
|
|
var path = CreateSource("identity/song.flac", "identity");
|
|
var operations = new PhysicalManagedFileOperations();
|
|
|
|
var supported = operations.TryGetFileIdentity(path, out var first);
|
|
var repeated = operations.TryGetFileIdentity(path, out var second);
|
|
|
|
if (!supported || !repeated) return;
|
|
Assert.Equal(first.DeviceId, second.DeviceId);
|
|
Assert.Equal(first.FileId, second.FileId);
|
|
Assert.True(first.LinkCount >= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PhysicalOperations_ReflinkIsIndependentOrLeavesNoPartialDestination()
|
|
{
|
|
var source = CreateSource("reflink/source.flac", "copy-on-write-audio");
|
|
var destination = Path.Combine(testRoot, "reflink", "destination.flac");
|
|
var operations = new PhysicalManagedFileOperations();
|
|
|
|
var cloned = operations.TryCreateReflink(destination, source);
|
|
|
|
if (!cloned)
|
|
{
|
|
Assert.False(File.Exists(destination));
|
|
return;
|
|
}
|
|
await File.WriteAllTextAsync(destination, "changed-clone");
|
|
Assert.Equal("copy-on-write-audio", await File.ReadAllTextAsync(source));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlacementEnrichmentReuseAndRemoval_RefreshesIdentityAndNeverTouchesSource()
|
|
{
|
|
var source = CreateSource("source/lifecycle.flac", "source-audio");
|
|
var targetRoot = Path.Combine(testRoot, "managed-lifecycle");
|
|
var store = new MemoryOwnershipStore();
|
|
var operations = new PhysicalManagedFileOperations();
|
|
var placement = new FilePlacementService(store, operations);
|
|
var request = Request(source, targetRoot, false) with { ReferenceKey = "favorite:first" };
|
|
var first = await placement.PlaceAsync(request);
|
|
var writer = new TagLibManagedMetadataWriter(new TextAppendingMutator(), operations);
|
|
var operation = new string('9', 64);
|
|
var write = await writer.WriteAsync(new(first.File.CanonicalPath, first.File.ContentSha256, true, false)
|
|
{
|
|
TargetRootPath = first.File.TargetRootPath,
|
|
FileSystemDeviceId = first.File.FileSystemDeviceId,
|
|
FileSystemFileId = first.File.FileSystemFileId,
|
|
OperationFingerprint = operation
|
|
}, new Dictionary<string, string> { ["title"] = "Tagged" }, default);
|
|
var updated = first.File with
|
|
{
|
|
ContentSha256 = write.ContentSha256,
|
|
Length = write.Length,
|
|
FileSystemDeviceId = write.FileSystemDeviceId,
|
|
FileSystemFileId = write.FileSystemFileId,
|
|
FileSystemLinkCount = write.FileSystemLinkCount
|
|
};
|
|
store.Update(updated);
|
|
await write.Lease!.CommitAsync(default);
|
|
|
|
var reused = await placement.PlaceAsync(request with
|
|
{
|
|
SourcePath = updated.CanonicalPath,
|
|
ReferenceKey = "playlist:second"
|
|
});
|
|
var released = await store.ReleaseReferenceAsync(reused.File.Id, "playlist:second", default);
|
|
var removalStore = new MemoryRemovalStore(released);
|
|
await new ManagedFileRemovalService(removalStore, operations)
|
|
.RemoveAsync(released.Id, released.ScopeKey, explicitlyConfirmed: true);
|
|
|
|
Assert.True(reused.Reused);
|
|
Assert.NotEqual(first.File.FileSystemFileId, updated.FileSystemFileId);
|
|
Assert.False(File.Exists(updated.CanonicalPath));
|
|
Assert.Equal("source-audio", await File.ReadAllTextAsync(source));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlaceAsync_RetryReusesStableReferenceWithoutInflatingCount()
|
|
{
|
|
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 request = Request(source, Path.Combine(testRoot, "managed"), false) with { ReferenceKey = "operation-1" };
|
|
var first = await service.PlaceAsync(request);
|
|
var second = await service.PlaceAsync(request);
|
|
|
|
Assert.False(first.Reused);
|
|
Assert.True(second.Reused);
|
|
Assert.Equal(first.File.Id, second.File.Id);
|
|
Assert.Equal(1, second.File.ReferenceCount);
|
|
Assert.Equal(1, operations.CopyCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlaceAsync_DistinctDurableReferenceIncrementsAndReleaseDecrementsOnce()
|
|
{
|
|
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 with { ReferenceKey = "playlist:a" });
|
|
var second = await service.PlaceAsync(request with { ReferenceKey = "playlist:b" });
|
|
|
|
var released = await store.ReleaseReferenceAsync(second.File.Id, "playlist:b", default);
|
|
var repeated = await store.ReleaseReferenceAsync(second.File.Id, "playlist:b", default);
|
|
|
|
Assert.Equal(2, second.File.ReferenceCount);
|
|
Assert.Equal(1, released.ReferenceCount);
|
|
Assert.Equal(1, repeated.ReferenceCount);
|
|
Assert.Equal(first.File.Id, second.File.Id);
|
|
}
|
|
|
|
[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<IOException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => service.RemoveAsync(record.Id, "owned-scope", false));
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.RemoveAsync(record.Id, "other-scope", true));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<UnauthorizedAccessException>(() =>
|
|
new ManagedFileRemovalService(store).RemoveAsync(record.Id, "owned-scope", true));
|
|
|
|
Assert.Equal("outside-safe", await File.ReadAllTextAsync(outside));
|
|
Assert.False(store.Removed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveAsync_RejectsAFileReplacedAfterItsIdentityWasRecorded()
|
|
{
|
|
var path = CreateSource("managed/identity.flac", "managed");
|
|
var operations = new PhysicalManagedFileOperations();
|
|
if (!operations.TryGetFileIdentity(path, out var identity)) return;
|
|
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)!,
|
|
FileSystemDeviceId = identity.DeviceId,
|
|
FileSystemFileId = identity.FileId,
|
|
FileSystemLinkCount = identity.LinkCount
|
|
};
|
|
File.Delete(path);
|
|
await File.WriteAllTextAsync(path, "replacement");
|
|
var store = new MemoryRemovalStore(record);
|
|
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
|
new ManagedFileRemovalService(store, operations).RemoveAsync(record.Id, "owned-scope", true));
|
|
|
|
Assert.Equal("replacement", await File.ReadAllTextAsync(path));
|
|
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 bool TryGetFileIdentity(string path, out ManagedFileSystemIdentity identity)
|
|
{
|
|
identity = new ManagedFileSystemIdentity("test-device", Path.GetFullPath(path), 1);
|
|
return File.Exists(path);
|
|
}
|
|
|
|
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<Guid, ManagedFileRecord> records = [];
|
|
private readonly Dictionary<(Guid FileId, string Key), bool> references = [];
|
|
public bool FailAdd { get; init; }
|
|
|
|
public void Seed(ManagedFileRecord record) => records.Add(record.Id, record);
|
|
public void Update(ManagedFileRecord record) => records[record.Id] = record;
|
|
|
|
public Task<ManagedFileRecord?> FindByPathAsync(string canonicalPath, CancellationToken cancellationToken) =>
|
|
Task.FromResult(records.Values.SingleOrDefault(item => item.CanonicalPath == canonicalPath));
|
|
|
|
public Task<ManagedFileRecord?> 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<ManagedFileRecord> AddAsync(ManagedFileRecord record, ManagedFileReference reference, CancellationToken cancellationToken)
|
|
{
|
|
if (FailAdd) throw new InvalidOperationException("simulated ownership failure");
|
|
records.Add(record.Id, record);
|
|
references[(record.Id, reference.ReferenceKey)] = true;
|
|
return Task.FromResult(record);
|
|
}
|
|
|
|
public Task<ManagedFileRecord> AddReferenceAsync(Guid id, ManagedFileReference reference, CancellationToken cancellationToken)
|
|
{
|
|
if (references.TryGetValue((id, reference.ReferenceKey), out var active) && active)
|
|
return Task.FromResult(records[id]);
|
|
var record = records[id] with { ReferenceCount = records[id].ReferenceCount + 1 };
|
|
records[id] = record;
|
|
references[(id, reference.ReferenceKey)] = true;
|
|
return Task.FromResult(record);
|
|
}
|
|
|
|
public Task<ManagedFileRecord> ReleaseReferenceAsync(Guid id, string referenceKey, CancellationToken cancellationToken)
|
|
{
|
|
if (!references.TryGetValue((id, referenceKey), out var active)) throw new KeyNotFoundException();
|
|
if (!active) return Task.FromResult(records[id]);
|
|
references[(id, referenceKey)] = false;
|
|
records[id] = records[id] with { ReferenceCount = records[id].ReferenceCount - 1 };
|
|
return Task.FromResult(records[id]);
|
|
}
|
|
}
|
|
|
|
private sealed class TextAppendingMutator : IManagedTagFileMutator
|
|
{
|
|
public void Apply(string path, IReadOnlyDictionary<string, string> tags, CancellationToken cancellationToken) =>
|
|
File.AppendAllText(path, "\ntagged");
|
|
|
|
public bool Matches(string path, IReadOnlyDictionary<string, string> tags) =>
|
|
File.ReadAllText(path).EndsWith("\ntagged", StringComparison.Ordinal);
|
|
}
|
|
|
|
private sealed class MemoryRemovalStore(ManagedFileRecord record) : IManagedFileRemovalStore
|
|
{
|
|
public bool Removed { get; private set; }
|
|
public Task<ManagedFileRecord?> GetAsync(Guid id, CancellationToken cancellationToken) =>
|
|
Task.FromResult<ManagedFileRecord?>(id == record.Id ? record : null);
|
|
public Task MarkRemovedAsync(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
Removed = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|