diff --git a/allstarr.Tests/FilePlacementServiceTests.cs b/allstarr.Tests/FilePlacementServiceTests.cs index fdcc1026..0181d15e 100644 --- a/allstarr.Tests/FilePlacementServiceTests.cs +++ b/allstarr.Tests/FilePlacementServiceTests.cs @@ -1,4 +1,6 @@ using allstarr.Core.ManagedFiles; +using System.Security.Cryptography; +using System.Text; namespace allstarr.Tests; @@ -76,6 +78,49 @@ public sealed class FilePlacementServiceTests : IDisposable 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() { @@ -94,6 +139,21 @@ public sealed class FilePlacementServiceTests : IDisposable 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() { @@ -131,7 +191,8 @@ public sealed class FilePlacementServiceTests : IDisposable 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); + "owned-scope", 2, true, DateTimeOffset.UtcNow) + { TargetRootPath = Path.GetDirectoryName(path)! }; var store = new MemoryRemovalStore(record); var service = new ManagedFileRemovalService(store); @@ -148,7 +209,8 @@ public sealed class FilePlacementServiceTests : IDisposable 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); + "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); @@ -157,6 +219,29 @@ public sealed class FilePlacementServiceTests : IDisposable 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}", diff --git a/allstarr.Tests/ProviderDownloadArtifactResolverTests.cs b/allstarr.Tests/ProviderDownloadArtifactResolverTests.cs index 01c58454..7a96e535 100644 --- a/allstarr.Tests/ProviderDownloadArtifactResolverTests.cs +++ b/allstarr.Tests/ProviderDownloadArtifactResolverTests.cs @@ -107,6 +107,24 @@ public sealed class ProviderDownloadArtifactResolverTests : IDisposable Assert.Single(store.Artifacts); } + [Fact] + public async Task FindByJob_RejectsArtifactChangedAfterVerification() + { + var store = new MemoryStore(); + var resolver = Resolver(store); + var request = Request(); + var workspace = await resolver.CreateWorkspaceAsync(request); + var verified = Encoding.UTF8.GetBytes("verified-audio"); + var path = Write(workspace.Reference, "track.flac", verified); + await resolver.ResolveAsync(workspace.Reference, Output("track.flac", verified)); + await File.WriteAllBytesAsync(path, Encoding.UTF8.GetBytes("modified-audio")); + + var exception = await Assert.ThrowsAsync(() => + resolver.FindByJobAsync(request.TenantId, request.DurableJobId, request.ProviderId)); + + Assert.Contains("content changed", exception.Message, StringComparison.Ordinal); + } + private ProviderDownloadArtifactResolver Resolver(MemoryStore store) => new(store, new() { RootPath = root }); private ProviderDownloadWorkspaceRequest Request() => new(Guid.CreateVersion7(), Guid.CreateVersion7(), Guid.CreateVersion7(), "qobuz", Guid.CreateVersion7(), "favorite:event:download"); private string Write(ProviderManagedWorkspaceReference workspace, string relative, byte[] content) diff --git a/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs b/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs index a20a7f11..b8ca258a 100644 --- a/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs +++ b/allstarr/Core/Downloads/ProviderDownloadArtifactResolver.cs @@ -17,11 +17,17 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt RejectSymlink(directory); var entity = await store.CreateWorkspaceAsync(new() { - Id = Guid.CreateVersion7(), WorkspaceId = workspaceId, TenantId = request.TenantId, - OwnerUserId = request.OwnerUserId, DurableJobId = request.DurableJobId, + Id = Guid.CreateVersion7(), + WorkspaceId = workspaceId, + TenantId = request.TenantId, + OwnerUserId = request.OwnerUserId, + DurableJobId = request.DurableJobId, LibraryScopeId = request.LibraryScopeId, - ProviderId = request.ProviderId.Trim().ToLowerInvariant(), ProviderAccountId = request.ProviderAccountId, - IdempotencyKey = request.IdempotencyKey, CreatedAt = DateTimeOffset.UtcNow, Revision = 1 + ProviderId = request.ProviderId.Trim().ToLowerInvariant(), + ProviderAccountId = request.ProviderAccountId, + IdempotencyKey = request.IdempotencyKey, + CreatedAt = DateTimeOffset.UtcNow, + Revision = 1 }, cancellationToken); return new(entity.Id, new ProviderManagedWorkspaceReference(entity.WorkspaceId)); } @@ -45,13 +51,23 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt throw new InvalidOperationException("The provider download artifact checksum does not match its contract."); var stored = await store.AddVerifiedAsync(new() { - Id = Guid.CreateVersion7(), WorkspaceRecordId = persistedWorkspace.Id, WorkspaceId = persistedWorkspace.WorkspaceId, TenantId = persistedWorkspace.TenantId, - OwnerUserId = persistedWorkspace.OwnerUserId, DurableJobId = persistedWorkspace.DurableJobId, + Id = Guid.CreateVersion7(), + WorkspaceRecordId = persistedWorkspace.Id, + WorkspaceId = persistedWorkspace.WorkspaceId, + TenantId = persistedWorkspace.TenantId, + OwnerUserId = persistedWorkspace.OwnerUserId, + DurableJobId = persistedWorkspace.DurableJobId, LibraryScopeId = persistedWorkspace.LibraryScopeId, - ProviderId = persistedWorkspace.ProviderId, ProviderAccountId = persistedWorkspace.ProviderAccountId, - ProviderArtifactId = output.ArtifactId, RelativePath = relative, ContentSha256 = hash, Length = info.Length, - State = ProviderDownloadArtifactState.Verified, CreatedAt = DateTimeOffset.UtcNow, - VerifiedAt = DateTimeOffset.UtcNow, Revision = 1 + ProviderId = persistedWorkspace.ProviderId, + ProviderAccountId = persistedWorkspace.ProviderAccountId, + ProviderArtifactId = output.ArtifactId, + RelativePath = relative, + ContentSha256 = hash, + Length = info.Length, + State = ProviderDownloadArtifactState.Verified, + CreatedAt = DateTimeOffset.UtcNow, + VerifiedAt = DateTimeOffset.UtcNow, + Revision = 1 }, cancellationToken); return Result(stored, path); } @@ -60,7 +76,33 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt { var item = await store.FindByJobAsync(tenantId, jobId, providerId, cancellationToken); if (item is null) return null; - return Result(item, Contained(Contained(WorkspaceRoot(), item.WorkspaceId), item.RelativePath)); + if (item.State != ProviderDownloadArtifactState.Verified) + return Result(item, Contained(Contained(WorkspaceRoot(), item.WorkspaceId), item.RelativePath)); + + var workspaceRoot = Contained(WorkspaceRoot(), item.WorkspaceId); + RejectSymlink(workspaceRoot); + var path = Contained(workspaceRoot, NormalizeArtifactReference(item.RelativePath)); + RejectPathSymlinks(workspaceRoot, path); + if (!File.Exists(path)) + throw new InvalidOperationException("The verified provider download artifact is missing."); + var info = new FileInfo(path); + if (info.Length != item.Length) + throw new InvalidOperationException("The verified provider download artifact length changed."); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + byte[] expected; + try + { + expected = Convert.FromHexString(item.ContentSha256); + } + catch (FormatException exception) + { + throw new InvalidOperationException("The stored provider download artifact checksum is invalid.", exception); + } + if (expected.Length != SHA256.HashSizeInBytes || !CryptographicOperations.FixedTimeEquals(hash, expected)) + throw new InvalidOperationException("The verified provider download artifact content changed."); + return Result(item, path); } public Task MarkPlacedAsync(Guid artifactId, Guid managedFileId, CancellationToken cancellationToken = default) => @@ -115,5 +157,5 @@ public sealed class ProviderDownloadArtifactResolver(IProviderDownloadArtifactSt private static VerifiedProviderDownloadArtifact Result(ProviderDownloadArtifactEntity item, string path) => new( item.Id, item.WorkspaceRecordId, path, item.ContentSha256, item.Length, item.TenantId, item.OwnerUserId, item.DurableJobId, item.ProviderId, item.ProviderAccountId, item.State, item.ManagedFileId) - { LibraryScopeId = item.LibraryScopeId }; + { LibraryScopeId = item.LibraryScopeId }; } diff --git a/allstarr/Core/Favorites/FavoritePlaceActionExecutor.cs b/allstarr/Core/Favorites/FavoritePlaceActionExecutor.cs index cc8d7214..2c9b8e68 100644 --- a/allstarr/Core/Favorites/FavoritePlaceActionExecutor.cs +++ b/allstarr/Core/Favorites/FavoritePlaceActionExecutor.cs @@ -58,7 +58,10 @@ public sealed class FavoritePlaceActionExecutor( favoriteEvent.OwnerUserId), favoriteEvent.TenantId, favoriteEvent.OwnerUserId, favoriteEvent.LibraryScopeId), artifact.SourcePath, options.PathTemplate, track, favoriteEvent.JobId, action.IdempotencyKey, - SourceIsAllstarrManaged: true), cancellationToken); + SourceIsAllstarrManaged: true, + SourceIsImmutable: false, + ExpectedContentSha256: artifact.ContentSha256, + ExpectedLength: artifact.Length), cancellationToken); await artifacts.MarkPlacedAsync(artifact.Id, result.File.Id, cancellationToken); return FavoriteActionExecutionResult.Success(); } diff --git a/allstarr/Core/ManagedFiles/FilePlacementService.cs b/allstarr/Core/ManagedFiles/FilePlacementService.cs index 9bf4f361..69e5fae5 100644 --- a/allstarr/Core/ManagedFiles/FilePlacementService.cs +++ b/allstarr/Core/ManagedFiles/FilePlacementService.cs @@ -19,13 +19,20 @@ public sealed class FilePlacementService(IManagedFileOwnershipStore ownership, I var requestedTarget = ContainedPath(root, relative); var fingerprint = await FingerprintAsync(source, cancellationToken); var length = new FileInfo(source).Length; + ValidateExpectedSource(request, fingerprint, length); var compatible = await ownership.FindCompatibleAsync(request.Root.Id, fingerprint, request.ScopeKey, cancellationToken); if (compatible is not null && File.Exists(compatible.CanonicalPath)) + { + await ValidateExistingManagedFileAsync(root, compatible, fingerprint, length, cancellationToken); return new(await ownership.AddReferenceAsync(compatible.Id, cancellationToken), true); + } var requestedRecord = await ownership.FindByPathAsync(requestedTarget, cancellationToken); if (requestedRecord is not null && requestedRecord.ContentSha256 == fingerprint && File.Exists(requestedTarget)) + { + await ValidateExistingManagedFileAsync(root, requestedRecord, fingerprint, length, cancellationToken); return new(await ownership.AddReferenceAsync(requestedRecord.Id, cancellationToken), true); + } Directory.CreateDirectory(root); RejectSymlinkLeaf(root); @@ -51,7 +58,8 @@ public sealed class FilePlacementService(IManagedFileOwnershipStore ownership, I finalized = true; var record = new ManagedFileRecord(Guid.NewGuid(), request.Root.Id, target, fingerprint, length, method, request.Root.TenantId, request.Root.OwnerUserId, request.Root.LibraryScopeId, request.SourceJobId, - request.ScopeKey, 1, true, DateTimeOffset.UtcNow) { TargetRootPath = root }; + request.ScopeKey, 1, true, DateTimeOffset.UtcNow) + { TargetRootPath = root }; try { return new(await ownership.AddAsync(record, cancellationToken), false); @@ -148,4 +156,48 @@ public sealed class FilePlacementService(IManagedFileOwnershipStore ownership, I await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant(); } + + private static void ValidateExpectedSource(ManagedFilePlacementRequest request, string fingerprint, long length) + { + if (request.ExpectedLength.HasValue && request.ExpectedLength.Value != length) + throw new IOException("The placement source length no longer matches its verified artifact."); + if (request.ExpectedContentSha256 is null) return; + + byte[] expected; + try + { + expected = Convert.FromHexString(request.ExpectedContentSha256); + } + catch (FormatException exception) + { + throw new InvalidOperationException("The expected placement checksum is invalid.", exception); + } + + if (expected.Length != SHA256.HashSizeInBytes || + !CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(fingerprint))) + throw new IOException("The placement source no longer matches its verified artifact."); + } + + private static async Task ValidateExistingManagedFileAsync( + string root, + ManagedFileRecord record, + string expectedFingerprint, + long expectedLength, + CancellationToken cancellationToken) + { + var path = Path.GetFullPath(record.CanonicalPath); + if (!path.StartsWith(root + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + throw new UnauthorizedAccessException("The existing managed file is outside its recorded root."); + RejectSymlinksUnder(root, path); + var info = new FileInfo(path); + if (info.Length != record.Length || info.Length != expectedLength) + throw new IOException("The existing managed file length no longer matches its ownership record."); + var actual = await FingerprintAsync(path, cancellationToken); + if (!CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(expectedFingerprint), Convert.FromHexString(actual)) || + !CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(record.ContentSha256), Convert.FromHexString(actual))) + throw new IOException("The existing managed file content no longer matches its ownership record."); + } } diff --git a/allstarr/Core/ManagedFiles/ManagedFileModels.cs b/allstarr/Core/ManagedFiles/ManagedFileModels.cs index 06a06e60..fb6cf237 100644 --- a/allstarr/Core/ManagedFiles/ManagedFileModels.cs +++ b/allstarr/Core/ManagedFiles/ManagedFileModels.cs @@ -32,7 +32,9 @@ public sealed record ManagedFilePlacementRequest( Guid? SourceJobId, string ScopeKey, bool SourceIsAllstarrManaged, - bool SourceIsImmutable = true); + bool SourceIsImmutable = true, + string? ExpectedContentSha256 = null, + long? ExpectedLength = null); public sealed record ManagedFileRecord( Guid Id, diff --git a/allstarr/Core/ManagedFiles/ManagedFileRemovalService.cs b/allstarr/Core/ManagedFiles/ManagedFileRemovalService.cs index 5bf4c94d..390226ab 100644 --- a/allstarr/Core/ManagedFiles/ManagedFileRemovalService.cs +++ b/allstarr/Core/ManagedFiles/ManagedFileRemovalService.cs @@ -17,6 +17,7 @@ public sealed class ManagedFileRemovalService(IManagedFileRemovalStore store) throw new UnauthorizedAccessException("The file is not owned by this managed scope."); if (record.ReferenceCount != 1) throw new InvalidOperationException("The managed file still has protected references."); + ValidateManagedPath(record); if (!File.Exists(record.CanonicalPath)) { await store.MarkRemovedAsync(id, cancellationToken); @@ -31,4 +32,32 @@ public sealed class ManagedFileRemovalService(IManagedFileRemovalStore store) await store.MarkRemovedAsync(id, cancellationToken); File.Delete(record.CanonicalPath); } + + private static void ValidateManagedPath(ManagedFileRecord record) + { + if (string.IsNullOrWhiteSpace(record.TargetRootPath) || !Path.IsPathRooted(record.TargetRootPath) || + string.IsNullOrWhiteSpace(record.CanonicalPath) || !Path.IsPathRooted(record.CanonicalPath)) + throw new UnauthorizedAccessException("The managed file does not have a valid recorded root."); + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(record.TargetRootPath)); + var path = Path.GetFullPath(record.CanonicalPath); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!path.StartsWith(root + Path.DirectorySeparatorChar, comparison)) + throw new UnauthorizedAccessException("The managed file is outside its recorded root."); + + RejectSymlink(root); + var current = root; + foreach (var part in Path.GetRelativePath(root, path) + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, part); + if (File.Exists(current) || Directory.Exists(current)) RejectSymlink(current); + } + } + + private static void RejectSymlink(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new UnauthorizedAccessException("Refusing to remove a managed file through a symbolic link."); + } }