fix(files): harden managed artifact lifecycle

This commit is contained in:
joshpatra committed 2026-07-13 22:00:12 -04:00
1 parent d92fa4512d
commit e452dfbb3d
7 files changed
+248 -17

No files matched your search

+87 -2
View File
@@ -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<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_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<IOException>(() => 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<UnauthorizedAccessException>(() =>
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}",
@@ -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<InvalidOperationException>(() =>
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)
@@ -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 };
}
@@ -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();
}
@@ -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.");
}
}
@@ -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,
@@ -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.");
}
}