diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs index c381aa4ddbbe..3029af8593e3 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs @@ -11,12 +11,16 @@ using Files.Platform.Abstractions.Archives; using Files.Platform.Abstractions.FileOperations; using Files.Platform.Linux.FileOperations; +using Files.Platform.Linux.Native; +using Microsoft.Win32.SafeHandles; using SharpCompress.Common; namespace Files.Platform.Linux.Archives { public sealed partial class LinuxArchiveService { + internal Action? PublicationCheckpoint { get; set; } + /// public Task ExtractAsync(string archivePath, string destinationFolder, ArchiveExtractOptions? options = null, CancellationToken cancellationToken = default) { @@ -35,7 +39,11 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE { string? staging = null; string? destination = null; + DirectoryHandle? destinationHandle = null; + DirectoryHandle? destinationParent = null; var createdDestination = false; + DirectoryHandle? stagingHandle = null; + string? stagingName = null; long processed = 0, skipped = 0; try @@ -66,18 +74,19 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (extract) { - destination = Path.GetFullPath(destinationFolder); - if (File.Exists(destination)) - throw new IOException("The destination is a file."); - - if (!Directory.Exists(destination)) + destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destinationFolder)); + if (destination != "/") { - Directory.CreateDirectory(destination); - createdDestination = true; + destinationParent = DirectoryHandle.OpenPath(Path.GetDirectoryName(destination)!, create: true); + createdDestination = !destinationParent.EntryExists(Path.GetFileName(destination)); + destinationHandle = destinationParent.OpenRelativePath(Path.GetFileName(destination), create: true); } - - staging = Path.Combine(destination, ".files-extract-" + Guid.NewGuid().ToString("N")); - UnixMode.CreatePrivateDirectory(staging); + else + destinationHandle = DirectoryHandle.OpenPath(destination); + stagingName = ".files-extract-" + Guid.NewGuid().ToString("N"); + staging = Path.Combine(destination, stagingName); + PosixNative.MkdirAt(destinationHandle.Descriptor, stagingName, 0x1C0, staging); + stagingHandle = DirectoryHandle.OpenChild(destinationHandle.Descriptor, stagingName, staging); } // Pass 2: stream the entries @@ -136,39 +145,35 @@ void ReportProgress(string? currentEntry, bool force = false) continue; var full = ArchivePathValidator.ResolveInside(staging!, relative); - ArchivePathValidator.EnsureNoLinkInPath(staging!, full); + using var parent = stagingHandle!.OpenRelativePath(Path.GetDirectoryName(relative) ?? string.Empty, create: true); + var name = Path.GetFileName(relative); if (entry.LinkTarget is not null) { // Hardlinks use archive-root targets and are reported as unsupported, never converted to symlinks. // Parent segments could escape through another archive link; accept only downward relative targets. - if (entry.IsHardLink || !ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || FileSystemEntry.GetKind(full) != EntryKind.None) + if (entry.IsHardLink || !ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || parent.EntryExists(name)) { skipped++; continue; } - Directory.CreateDirectory(Path.GetDirectoryName(full)!); - File.CreateSymbolicLink(full, entry.LinkTarget); + PosixNative.SymlinkAt(entry.LinkTarget, parent.Descriptor, name, full); processed++; continue; } if (entry.IsDirectory) { - Directory.CreateDirectory(full); + using var directory = parent.OpenRelativePath(name, create: true); processed++; continue; } - Directory.CreateDirectory(Path.GetDirectoryName(full)!); - var streamOptions = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None }; - if (!OperatingSystem.IsWindows()) - streamOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; - + var temporaryName = ".files-entry-" + Guid.NewGuid().ToString("N"); uint crc = 0; using (var input = openEntry()) - using (var output = new FileStream(full, streamOptions)) + using (var output = PosixNative.OpenFileAt(parent.Descriptor, temporaryName, full, create: true)) { int read; while ((read = input.Read(buffer, 0, buffer.Length)) > 0) @@ -180,17 +185,16 @@ void ReportProgress(string? currentEntry, bool force = false) bytesDone += read; ReportProgress(currentEntry); } + VerifyCrc(entry, crc); + if (entry.Modified is { } modified) + { + try { File.SetLastWriteTime(output.SafeFileHandle, modified); } + catch (Exception ex) when (ex is ArgumentOutOfRangeException or IOException) { } + } + ApplyMode(output.SafeFileHandle, entry); } - VerifyCrc(entry, crc); - - if (entry.Modified is { } modified) - { - try { File.SetLastWriteTime(full, modified); } - catch (Exception ex) when (ex is ArgumentOutOfRangeException or IOException) { } - } - - ApplyMode(full, entry); + PosixNative.RenameAt(parent.Descriptor, temporaryName, parent.Descriptor, name, true, full); processed++; } } @@ -198,8 +202,15 @@ void ReportProgress(string? currentEntry, bool force = false) if (!extract) return new ArchiveResult(true, false, processed, skipped); - var state = new MergeState(options, ct, destination!); - Merge(staging!, destination!, string.Empty, state); + var state = new MergeState(options, ct, destination!, destinationHandle!, PublicationCheckpoint); + var rootIdentity = PosixNative.StatAt(destinationHandle!.Descriptor, ".", destination!); + void ValidateRoot() + { + using var current = DirectoryHandle.OpenPath(destination!); + if (!current.IsSameEntry(rootIdentity)) + throw new ArchiveSecurityException("The extraction destination changed."); + } + Merge(stagingHandle!, destinationHandle, string.Empty, state, ValidateRoot); skipped += state.Skipped; ReportProgress(null, force: true); @@ -230,15 +241,23 @@ void ReportProgress(string? currentEntry, bool force = false) } finally { - if (staging is not null) + stagingHandle?.Dispose(); + if (destinationHandle is not null && stagingName is not null) { - try { Directory.Delete(staging, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + try { destinationHandle.DeleteEntry(stagingName); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } - - if (createdDestination && destination is not null) + if (createdDestination && destinationParent is not null && destinationHandle is not null) { - try { Directory.Delete(destination, recursive: false); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + try + { + var name = Path.GetFileName(destination!); + if (destinationHandle.IsSameEntry(PosixNative.StatAt(destinationParent.Descriptor, name, destination!))) + PosixNative.UnlinkAt(destinationParent.Descriptor, name, PosixNative.AtRemoveDir, destination!); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } + destinationHandle?.Dispose(); + destinationParent?.Dispose(); } } @@ -248,7 +267,7 @@ private static void VerifyCrc(EntryData entry, uint actual) throw new InvalidDataException($"CRC mismatch, the archive is corrupt or the password is wrong: '{ArchivePathValidator.Printable(entry.Key ?? string.Empty)}'."); } - private static void ApplyMode(string path, EntryData entry) + private static void ApplyMode(SafeFileHandle handle, EntryData entry) { if (OperatingSystem.IsWindows() || entry.Attrib is not { } attributes) return; @@ -267,7 +286,7 @@ private static void ApplyMode(string path, EntryData entry) if (executable) result |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - try { File.SetUnixFileMode(path, result); } + try { File.SetUnixFileMode(handle, result); } catch (IOException) { } } @@ -276,17 +295,23 @@ private sealed class MergeState private readonly ArchiveExtractOptions options; private ConflictResolution? sticky; - public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken, string root) + public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken, string root, DirectoryHandle rootHandle, Action? checkpoint) { this.options = options; CancellationToken = cancellationToken; Root = root; + RootHandle = rootHandle; + Checkpoint = checkpoint; } public CancellationToken CancellationToken { get; } public string Root { get; } + public DirectoryHandle RootHandle { get; } + + public Action? Checkpoint { get; } + public long Skipped { get; set; } public ConflictAction Resolve(ArchiveConflict conflict) @@ -306,26 +331,26 @@ public ConflictAction Resolve(ArchiveConflict conflict) } // Moves the staged tree into the destination one item at a time. Folders merge, existing files only change via the callback. - private static void Merge(string source, string destination, string relative, MergeState state) + private static void Merge(DirectoryHandle source, DirectoryHandle destination, string relative, MergeState state, Action validateDestination) { - foreach (var child in Directory.EnumerateFileSystemEntries(source, "*", FileSystemEntry.AllEntries)) + foreach (var name in source.ListNames()) { state.CancellationToken.ThrowIfCancellationRequested(); - var name = Path.GetFileName(child); + validateDestination(); var relativePath = relative.Length == 0 ? name : relative + "/" + name; - var target = Path.Combine(destination, name); - - var sourceKind = FileSystemEntry.GetKind(child); - var targetKind = FileSystemEntry.GetKind(target); - var isDirectory = sourceKind == EntryKind.Directory; - - if (sourceKind == EntryKind.Symlink) + var target = ArchivePathValidator.ResolveInside(state.Root, relativePath); + var sourceStat = PosixNative.StatAt(source.Descriptor, name, target); + var isDirectory = sourceStat.IsDirectory; + var exists = PosixNative.TryStat(destination.Descriptor, name, PosixNative.AtSymlinkNofollow, out var targetStat, out var errno); + if (!exists && !PosixNative.IsNotFound(errno)) + throw PosixNative.CreateException(errno, target); + + if (sourceStat.IsSymbolicLink) { try { - var linkTarget = new FileInfo(child).LinkTarget!; - var resolved = Path.GetFullPath(Path.Combine(destination, linkTarget)); - ArchivePathValidator.ResolveInside(state.Root, Path.GetRelativePath(state.Root, resolved)); + var linkTarget = PosixNative.ReadLinkAt(source.Descriptor, name, target); + var resolved = ArchivePathValidator.ResolveInside(state.Root, Path.GetRelativePath(state.Root, Path.GetFullPath(Path.Combine(destination.Path, linkTarget)))); ArchivePathValidator.EnsureNoLinkInPath(state.Root, resolved); } catch (ArchiveSecurityException) @@ -335,49 +360,127 @@ private static void Merge(string source, string destination, string relative, Me } } - if (targetKind == EntryKind.None) + if (isDirectory && exists && targetStat.IsDirectory) { - MoveItem(child, target, isDirectory); + using var sourceChild = DirectoryHandle.OpenChild(source.Descriptor, name, target); + using var targetChild = DirectoryHandle.OpenChild(destination.Descriptor, name, target); + void ValidateChild() + { + validateDestination(); + var current = PosixNative.StatAt(destination.Descriptor, name, target); + if (!PosixNative.SameEntry(targetStat, current) || !targetChild.IsSameEntry(targetStat)) + throw new ArchiveSecurityException("The extraction destination changed."); + } + Merge(sourceChild, targetChild, relativePath, state, ValidateChild); continue; } - if (isDirectory && targetKind == EntryKind.Directory) + // Hold the consented inode across the callback, including unlink/recreate attempts. + SafeFileHandle? conflictHandle = null; + if (exists) { - Merge(child, target, relativePath, state); - continue; + var conflictFd = PosixNative.OpenPathAt(destination.Descriptor, name, out var conflictError); + if (conflictFd < 0) throw PosixNative.CreateException(conflictError, target); + conflictHandle = new SafeFileHandle(conflictFd, true); } - - switch (state.Resolve(new ArchiveConflict(ArchivePathValidator.Printable(relativePath), target, isDirectory))) + using var heldConflict = conflictHandle; + if (heldConflict is not null && (!PosixNative.TryStat(heldConflict.DangerousGetHandle().ToInt32(), out var consentedStat) || !PosixNative.SameEntry(targetStat, consentedStat))) + throw new ArchiveSecurityException("The extraction conflict changed."); + var publicationName = name; + if (exists) { - case ConflictAction.Cancel: - throw new OperationCanceledException(); - - case ConflictAction.Skip: - state.Skipped++; - break; - - case ConflictAction.KeepBoth: - MoveItem(child, Path.Combine(destination, FileNameGenerator.GenerateUniqueName(destination, name, isDirectory)), isDirectory); - break; - - default: - if (targetKind == EntryKind.Directory) - Directory.Delete(target, recursive: true); - else - File.Delete(target); - - MoveItem(child, target, isDirectory); - break; + var action = state.Resolve(new ArchiveConflict(ArchivePathValidator.Printable(relativePath), target, isDirectory)); + state.CancellationToken.ThrowIfCancellationRequested(); + validateDestination(); + switch (action) + { + case ConflictAction.Cancel: + throw new OperationCanceledException(); + case ConflictAction.Skip: + state.Skipped++; + continue; + case ConflictAction.KeepBoth: + publicationName = FileNameGenerator.GenerateUniqueName(name, isDirectory, destination.EntryExists); + break; + default: + { + if (!PosixNative.SameEntry(targetStat, PosixNative.StatAt(destination.Descriptor, name, target))) + throw new ArchiveSecurityException("The extraction conflict changed."); + // Never recursively remove a consented directory: it can move outside the destination. + if (targetStat.IsDirectory) + throw new ArchiveSecurityException("Cannot safely overwrite a directory with an archive file."); + using var retirementParent = OpenPublicationParent(state, relative, destination, "retire"); + var parentFd = retirementParent.DangerousGetHandle().ToInt32(); + if (!PosixNative.SameEntry(targetStat, PosixNative.StatAt(parentFd, name, target))) + throw new ArchiveSecurityException("The extraction conflict changed."); + var retiredName = ".files-overwrite-" + Guid.NewGuid().ToString("N"); + PosixNative.RenameAt(parentFd, name, parentFd, retiredName, false, target); + try + { + state.Checkpoint?.Invoke("retire-renamed"); + VerifyPublicationParent(state, relative, retirementParent); + } + catch + { + PosixNative.RenameAt(parentFd, retiredName, parentFd, name, false, target); + throw; + } + var retiredFd = PosixNative.OpenPathAt(parentFd, retiredName, out var retiredError); + if (retiredFd < 0) throw PosixNative.CreateException(retiredError, target); + try + { + if (!PosixNative.TryStat(retiredFd, out var retiredStat) || !PosixNative.SameEntry(targetStat, retiredStat)) + { + PosixNative.RenameAt(parentFd, retiredName, parentFd, name, false, target); + throw new ArchiveSecurityException("The extraction conflict changed."); + } + PosixNative.UnlinkAt(parentFd, retiredName, 0, target); + } + finally { PosixNative.Close(retiredFd); } + break; + } + } + } + validateDestination(); + using var publicationParent = OpenPublicationParent(state, relative, destination, "publish"); + var publicationFd = publicationParent.DangerousGetHandle().ToInt32(); + PosixNative.RenameAt(source.Descriptor, name, publicationFd, publicationName, false, target); + try + { + state.Checkpoint?.Invoke("publish-renamed"); + VerifyPublicationParent(state, relative, publicationParent); + } + catch + { + PosixNative.RenameAt(publicationFd, publicationName, source.Descriptor, name, false, target); + throw; } } } - private static void MoveItem(string source, string target, bool isDirectory) + private static SafeFileHandle OpenPublicationParent(MergeState state, string relative, DirectoryHandle expected, string operation) { - if (isDirectory) - Directory.Move(source, target); - else - File.Move(source, target); + state.Checkpoint?.Invoke(operation + "-opening"); + var handle = new SafeFileHandle(ElevationNative.OpenBeneath(state.RootHandle.Descriptor, relative, PosixNative.PathFlags | PosixNative.ODirectory), true); + try + { + if (!PosixNative.TryStat(handle.DangerousGetHandle().ToInt32(), out var stat) || !expected.IsSameEntry(stat)) + throw new ArchiveSecurityException("The extraction destination changed."); + state.Checkpoint?.Invoke(operation + "-opened"); + return handle; + } + catch { handle.Dispose(); throw; } + } + + private static void VerifyPublicationParent(MergeState state, string relative, SafeFileHandle parent) + { + // Same-user renames can still move a pinned parent during the syscall or after this check. + // Re-resolve beneath the root and roll back on mismatch; this is detection, not isolation. + using var current = new SafeFileHandle(ElevationNative.OpenBeneath(state.RootHandle.Descriptor, relative, PosixNative.PathFlags | PosixNative.ODirectory), true); + if (!PosixNative.TryStat(parent.DangerousGetHandle().ToInt32(), out var before) + || !PosixNative.TryStat(current.DangerousGetHandle().ToInt32(), out var after) + || !PosixNative.SameEntry(before, after)) + throw new ArchiveSecurityException("The extraction destination changed."); } private sealed class ExtractionGuard diff --git a/src/Files.Platform.Linux/Files.Platform.Linux.csproj b/src/Files.Platform.Linux/Files.Platform.Linux.csproj index 6190f99effdb..139d2407e6de 100644 --- a/src/Files.Platform.Linux/Files.Platform.Linux.csproj +++ b/src/Files.Platform.Linux/Files.Platform.Linux.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/src/Files.Platform.Linux/Native/ElevationNative.cs b/src/Files.Platform.Linux/Native/ElevationNative.cs index b6442d0c82a0..9a2459f9e58f 100644 --- a/src/Files.Platform.Linux/Native/ElevationNative.cs +++ b/src/Files.Platform.Linux/Native/ElevationNative.cs @@ -209,6 +209,52 @@ internal static int Open(int dirfd, string name, int flags, bool fallback, uint return fd; } + // Async-local fault injection keeps archive regression tests isolated. + internal static readonly System.Threading.AsyncLocal OpenBeneathError = new(); + + internal static int OpenBeneath(int rootFd, string relativePath, int flags) + { + var how = new OpenHow { Flags = (ulong)flags, Resolve = 0x08 | 0x04 | 0x02 }; // BENEATH | NO_SYMLINKS | NO_MAGICLINKS + var injectedError = OpenBeneathError.Value; + var fd = injectedError.HasValue ? -1 : (int)OpenAt2(437, rootFd, relativePath.Length == 0 ? "." : relativePath, ref how, 24); + if (fd >= 0) return fd; + var error = injectedError ?? Marshal.GetLastPInvokeError(); + if (error != 38) throw PosixNative.CreateException(error, relativePath); // ENOSYS only + + var components = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (relativePath.StartsWith('/') || Array.Exists(components, component => component == "..")) + throw new IOException("Path must remain beneath its directory handle."); + + // Pin each directory and never follow symlinks, including with O_PATH. + var pathFlags = 0x200000 | PosixNative.ReadOnlyFlags; // O_PATH | O_CLOEXEC + var current = -1; + try + { + current = Open(rootFd, ".", pathFlags | PosixNative.ODirectory, true); + for (var i = 0; i < components.Length; i++) + { + var next = Open(current, components[i], i == components.Length - 1 ? flags : pathFlags | PosixNative.ODirectory, true); + if (!PosixNative.TryStat(next, out var stat) || stat.FileType == 0xA000) + { + PosixNative.Close(next); + throw new IOException("Unable to open path without symlinks."); + } + PosixNative.Close(current); + current = next; + } + if (components.Length == 0) + { + var next = Open(current, ".", flags, true); + PosixNative.Close(current); + current = next; + } + fd = current; + current = -1; + return fd; + } + finally { if (current >= 0) PosixNative.Close(current); } + } + internal static void Rename(int sourceFd, string source, int targetFd, string target) { if (RenameAt2(sourceFd, source, targetFd, target, 1) != 0) diff --git a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs index fc82f6afc2ae..ada7f2fc98b7 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs @@ -16,6 +16,7 @@ using Files.App.Storage.Archives; using Files.Platform.Abstractions.FileOperations; using Files.Platform.Linux.Archives; +using Files.Platform.Linux.Native; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Files.Platform.Tests.Archives @@ -46,6 +47,53 @@ public void Cleanup() Directory.Delete(root, true); } + [TestMethod] + public async Task ExtractionPublishesWithoutOpenAt2() + { + var zip = ZipWith(Path.Combine(Work, "fallback.zip"), + ("plain.txt", Encoding.UTF8.GetBytes("plain")), + ("one/two/nested.txt", Encoding.UTF8.GetBytes("nested"))); + ElevationNative.OpenBeneathError.Value = 38; + try + { + var result = await service.ExtractAsync(zip, Out); + Assert.IsTrue(result.Succeeded); + Assert.AreEqual("plain", File.ReadAllText(Path.Combine(Out, "plain.txt"))); + Assert.AreEqual("nested", File.ReadAllText(Path.Combine(Out, "one/two/nested.txt"))); + } + finally { ElevationNative.OpenBeneathError.Value = null; } + } + + [TestMethod] + [DataRow("../outside")] + [DataRow("/tmp")] + [DataRow("link")] + [DataRow("link/child")] + public void OpenBeneathFallbackRejectsEscapes(string relative) + { + Directory.CreateDirectory(Path.Combine(Work, "child")); + Directory.CreateSymbolicLink(Path.Combine(Out, "link"), Work); + var fd = ElevationNative.Open(PosixNative.AtFdCwd, Out, PosixNative.PathFlags | PosixNative.ODirectory, true); + ElevationNative.OpenBeneathError.Value = 38; + try + { + Assert.ThrowsExactly(() => ElevationNative.OpenBeneath(fd, relative, PosixNative.PathFlags)); + } + finally + { + ElevationNative.OpenBeneathError.Value = null; + PosixNative.Close(fd); + } + } + + [TestMethod] + public void OpenBeneathDoesNotFallbackForPermissionErrors() + { + ElevationNative.OpenBeneathError.Value = 13; + try { Assert.ThrowsExactly(() => ElevationNative.OpenBeneath(PosixNative.AtFdCwd, ".", PosixNative.PathFlags)); } + finally { ElevationNative.OpenBeneathError.Value = null; } + } + private string MakeTree() { var dir = Path.Combine(Work, "Documents"); @@ -579,6 +627,38 @@ public async Task DefaultLimitsAllowNormalArchives() Assert.IsTrue((await service.ExtractAsync(archive, Out)).Succeeded); } + [TestMethod] + [DataRow("a.txt")] + [DataRow("./a.txt")] + public async Task DuplicateTarFilesUseLastContents(string secondName) + { + var tar = Path.Combine(Work, "duplicates.tar"); + using (var stream = File.Create(tar)) + using (var writer = new TarWriter(stream)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "a.txt") { DataStream = new MemoryStream(Bytes("long old contents")) }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, secondName) { DataStream = new MemoryStream(Bytes("new")) }); + } + var result = await service.ExtractAsync(tar, Out); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.AreEqual("new", File.ReadAllText(Path.Combine(Out, "a.txt"))); + } + + [TestMethod] + public async Task FileOverwriteNeverRecursivelyDeletesExistingDirectory() + { + var directory = Path.Combine(Out, "a.txt"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "victim"), "preserved"); + var zip = ZipWith(Path.Combine(Work, "overwrite.zip"), ("a.txt", Bytes("incoming"))); + var result = await service.ExtractAsync(zip, Out, new ArchiveExtractOptions + { + ResolveConflict = (_, _) => Task.FromResult(new ConflictResolution(ConflictAction.Overwrite)), + }); + Assert.IsFalse(result.Succeeded); + Assert.AreEqual("preserved", File.ReadAllText(Path.Combine(directory, "victim"))); + } + // ---- conflicts ---- private async Task ArchiveWithAText(string content) @@ -594,6 +674,44 @@ private void ExistingFile() File.WriteAllText(Path.Combine(Out, "dir", "a.txt"), "old"); } + [TestMethod] + [DataRow("publish-opening", false, true)] + [DataRow("publish-opened", false, true)] + [DataRow("publish-renamed", false, true)] + [DataRow("retire-opening", true, true)] + [DataRow("retire-opened", true, true)] + [DataRow("retire-renamed", true, true)] + [DataRow("publish-renamed", false, false)] + [DataRow("retire-renamed", true, false)] + public async Task MovedPublicationParentIsRejectedAndRolledBack(string checkpoint, bool overwrite, bool replaceParent) + { + ExistingFile(); + var outside = Path.Combine(root, "moved-parent"); + var moved = false; + var extracting = new LinuxArchiveService + { + PublicationCheckpoint = phase => + { + if (phase != checkpoint || moved) return; + Directory.Move(Path.Combine(Out, "dir"), outside); + // A replacement tests inode comparison; a missing path tests failed re-resolution. + if (replaceParent) Directory.CreateDirectory(Path.Combine(Out, "dir")); + moved = true; + }, + }; + var zip = ZipWith(Path.Combine(Work, "race.zip"), (overwrite ? "dir/a.txt" : "dir/new.txt", Bytes("incoming"))); + var result = await extracting.ExtractAsync(zip, Out, new ArchiveExtractOptions + { + ResolveConflict = (_, _) => Task.FromResult(new ConflictResolution(ConflictAction.Overwrite)), + }); + Assert.IsTrue(moved); + Assert.IsFalse(result.Succeeded); + Assert.AreEqual("old", File.ReadAllText(Path.Combine(outside, "a.txt"))); + CollectionAssert.AreEquivalent(new[] { "a.txt" }, Directory.GetFileSystemEntries(outside).Select(Path.GetFileName).ToArray()); + if (replaceParent) Assert.AreEqual(0, Directory.GetFileSystemEntries(Path.Combine(Out, "dir")).Length); + else Assert.IsFalse(Directory.Exists(Path.Combine(Out, "dir"))); + } + [TestMethod] public async Task ExistingFilesAreNeverOverwrittenWithoutCallback() { @@ -605,6 +723,68 @@ public async Task ExistingFilesAreNeverOverwrittenWithoutCallback() Assert.AreEqual("new", File.ReadAllText(Path.Combine(Out, "new.txt"))); } + [TestMethod] + [DataRow(ConflictAction.Overwrite, false, false)] + [DataRow(ConflictAction.KeepBoth, false, false)] + [DataRow(ConflictAction.Overwrite, true, false)] + [DataRow(ConflictAction.Overwrite, false, true)] + [DataRow(ConflictAction.KeepBoth, false, true)] + public async Task ConflictAncestorSwapCannotModifyOutsideDestination(ConflictAction action, bool directoryConflict, bool swapRoot) + { + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(outside); + ExistingFile(); + if (directoryConflict) + { + File.Delete(Path.Combine(Out, "dir", "a.txt")); + Directory.CreateDirectory(Path.Combine(Out, "dir", "a.txt")); + Directory.CreateDirectory(Path.Combine(outside, "a.txt")); + File.WriteAllText(Path.Combine(outside, "a.txt", "victim"), "outside"); + } + else + File.WriteAllText(Path.Combine(outside, "a.txt"), "outside"); + + var result = await service.ExtractAsync(await ArchiveWithAText("incoming"), Out, new ArchiveExtractOptions + { + ResolveConflict = (_, _) => + { + if (swapRoot) + { + Directory.CreateDirectory(Path.Combine(outside, "dir")); + File.Move(Path.Combine(outside, "a.txt"), Path.Combine(outside, "dir", "a.txt")); + Directory.Move(Out, Path.Combine(root, "saved")); + Directory.CreateSymbolicLink(Out, outside); + } + else + { + Directory.Move(Path.Combine(Out, "dir"), Path.Combine(Out, "saved")); + Directory.CreateSymbolicLink(Path.Combine(Out, "dir"), outside); + } + return Task.FromResult(new ConflictResolution(action)); + }, + }); + + Assert.IsFalse(result.Succeeded); + if (swapRoot) + Assert.AreEqual(1, Directory.GetFileSystemEntries(Path.Combine(outside, "dir")).Length); + Assert.AreEqual("outside", File.ReadAllText(swapRoot ? Path.Combine(outside, "dir", "a.txt") : directoryConflict ? Path.Combine(outside, "a.txt", "victim") : Path.Combine(outside, "a.txt"))); + Assert.AreEqual(1, Directory.GetFileSystemEntries(outside).Length); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ExtractionRejectsSymlinkedDestinationAncestors(bool linkIsRoot) + { + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(outside); + var link = Path.Combine(Out, "link"); + Directory.CreateSymbolicLink(link, outside); + var result = await service.ExtractAsync(await ArchiveWithAText("incoming"), linkIsRoot ? link : Path.Combine(link, "nested")); + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(0, Directory.GetFileSystemEntries(outside).Length); + } + [TestMethod] public async Task ConflictCallbackDecides() {