mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
fix(files): harden managed artifact lifecycle
This commit is contained in:
7 files changed
+248
-17
No files matched your search
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user