From 41ca91a3f6ea4e9c24406e16247947fce50356d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 20:45:09 +0300 Subject: [PATCH 1/4] Linux: read-only archive browsing via storable routes (P4-A) Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Archives/ArchiveContext.cs | 94 ++ .../Archives/ArchiveEntryFile.cs | 49 + .../Archives/ArchiveFolder.cs | 73 ++ .../ArchiveServiceCollectionExtensions.cs | 26 + .../Archives/ArchiveStorableRoute.cs | 59 ++ .../Files.App.Storage.csproj | 3 + .../Content/Archives/ArchivePasswordPrompt.cs | 26 + .../Compress/BaseCompressArchiveAction.cs | 6 +- .../BaseDecompressArchiveAction.Windows.cs | 134 +++ .../Decompress/BaseDecompressArchiveAction.cs | 43 +- .../Decompress/DecompressArchive.Windows.cs | 145 +++ .../Archives/Decompress/DecompressArchive.cs | 35 +- ...pressArchiveToChildFolderAction.Windows.cs | 111 +++ .../DecompressArchiveToChildFolderAction.cs | 31 +- .../Models/CompressArchiveModel.Windows.cs | 457 +++++++++ .../Data/Models/CompressArchiveModel.cs | 385 +------- .../Helpers/Application/AppLifecycleHelper.cs | 4 + .../Storage/Helpers/ArchiveEntryHelpers.cs | 3 + .../StorageItems/ZipStorageFile.Windows.cs | 848 +++++++++++++++++ .../Storage/StorageItems/ZipStorageFile.cs | 842 +--------------- .../StorageItems/ZipStorageFolder.Windows.cs | 898 ++++++++++++++++++ .../Storage/StorageItems/ZipStorageFolder.cs | 897 +---------------- ...ecompressArchiveDialogViewModel.Windows.cs | 190 ++++ .../DecompressArchiveDialogViewModel.cs | 31 +- .../ArchivePreviewViewModel.Windows.cs | 70 ++ .../Previews/ArchivePreviewViewModel.cs | 65 +- .../Archives/ArchiveTypes.cs | 2 +- .../Archives/IArchivePasswordPrompt.cs | 15 + .../Archives/IArchiveService.cs | 7 + .../Archives/ArchivePathValidator.cs | 16 +- .../Archives/ArchiveSource.cs | 73 +- .../Archives/LinuxArchiveService.Extract.cs | 43 +- .../Archives/LinuxArchiveService.OpenEntry.cs | 88 ++ .../Archives/LinuxArchiveService.cs | 21 +- .../Helpers/FileExtensionHelpers.cs | 61 +- .../Archives/ArchiveBrowsingTests.cs | 280 ++++++ .../Archives/ArchiveServiceTests.cs | 24 + .../Files.Platform.Tests.csproj | 4 + 38 files changed, 3906 insertions(+), 2253 deletions(-) create mode 100644 src/Files.App.Storage/Archives/ArchiveContext.cs create mode 100644 src/Files.App.Storage/Archives/ArchiveEntryFile.cs create mode 100644 src/Files.App.Storage/Archives/ArchiveFolder.cs create mode 100644 src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs create mode 100644 src/Files.App.Storage/Archives/ArchiveStorableRoute.cs create mode 100644 src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs create mode 100644 src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs create mode 100644 src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs create mode 100644 src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs create mode 100644 src/Files.App/Data/Models/CompressArchiveModel.Windows.cs create mode 100644 src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs create mode 100644 src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs create mode 100644 src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs create mode 100644 src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs create mode 100644 src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs create mode 100644 src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs create mode 100644 tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs diff --git a/src/Files.App.Storage/Archives/ArchiveContext.cs b/src/Files.App.Storage/Archives/ArchiveContext.cs new file mode 100644 index 000000000000..87681012a4f8 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveContext.cs @@ -0,0 +1,94 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.IO; +using System.Text; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + internal sealed class ArchiveContext(string path, IArchiveService service, IArchivePasswordPrompt? prompt) + { + private string? password; + private Dictionary? entries; + private readonly SemaphoreSlim gate = new(1, 1); + public string Path { get; } = path; + public IArchiveService Service { get; } = service; + + public async Task WithPasswordAsync(Func> operation, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + for (var attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try { return await operation(password).ConfigureAwait(false); } + catch (ArchivePasswordException) when (prompt is not null && attempt < 3) + { + password = await prompt.RequestPasswordAsync(Path, password is not null, cancellationToken).ConfigureAwait(false); + if (password is null) + throw new OperationCanceledException(cancellationToken); + } + } + } + finally { gate.Release(); } + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entries is { } cached) + return cached; + var listing = await WithPasswordAsync(password => Service.ListAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var entry in listing.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Normalize(entry.Path); + if (name.Length == 0 || entry.LinkTarget is not null) + continue; + if (!result.TryAdd(name, entry with { Path = name })) + throw new ArchiveSecurityException("The archive contains ambiguous entry names."); + } + foreach (var name in result.Keys.ToArray()) + { + var parent = name; + while (parent.LastIndexOf('/') is var index && index >= 0) + { + parent = parent[..index]; + if (result.TryGetValue(parent, out var item) && !item.IsDirectory) + throw new ArchiveSecurityException("An archive file is also used as a directory."); + if (result.Count >= 10000 && !result.ContainsKey(parent)) + throw new ArchiveSecurityException("The archive hierarchy limit was exceeded."); + result.TryAdd(parent, new ArchiveEntryInfo(parent, true, 0, 0, null, false)); + } + } + entries = result; + return result; + } + + internal static string Normalize(string value) + { + if (Encoding.UTF8.GetByteCount(value) > 4096) + throw new ArchiveSecurityException("An archive entry path is too long."); + var path = value.Replace('\\', '/'); + if (path.StartsWith('/') || path.Contains('\0') || (path.Length >= 2 && path[1] == ':')) + throw new ArchiveSecurityException("An archive entry has an unsafe path."); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 128 || segments.Any(segment => Encoding.UTF8.GetByteCount(segment) > 255)) + throw new ArchiveSecurityException("An archive entry path exceeds browsing limits."); + if (segments.Any(segment => segment == "..")) + throw new ArchiveSecurityException("An archive entry traverses its parent."); + return string.Join('/', segments.Where(segment => segment != ".")); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveEntryFile.cs b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs new file mode 100644 index 000000000000..b0be489784f1 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs @@ -0,0 +1,49 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using Files.Shared.Helpers; +using System.IO; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// A regular archive member. The caller owns the returned bounded stream. + public sealed class ArchiveEntryFile : IChildFile + { + private readonly ArchiveContext context; + private readonly ArchiveFolder parent; + public ArchiveEntryInfo Entry { get; } + public string Id => context.Path + "/" + Entry.Path; + public string Name => ArchiveDisplayName.Escape(Entry.Path[(Entry.Path.LastIndexOf('/') + 1)..]); + + internal ArchiveEntryFile(ArchiveContext context, ArchiveEntryInfo entry, ArchiveFolder parent) + { + this.context = context; + Entry = entry; + this.parent = parent; + } + + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(parent); + } + + public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // LINUX-TODO(archives): entry modification remains disabled behind IArchiveService.CanWriteEntries. + if (accessMode != FileAccess.Read) + throw new NotSupportedException("Archive browsing is read-only."); + return context.WithPasswordAsync(password => context.Service.OpenEntryAsync(context.Path, Entry.Path, password, cancellationToken), cancellationToken); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveFolder.cs b/src/Files.App.Storage/Archives/ArchiveFolder.cs new file mode 100644 index 000000000000..039d599d58e8 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveFolder.cs @@ -0,0 +1,73 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using Files.Shared.Helpers; +using OwlCore.Storage.System.IO; +using System.IO; +using System.Runtime.CompilerServices; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// A read-only view of an archive directory, including directories implied by entry names. + public sealed class ArchiveFolder : IChildFolder, IGetItem + { + private readonly ArchiveContext context; + internal string EntryPath { get; } + public string Id => EntryPath.Length == 0 ? context.Path : context.Path + "/" + EntryPath; + public string Name => ArchiveDisplayName.Escape(EntryPath.Length == 0 ? Path.GetFileName(context.Path) : EntryPath[(EntryPath.LastIndexOf('/') + 1)..]); + + internal ArchiveFolder(ArchiveContext context, string entryPath) + { + this.context = context; + EntryPath = entryPath; + } + + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + IFolder? parent = EntryPath.Length == 0 + ? new SystemFolder(Path.GetDirectoryName(context.Path)!) + : new ArchiveFolder(context, EntryPath.Contains('/') ? EntryPath[..EntryPath.LastIndexOf('/')] : string.Empty); + return Task.FromResult(parent); + } + + public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var entries = await context.ListAsync(cancellationToken).ConfigureAwait(false); + var prefix = EntryPath.Length == 0 ? string.Empty : EntryPath + "/"; + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!entry.Key.StartsWith(prefix, StringComparison.Ordinal) || entry.Key.Length == prefix.Length || entry.Key.AsSpan(prefix.Length).Contains('/')) + continue; + if (entry.Value.IsDirectory && type.HasFlag(StorableType.Folder)) + yield return new ArchiveFolder(context, entry.Key); + else if (!entry.Value.IsDirectory && type.HasFlag(StorableType.File)) + yield return new ArchiveEntryFile(context, entry.Value, this); + } + } + + public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) + { + await foreach (var item in GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) + if (item.Id == id) + return item; + throw new FileNotFoundException("The archive child was not found."); + } + + public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromException(new NotSupportedException()); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs new file mode 100644 index 000000000000..5fd7e21667d8 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + public static class ArchiveServiceCollectionExtensions + { + /// Registers archive routing; the host provides IArchiveService and an optional IArchivePasswordPrompt. + public static IServiceCollection AddArchiveStorables(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs new file mode 100644 index 000000000000..17b8a2485ec1 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs @@ -0,0 +1,59 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.IO; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// Routes archive roots and members before the local file system route. + public sealed class ArchiveStorableRoute(IArchiveService service, IArchivePasswordPrompt? passwordPrompt = null) : IStorableRoute + { + public int Order => 100; + + public async Task TryGetAsync(string path, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(path) || path.Contains('\0') || !Path.IsPathFullyQualified(path)) + return StorableResult.NotMine; + try + { + for (var end = 1; end <= path.Length; end++) + { + if (end < path.Length && path[end] != Path.DirectorySeparatorChar) + continue; + var container = path[..end]; + if (!service.IsArchiveFileName(container)) + continue; + var attributes = File.GetAttributes(container); + if (attributes.HasFlag(FileAttributes.Directory)) + continue; + var context = new ArchiveContext(container, service, passwordPrompt); + var entryPath = ArchiveContext.Normalize(path[end..].TrimStart('/')); + var listing = await context.ListAsync(cancellationToken).ConfigureAwait(false); + if (entryPath.Length == 0) + return StorableResult.Success(new ArchiveFolder(context, string.Empty)); + if (!listing.TryGetValue(entryPath, out var entry)) + return StorableResult.NotFound; + var parentPath = entryPath.Contains('/') ? entryPath[..entryPath.LastIndexOf('/')] : string.Empty; + return StorableResult.Success(entry.IsDirectory + ? new ArchiveFolder(context, entryPath) + : new ArchiveEntryFile(context, entry, new ArchiveFolder(context, parentPath))); + } + return StorableResult.NotMine; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return StorableResult.NotFound; } + catch (UnauthorizedAccessException) { return StorableResult.AccessDenied; } + catch (OperationCanceledException) { throw; } + catch (Exception) { return StorableResult.Error; } + } + } +} diff --git a/src/Files.App.Storage/Files.App.Storage.csproj b/src/Files.App.Storage/Files.App.Storage.csproj index 65e00931daf9..4fa09b8bfb31 100644 --- a/src/Files.App.Storage/Files.App.Storage.csproj +++ b/src/Files.App.Storage/Files.App.Storage.csproj @@ -57,4 +57,7 @@ + + + \ No newline at end of file diff --git a/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs new file mode 100644 index 000000000000..5b844b9364c2 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs @@ -0,0 +1,26 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.Text; + +namespace Files.App.Actions +{ + internal sealed class ArchivePasswordPrompt : IArchivePasswordPrompt + { + public async Task RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var viewModel = new CredentialDialogViewModel { PasswordOnly = true, CanBeAnonymous = false, IsWrongPassword = retry }; + var dialogService = Ioc.Default.GetRequiredService(); + var result = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => dialogService.ShowDialogAsync(viewModel)); + cancellationToken.ThrowIfCancellationRequested(); + if (result != DialogResult.Primary) + return null; + if (viewModel.Password is not { } password) + return null; + using (password) + return Encoding.UTF8.GetString(password); + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs index 2cae00576d18..74e6863c9d2f 100644 --- a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs +++ b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs @@ -67,7 +67,11 @@ private bool IsContextPageTypeAdaptedToCommand() { return context.PageType != ContentPageTypes.RecycleBin && - context.PageType != ContentPageTypes.ZipFolder && + (context.PageType != ContentPageTypes.ZipFolder +#if !WINDOWS + || Ioc.Default.GetRequiredService().CanWriteEntries +#endif + ) && context.PageType != ContentPageTypes.ReleaseNotes && context.PageType != ContentPageTypes.Settings && context.PageType != ContentPageTypes.None; diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs new file mode 100644 index 000000000000..53f80ccb7299 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs @@ -0,0 +1,134 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Microsoft.UI.Xaml.Controls; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + internal abstract class BaseDecompressArchiveAction : BaseUIAction, IAction + { + protected readonly IContentPageContext context; + protected IStorageArchiveService StorageArchiveService { get; } = Ioc.Default.GetRequiredService(); + + public abstract string Label { get; } + + public abstract string Description { get; } + + public virtual ActionCategory Category + => ActionCategory.Archive; + + public virtual HotKey HotKey + => HotKey.None; + + public override bool IsExecutable => + (IsContextPageTypeAdaptedToCommand() && + CanDecompressSelectedItems() || + CanDecompressInsideArchive()) && + UIHelpers.CanShowDialog; + + public BaseDecompressArchiveAction() + { + context = Ioc.Default.GetRequiredService(); + + context.PropertyChanged += Context_PropertyChanged; + } + + public abstract Task ExecuteAsync(object? parameter = null); + + protected bool IsContextPageTypeAdaptedToCommand() + { + return + context.PageType != ContentPageTypes.RecycleBin && + context.PageType != ContentPageTypes.ZipFolder && + context.PageType != ContentPageTypes.ReleaseNotes && + context.PageType != ContentPageTypes.Settings && + context.PageType != ContentPageTypes.None; + } + + protected async Task DecompressArchiveHereAsync(bool smart = false) + { + if (context.SelectedItems.Count is 0) + return; + + var selectedItems = context.SelectedItems.ToList(); + var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty; + BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath); + + foreach (var selectedItem in selectedItems) + { + var password = string.Empty; + BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + + if (archive?.Path is null) + return; + + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + { + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = true, + ShowPathSelection = false + }; + + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + if (decompressArchiveViewModel.Password is not null) + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); + } + + BaseStorageFolder? destinationFolder = null; + + var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password); + + if (smart && currentFolder is not null && isMultipleItems) + { + destinationFolder = + await FilesystemTasks.WrapNullable(() => + currentFolder.CreateFolderAsync( + SystemIO.Path.GetFileNameWithoutExtension(archive.Path), + CreationCollisionOption.GenerateUniqueName).AsTask()); + } + else + { + destinationFolder = currentFolder; + } + + // Operate decompress + var result = await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + } + } + + protected virtual bool CanDecompressInsideArchive() + { + return false; + } + + protected virtual bool CanDecompressSelectedItems() + { + return StorageArchiveService.CanDecompress(context.SelectedItems); + } + + protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(IContentPageContext.SelectedItems): + case nameof(IContentPageContext.Folder): + OnPropertyChanged(nameof(IsExecutable)); + break; + } + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs index 53f80ccb7299..9e8af621965c 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs @@ -1,11 +1,12 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Microsoft.UI.Xaml.Controls; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -56,19 +57,17 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) var selectedItems = context.SelectedItems.ToList(); var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty; - BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath); foreach (var selectedItem in selectedItems) { var password = string.Empty; - BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); - - if (archive?.Path is null) + var archivePath = selectedItem.ItemPath; + if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath)) return; - if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath))) { - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = true, ShowPathSelection = false @@ -76,8 +75,7 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -87,26 +85,15 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); } - BaseStorageFolder? destinationFolder = null; - - var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password); - - if (smart && currentFolder is not null && isMultipleItems) - { - destinationFolder = - await FilesystemTasks.WrapNullable(() => - currentFolder.CreateFolderAsync( - SystemIO.Path.GetFileNameWithoutExtension(archive.Path), - CreationCollisionOption.GenerateUniqueName).AsTask()); - } - else - { - destinationFolder = currentFolder; - } + var destinationFolderPath = currentFolderPath; + var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archivePath, password); + if (smart && isMultipleItems) + destinationFolderPath = SystemIO.Path.Combine(currentFolderPath, + Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath)); // Operate decompress var result = await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password)); } } @@ -132,3 +119,5 @@ protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEv } } } + +#endif diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs new file mode 100644 index 000000000000..5a1d4a8f4e68 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs @@ -0,0 +1,145 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Files.Shared.Helpers; +using Microsoft.UI.Xaml.Controls; +using System.IO; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class DecompressArchiveAction : BaseDecompressArchiveAction + { + private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + public override string Label + => Strings.ExtractFiles.GetLocalizedResource(); + + public override string Description + => Strings.DecompressArchiveDescription.GetLocalizedFormatResource(context.SelectedItems.Count); + + public string AccessKey + => "E"; + + public override HotKey HotKey + => new(Keys.E, KeyModifiers.Ctrl); + + public DecompressArchiveAction() + { + } + + public override async Task ExecuteAsync(object? parameter = null) + { + if (context.ShellPage is null) + return; + + var archivePath = GetArchivePath(); + + if (string.IsNullOrEmpty(archivePath)) + return; + + BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath); + + if (archive?.Path is null) + return; + + var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); + var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); + Encoding? detectedEncoding = null; + if (isArchiveEncodingUndetermined) + { + detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path)); + } + var password = string.Empty; + Encoding? encoding = null; + + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = isArchiveEncrypted, + IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, + ShowPathSelection = true, + DetectedEncoding = detectedEncoding, + }; + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + if (isArchiveEncrypted && decompressArchiveViewModel.Password is not null) + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); + + encoding = decompressArchiveViewModel.SelectedEncoding.Encoding; + + // Check if archive still exists + if (!StorageHelpers.Exists(archive.Path)) + return; + + BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder; + string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath; + + // Save extraction location for future use + SaveExtractionLocation(destinationFolderPath); + + if (destinationFolder is null) + { + BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty); + if (parentFolder is null) + return; + + destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask()); + } + + // Operate decompress + var result = await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding)); + + if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion) + await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory); + } + + protected override bool CanDecompressInsideArchive() + { + return + context.PageType == ContentPageTypes.ZipFolder && + !context.HasSelection && + context.Folder is not null && + FileExtensionHelpers.IsZipFile(Path.GetExtension(context.Folder.ItemPath)); + } + + protected override bool CanDecompressSelectedItems() + { + return context.SelectedItems.Count == 1 && base.CanDecompressSelectedItems(); + } + + private string? GetArchivePath() + { + if (!string.IsNullOrEmpty(context.SelectedItem?.ItemPath)) + return context.SelectedItem?.ItemPath; + + if (context.PageType == ContentPageTypes.ZipFolder && !context.HasSelection) + return context.Folder?.ItemPath; + + return null; + } + + private void SaveExtractionLocation(string path) + { + var previousArchiveExtractionLocations = UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations?.ToList() ?? []; + previousArchiveExtractionLocations.Remove(path); + previousArchiveExtractionLocations.Insert(0, path); + + if (previousArchiveExtractionLocations.Count > 10) + UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations.RemoveFrom(11); + else + UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations; + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs index 5a1d4a8f4e68..31d0fd4b8e1b 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs @@ -1,13 +1,14 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Files.Shared.Helpers; using Microsoft.UI.Xaml.Controls; using System.IO; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -42,22 +43,20 @@ public override async Task ExecuteAsync(object? parameter = null) if (string.IsNullOrEmpty(archivePath)) return; - BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath); - - if (archive?.Path is null) + if (!File.Exists(archivePath)) return; - var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); - var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); + var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath)); + var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archivePath)); Encoding? detectedEncoding = null; if (isArchiveEncodingUndetermined) { - detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path)); + detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archivePath)); } var password = string.Empty; Encoding? encoding = null; - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = isArchiveEncrypted, IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, @@ -66,8 +65,7 @@ public override async Task ExecuteAsync(object? parameter = null) }; DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -79,27 +77,18 @@ public override async Task ExecuteAsync(object? parameter = null) encoding = decompressArchiveViewModel.SelectedEncoding.Encoding; // Check if archive still exists - if (!StorageHelpers.Exists(archive.Path)) + if (!File.Exists(archivePath)) return; - BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder; string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath; // Save extraction location for future use SaveExtractionLocation(destinationFolderPath); - if (destinationFolder is null) - { - BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty); - if (parentFolder is null) - return; - - destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask()); - } // Operate decompress var result = await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding)); + StorageArchiveService.DecompressAsync(archivePath, destinationFolderPath, password, encoding)); if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion) await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory); @@ -143,3 +132,5 @@ private void SaveExtractionLocation(string path) } } } + +#endif diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs new file mode 100644 index 000000000000..a730c4ea548d --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs @@ -0,0 +1,111 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Microsoft.UI.Xaml.Controls; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class DecompressArchiveToChildFolderAction : BaseDecompressArchiveAction + { + public override string Label + => ComputeLabel(); + + public override string Description + => Strings.DecompressArchiveToChildFolderDescription.GetLocalizedFormatResource(context.SelectedItems.Count); + + public string AccessKey + => "C"; + + public DecompressArchiveToChildFolderAction() + { + } + + public override async Task ExecuteAsync(object? parameter = null) + { + if (context.SelectedItems.Count is 0) + return; + + foreach (var selectedItem in context.SelectedItems) + { + var password = string.Empty; + + var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath; + if (archive?.Path is null) + return; + + var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty); + + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + { + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = true, + ShowPathSelection = false + }; + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!); + } + + BaseStorageFolder? destinationFolder = null; + if (currentFolder is not null) + { + var destinationResult = await FilesystemTasks.WrapNullable(() => + currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask()); + destinationFolder = destinationResult.Result; + } + + // Operate decompress + await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + } + } + + protected override void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(IContentPageContext.SelectedItems): + case nameof(IContentPageContext.Folder): + { + if (IsContextPageTypeAdaptedToCommand()) + { + OnPropertyChanged(nameof(Label)); + OnPropertyChanged(nameof(IsExecutable)); + } + + break; + } + } + } + + private string ComputeLabel() + => ToPlatformSeparator(ComputeLabelCore()); + + private static string ToPlatformSeparator(string label) + => SystemIO.Path.DirectorySeparatorChar == '\\' ? label : label.Replace('\\', SystemIO.Path.DirectorySeparatorChar); + + private string ComputeLabelCore() + { + if (context.SelectedItems == null || context.SelectedItems.Count == 0) + return string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), string.Empty); + + return context.SelectedItems.Count > 1 + ? string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), "*") + : string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), SystemIO.Path.GetFileNameWithoutExtension(context.SelectedItems.First().Name)); + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs index a730c4ea548d..8036eb252ea0 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs @@ -1,11 +1,12 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Microsoft.UI.Xaml.Controls; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -34,24 +35,21 @@ public override async Task ExecuteAsync(object? parameter = null) { var password = string.Empty; - var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + var archivePath = selectedItem.ItemPath; var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath; - if (archive?.Path is null) + if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath) || string.IsNullOrEmpty(currentFolderPath)) return; - var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty); - - if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath))) { - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = true, ShowPathSelection = false }; DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -60,17 +58,12 @@ public override async Task ExecuteAsync(object? parameter = null) password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!); } - BaseStorageFolder? destinationFolder = null; - if (currentFolder is not null) - { - var destinationResult = await FilesystemTasks.WrapNullable(() => - currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask()); - destinationFolder = destinationResult.Result; - } + var destinationFolderPath = SystemIO.Path.Combine(currentFolderPath, + Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath)); // Operate decompress await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password)); } } @@ -109,3 +102,5 @@ private string ComputeLabelCore() } } } + +#endif diff --git a/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs b/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs new file mode 100644 index 000000000000..303a24807ede --- /dev/null +++ b/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs @@ -0,0 +1,457 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Utils.Storage.Operations; +using Microsoft.Extensions.Logging; +using SevenZip; +using System.IO; + +namespace Files.App.Data.Models +{ + /// + /// Provides an archive creation support. + /// + public sealed class CompressArchiveModel : ICompressArchiveModel + { + private StatusCenterItemProgressModel? _fileSystemProgress; + + private FileSizeCalculator? _sizeCalculator; + + private string ArchiveExtension => FileFormat switch + { + ArchiveFormats.Zip => ".zip", + ArchiveFormats.SevenZip => ".7z", + ArchiveFormats.Tar => ".tar", + ArchiveFormats.GZip => ".gz", + _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), + }; + + private OutArchiveFormat SevenZipArchiveFormat => FileFormat switch + { + ArchiveFormats.Zip => OutArchiveFormat.Zip, + ArchiveFormats.SevenZip => OutArchiveFormat.SevenZip, + ArchiveFormats.Tar => OutArchiveFormat.Tar, + ArchiveFormats.GZip => OutArchiveFormat.GZip, + _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), + }; + + private CompressionLevel SevenZipCompressionLevel => CompressionLevel switch + { + ArchiveCompressionLevels.Ultra => SevenZip.CompressionLevel.Ultra, + ArchiveCompressionLevels.High => SevenZip.CompressionLevel.High, + ArchiveCompressionLevels.Normal => SevenZip.CompressionLevel.Normal, + ArchiveCompressionLevels.Low => SevenZip.CompressionLevel.Low, + ArchiveCompressionLevels.Fast => SevenZip.CompressionLevel.Fast, + ArchiveCompressionLevels.None => SevenZip.CompressionLevel.None, + _ => throw new ArgumentOutOfRangeException(nameof(CompressionLevel)), + }; + + private long SevenZipVolumeSize => SplittingSize switch + { + ArchiveSplittingSizes.None => 0L, + ArchiveSplittingSizes.Mo10 => 10 * 1000 * 1000L, + ArchiveSplittingSizes.Mo100 => 100 * 1000 * 1000L, + ArchiveSplittingSizes.Mo1024 => 1024 * 1000 * 1000L, + ArchiveSplittingSizes.Mo2048 => 2048 * 1000 * 1000L, + ArchiveSplittingSizes.Mo5120 => 5120 * 1000 * 1000L, + ArchiveSplittingSizes.Fat4092 => 4092 * 1000 * 1000L, + ArchiveSplittingSizes.Cd650 => 650 * 1000 * 1000L, + ArchiveSplittingSizes.Cd700 => 700 * 1000 * 1000L, + ArchiveSplittingSizes.Dvd4480 => 4480 * 1000 * 1000L, + ArchiveSplittingSizes.Dvd8128 => 8128 * 1000 * 1000L, + ArchiveSplittingSizes.Bd23040 => 23040 * 1000 * 1000L, + _ => throw new ArgumentOutOfRangeException(nameof(SplittingSize)), + }; + + private IProgress? _Progress; + public IProgress? Progress + { + get => _Progress; + set + { + _Progress = value; + _fileSystemProgress = new( + value, + false, + FileSystemStatusCode.InProgress); + + _fileSystemProgress.Report(0); + } + } + + /// + public string ArchivePath { get; set; } + + /// + public string Directory { get; init; } + + /// + public string FileName { get; init; } + + /// + public string Password { get; init; } + + /// + public IEnumerable Sources { get; init; } + + /// + public ArchiveFormats FileFormat { get; init; } + + /// + public ArchiveCompressionLevels CompressionLevel { get; init; } + + /// + public ArchiveSplittingSizes SplittingSize { get; init; } + + /// + public ArchiveDictionarySizes DictionarySize { get; init; } + + /// + public ArchiveWordSizes WordSize { get; init; } + + /// + public CancellationToken CancellationToken { get; set; } + + /// + public bool IsCancelled { get; private set; } + + /// + public int CPUThreads { get; set; } + + public CompressArchiveModel( + string[] source, + string directory, + string fileName, + int cpuThreads, + string? password = null, + ArchiveFormats fileFormat = ArchiveFormats.Zip, + ArchiveCompressionLevels compressionLevel = ArchiveCompressionLevels.Normal, + ArchiveSplittingSizes splittingSize = ArchiveSplittingSizes.None, + ArchiveDictionarySizes dictionarySize = ArchiveDictionarySizes.Auto, + ArchiveWordSizes wordSize = ArchiveWordSizes.Auto) + { + _Progress = new Progress(); + + Sources = source; + Directory = directory; + FileName = fileName; + Password = password ?? string.Empty; + ArchivePath = string.Empty; + FileFormat = fileFormat; + CompressionLevel = compressionLevel; + SplittingSize = splittingSize; + DictionarySize = dictionarySize; + WordSize = wordSize; + CPUThreads = cpuThreads; + } + + /// + public string GetArchivePath(string suffix = "") + { + return Path.Combine(Directory, $"{FileName}{suffix}{ArchiveExtension}"); + } + + /// + public async Task RunCreationAsync() + { + if (_fileSystemProgress is null) + throw new InvalidOperationException("Compression progress must be initialized before archive creation starts."); + + string[] sources = Sources.ToArray(); + + var compressor = new SevenZipCompressor() + { + ArchiveFormat = SevenZipArchiveFormat, + CompressionLevel = SevenZipCompressionLevel, + VolumeSize = FileFormat is ArchiveFormats.SevenZip ? SevenZipVolumeSize : 0, + FastCompression = false, + IncludeEmptyDirectories = true, + EncryptHeaders = true, + PreserveDirectoryRoot = sources.Length > 1, + }; + + compressor.CustomParameters.Add("mt", CPUThreads.ToString()); + // Use UTF-8 encoding. + // References: 7-zip chm --> Command Line Version --> Switches + // --> -m --> cu=[off | on]. + // Don't add "cu" parameter for 7zip files, see https://github.com/files-community/Files/issues/17257 + if (FileFormat != ArchiveFormats.SevenZip) + compressor.CustomParameters.Add("cu", "on"); + + if (FileFormat is ArchiveFormats.SevenZip) + { + var dictParam = GetDictionarySizeParam(); + if (dictParam is not null) + compressor.CustomParameters.Add("d", dictParam); + + var wordParam = GetWordSizeParam(); + if (wordParam is not null) + compressor.CustomParameters.Add("fb", wordParam); + } + + compressor.Compressing += Compressor_Compressing; + compressor.FileCompressionStarted += Compressor_FileCompressionStarted; + compressor.FileCompressionFinished += Compressor_FileCompressionFinished; + + var cts = new CancellationTokenSource(); + + try + { + var files = sources.Where(File.Exists).ToArray(); + var directories = sources.Where(SystemIO.Directory.Exists); + var skippedItems = new List(); + + _sizeCalculator = new FileSizeCalculator([.. files, .. directories]); + var sizeTask = _sizeCalculator.ComputeSizeAsync(cts.Token); + _ = sizeTask.ContinueWith(_ => + { + _fileSystemProgress.TotalSize = _sizeCalculator.Size; + _fileSystemProgress.ItemsCount = _sizeCalculator.ItemsCount; + _fileSystemProgress.EnumerationCompleted = true; + _fileSystemProgress.Report(); + }); + + // Enumerate the sources ourselves, skipping items that cannot be + // archived (e.g. broken junctions or locked files), see #16240 + var directoryItems = new List<(string Path, List ArchivableItems)>(); + var archivableFiles = new List(); + + await Task.Run(() => + { + foreach (string directory in directories) + { + var directoryPath = Path.GetFullPath(directory); + var archivableItems = new List(); + + AddArchivableItems(directoryPath, archivableItems, skippedItems); + directoryItems.Add((directoryPath, archivableItems)); + } + + foreach (var file in files) + { + if (CanOpenFile(file)) + archivableFiles.Add(file); + else + skippedItems.Add(file); + } + }); + + if (skippedItems.Count > 0) + { + var logger = Ioc.Default.GetRequiredService>(); + logger?.LogWarning($"Skipped {skippedItems.Count} item(s) that could not be archived to {LogPathHelper.RedactPath(ArchivePath)}: {string.Join(", ", skippedItems.Select(LogPathHelper.RedactPath))}"); + + // Ask the user whether to skip the items or cancel the operation, see #16240 + var dialogService = Ioc.Default.GetRequiredService(); + var dialogResult = await dialogService.ShowDialogAsync(new CompressSkippedItemsDialogViewModel(skippedItems)); + + if (dialogResult is not DialogResult.Primary) + { + IsCancelled = true; + cts.Cancel(); + + return false; + } + } + + foreach ((string directoryPath, List archivableItems) in directoryItems) + { + if (archivableItems.Any(File.Exists)) + { + var commonRootLength = GetCommonRootLength(directoryPath, sources.Length > 1); + + if (string.IsNullOrEmpty(Password)) + await compressor.CompressFilesAsync(ArchivePath, commonRootLength, [.. archivableItems]); + else + await compressor.CompressFilesEncryptedAsync(ArchivePath, commonRootLength, Password, [.. archivableItems]); + } + else + { + // The directory has no files, so we need to create entries manually + var fileDictionary = new Dictionary(); + AddEntry(fileDictionary, directoryPath, ""); + + compressor.CompressFileDictionary(fileDictionary, ArchivePath, Password); + + static void AddEntry(IDictionary fileDictionary, string directory, string entryPrefix) + { + DirectoryInfo directoryInfo = new DirectoryInfo(directory); + + DirectoryInfo[] directories; + + try + { + directories = directoryInfo.GetDirectories(); + } + catch (Exception) + { + // The directory contents are inaccessible (e.g. a broken junction), skip it + return; + } + + if (directories.Length == 0) + { + AddArchiveEntry(fileDictionary, entryPrefix + directoryInfo.Name, null); + } + else + { + entryPrefix += directoryInfo.Name + Path.DirectorySeparatorChar; + foreach (DirectoryInfo directoryInfo2 in directories) + AddEntry(fileDictionary, directoryInfo2.FullName, entryPrefix); + } + } + + // SevenZipSharp uses a null source path to represent an empty directory. + static void AddArchiveEntry(IDictionary entries, string name, string? sourcePath) + => entries.Add(name, sourcePath!); + } + + compressor.CompressionMode = CompressionMode.Append; + } + + if (archivableFiles.Count > 0) + { + if (string.IsNullOrEmpty(Password)) + await compressor.CompressFilesAsync(ArchivePath, [.. archivableFiles]); + else + await compressor.CompressFilesEncryptedAsync(ArchivePath, Password, [.. archivableFiles]); + } + + cts.Cancel(); + + return true; + } + catch (Exception ex) + { + var logger = Ioc.Default.GetRequiredService>(); + logger?.LogWarning(ex, $"Error compressing folder: {LogPathHelper.RedactPath(ArchivePath)}"); + + cts.Cancel(); + + return false; + } + } + + /// + /// Recursively collects the items of a directory that can be archived, + /// skipping items that cannot be read (e.g. broken junctions or locked files). + /// + private void AddArchivableItems(string directory, List items, List skippedItems) + { + CancellationToken.ThrowIfCancellationRequested(); + + FileSystemInfo[] children; + + try + { + children = new DirectoryInfo(directory).GetFileSystemInfos(); + } + catch (Exception) + { + // The directory contents are inaccessible (e.g. a broken junction or access denied) + skippedItems.Add(directory); + return; + } + + foreach (var child in children) + { + if (child is DirectoryInfo) + { + // Add the directory entry itself so empty directories are preserved + items.Add(child.FullName); + AddArchivableItems(child.FullName, items, skippedItems); + } + else if (CanOpenFile(child.FullName)) + { + items.Add(child.FullName); + } + else + { + skippedItems.Add(child.FullName); + } + } + } + + private static bool CanOpenFile(string path) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + return true; + } + catch (Exception) + { + return false; + } + } + + private static int GetCommonRootLength(string directoryPath, bool preserveRootEntry) + { + // Entries are relative to the directory itself unless the root should be preserved + var trimmedPath = directoryPath.TrimEnd(Path.DirectorySeparatorChar); + var root = preserveRootEntry ? Path.GetDirectoryName(trimmedPath) : trimmedPath; + + return string.IsNullOrEmpty(root) + ? trimmedPath.Length + 1 + : root.TrimEnd(Path.DirectorySeparatorChar).Length + 1; + } + + private void Compressor_FileCompressionStarted(object? sender, FileNameEventArgs e) + { + if (CancellationToken.IsCancellationRequested) + { + e.Cancel = true; + return; + } + + _sizeCalculator!.ForceComputeFileSize(e.FilePath); + _fileSystemProgress!.FileName = e.FileName; + _fileSystemProgress.Report(); + } + + private void Compressor_FileCompressionFinished(object? sender, EventArgs e) + { + _fileSystemProgress!.AddProcessedItemsCount(1); + _fileSystemProgress.Report(); + } + + private void Compressor_Compressing(object? _, ProgressEventArgs e) + { + if (_fileSystemProgress!.TotalSize > 0) + _fileSystemProgress.Report((_fileSystemProgress.ProcessedSize + e.PercentDelta / 100.0 * e.BytesCount) / _fileSystemProgress.TotalSize * 100); + } + + private string? GetDictionarySizeParam() => DictionarySize switch + { + ArchiveDictionarySizes.Auto => null, + ArchiveDictionarySizes.Kb64 => "64k", + ArchiveDictionarySizes.Kb256 => "256k", + ArchiveDictionarySizes.Mb1 => "1m", + ArchiveDictionarySizes.Mb2 => "2m", + ArchiveDictionarySizes.Mb4 => "4m", + ArchiveDictionarySizes.Mb8 => "8m", + ArchiveDictionarySizes.Mb16 => "16m", + ArchiveDictionarySizes.Mb32 => "32m", + ArchiveDictionarySizes.Mb64 => "64m", + ArchiveDictionarySizes.Mb128 => "128m", + ArchiveDictionarySizes.Mb256 => "256m", + ArchiveDictionarySizes.Mb512 => "512m", + ArchiveDictionarySizes.Mb1024 => "1024m", + _ => null, + }; + + private string? GetWordSizeParam() => WordSize switch + { + ArchiveWordSizes.Auto => null, + ArchiveWordSizes.Fb8 => "8", + ArchiveWordSizes.Fb16 => "16", + ArchiveWordSizes.Fb32 => "32", + ArchiveWordSizes.Fb64 => "64", + ArchiveWordSizes.Fb128 => "128", + ArchiveWordSizes.Fb256 => "256", + ArchiveWordSizes.Fb273 => "273", + _ => null, + }; + } +} diff --git a/src/Files.App/Data/Models/CompressArchiveModel.cs b/src/Files.App/Data/Models/CompressArchiveModel.cs index 303a24807ede..8c7c579bd07c 100644 --- a/src/Files.App/Data/Models/CompressArchiveModel.cs +++ b/src/Files.App/Data/Models/CompressArchiveModel.cs @@ -1,9 +1,8 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.App.Utils.Storage.Operations; -using Microsoft.Extensions.Logging; -using SevenZip; +#if !WINDOWS +using Files.Platform.Abstractions.Archives; using System.IO; namespace Files.App.Data.Models @@ -15,54 +14,6 @@ public sealed class CompressArchiveModel : ICompressArchiveModel { private StatusCenterItemProgressModel? _fileSystemProgress; - private FileSizeCalculator? _sizeCalculator; - - private string ArchiveExtension => FileFormat switch - { - ArchiveFormats.Zip => ".zip", - ArchiveFormats.SevenZip => ".7z", - ArchiveFormats.Tar => ".tar", - ArchiveFormats.GZip => ".gz", - _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), - }; - - private OutArchiveFormat SevenZipArchiveFormat => FileFormat switch - { - ArchiveFormats.Zip => OutArchiveFormat.Zip, - ArchiveFormats.SevenZip => OutArchiveFormat.SevenZip, - ArchiveFormats.Tar => OutArchiveFormat.Tar, - ArchiveFormats.GZip => OutArchiveFormat.GZip, - _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), - }; - - private CompressionLevel SevenZipCompressionLevel => CompressionLevel switch - { - ArchiveCompressionLevels.Ultra => SevenZip.CompressionLevel.Ultra, - ArchiveCompressionLevels.High => SevenZip.CompressionLevel.High, - ArchiveCompressionLevels.Normal => SevenZip.CompressionLevel.Normal, - ArchiveCompressionLevels.Low => SevenZip.CompressionLevel.Low, - ArchiveCompressionLevels.Fast => SevenZip.CompressionLevel.Fast, - ArchiveCompressionLevels.None => SevenZip.CompressionLevel.None, - _ => throw new ArgumentOutOfRangeException(nameof(CompressionLevel)), - }; - - private long SevenZipVolumeSize => SplittingSize switch - { - ArchiveSplittingSizes.None => 0L, - ArchiveSplittingSizes.Mo10 => 10 * 1000 * 1000L, - ArchiveSplittingSizes.Mo100 => 100 * 1000 * 1000L, - ArchiveSplittingSizes.Mo1024 => 1024 * 1000 * 1000L, - ArchiveSplittingSizes.Mo2048 => 2048 * 1000 * 1000L, - ArchiveSplittingSizes.Mo5120 => 5120 * 1000 * 1000L, - ArchiveSplittingSizes.Fat4092 => 4092 * 1000 * 1000L, - ArchiveSplittingSizes.Cd650 => 650 * 1000 * 1000L, - ArchiveSplittingSizes.Cd700 => 700 * 1000 * 1000L, - ArchiveSplittingSizes.Dvd4480 => 4480 * 1000 * 1000L, - ArchiveSplittingSizes.Dvd8128 => 8128 * 1000 * 1000L, - ArchiveSplittingSizes.Bd23040 => 23040 * 1000 * 1000L, - _ => throw new ArgumentOutOfRangeException(nameof(SplittingSize)), - }; - private IProgress? _Progress; public IProgress? Progress { @@ -151,307 +102,61 @@ public string GetArchivePath(string suffix = "") return Path.Combine(Directory, $"{FileName}{suffix}{ArchiveExtension}"); } - /// public async Task RunCreationAsync() { - if (_fileSystemProgress is null) - throw new InvalidOperationException("Compression progress must be initialized before archive creation starts."); - - string[] sources = Sources.ToArray(); - - var compressor = new SevenZipCompressor() + // LINUX-TODO(archives): encrypted creation, split volumes and custom 7z tuning need backend support. + if (!string.IsNullOrEmpty(Password) || SplittingSize != ArchiveSplittingSizes.None || DictionarySize != ArchiveDictionarySizes.Auto || WordSize != ArchiveWordSizes.Auto) + throw new NotSupportedException(); + var service = Ioc.Default.GetRequiredService(); + var format = FileFormat switch { - ArchiveFormat = SevenZipArchiveFormat, - CompressionLevel = SevenZipCompressionLevel, - VolumeSize = FileFormat is ArchiveFormats.SevenZip ? SevenZipVolumeSize : 0, - FastCompression = false, - IncludeEmptyDirectories = true, - EncryptHeaders = true, - PreserveDirectoryRoot = sources.Length > 1, + ArchiveFormats.Zip => ArchiveFormat.Zip, + ArchiveFormats.SevenZip => ArchiveFormat.SevenZip, + ArchiveFormats.Tar => ArchiveFormat.Tar, + ArchiveFormats.GZip => ArchiveFormat.TarGz, + _ => throw new NotSupportedException(), }; - - compressor.CustomParameters.Add("mt", CPUThreads.ToString()); - // Use UTF-8 encoding. - // References: 7-zip chm --> Command Line Version --> Switches - // --> -m --> cu=[off | on]. - // Don't add "cu" parameter for 7zip files, see https://github.com/files-community/Files/issues/17257 - if (FileFormat != ArchiveFormats.SevenZip) - compressor.CustomParameters.Add("cu", "on"); - - if (FileFormat is ArchiveFormats.SevenZip) - { - var dictParam = GetDictionarySizeParam(); - if (dictParam is not null) - compressor.CustomParameters.Add("d", dictParam); - - var wordParam = GetWordSizeParam(); - if (wordParam is not null) - compressor.CustomParameters.Add("fb", wordParam); - } - - compressor.Compressing += Compressor_Compressing; - compressor.FileCompressionStarted += Compressor_FileCompressionStarted; - compressor.FileCompressionFinished += Compressor_FileCompressionFinished; - - var cts = new CancellationTokenSource(); - - try + if (string.IsNullOrEmpty(ArchivePath)) + ArchivePath = GetArchivePath(); + var result = await service.CreateAsync(Sources.ToArray(), ArchivePath, new ArchiveCreateOptions { - var files = sources.Where(File.Exists).ToArray(); - var directories = sources.Where(SystemIO.Directory.Exists); - var skippedItems = new List(); - - _sizeCalculator = new FileSizeCalculator([.. files, .. directories]); - var sizeTask = _sizeCalculator.ComputeSizeAsync(cts.Token); - _ = sizeTask.ContinueWith(_ => + Format = format, + Progress = new Progress(progress => { - _fileSystemProgress.TotalSize = _sizeCalculator.Size; - _fileSystemProgress.ItemsCount = _sizeCalculator.ItemsCount; + if (_fileSystemProgress is null) + return; + _fileSystemProgress.FileName = Files.Shared.Helpers.ArchiveDisplayName.Escape(progress.CurrentEntry ?? string.Empty); + _fileSystemProgress.ItemsCount = progress.EntriesTotal; + _fileSystemProgress.AddProcessedItemsCount(Math.Max(0, progress.EntriesProcessed - _fileSystemProgress.ProcessedItemsCount)); + _fileSystemProgress.TotalSize = progress.BytesTotal; + _fileSystemProgress.SetProcessedSize(progress.BytesProcessed); _fileSystemProgress.EnumerationCompleted = true; _fileSystemProgress.Report(); - }); - - // Enumerate the sources ourselves, skipping items that cannot be - // archived (e.g. broken junctions or locked files), see #16240 - var directoryItems = new List<(string Path, List ArchivableItems)>(); - var archivableFiles = new List(); - - await Task.Run(() => - { - foreach (string directory in directories) - { - var directoryPath = Path.GetFullPath(directory); - var archivableItems = new List(); - - AddArchivableItems(directoryPath, archivableItems, skippedItems); - directoryItems.Add((directoryPath, archivableItems)); - } - - foreach (var file in files) - { - if (CanOpenFile(file)) - archivableFiles.Add(file); - else - skippedItems.Add(file); - } - }); - - if (skippedItems.Count > 0) - { - var logger = Ioc.Default.GetRequiredService>(); - logger?.LogWarning($"Skipped {skippedItems.Count} item(s) that could not be archived to {LogPathHelper.RedactPath(ArchivePath)}: {string.Join(", ", skippedItems.Select(LogPathHelper.RedactPath))}"); - - // Ask the user whether to skip the items or cancel the operation, see #16240 - var dialogService = Ioc.Default.GetRequiredService(); - var dialogResult = await dialogService.ShowDialogAsync(new CompressSkippedItemsDialogViewModel(skippedItems)); - - if (dialogResult is not DialogResult.Primary) - { - IsCancelled = true; - cts.Cancel(); - - return false; - } - } - - foreach ((string directoryPath, List archivableItems) in directoryItems) - { - if (archivableItems.Any(File.Exists)) - { - var commonRootLength = GetCommonRootLength(directoryPath, sources.Length > 1); - - if (string.IsNullOrEmpty(Password)) - await compressor.CompressFilesAsync(ArchivePath, commonRootLength, [.. archivableItems]); - else - await compressor.CompressFilesEncryptedAsync(ArchivePath, commonRootLength, Password, [.. archivableItems]); - } - else - { - // The directory has no files, so we need to create entries manually - var fileDictionary = new Dictionary(); - AddEntry(fileDictionary, directoryPath, ""); - - compressor.CompressFileDictionary(fileDictionary, ArchivePath, Password); - - static void AddEntry(IDictionary fileDictionary, string directory, string entryPrefix) - { - DirectoryInfo directoryInfo = new DirectoryInfo(directory); - - DirectoryInfo[] directories; - - try - { - directories = directoryInfo.GetDirectories(); - } - catch (Exception) - { - // The directory contents are inaccessible (e.g. a broken junction), skip it - return; - } - - if (directories.Length == 0) - { - AddArchiveEntry(fileDictionary, entryPrefix + directoryInfo.Name, null); - } - else - { - entryPrefix += directoryInfo.Name + Path.DirectorySeparatorChar; - foreach (DirectoryInfo directoryInfo2 in directories) - AddEntry(fileDictionary, directoryInfo2.FullName, entryPrefix); - } - } - - // SevenZipSharp uses a null source path to represent an empty directory. - static void AddArchiveEntry(IDictionary entries, string name, string? sourcePath) - => entries.Add(name, sourcePath!); - } - - compressor.CompressionMode = CompressionMode.Append; - } - - if (archivableFiles.Count > 0) - { - if (string.IsNullOrEmpty(Password)) - await compressor.CompressFilesAsync(ArchivePath, [.. archivableFiles]); - else - await compressor.CompressFilesEncryptedAsync(ArchivePath, Password, [.. archivableFiles]); - } - - cts.Cancel(); - - return true; - } - catch (Exception ex) - { - var logger = Ioc.Default.GetRequiredService>(); - logger?.LogWarning(ex, $"Error compressing folder: {LogPathHelper.RedactPath(ArchivePath)}"); - - cts.Cancel(); - - return false; - } - } - - /// - /// Recursively collects the items of a directory that can be archived, - /// skipping items that cannot be read (e.g. broken junctions or locked files). - /// - private void AddArchivableItems(string directory, List items, List skippedItems) - { - CancellationToken.ThrowIfCancellationRequested(); - - FileSystemInfo[] children; - - try - { - children = new DirectoryInfo(directory).GetFileSystemInfos(); - } - catch (Exception) - { - // The directory contents are inaccessible (e.g. a broken junction or access denied) - skippedItems.Add(directory); - return; - } - - foreach (var child in children) - { - if (child is DirectoryInfo) + }), + Level = CompressionLevel switch { - // Add the directory entry itself so empty directories are preserved - items.Add(child.FullName); - AddArchivableItems(child.FullName, items, skippedItems); - } - else if (CanOpenFile(child.FullName)) - { - items.Add(child.FullName); - } - else - { - skippedItems.Add(child.FullName); - } - } - } - - private static bool CanOpenFile(string path) - { - try - { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - return true; - } - catch (Exception) - { - return false; - } - } - - private static int GetCommonRootLength(string directoryPath, bool preserveRootEntry) - { - // Entries are relative to the directory itself unless the root should be preserved - var trimmedPath = directoryPath.TrimEnd(Path.DirectorySeparatorChar); - var root = preserveRootEntry ? Path.GetDirectoryName(trimmedPath) : trimmedPath; - - return string.IsNullOrEmpty(root) - ? trimmedPath.Length + 1 - : root.TrimEnd(Path.DirectorySeparatorChar).Length + 1; - } - - private void Compressor_FileCompressionStarted(object? sender, FileNameEventArgs e) - { - if (CancellationToken.IsCancellationRequested) - { - e.Cancel = true; - return; - } - - _sizeCalculator!.ForceComputeFileSize(e.FilePath); - _fileSystemProgress!.FileName = e.FileName; - _fileSystemProgress.Report(); - } - - private void Compressor_FileCompressionFinished(object? sender, EventArgs e) - { - _fileSystemProgress!.AddProcessedItemsCount(1); - _fileSystemProgress.Report(); + ArchiveCompressionLevels.None => ArchiveCompressionLevel.None, + ArchiveCompressionLevels.Fast or ArchiveCompressionLevels.Low => ArchiveCompressionLevel.Fast, + ArchiveCompressionLevels.High => ArchiveCompressionLevel.High, + ArchiveCompressionLevels.Ultra => ArchiveCompressionLevel.Ultra, + _ => ArchiveCompressionLevel.Normal, + }, + }, CancellationToken); + IsCancelled = result.Cancelled; + if (result.Succeeded) + _fileSystemProgress?.Report(100); + return result.Succeeded; } - private void Compressor_Compressing(object? _, ProgressEventArgs e) - { - if (_fileSystemProgress!.TotalSize > 0) - _fileSystemProgress.Report((_fileSystemProgress.ProcessedSize + e.PercentDelta / 100.0 * e.BytesCount) / _fileSystemProgress.TotalSize * 100); - } - - private string? GetDictionarySizeParam() => DictionarySize switch - { - ArchiveDictionarySizes.Auto => null, - ArchiveDictionarySizes.Kb64 => "64k", - ArchiveDictionarySizes.Kb256 => "256k", - ArchiveDictionarySizes.Mb1 => "1m", - ArchiveDictionarySizes.Mb2 => "2m", - ArchiveDictionarySizes.Mb4 => "4m", - ArchiveDictionarySizes.Mb8 => "8m", - ArchiveDictionarySizes.Mb16 => "16m", - ArchiveDictionarySizes.Mb32 => "32m", - ArchiveDictionarySizes.Mb64 => "64m", - ArchiveDictionarySizes.Mb128 => "128m", - ArchiveDictionarySizes.Mb256 => "256m", - ArchiveDictionarySizes.Mb512 => "512m", - ArchiveDictionarySizes.Mb1024 => "1024m", - _ => null, - }; - - private string? GetWordSizeParam() => WordSize switch + private string ArchiveExtension => FileFormat switch { - ArchiveWordSizes.Auto => null, - ArchiveWordSizes.Fb8 => "8", - ArchiveWordSizes.Fb16 => "16", - ArchiveWordSizes.Fb32 => "32", - ArchiveWordSizes.Fb64 => "64", - ArchiveWordSizes.Fb128 => "128", - ArchiveWordSizes.Fb256 => "256", - ArchiveWordSizes.Fb273 => "273", - _ => null, + ArchiveFormats.Zip => ".zip", + ArchiveFormats.SevenZip => ".7z", + ArchiveFormats.Tar => ".tar", + ArchiveFormats.GZip => ".tar.gz", + _ => throw new NotSupportedException(), }; } } + +#endif diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index c10ca96ccdf7..5f0abf0932a6 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -436,6 +436,10 @@ public static IServiceProvider ConfigureHost(AppModel appModel) // Storable path resolution services.AddStorables(); +#if !WINDOWS + Files.App.Storage.Archives.ArchiveServiceCollectionExtensions.AddArchiveStorables(services); + services.AddSingleton(); +#endif // Conditional DI #if WINDOWS diff --git a/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs b/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs index 11752c52f906..f09a95673f38 100644 --- a/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs @@ -1,6 +1,7 @@ // Copyright (c) Files Community // SPDX-License-Identifier: MPL-2.0 +#if WINDOWS using SevenZip; using IO = System.IO; @@ -38,3 +39,5 @@ public static string GetEntryName(ArchiveFileInfo entry, string containerPath) } } } + +#endif diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs new file mode 100644 index 000000000000..59ba26636d8e --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs @@ -0,0 +1,848 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using ICSharpCode.SharpZipLib.Zip; +using SevenZip; +using System.IO; +using System.Text; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Streams; +using Windows.Win32; +using IO = System.IO; + +namespace Files.App.Utils.Storage +{ + public sealed partial class ZipStorageFile : BaseStorageFile, IPasswordProtectedItem + { + private readonly string containerPath; + private readonly BaseStorageFile? backingFile; + + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string ContentType => "application/octet-stream"; + public override string FileType => IO.Path.GetExtension(Name); + public override string FolderRelativeId => $"0\\{Name}"; + + public override string DisplayType + { + get + { + var itemType = Strings.File.GetLocalizedResource(); + if (Name.Contains('.', StringComparison.Ordinal)) + { + itemType = FileType.Trim('.') + " " + itemType; + } + return itemType; + } + } + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Normal | Windows.Storage.FileAttributes.ReadOnly; + + private IStorageItemExtraProperties? properties; + public override IStorageItemExtraProperties Properties => properties ??= new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential? Credentials { get; set; } = new(); + private string Password => Credentials?.Password + ?? throw new InvalidOperationException("Archive credentials are not available."); + + internal Encoding? CurrentEncoding { get; set; } + + public Func>? PasswordRequestedCallback { get; set; } + + public ZipStorageFile(string path, string containerPath) + { + Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); + Path = path; + this.containerPath = containerPath; + } + public ZipStorageFile(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) + => this.backingFile = backingFile; + public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) + => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) + => this.backingFile = backingFile; + + public override IAsyncOperation ToStorageFileAsync() + => StorageFile.CreateStreamedFileAsync( + Name, + CurrentEncoding is not null && Path != containerPath + ? ZipDataStreamingHandlerWithEncoding(Path) + : ZipDataStreamingHandler(Path), + null + ); + + public static IAsyncOperation FromPathAsync(string path) + { + var containerPath = ZipStorageFolder.GetContainerPath(path); + if (containerPath is null) + return Task.FromResult(null).AsAsyncOperation(); + + if (path == containerPath) + return Task.FromResult(null).AsAsyncOperation(); // Root + + if (CheckAccess(containerPath)) + { + var file = new ZipStorageFile(path, containerPath); + if (ZipStorageFolder.TryGetEncodingForContainerPath(containerPath, out var encoding)) + file.CurrentEncoding = encoding; + return Task.FromResult(file).AsAsyncOperation(); + } + return Task.FromResult(null).AsAsyncOperation(); + } + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.File; + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (CurrentEncoding is not null && Path != containerPath) + return await GetBasicPropertiesWithEncodingAsync(); + + return await GetBasicProperties(); + }); + } + + public override IAsyncOperation OpenAsync(FileAccessMode accessMode) + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenWithEncodingAsync(accessMode); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + bool rw = accessMode is FileAccessMode.ReadWrite; + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenAsync(accessMode); + } + + var file = Win32Helper.OpenFileForRead(containerPath, rw); + return file.IsInvalid ? null : new FileStream(file, rw ? FileAccess.ReadWrite : FileAccess.Read).AsRandomAccessStream(); + } + + if (!rw) + { + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + + if (entry.FileName is not null) + { + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + } + return null; + } + + throw new NotSupportedException("Can't open zip file as RW"); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenWithEncodingAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + bool rw = accessMode is FileAccessMode.ReadWrite; + if (rw) + throw new NotSupportedException("Can't open zip file as RW"); + + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + } + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenAsync(FileAccessMode accessMode, StorageOpenOptions options) + => OpenAsync(accessMode); + + public override IAsyncOperation OpenReadAsync() + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenReadWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenReadAsync(); + } + + var hFile = Win32Helper.OpenFileForRead(containerPath); + return hFile.IsInvalid ? null : new StreamWithContentType(new FileStream(hFile, FileAccess.Read).AsRandomAccessStream()); + } + + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + var nsStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + return new StreamWithContentType(nsStream); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenReadWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + var nsStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + return new StreamWithContentType(nsStream); + } + } + return null; + + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenSequentialReadAsync() + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenSequentialReadWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenSequentialReadAsync(); + } + + var hFile = Win32Helper.OpenFileForRead(containerPath); + return hFile.IsInvalid ? null : new FileStream(hFile, FileAccess.Read).AsInputStream(); + } + + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenSequentialReadWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + } + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenTransactedWriteAsync() + => throw new NotSupportedException(); + public override IAsyncOperation OpenTransactedWriteAsync(StorageOpenOptions options) + => throw new NotSupportedException(); + + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder) + => CopyAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName) + => CopyAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + if (CurrentEncoding is not null && Path != containerPath) + return CopyWithEncodingAsync(destinationFolder, desiredNewName, option); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var destFolder = destinationFolder.AsBaseStorageFolder() + ?? throw new NotSupportedException("The destination folder type is not supported."); + + if (destFolder is ICreateFileWithStream cwsf) + { + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + using var inStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size); + return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); + } + else + { + var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) + ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); + + await using var outStream = await destFile.OpenStreamForWriteAsync(); + await SafetyExtensions.WrapAsync(() => zipFile.ExtractFileAsync(entry.Index, outStream), async (_, exception) => + { + await destFile.DeleteAsync(); + throw exception; + }); + return destFile; + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation CopyWithEncodingAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + var entry = zipFile.Cast().FirstOrDefault(x => x.IsFile && string.Equals(x.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + return null; + } + + var destFolder = destinationFolder.AsBaseStorageFolder() + ?? throw new NotSupportedException("The destination folder type is not supported."); + if (destFolder is ICreateFileWithStream cwsf) + { + var ms = new MemoryStream(); + using var zipStream = zipFile.GetInputStream(entry); + zipStream.CopyTo(ms); + ms.Position = 0; + using var inStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); + } + else + { + var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) + ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); + await using var outStream = await destFile.OpenStreamForWriteAsync(); + using var zipStream = zipFile.GetInputStream(entry); + zipStream.CopyTo(outStream); + return destFile; + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) + { + if (CurrentEncoding is not null && Path != containerPath) + return CopyAndReplaceWithEncodingAsync(fileToReplace); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return; + } + + await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) + { + outStream.SetLength(0); + await zipFile.ExtractFileAsync(entry.Index, outStream); + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncAction CopyAndReplaceWithEncodingAsync(IStorageFile fileToReplace) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) + using (var zipStream = zipFile.GetInputStream(entry)) + { + outStream.SetLength(0); + zipStream.CopyTo(outStream); + } + return; + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder) + => throw new NotSupportedException(); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName) + => throw new NotSupportedException(); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + => throw new NotSupportedException(); + public override IAsyncAction MoveAndReplaceAsync(IStorageFile fileToReplace) + => throw new NotSupportedException(); + + public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.RenameAsync(desiredName, option); + } + else + { + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); + var fileName = IO.Path.Combine(parentPath, desiredName); + PInvoke.MoveFileFromApp(Path, fileName); + } + } + else + { + var index = await FetchZipIndex(); + if (index < 0) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive entry has no parent."); + var fileName = IO.Path.GetRelativePath(containerPath, IO.Path.Combine(parentPath, desiredName)); + await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, fileName } }, Password, ms); + } + + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); + public override IAsyncAction DeleteAsync(StorageDeleteOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.DeleteAsync(); + } + else if (option == StorageDeleteOption.PermanentDelete) + { + PInvoke.DeleteFileFromApp(Path); + } + else + { + throw new NotSupportedException("Moving to recycle bin is not supported."); + } + } + else + { + var index = await FetchZipIndex(); + if (index < 0) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, null } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + => Task.FromResult(null).AsAsyncOperation(); + + private string GetEntryRelativePath() + { + var relative = Path.Substring(containerPath.Length).Trim('\\', '/'); + return relative.Replace('\\', '/'); + } + + private static bool CheckAccess(string path) + { + try + { + var hFile = Win32Helper.OpenFileForRead(path); + if (hFile.IsInvalid) + { + return false; + } + using (SevenZipExtractor zipFile = new SevenZipExtractor(new FileStream(hFile, FileAccess.Read))) + { + //zipFile.IsStreamOwner = true; + return zipFile.ArchiveFileData is not null; + } + } + catch (SevenZipOpenFailedException ex) + { + return ex.Result == OperationResult.WrongPassword; + } + catch + { + return false; + } + } + + private async Task FetchZipIndex() + { + using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) + { + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return -1; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is not null) + { + return entry.Index; + } + return -1; + } + } + + private async Task GetBasicProperties() + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return new BaseBasicProperties(); + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + + return entry.FileName is null + ? new BaseBasicProperties() + : new ZipFileBasicProperties(entry); + } + + private Task GetBasicPropertiesWithEncodingAsync() + { + return Task.Run(() => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + return new ZipFileBasicPropertiesWithEncoding(entry); + } + + return new BaseBasicProperties(); + }); + } + + private IAsyncOperation OpenZipFileAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + var zipFile = await OpenZipFileAsync(FileAccessMode.Read); + return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; + }); + } + + private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + bool readWrite = accessMode == FileAccessMode.ReadWrite; + if (backingFile is not null) + { + var stream = await backingFile.OpenAsync(accessMode); + return stream?.AsStream(); + } + else + { + var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); + return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); + } + }); + } + + private StreamedFileDataRequestedHandler ZipDataStreamingHandler(string name) + { + return async request => + { + try + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + return; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == name); + if (entry.FileName is null) + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + } + else + { + await using (var outStream = request.AsStreamForWrite()) + { + await zipFile.ExtractFileAsync(entry.Index, outStream); + } + request.Dispose(); + } + } + catch + { + request.FailAndClose(StreamedFileFailureMode.Failed); + } + }; + } + + private StreamedFileDataRequestedHandler ZipDataStreamingHandlerWithEncoding(string name) + { + return async request => + { + try + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var entry = zipFile.Cast().FirstOrDefault( + x => x.IsFile + && string.Equals( + System.IO.Path.Combine( + containerPath, x.Name.Replace('/', '\\') + ), + name, + StringComparison.OrdinalIgnoreCase + ) + ); + if (entry is not null && entry.IsFile) + { + using var zipStream = zipFile.GetInputStream(entry); + await using (var outStream = request.AsStreamForWrite()) + { + await zipStream.CopyToAsync(outStream); + } + request.Dispose(); + } + else + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + } + } + catch + { + request.FailAndClose(StreamedFileFailureMode.Failed); + } + }; + } + + private sealed partial class ZipFileBasicProperties : BaseBasicProperties + { + private ArchiveFileInfo entry; + + public ZipFileBasicProperties(ArchiveFileInfo entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; + + public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + + public override ulong Size => entry.Size; + } + + private sealed partial class ZipFileBasicPropertiesWithEncoding : BaseBasicProperties + { + private ZipEntry entry; + + public ZipFileBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; + + public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; + + public override ulong Size => (ulong)entry.Size; + } + } +} diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs index 59ba26636d8e..98f9b2ed1949 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs @@ -1,848 +1,16 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.Shared.Helpers; -using ICSharpCode.SharpZipLib.Zip; -using SevenZip; -using System.IO; -using System.Text; +#if !WINDOWS using System.Runtime.InteropServices.WindowsRuntime; using Windows.Foundation; -using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Storage.Streams; -using Windows.Win32; -using IO = System.IO; namespace Files.App.Utils.Storage { - public sealed partial class ZipStorageFile : BaseStorageFile, IPasswordProtectedItem + // LINUX-TODO(archives): remove this inactive type after legacy consumers migrate to ArchiveEntryFile. + public abstract class ZipStorageFile : BaseStorageFile { - private readonly string containerPath; - private readonly BaseStorageFile? backingFile; - - public override string Path { get; } - public override string Name { get; } - public override string DisplayName => Name; - public override string ContentType => "application/octet-stream"; - public override string FileType => IO.Path.GetExtension(Name); - public override string FolderRelativeId => $"0\\{Name}"; - - public override string DisplayType - { - get - { - var itemType = Strings.File.GetLocalizedResource(); - if (Name.Contains('.', StringComparison.Ordinal)) - { - itemType = FileType.Trim('.') + " " + itemType; - } - return itemType; - } - } - - public override DateTimeOffset DateCreated { get; } - public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Normal | Windows.Storage.FileAttributes.ReadOnly; - - private IStorageItemExtraProperties? properties; - public override IStorageItemExtraProperties Properties => properties ??= new BaseBasicStorageItemExtraProperties(this); - - public StorageCredential? Credentials { get; set; } = new(); - private string Password => Credentials?.Password - ?? throw new InvalidOperationException("Archive credentials are not available."); - - internal Encoding? CurrentEncoding { get; set; } - - public Func>? PasswordRequestedCallback { get; set; } - - public ZipStorageFile(string path, string containerPath) - { - Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); - Path = path; - this.containerPath = containerPath; - } - public ZipStorageFile(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) - => this.backingFile = backingFile; - public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) - => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) - => this.backingFile = backingFile; - - public override IAsyncOperation ToStorageFileAsync() - => StorageFile.CreateStreamedFileAsync( - Name, - CurrentEncoding is not null && Path != containerPath - ? ZipDataStreamingHandlerWithEncoding(Path) - : ZipDataStreamingHandler(Path), - null - ); - - public static IAsyncOperation FromPathAsync(string path) - { - var containerPath = ZipStorageFolder.GetContainerPath(path); - if (containerPath is null) - return Task.FromResult(null).AsAsyncOperation(); - - if (path == containerPath) - return Task.FromResult(null).AsAsyncOperation(); // Root - - if (CheckAccess(containerPath)) - { - var file = new ZipStorageFile(path, containerPath); - if (ZipStorageFolder.TryGetEncodingForContainerPath(containerPath, out var encoding)) - file.CurrentEncoding = encoding; - return Task.FromResult(file).AsAsyncOperation(); - } - return Task.FromResult(null).AsAsyncOperation(); - } - - public override bool IsEqual(IStorageItem item) => item?.Path == Path; - public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.File; - - public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); - public override IAsyncOperation GetBasicPropertiesAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (CurrentEncoding is not null && Path != containerPath) - return await GetBasicPropertiesWithEncodingAsync(); - - return await GetBasicProperties(); - }); - } - - public override IAsyncOperation OpenAsync(FileAccessMode accessMode) - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenWithEncodingAsync(accessMode); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - bool rw = accessMode is FileAccessMode.ReadWrite; - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenAsync(accessMode); - } - - var file = Win32Helper.OpenFileForRead(containerPath, rw); - return file.IsInvalid ? null : new FileStream(file, rw ? FileAccess.ReadWrite : FileAccess.Read).AsRandomAccessStream(); - } - - if (!rw) - { - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - - if (entry.FileName is not null) - { - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - } - return null; - } - - throw new NotSupportedException("Can't open zip file as RW"); - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenWithEncodingAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - bool rw = accessMode is FileAccessMode.ReadWrite; - if (rw) - throw new NotSupportedException("Can't open zip file as RW"); - - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - } - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenAsync(FileAccessMode accessMode, StorageOpenOptions options) - => OpenAsync(accessMode); - - public override IAsyncOperation OpenReadAsync() - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenReadWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenReadAsync(); - } - - var hFile = Win32Helper.OpenFileForRead(containerPath); - return hFile.IsInvalid ? null : new StreamWithContentType(new FileStream(hFile, FileAccess.Read).AsRandomAccessStream()); - } - - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - var nsStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - return new StreamWithContentType(nsStream); - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenReadWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - var nsStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - return new StreamWithContentType(nsStream); - } - } - return null; - - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenSequentialReadAsync() - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenSequentialReadWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenSequentialReadAsync(); - } - - var hFile = Win32Helper.OpenFileForRead(containerPath); - return hFile.IsInvalid ? null : new FileStream(hFile, FileAccess.Read).AsInputStream(); - } - - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenSequentialReadWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - } - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenTransactedWriteAsync() - => throw new NotSupportedException(); - public override IAsyncOperation OpenTransactedWriteAsync(StorageOpenOptions options) - => throw new NotSupportedException(); - - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder) - => CopyAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName) - => CopyAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - { - if (CurrentEncoding is not null && Path != containerPath) - return CopyWithEncodingAsync(destinationFolder, desiredNewName, option); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var destFolder = destinationFolder.AsBaseStorageFolder() - ?? throw new NotSupportedException("The destination folder type is not supported."); - - if (destFolder is ICreateFileWithStream cwsf) - { - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - using var inStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size); - return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); - } - else - { - var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) - ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); - - await using var outStream = await destFile.OpenStreamForWriteAsync(); - await SafetyExtensions.WrapAsync(() => zipFile.ExtractFileAsync(entry.Index, outStream), async (_, exception) => - { - await destFile.DeleteAsync(); - throw exception; - }); - return destFile; - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation CopyWithEncodingAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - var entry = zipFile.Cast().FirstOrDefault(x => x.IsFile && string.Equals(x.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)); - if (entry is null) - { - return null; - } - - var destFolder = destinationFolder.AsBaseStorageFolder() - ?? throw new NotSupportedException("The destination folder type is not supported."); - if (destFolder is ICreateFileWithStream cwsf) - { - var ms = new MemoryStream(); - using var zipStream = zipFile.GetInputStream(entry); - zipStream.CopyTo(ms); - ms.Position = 0; - using var inStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); - } - else - { - var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) - ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); - await using var outStream = await destFile.OpenStreamForWriteAsync(); - using var zipStream = zipFile.GetInputStream(entry); - zipStream.CopyTo(outStream); - return destFile; - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - public override IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) - { - if (CurrentEncoding is not null && Path != containerPath) - return CopyAndReplaceWithEncodingAsync(fileToReplace); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return; - } - - await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) - { - outStream.SetLength(0); - await zipFile.ExtractFileAsync(entry.Index, outStream); - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncAction CopyAndReplaceWithEncodingAsync(IStorageFile fileToReplace) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) - using (var zipStream = zipFile.GetInputStream(entry)) - { - outStream.SetLength(0); - zipStream.CopyTo(outStream); - } - return; - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder) - => throw new NotSupportedException(); - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName) - => throw new NotSupportedException(); - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - => throw new NotSupportedException(); - public override IAsyncAction MoveAndReplaceAsync(IStorageFile fileToReplace) - => throw new NotSupportedException(); - - public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); - public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.RenameAsync(desiredName, option); - } - else - { - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); - var fileName = IO.Path.Combine(parentPath, desiredName); - PInvoke.MoveFileFromApp(Path, fileName); - } - } - else - { - var index = await FetchZipIndex(); - if (index < 0) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive entry has no parent."); - var fileName = IO.Path.GetRelativePath(containerPath, IO.Path.Combine(parentPath, desiredName)); - await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, fileName } }, Password, ms); - } - - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); - public override IAsyncAction DeleteAsync(StorageDeleteOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.DeleteAsync(); - } - else if (option == StorageDeleteOption.PermanentDelete) - { - PInvoke.DeleteFileFromApp(Path); - } - else - { - throw new NotSupportedException("Moving to recycle bin is not supported."); - } - } - else - { - var index = await FetchZipIndex(); - if (index < 0) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, null } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) - => Task.FromResult(null).AsAsyncOperation(); - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) - => Task.FromResult(null).AsAsyncOperation(); - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) - => Task.FromResult(null).AsAsyncOperation(); - - private string GetEntryRelativePath() - { - var relative = Path.Substring(containerPath.Length).Trim('\\', '/'); - return relative.Replace('\\', '/'); - } - - private static bool CheckAccess(string path) - { - try - { - var hFile = Win32Helper.OpenFileForRead(path); - if (hFile.IsInvalid) - { - return false; - } - using (SevenZipExtractor zipFile = new SevenZipExtractor(new FileStream(hFile, FileAccess.Read))) - { - //zipFile.IsStreamOwner = true; - return zipFile.ArchiveFileData is not null; - } - } - catch (SevenZipOpenFailedException ex) - { - return ex.Result == OperationResult.WrongPassword; - } - catch - { - return false; - } - } - - private async Task FetchZipIndex() - { - using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) - { - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return -1; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is not null) - { - return entry.Index; - } - return -1; - } - } - - private async Task GetBasicProperties() - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return new BaseBasicProperties(); - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - - return entry.FileName is null - ? new BaseBasicProperties() - : new ZipFileBasicProperties(entry); - } - - private Task GetBasicPropertiesWithEncodingAsync() - { - return Task.Run(() => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - return new ZipFileBasicPropertiesWithEncoding(entry); - } - - return new BaseBasicProperties(); - }); - } - - private IAsyncOperation OpenZipFileAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - var zipFile = await OpenZipFileAsync(FileAccessMode.Read); - return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; - }); - } - - private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - bool readWrite = accessMode == FileAccessMode.ReadWrite; - if (backingFile is not null) - { - var stream = await backingFile.OpenAsync(accessMode); - return stream?.AsStream(); - } - else - { - var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); - return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); - } - }); - } - - private StreamedFileDataRequestedHandler ZipDataStreamingHandler(string name) - { - return async request => - { - try - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - return; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == name); - if (entry.FileName is null) - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - } - else - { - await using (var outStream = request.AsStreamForWrite()) - { - await zipFile.ExtractFileAsync(entry.Index, outStream); - } - request.Dispose(); - } - } - catch - { - request.FailAndClose(StreamedFileFailureMode.Failed); - } - }; - } - - private StreamedFileDataRequestedHandler ZipDataStreamingHandlerWithEncoding(string name) - { - return async request => - { - try - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var entry = zipFile.Cast().FirstOrDefault( - x => x.IsFile - && string.Equals( - System.IO.Path.Combine( - containerPath, x.Name.Replace('/', '\\') - ), - name, - StringComparison.OrdinalIgnoreCase - ) - ); - if (entry is not null && entry.IsFile) - { - using var zipStream = zipFile.GetInputStream(entry); - await using (var outStream = request.AsStreamForWrite()) - { - await zipStream.CopyToAsync(outStream); - } - request.Dispose(); - } - else - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - } - } - catch - { - request.FailAndClose(StreamedFileFailureMode.Failed); - } - }; - } - - private sealed partial class ZipFileBasicProperties : BaseBasicProperties - { - private ArchiveFileInfo entry; - - public ZipFileBasicProperties(ArchiveFileInfo entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; - - public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - - public override ulong Size => entry.Size; - } - - private sealed partial class ZipFileBasicPropertiesWithEncoding : BaseBasicProperties - { - private ZipEntry entry; - - public ZipFileBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; - - public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; - - public override ulong Size => (ulong)entry.Size; - } + public static IAsyncOperation FromPathAsync(string path) => Task.FromResult(null).AsAsyncOperation(); } } +#endif diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs new file mode 100644 index 000000000000..b72ef63f46ab --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs @@ -0,0 +1,898 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using ICSharpCode.SharpZipLib.Zip; +using SevenZip; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using Windows.ApplicationModel; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; +using Windows.Win32; +using IO = System.IO; + +namespace Files.App.Utils.Storage +{ + public sealed partial class ZipStorageFolder : BaseStorageFolder, ICreateFileWithStream, IPasswordProtectedItem + { + private readonly string containerPath; + private BaseStorageFile? backingFile; + + private Encoding? _currentEncoding; + + // Maps container paths to their configured encoding. + // - Key missing: encoding not yet set; detection required. + // - Value null: archive opened with system default encoding. + // - Value set: archive opened with the specified encoding. + private static readonly ConcurrentDictionary _encodingByContainerPath = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the encoding to use when browsing this ZIP file. + /// When set, SharpZipLib is used instead of SevenZipSharp. + /// + internal Encoding? CurrentEncoding + { + get => _currentEncoding; + set + { + _currentEncoding = value; + _encodingByContainerPath[containerPath] = value; + } + } + + internal static bool TryGetEncodingForContainerPath(string containerPath, out Encoding? encoding) + => _encodingByContainerPath.TryGetValue(containerPath, out encoding); + + internal static void SetEncodingForContainerPath(string containerPath, Encoding? encoding) + { + _encodingByContainerPath[containerPath] = encoding; + } + + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string DisplayType => Strings.Folder.GetLocalizedResource(); + public override string FolderRelativeId => $"0\\{Name}"; + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Directory; + public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential? Credentials { get; set; } = new(); + private string Password => Credentials?.Password + ?? throw new InvalidOperationException("Archive credentials are not available."); + + public Func>? PasswordRequestedCallback { get; set; } + + public ZipStorageFolder(string path, string containerPath) + { + Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); + Path = path; + this.containerPath = containerPath; + _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); + } + public ZipStorageFolder(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) + => this.backingFile = backingFile; + public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) + => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + public ZipStorageFolder(BaseStorageFile backingFile) + { + ArgumentException.ThrowIfNullOrEmpty(backingFile.Path); + Name = IO.Path.GetFileName(backingFile.Path.TrimEnd('\\', '/')); + Path = backingFile.Path; + this.containerPath = backingFile.Path; + this.backingFile = backingFile; + _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); + } + public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) + => this.backingFile = backingFile; + + public static string? GetContainerPath(string path) + { + if (!FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) + return null; + + var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); + if (marker is -1) + return null; + + return path.Substring(0, marker + ext.Length); + } + + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) + { + if (path is null || !FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) + { + return false; + } + var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); + if (marker is -1) + { + return false; + } + marker += ext.Length; + // If IO.Path.Exists returns true, it is not a zip path but a normal directory path that contains ".zip". + return (marker == path.Length && includeRoot && !IO.Path.Exists(path + "\\")) + || (marker < path.Length && path[marker] is '\\' && !IO.Path.Exists(path)); + } + + public async Task GetUncompressedSize() + { + long uncompressedSize = 0; + using SevenZipExtractor? zipFile = await FilesystemTasks.WrapNullable(async () => + { + var arch = await OpenZipFileAsync(); + return arch?.ArchiveFileData is null ? null : arch; // Force load archive (1665013614u) + }); + + if (zipFile is not null) + { + foreach (var info in zipFile.ArchiveFileData.Where(x => !x.IsDirectory)) + { + uncompressedSize += (long)info.Size; + } + } + + return uncompressedSize; + } + + private static ConcurrentDictionary> defaultAppDict = new(); + public static async Task CheckDefaultZipApp(string filePath) + { + Func> queryFileAssoc = async () => + { + var assoc = await Win32Helper.GetDefaultFileAssociationAsync(filePath); + if (assoc is not null) + { + return Constants.Distributions.KnownAppNames.Any(x => assoc.StartsWith(x, StringComparison.OrdinalIgnoreCase)) + || assoc == Package.Current.Id.FamilyName + || assoc.EndsWith("Files.exe", StringComparison.OrdinalIgnoreCase) + || assoc.Equals(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), StringComparison.OrdinalIgnoreCase); + } + return true; + }; + var ext = IO.Path.GetExtension(filePath)?.ToLowerInvariant(); + return await defaultAppDict.GetAsync(ext ?? "", queryFileAssoc); + } + + public static IAsyncOperation FromPathAsync(string path) + { + var containerPath = GetContainerPath(path); + if (containerPath is not null && CheckAccess(containerPath)) + { + return Task.FromResult(new ZipStorageFolder(path, containerPath)).AsAsyncOperation(); + } + return Task.FromResult(null).AsAsyncOperation(); + } + + public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) + => AsyncInfo.Run(async (cancellationToken) => await CheckAccess(file) ? new ZipStorageFolder(file) : null); + + public override IAsyncOperation ToStorageFolderAsync() => throw new NotSupportedException(); + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type == StorageItemTypes.Folder; + + public override IAsyncOperation GetIndexedStateAsync() => Task.FromResult(IndexedState.NotIndexed).AsAsyncOperation(); + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + + private async Task GetBasicProperties() + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return new BaseBasicProperties(); + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + return entry.FileName is null + ? new BaseBasicProperties() + : new ZipFolderBasicProperties(entry); + } + private Task GetBasicPropertiesWithEncodingAsync() + { + return Task.Run(() => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var normalizedTarget = Path.TrimEnd('\\', '/'); + + foreach (ZipEntry entry in zipFile) + { + var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + var normalizedEntry = entryPath.TrimEnd('\\', '/'); + + if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) + return new ZipFolderBasicPropertiesWithEncoding(entry); + } + + return new BaseBasicProperties(); + }); + } + + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path == containerPath) + { + var zipFile = new SystemStorageFile(await StorageFile.GetFileFromPathAsync(Path)); + return await zipFile.GetBasicPropertiesAsync(); + } + + if (CurrentEncoding is not null) + return await GetBasicPropertiesWithEncodingAsync(); + + return await GetBasicProperties(); + }); + } + + public override IAsyncOperation GetItemAsync(string name) + { + if (CurrentEncoding is not null) + return GetItemWithEncodingAsync(name); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + + var filePath = System.IO.Path.Combine(Path, name); + + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == filePath); + if (entry.FileName is null) + { + return null; + } + + if (entry.IsDirectory) + { + var folder = new ZipStorageFolder(filePath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + + var file = new ZipStorageFile(filePath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation GetItemWithEncodingAsync(string name) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetPath = System.IO.Path.Combine(Path, name); + var normalizedTarget = targetPath.TrimEnd('\\', '/'); + bool foundChild = false; + + foreach (ZipEntry entry in zipFile) + { + var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + var normalizedEntry = entryPath.TrimEnd('\\', '/'); + + if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) + { + if (entry.IsDirectory) + { + var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + else + { + var file = new ZipStorageFile(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + } + } + + if (!foundChild && normalizedEntry.StartsWith(normalizedTarget + "\\", StringComparison.OrdinalIgnoreCase)) + foundChild = true; + } + + // No exact match found; check if target is an implicit directory + if (foundChild) + { + var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation TryGetItemAsync(string name) + { + return AsyncInfo.Run(async (cancellationToken) => + { + try + { + return await GetItemAsync(name); + } + catch + { + return null; + } + }); + } + public override IAsyncOperation?> GetItemsAsync() + { + if (CurrentEncoding is not null) + return GetItemsWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + var items = new List(); + foreach (var entry in zipFile.GetArchiveFileData(containerPath)) // Returns all items recursively + { + string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.FileName); + if (winPath.StartsWith(Path.WithEnding("\\"), StringComparison.Ordinal)) // Child of self + { + var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (split.Length > 0) + { + if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry + { + var itemPath = System.IO.Path.Combine(Path, split[0]); + if (!items.Any(x => x.Path == itemPath)) + { + var folder = new ZipStorageFolder(itemPath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + items.Add(folder); + } + } + else + { + var file = new ZipStorageFile(winPath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + items.Add(file); + } + } + } + } + return items; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation?> GetItemsWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var items = new List(); + var dirPrefix = Path.WithEnding("\\"); + + foreach (ZipEntry entry in zipFile) + { + string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + if (!winPath.StartsWith(dirPrefix, StringComparison.Ordinal)) + continue; + + var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (split.Length <= 0) + continue; + + if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry + { + var itemPath = System.IO.Path.Combine(Path, split[0]); + if (!items.Any(x => x.Path == itemPath)) + { + var folder = new ZipStorageFolder(itemPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + items.Add(folder); + } + } + else + { + var file = new ZipStorageFile(winPath, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + items.Add(file); + } + + } + return items; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation?> GetItemsAsync(uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + + public override IAsyncOperation GetFileAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFile); + public override IAsyncOperation?> GetFilesAsync() + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync(); + return items?.OfType().ToList(); + }); + public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query) + => AsyncInfo.Run?>(async (cancellationToken) => await GetFilesAsync()); + public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query, uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run?>(async (cancellationToken) => + { + var files = await GetFilesAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return files.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + + public override IAsyncOperation GetFolderAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFolder); + public override IAsyncOperation?> GetFoldersAsync() + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync(); + return items?.OfType().ToList(); + }); + public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query) + => AsyncInfo.Run?>(async (cancellationToken) => await GetFoldersAsync()); + public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query, uint startIndex, uint maxItemsToRetrieve) + { + return AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetFoldersAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + } + + public override IAsyncOperation CreateFileAsync(string desiredName) + => CreateFileAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFileAsync(string desiredName, CreationCollisionOption options) + => CreateFileAsync(new MemoryStream(), desiredName, options); + + public override IAsyncOperation CreateFolderAsync(string desiredName) + => CreateFolderAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFolderAsync(string desiredName, CreationCollisionOption options) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var zipDesiredName = System.IO.Path.Combine(Path, desiredName); + var item = await GetItemAsync(desiredName); + if (item is not null) + { + if (options != CreationCollisionOption.ReplaceExisting) + { + return null; + } + await item.DeleteAsync(); + } + + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); + await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, null } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + + var folder = new ZipStorageFolder(zipDesiredName, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder) => throw new NotSupportedException(); + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder, NameCollisionOption option) => throw new NotSupportedException(); + + public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.RenameAsync(desiredName, option); + } + else + { + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); + var fileName = IO.Path.Combine(parentPath, desiredName); + PInvoke.MoveFileFromApp(Path, fileName); + } + } + else + { + var index = await FetchZipIndex() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + if (index.IsEmpty()) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var folderKey = IO.Path.GetRelativePath(containerPath, Path); + var parentPath = IO.Path.GetDirectoryName(folderKey) + ?? throw new InvalidOperationException("The archive entry has no parent."); + var folderDes = IO.Path.Combine(parentPath, desiredName); + var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, + IO.Path.Combine(folderDes, IO.Path.GetRelativePath(folderKey, x.Key))))); + await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); + public override IAsyncAction DeleteAsync(StorageDeleteOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.DeleteAsync(); + } + else if (option == StorageDeleteOption.PermanentDelete) + { + PInvoke.DeleteFileFromApp(Path); + } + else + { + throw new NotSupportedException("Moving to recycle bin is not supported."); + } + } + else + { + var index = await FetchZipIndex() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + if (index.IsEmpty()) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, null))); + await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override bool AreQueryOptionsSupported(QueryOptions queryOptions) => false; + public override bool IsCommonFileQuerySupported(CommonFileQuery query) => false; + public override bool IsCommonFolderQuerySupported(CommonFolderQuery query) => false; + + public override StorageItemQueryResult CreateItemQuery() => throw new NotSupportedException(); + public override BaseStorageItemQueryResult CreateItemQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFileQueryResult CreateFileQuery() => throw new NotSupportedException(); + public override StorageFileQueryResult CreateFileQuery(CommonFileQuery query) => throw new NotSupportedException(); + public override BaseStorageFileQueryResult CreateFileQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFolderQueryResult CreateFolderQuery() => throw new NotSupportedException(); + public override StorageFolderQueryResult CreateFolderQuery(CommonFolderQuery query) => throw new NotSupportedException(); + public override BaseStorageFolderQueryResult CreateFolderQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode); + }); + } + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode, requestedSize); + }); + } + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode, requestedSize, options); + }); + } + + private static bool CheckAccess(string path) + { + return SafetyExtensions.IgnoreExceptions(() => + { + var hFile = Win32Helper.OpenFileForRead(path); + if (hFile.IsInvalid) + { + return false; + } + using var stream = new FileStream(hFile, FileAccess.Read); + return CheckAccess(stream); + }); + } + private static bool CheckAccess(Stream stream) + { + try + { + using (SevenZipExtractor zipFile = new SevenZipExtractor(stream)) + { + //zipFile.IsStreamOwner = false; + return zipFile.ArchiveFileData is not null; + } + } + catch (SevenZipOpenFailedException ex) + { + return ex.Result == OperationResult.WrongPassword; + } + catch + { + return false; + } + } + private static async Task CheckAccess(BaseStorageFile file) + { + return await SafetyExtensions.IgnoreExceptions(async () => + { + using var stream = await file.OpenReadAsync() + ?? throw new IOException("The archive could not be opened for reading."); + return CheckAccess(stream.AsStream()); + }); + } + + public static Task InitArchive(string path, OutArchiveFormat format) + { + return SafetyExtensions.IgnoreExceptions(() => + { + var hFile = Win32Helper.OpenFileForRead(path, true); + if (hFile.IsInvalid) + { + return Task.FromResult(false); + } + using var stream = new FileStream(hFile, FileAccess.ReadWrite); + return InitArchive(stream, format); + }); + } + public static Task InitArchive(IStorageFile file, OutArchiveFormat format) + { + return SafetyExtensions.IgnoreExceptions(async () => + { + using var fileStream = await file.OpenAsync(FileAccessMode.ReadWrite); + await using var stream = fileStream.AsStream(); + return await InitArchive(stream, format); + }); + } + private static async Task InitArchive(Stream stream, OutArchiveFormat format) + { + stream.SetLength(0); + var compressor = new SevenZipCompressor() + { + CompressionMode = CompressionMode.Create, + ArchiveFormat = format + }; + compressor.CustomParameters.Add("cu", "on"); + await compressor.CompressStreamDictionaryAsync(stream, new Dictionary()); + await stream.FlushAsync(); + return true; + } + + public async Task ValidateCredentialsAsync() + { + try + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + return zipFile?.ArchiveFileData is not null; + } + catch (Exception) // SevenZipOpenFailedException(WrongPassword) for bad credentials; IO exceptions (e.g. archive deleted meanwhile) equally mean the credentials can't be verified + { + return false; + } + } + + private IAsyncOperation OpenZipFileAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + var zipFile = await OpenZipFileAsync(FileAccessMode.Read); + return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; + }); + } + + private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + bool readWrite = accessMode is FileAccessMode.ReadWrite; + if (backingFile is not null) + { + var stream = await backingFile.OpenAsync(accessMode); + return stream?.AsStream(); + } + else + { + var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); + return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); + } + }); + } + + private async Task?> FetchZipIndex() + { + using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) + { + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + return zipFile.GetArchiveFileData(containerPath).Where(x => System.IO.Path.Combine(containerPath, x.FileName).IsSubPathOf(Path)).Select(e => (e.Index, e.FileName)); + } + } + + public IAsyncOperation CreateFileAsync(Stream contents, string desiredName) + => CreateFileAsync(new MemoryStream(), desiredName, CreationCollisionOption.FailIfExists); + + public IAsyncOperation CreateFileAsync(Stream contents, string desiredName, CreationCollisionOption options) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var zipDesiredName = System.IO.Path.Combine(Path, desiredName); + var item = await GetItemAsync(desiredName); + if (item is not null) + { + if (options != CreationCollisionOption.ReplaceExisting) + { + return null; + } + await item.DeleteAsync(); + } + + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); + await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, contents } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + + var file = new ZipStorageFile(zipDesiredName, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private sealed partial class ZipFolderBasicProperties : BaseBasicProperties + { + private ArchiveFileInfo entry; + + public ZipFolderBasicProperties(ArchiveFileInfo entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; + + public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + + public override ulong Size => entry.Size; + } + + private sealed partial class ZipFolderBasicPropertiesWithEncoding : BaseBasicProperties + { + private ZipEntry entry; + + public ZipFolderBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; + + public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; + + public override ulong Size => (ulong)entry.Size; + } + } +} diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs index b72ef63f46ab..d19ef26bca0a 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs @@ -1,898 +1,35 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.Shared.Helpers; -using ICSharpCode.SharpZipLib.Zip; -using SevenZip; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; -using Windows.ApplicationModel; using Windows.Foundation; using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Storage.Search; -using Windows.Win32; -using IO = System.IO; namespace Files.App.Utils.Storage { - public sealed partial class ZipStorageFolder : BaseStorageFolder, ICreateFileWithStream, IPasswordProtectedItem + // LINUX-TODO(archives): remove these inactive legacy consumer hooks after P4-C/E/H/I migrate to ArchiveFolder. + public abstract class ZipStorageFolder : BaseStorageFolder, IPasswordProtectedItem { - private readonly string containerPath; - private BaseStorageFile? backingFile; - - private Encoding? _currentEncoding; - - // Maps container paths to their configured encoding. - // - Key missing: encoding not yet set; detection required. - // - Value null: archive opened with system default encoding. - // - Value set: archive opened with the specified encoding. - private static readonly ConcurrentDictionary _encodingByContainerPath = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets or sets the encoding to use when browsing this ZIP file. - /// When set, SharpZipLib is used instead of SevenZipSharp. - /// - internal Encoding? CurrentEncoding - { - get => _currentEncoding; - set - { - _currentEncoding = value; - _encodingByContainerPath[containerPath] = value; - } - } - - internal static bool TryGetEncodingForContainerPath(string containerPath, out Encoding? encoding) - => _encodingByContainerPath.TryGetValue(containerPath, out encoding); - - internal static void SetEncodingForContainerPath(string containerPath, Encoding? encoding) - { - _encodingByContainerPath[containerPath] = encoding; - } - - public override string Path { get; } - public override string Name { get; } - public override string DisplayName => Name; - public override string DisplayType => Strings.Folder.GetLocalizedResource(); - public override string FolderRelativeId => $"0\\{Name}"; - - public override DateTimeOffset DateCreated { get; } - public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Directory; - public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); - - public StorageCredential? Credentials { get; set; } = new(); - private string Password => Credentials?.Password - ?? throw new InvalidOperationException("Archive credentials are not available."); - + private static readonly ConcurrentDictionary Encodings = new(StringComparer.Ordinal); + public StorageCredential? Credentials { get; set; } public Func>? PasswordRequestedCallback { get; set; } - - public ZipStorageFolder(string path, string containerPath) - { - Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); - Path = path; - this.containerPath = containerPath; - _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); - } - public ZipStorageFolder(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) - => this.backingFile = backingFile; - public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) - => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - public ZipStorageFolder(BaseStorageFile backingFile) - { - ArgumentException.ThrowIfNullOrEmpty(backingFile.Path); - Name = IO.Path.GetFileName(backingFile.Path.TrimEnd('\\', '/')); - Path = backingFile.Path; - this.containerPath = backingFile.Path; - this.backingFile = backingFile; - _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); - } - public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) - => this.backingFile = backingFile; - - public static string? GetContainerPath(string path) - { - if (!FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) - return null; - - var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); - if (marker is -1) - return null; - - return path.Substring(0, marker + ext.Length); - } - - public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) - { - if (path is null || !FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) - { - return false; - } - var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); - if (marker is -1) - { - return false; - } - marker += ext.Length; - // If IO.Path.Exists returns true, it is not a zip path but a normal directory path that contains ".zip". - return (marker == path.Length && includeRoot && !IO.Path.Exists(path + "\\")) - || (marker < path.Length && path[marker] is '\\' && !IO.Path.Exists(path)); - } - - public async Task GetUncompressedSize() - { - long uncompressedSize = 0; - using SevenZipExtractor? zipFile = await FilesystemTasks.WrapNullable(async () => - { - var arch = await OpenZipFileAsync(); - return arch?.ArchiveFileData is null ? null : arch; // Force load archive (1665013614u) - }); - - if (zipFile is not null) - { - foreach (var info in zipFile.ArchiveFileData.Where(x => !x.IsDirectory)) - { - uncompressedSize += (long)info.Size; - } - } - - return uncompressedSize; - } - - private static ConcurrentDictionary> defaultAppDict = new(); - public static async Task CheckDefaultZipApp(string filePath) - { - Func> queryFileAssoc = async () => - { - var assoc = await Win32Helper.GetDefaultFileAssociationAsync(filePath); - if (assoc is not null) - { - return Constants.Distributions.KnownAppNames.Any(x => assoc.StartsWith(x, StringComparison.OrdinalIgnoreCase)) - || assoc == Package.Current.Id.FamilyName - || assoc.EndsWith("Files.exe", StringComparison.OrdinalIgnoreCase) - || assoc.Equals(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), StringComparison.OrdinalIgnoreCase); - } - return true; - }; - var ext = IO.Path.GetExtension(filePath)?.ToLowerInvariant(); - return await defaultAppDict.GetAsync(ext ?? "", queryFileAssoc); - } - - public static IAsyncOperation FromPathAsync(string path) - { - var containerPath = GetContainerPath(path); - if (containerPath is not null && CheckAccess(containerPath)) - { - return Task.FromResult(new ZipStorageFolder(path, containerPath)).AsAsyncOperation(); - } - return Task.FromResult(null).AsAsyncOperation(); - } - - public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) - => AsyncInfo.Run(async (cancellationToken) => await CheckAccess(file) ? new ZipStorageFolder(file) : null); - - public override IAsyncOperation ToStorageFolderAsync() => throw new NotSupportedException(); - - public override bool IsEqual(IStorageItem item) => item?.Path == Path; - public override bool IsOfType(StorageItemTypes type) => type == StorageItemTypes.Folder; - - public override IAsyncOperation GetIndexedStateAsync() => Task.FromResult(IndexedState.NotIndexed).AsAsyncOperation(); - - public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); - - private async Task GetBasicProperties() - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return new BaseBasicProperties(); - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - return entry.FileName is null - ? new BaseBasicProperties() - : new ZipFolderBasicProperties(entry); - } - private Task GetBasicPropertiesWithEncodingAsync() - { - return Task.Run(() => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var normalizedTarget = Path.TrimEnd('\\', '/'); - - foreach (ZipEntry entry in zipFile) - { - var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - var normalizedEntry = entryPath.TrimEnd('\\', '/'); - - if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) - return new ZipFolderBasicPropertiesWithEncoding(entry); - } - - return new BaseBasicProperties(); - }); - } - - public override IAsyncOperation GetBasicPropertiesAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path == containerPath) - { - var zipFile = new SystemStorageFile(await StorageFile.GetFileFromPathAsync(Path)); - return await zipFile.GetBasicPropertiesAsync(); - } - - if (CurrentEncoding is not null) - return await GetBasicPropertiesWithEncodingAsync(); - - return await GetBasicProperties(); - }); - } - - public override IAsyncOperation GetItemAsync(string name) - { - if (CurrentEncoding is not null) - return GetItemWithEncodingAsync(name); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - - var filePath = System.IO.Path.Combine(Path, name); - - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == filePath); - if (entry.FileName is null) - { - return null; - } - - if (entry.IsDirectory) - { - var folder = new ZipStorageFolder(filePath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - - var file = new ZipStorageFile(filePath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation GetItemWithEncodingAsync(string name) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetPath = System.IO.Path.Combine(Path, name); - var normalizedTarget = targetPath.TrimEnd('\\', '/'); - bool foundChild = false; - - foreach (ZipEntry entry in zipFile) - { - var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - var normalizedEntry = entryPath.TrimEnd('\\', '/'); - - if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) - { - if (entry.IsDirectory) - { - var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - else - { - var file = new ZipStorageFile(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - } - } - - if (!foundChild && normalizedEntry.StartsWith(normalizedTarget + "\\", StringComparison.OrdinalIgnoreCase)) - foundChild = true; - } - - // No exact match found; check if target is an implicit directory - if (foundChild) - { - var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation TryGetItemAsync(string name) - { - return AsyncInfo.Run(async (cancellationToken) => - { - try - { - return await GetItemAsync(name); - } - catch - { - return null; - } - }); - } - public override IAsyncOperation?> GetItemsAsync() - { - if (CurrentEncoding is not null) - return GetItemsWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - var items = new List(); - foreach (var entry in zipFile.GetArchiveFileData(containerPath)) // Returns all items recursively - { - string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.FileName); - if (winPath.StartsWith(Path.WithEnding("\\"), StringComparison.Ordinal)) // Child of self - { - var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); - if (split.Length > 0) - { - if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry - { - var itemPath = System.IO.Path.Combine(Path, split[0]); - if (!items.Any(x => x.Path == itemPath)) - { - var folder = new ZipStorageFolder(itemPath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - items.Add(folder); - } - } - else - { - var file = new ZipStorageFile(winPath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - items.Add(file); - } - } - } - } - return items; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation?> GetItemsWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var items = new List(); - var dirPrefix = Path.WithEnding("\\"); - - foreach (ZipEntry entry in zipFile) - { - string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - if (!winPath.StartsWith(dirPrefix, StringComparison.Ordinal)) - continue; - - var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); - if (split.Length <= 0) - continue; - - if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry - { - var itemPath = System.IO.Path.Combine(Path, split[0]); - if (!items.Any(x => x.Path == itemPath)) - { - var folder = new ZipStorageFolder(itemPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - items.Add(folder); - } - } - else - { - var file = new ZipStorageFile(winPath, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - items.Add(file); - } - - } - return items; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation?> GetItemsAsync(uint startIndex, uint maxItemsToRetrieve) - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - - public override IAsyncOperation GetFileAsync(string name) - => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFile); - public override IAsyncOperation?> GetFilesAsync() - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync(); - return items?.OfType().ToList(); - }); - public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query) - => AsyncInfo.Run?>(async (cancellationToken) => await GetFilesAsync()); - public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query, uint startIndex, uint maxItemsToRetrieve) - => AsyncInfo.Run?>(async (cancellationToken) => - { - var files = await GetFilesAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return files.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - - public override IAsyncOperation GetFolderAsync(string name) - => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFolder); - public override IAsyncOperation?> GetFoldersAsync() - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync(); - return items?.OfType().ToList(); - }); - public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query) - => AsyncInfo.Run?>(async (cancellationToken) => await GetFoldersAsync()); - public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query, uint startIndex, uint maxItemsToRetrieve) - { - return AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetFoldersAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - } - - public override IAsyncOperation CreateFileAsync(string desiredName) - => CreateFileAsync(desiredName, CreationCollisionOption.FailIfExists); - public override IAsyncOperation CreateFileAsync(string desiredName, CreationCollisionOption options) - => CreateFileAsync(new MemoryStream(), desiredName, options); - - public override IAsyncOperation CreateFolderAsync(string desiredName) - => CreateFolderAsync(desiredName, CreationCollisionOption.FailIfExists); - public override IAsyncOperation CreateFolderAsync(string desiredName, CreationCollisionOption options) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var zipDesiredName = System.IO.Path.Combine(Path, desiredName); - var item = await GetItemAsync(desiredName); - if (item is not null) - { - if (options != CreationCollisionOption.ReplaceExisting) - { - return null; - } - await item.DeleteAsync(); - } - - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); - await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, null } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - - var folder = new ZipStorageFolder(zipDesiredName, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder) => throw new NotSupportedException(); - public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder, NameCollisionOption option) => throw new NotSupportedException(); - - public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); - public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.RenameAsync(desiredName, option); - } - else - { - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); - var fileName = IO.Path.Combine(parentPath, desiredName); - PInvoke.MoveFileFromApp(Path, fileName); - } - } - else - { - var index = await FetchZipIndex() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - if (index.IsEmpty()) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var folderKey = IO.Path.GetRelativePath(containerPath, Path); - var parentPath = IO.Path.GetDirectoryName(folderKey) - ?? throw new InvalidOperationException("The archive entry has no parent."); - var folderDes = IO.Path.Combine(parentPath, desiredName); - var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, - IO.Path.Combine(folderDes, IO.Path.GetRelativePath(folderKey, x.Key))))); - await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); - public override IAsyncAction DeleteAsync(StorageDeleteOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.DeleteAsync(); - } - else if (option == StorageDeleteOption.PermanentDelete) - { - PInvoke.DeleteFileFromApp(Path); - } - else - { - throw new NotSupportedException("Moving to recycle bin is not supported."); - } - } - else - { - var index = await FetchZipIndex() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - if (index.IsEmpty()) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, null))); - await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override bool AreQueryOptionsSupported(QueryOptions queryOptions) => false; - public override bool IsCommonFileQuerySupported(CommonFileQuery query) => false; - public override bool IsCommonFolderQuerySupported(CommonFolderQuery query) => false; - - public override StorageItemQueryResult CreateItemQuery() => throw new NotSupportedException(); - public override BaseStorageItemQueryResult CreateItemQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override StorageFileQueryResult CreateFileQuery() => throw new NotSupportedException(); - public override StorageFileQueryResult CreateFileQuery(CommonFileQuery query) => throw new NotSupportedException(); - public override BaseStorageFileQueryResult CreateFileQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override StorageFolderQueryResult CreateFolderQuery() => throw new NotSupportedException(); - public override StorageFolderQueryResult CreateFolderQuery(CommonFolderQuery query) => throw new NotSupportedException(); - public override BaseStorageFolderQueryResult CreateFolderQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode); - }); - } - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode, requestedSize); - }); - } - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode, requestedSize, options); - }); - } - - private static bool CheckAccess(string path) - { - return SafetyExtensions.IgnoreExceptions(() => - { - var hFile = Win32Helper.OpenFileForRead(path); - if (hFile.IsInvalid) - { - return false; - } - using var stream = new FileStream(hFile, FileAccess.Read); - return CheckAccess(stream); - }); - } - private static bool CheckAccess(Stream stream) - { - try - { - using (SevenZipExtractor zipFile = new SevenZipExtractor(stream)) - { - //zipFile.IsStreamOwner = false; - return zipFile.ArchiveFileData is not null; - } - } - catch (SevenZipOpenFailedException ex) - { - return ex.Result == OperationResult.WrongPassword; - } - catch - { - return false; - } - } - private static async Task CheckAccess(BaseStorageFile file) - { - return await SafetyExtensions.IgnoreExceptions(async () => - { - using var stream = await file.OpenReadAsync() - ?? throw new IOException("The archive could not be opened for reading."); - return CheckAccess(stream.AsStream()); - }); - } - - public static Task InitArchive(string path, OutArchiveFormat format) - { - return SafetyExtensions.IgnoreExceptions(() => - { - var hFile = Win32Helper.OpenFileForRead(path, true); - if (hFile.IsInvalid) - { - return Task.FromResult(false); - } - using var stream = new FileStream(hFile, FileAccess.ReadWrite); - return InitArchive(stream, format); - }); - } - public static Task InitArchive(IStorageFile file, OutArchiveFormat format) - { - return SafetyExtensions.IgnoreExceptions(async () => - { - using var fileStream = await file.OpenAsync(FileAccessMode.ReadWrite); - await using var stream = fileStream.AsStream(); - return await InitArchive(stream, format); - }); - } - private static async Task InitArchive(Stream stream, OutArchiveFormat format) - { - stream.SetLength(0); - var compressor = new SevenZipCompressor() - { - CompressionMode = CompressionMode.Create, - ArchiveFormat = format - }; - compressor.CustomParameters.Add("cu", "on"); - await compressor.CompressStreamDictionaryAsync(stream, new Dictionary()); - await stream.FlushAsync(); - return true; - } - - public async Task ValidateCredentialsAsync() - { - try - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - return zipFile?.ArchiveFileData is not null; - } - catch (Exception) // SevenZipOpenFailedException(WrongPassword) for bad credentials; IO exceptions (e.g. archive deleted meanwhile) equally mean the credentials can't be verified - { - return false; - } - } - - private IAsyncOperation OpenZipFileAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - var zipFile = await OpenZipFileAsync(FileAccessMode.Read); - return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; - }); - } - - private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - bool readWrite = accessMode is FileAccessMode.ReadWrite; - if (backingFile is not null) - { - var stream = await backingFile.OpenAsync(accessMode); - return stream?.AsStream(); - } - else - { - var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); - return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); - } - }); - } - - private async Task?> FetchZipIndex() - { - using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) - { - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - return zipFile.GetArchiveFileData(containerPath).Where(x => System.IO.Path.Combine(containerPath, x.FileName).IsSubPathOf(Path)).Select(e => (e.Index, e.FileName)); - } - } - - public IAsyncOperation CreateFileAsync(Stream contents, string desiredName) - => CreateFileAsync(new MemoryStream(), desiredName, CreationCollisionOption.FailIfExists); - - public IAsyncOperation CreateFileAsync(Stream contents, string desiredName, CreationCollisionOption options) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var zipDesiredName = System.IO.Path.Combine(Path, desiredName); - var item = await GetItemAsync(desiredName); - if (item is not null) - { - if (options != CreationCollisionOption.ReplaceExisting) - { - return null; - } - await item.DeleteAsync(); - } - - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); - await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, contents } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - - var file = new ZipStorageFile(zipDesiredName, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private sealed partial class ZipFolderBasicProperties : BaseBasicProperties - { - private ArchiveFileInfo entry; - - public ZipFolderBasicProperties(ArchiveFileInfo entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; - - public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - - public override ulong Size => entry.Size; - } - - private sealed partial class ZipFolderBasicPropertiesWithEncoding : BaseBasicProperties - { - private ZipEntry entry; - - public ZipFolderBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; - - public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; - - public override ulong Size => (ulong)entry.Size; - } + public static string? GetContainerPath(string path) => FileExtensionHelpers.GetArchiveContainerPath(path); + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) => FileExtensionHelpers.IsZipPath(path, includeRoot); + internal static bool TryGetEncodingForContainerPath(string path, out Encoding? encoding) => Encodings.TryGetValue(path, out encoding); + internal static void SetEncodingForContainerPath(string path, Encoding? encoding) => Encodings[path] = encoding; + public static Task CheckDefaultZipApp(string path) => Task.FromResult(true); + public static IAsyncOperation FromPathAsync(string path) => Task.FromResult(null).AsAsyncOperation(); + public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) => Task.FromResult(null).AsAsyncOperation(); + public static Task InitArchive(IStorageFile file, SevenZip.OutArchiveFormat format) => Task.FromException(new NotSupportedException()); + public Task GetUncompressedSize() => Task.FromException(new NotSupportedException()); + public Task ValidateCredentialsAsync() => Task.FromResult(false); + public IAsyncOperation CreateFileAsync(Stream contents, string name, CreationCollisionOption options) => Task.FromException(new NotSupportedException()).AsAsyncOperation(); } } +#endif diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs new file mode 100644 index 000000000000..92938188c57a --- /dev/null +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs @@ -0,0 +1,190 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.IO; +using System.Text; +using System.Windows.Input; +using Windows.Storage; + +namespace Files.App.ViewModels.Dialogs +{ + public sealed partial class DecompressArchiveDialogViewModel : ObservableObject + { + // Services + private ICommonDialogService CommonDialogService { get; } = Ioc.Default.GetRequiredService(); + private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + // Fields + private readonly IStorageFile archive; + + // Properties + public BaseStorageFolder? DestinationFolder { get; private set; } + + private string destinationFolderPath; + public string DestinationFolderPath + { + get => destinationFolderPath; + set + { + if (SetProperty(ref destinationFolderPath, value)) + { + OnPropertyChanged(nameof(IsDestinationPathValid)); + } + } + } + + public bool IsDestinationPathValid + { + get + { + try + { + if (string.IsNullOrWhiteSpace(DestinationFolderPath)) + return false; + + string? parentDir = Path.GetDirectoryName(DestinationFolderPath); + string finalSegment = Path.GetFileName(DestinationFolderPath); + + // Check parent directory exists + if (string.IsNullOrWhiteSpace(parentDir) || !Directory.Exists(parentDir)) + return false; + + // Check for invalid characters (IsValidForFilename already does this) + if (!FilesystemHelpers.IsValidForFilename(finalSegment)) + return false; + + return true; + } + catch + { + // Catch any exception to prevent crashes + return false; + } + } + } + + private bool openDestinationFolderOnCompletion; + public bool OpenDestinationFolderOnCompletion + { + get => openDestinationFolderOnCompletion; + set => SetProperty(ref openDestinationFolderOnCompletion, value); + } + + private bool isArchiveEncrypted; + public bool IsArchiveEncrypted + { + get => isArchiveEncrypted; + set => SetProperty(ref isArchiveEncrypted, value); + } + + private bool isArchiveEncodingUndetermined; + public bool IsArchiveEncodingUndetermined + { + get => isArchiveEncodingUndetermined; + set => SetProperty(ref isArchiveEncodingUndetermined, value); + } + + private Encoding? detectedEncoding; + public Encoding? DetectedEncoding + { + get => detectedEncoding; + set + { + SetProperty(ref detectedEncoding, value); + RefreshEncodingOptions(); + } + } + + private bool showPathSelection; + public bool ShowPathSelection + { + get => showPathSelection; + set => SetProperty(ref showPathSelection, value); + } + + public DisposableArray? Password { get; private set; } + + public EncodingItem[] EncodingOptions { get; set; } = EncodingItem.Defaults; + + public EncodingItem SelectedEncoding { get; set; } + + public ObservableCollection PreviousExtractionLocations { get; } = []; + + // Commands + public IRelayCommand PrimaryButtonClickCommand { get; private set; } + public ICommand SelectDestinationCommand { get; private set; } + public ICommand? QuerySubmittedCommand { get; private set; } + + // Constructor + public DecompressArchiveDialogViewModel(IStorageFile archive) + { + this.archive = archive; + destinationFolderPath = DefaultDestinationFolderPath(); + SelectedEncoding = EncodingOptions[0]; + + // Create commands + SelectDestinationCommand = new AsyncRelayCommand(SelectDestinationAsync); + PrimaryButtonClickCommand = new RelayCommand(password => Password = password); + } + + // Private Methods + private string DefaultDestinationFolderPath() + { + var directory = Path.GetDirectoryName(archive.Path); +#if HAS_UNO + var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archive.Path); +#else + var fileName = Path.GetFileNameWithoutExtension(archive.Path); +#endif + return string.IsNullOrEmpty(directory) ? fileName : Path.Combine(directory, fileName); + } + + private async Task SelectDestinationAsync() + { + bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath); + if (!result) + return; + + DestinationFolder = await StorageHelpers.ToStorageItem(filePath); + DestinationFolderPath = (DestinationFolder is not null) ? DestinationFolder.Path : DefaultDestinationFolderPath(); + } + + private void RefreshEncodingOptions() + { + if (detectedEncoding != null) + { + EncodingOptions = EncodingItem.Defaults + .Prepend(new EncodingItem( + detectedEncoding, + string.Format(Strings.EncodingDetected.GetLocalizedResource(), detectedEncoding.EncodingName) + )) + .ToArray(); + } + else + { + EncodingOptions = EncodingItem.Defaults; + } + SelectedEncoding = EncodingOptions[0]; + } + + // Public Methods + public void UpdateSuggestions(string query) + { + var allItems = UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations; + if (allItems is null) + return; + + var filtered = allItems + .Where(item => item.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Only update if results changed to prevent flickering + if (!filtered.SequenceEqual(PreviousExtractionLocations)) + { + PreviousExtractionLocations.Clear(); + foreach (var item in filtered) + PreviousExtractionLocations.Add(item); + } + } + } +} diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs index 92938188c57a..b5705d9988b4 100644 --- a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs @@ -1,10 +1,11 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using System.IO; using System.Text; using System.Windows.Input; -using Windows.Storage; + namespace Files.App.ViewModels.Dialogs { @@ -15,10 +16,9 @@ public sealed partial class DecompressArchiveDialogViewModel : ObservableObject private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); // Fields - private readonly IStorageFile archive; + private readonly string archivePath; // Properties - public BaseStorageFolder? DestinationFolder { get; private set; } private string destinationFolderPath; public string DestinationFolderPath @@ -116,9 +116,9 @@ public bool ShowPathSelection public ICommand? QuerySubmittedCommand { get; private set; } // Constructor - public DecompressArchiveDialogViewModel(IStorageFile archive) + public DecompressArchiveDialogViewModel(string archivePath) { - this.archive = archive; + this.archivePath = archivePath; destinationFolderPath = DefaultDestinationFolderPath(); SelectedEncoding = EncodingOptions[0]; @@ -130,23 +130,18 @@ public DecompressArchiveDialogViewModel(IStorageFile archive) // Private Methods private string DefaultDestinationFolderPath() { - var directory = Path.GetDirectoryName(archive.Path); -#if HAS_UNO - var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archive.Path); -#else - var fileName = Path.GetFileNameWithoutExtension(archive.Path); -#endif + var directory = Path.GetDirectoryName(archivePath); + var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath); + return string.IsNullOrEmpty(directory) ? fileName : Path.Combine(directory, fileName); } - private async Task SelectDestinationAsync() + private Task SelectDestinationAsync() { bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath); - if (!result) - return; - - DestinationFolder = await StorageHelpers.ToStorageItem(filePath); - DestinationFolderPath = (DestinationFolder is not null) ? DestinationFolder.Path : DefaultDestinationFolderPath(); + if (result) + DestinationFolderPath = filePath; + return Task.CompletedTask; } private void RefreshEncodingOptions() @@ -188,3 +183,5 @@ public void UpdateSuggestions(string query) } } } + +#endif diff --git a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs new file mode 100644 index 000000000000..1b105810bba6 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs @@ -0,0 +1,70 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.ViewModels.Properties; +using SevenZip; +using System.IO; + +namespace Files.App.ViewModels.Previews +{ + public sealed partial class ArchivePreviewViewModel : BasePreviewModel + { + public ArchivePreviewViewModel(ListedItem item) + : base(item) + { + } + + public override async Task> LoadPreviewAndDetailsAsync() + { + var details = new List(); + + var zipResult = await FilesystemTasks.WrapNullable(async () => + { + var arch = new SevenZipExtractor(await PreviewFile.OpenStreamForReadAsync()); + + // Force load archive (1665013614u) + if (arch.ArchiveFileData is null) + { + arch.Dispose(); + return null; + } + + return arch; + }); + using var zipFile = zipResult.Result; + + if (zipFile is null) + { + // Loads the thumbnail preview + _ = await base.LoadPreviewAndDetailsAsync(); + + return details; + } + + //zipFile.IsStreamOwner = true; + + var folderCount = 0; + var fileCount = 0; + ulong totalSize = 0; + + foreach (ArchiveFileInfo entry in zipFile.ArchiveFileData) + { + if (!entry.IsDirectory) + { + ++fileCount; + totalSize += entry.Size; + } + } + + folderCount = (int)zipFile.FilesCount - fileCount; + + string propertyItemCount = Strings.DetailsArchiveItems.GetLocalizedFormatResource(zipFile.FilesCount, fileCount, folderCount); + details.Add(GetFileProperty("PropertyItemCount", propertyItemCount)); + details.Add(GetFileProperty("PropertyUncompressedSize", totalSize.ToLongSizeString())); + + // Loads the thumbnail preview + _ = await base.LoadPreviewAndDetailsAsync(); + return details; + } + } +} diff --git a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs index 1b105810bba6..c234a6ddf7ee 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs @@ -1,70 +1,27 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.ViewModels.Properties; -using SevenZip; -using System.IO; +using Files.Platform.Abstractions.Archives; namespace Files.App.ViewModels.Previews { - public sealed partial class ArchivePreviewViewModel : BasePreviewModel + public sealed partial class ArchivePreviewViewModel(ListedItem item) : BasePreviewModel(item) { - public ArchivePreviewViewModel(ListedItem item) - : base(item) - { - } - public override async Task> LoadPreviewAndDetailsAsync() { - var details = new List(); - - var zipResult = await FilesystemTasks.WrapNullable(async () => - { - var arch = new SevenZipExtractor(await PreviewFile.OpenStreamForReadAsync()); - - // Force load archive (1665013614u) - if (arch.ArchiveFileData is null) - { - arch.Dispose(); - return null; - } - - return arch; - }); - using var zipFile = zipResult.Result; - - if (zipFile is null) + var listing = await Ioc.Default.GetRequiredService().ListPreviewAsync(item.ItemPath!); + var files = listing.Entries.Count(entry => !entry.IsDirectory); + var total = listing.Entries.Aggregate(0UL, (sum, entry) => checked(sum + (ulong)entry.Size)); + var details = new List { - // Loads the thumbnail preview - _ = await base.LoadPreviewAndDetailsAsync(); - - return details; - } - - //zipFile.IsStreamOwner = true; - - var folderCount = 0; - var fileCount = 0; - ulong totalSize = 0; - - foreach (ArchiveFileInfo entry in zipFile.ArchiveFileData) - { - if (!entry.IsDirectory) - { - ++fileCount; - totalSize += entry.Size; - } - } - - folderCount = (int)zipFile.FilesCount - fileCount; - - string propertyItemCount = Strings.DetailsArchiveItems.GetLocalizedFormatResource(zipFile.FilesCount, fileCount, folderCount); - details.Add(GetFileProperty("PropertyItemCount", propertyItemCount)); - details.Add(GetFileProperty("PropertyUncompressedSize", totalSize.ToLongSizeString())); - - // Loads the thumbnail preview + GetFileProperty("PropertyItemCount", Strings.DetailsArchiveItems.GetLocalizedFormatResource(listing.Entries.Count, files, listing.Entries.Count - files)), + GetFileProperty("PropertyUncompressedSize", total.ToLongSizeString()) + }; _ = await base.LoadPreviewAndDetailsAsync(); return details; } } } +#endif diff --git a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs index a57ef4033a70..23863ce77896 100644 --- a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs +++ b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs @@ -110,7 +110,7 @@ public sealed record ArchiveLimitViolation(string Limit, double Actual, double A /// /// Describes an item of the extraction that already exists at the destination. /// - /// The entry path relative to the destination. + /// The entry path relative to the destination, escaped for display. Use DestinationPath for file operations. /// The existing path. /// Whether the incoming item is a folder. public sealed record ArchiveConflict(string EntryPath, string DestinationPath, bool IsDirectory); diff --git a/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs b/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs new file mode 100644 index 000000000000..9d8b3bde9fc6 --- /dev/null +++ b/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Abstractions.Archives +{ + /// Requests an archive password on demand. Null cancels the operation; passwords are never persisted. + public interface IArchivePasswordPrompt + { + /// Requests a password, indicating whether a previous attempt failed. + Task RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default); + } +} diff --git a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs index 369d4a8d56e5..87e7e0150b1e 100644 --- a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs +++ b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -39,6 +40,12 @@ public interface IArchiveService /// Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default); + /// Opens one safe entry as a caller-owned, read-only stream with fixed browsing size and ratio limits. + Task OpenEntryAsync(string archivePath, string entryPath, string? password = null, CancellationToken cancellationToken = default); + + /// Whether existing archives can be modified through this service. + bool CanWriteEntries => false; + /// Lists untrusted preview headers with bounded input, expanded bytes and entry count, without extracting files. Task ListPreviewAsync(string archivePath, CancellationToken cancellationToken = default); diff --git a/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs b/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs index 0fb02f3cba4b..8ba0d12a06a4 100644 --- a/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs +++ b/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs @@ -7,6 +7,7 @@ using System.Text; using Files.Platform.Abstractions.Archives; using Files.Platform.Linux.FileOperations; +using Files.Platform.Linux.Previews; namespace Files.Platform.Linux.Archives { @@ -27,6 +28,9 @@ public static string NormalizeEntryName(string? entryName) if (string.IsNullOrEmpty(entryName)) return string.Empty; + if (Encoding.UTF8.GetByteCount(entryName) > 4096) + throw new ArchiveSecurityException("Archive entry path is too long."); + if (entryName.Contains('\0')) throw new ArchiveSecurityException($"Archive entry name contains a NUL character: '{Printable(entryName)}'."); @@ -51,6 +55,9 @@ public static string NormalizeEntryName(string? entryName) if (Encoding.UTF8.GetByteCount(segment) > MaxSegmentBytes) throw new ArchiveSecurityException($"Archive entry has a name that is too long: '{Printable(entryName)}'."); + if (segments.Count >= 128) + throw new ArchiveSecurityException("Archive entry path is too deep."); + segments.Add(segment); } @@ -122,13 +129,6 @@ internal static void EnsureNoLinkInPath(string root, string fullPath) } } - private static string Printable(string value) - { - var builder = new StringBuilder(Math.Min(value.Length, 200)); - foreach (var c in value.Length > 200 ? value[..200] : value) - builder.Append(char.IsControl(c) ? '?' : c); - - return builder.ToString(); - } + internal static string Printable(string value) => PreviewEntryName.Sanitize(value, 200); } } diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index 530590813d43..59120f248e32 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Formats.Tar; using System.IO.Compression; using System.Text; +using System.Threading; +using Files.Platform.Linux.Previews; using Files.Platform.Abstractions.Archives; using SharpCompress.Archives; using SharpCompress.Common; @@ -29,13 +32,13 @@ internal abstract class ArchiveSource : IDisposable public abstract bool IsSolid { get; } /// Enumerates the entries. Each call starts over. The stream factory is only valid while the enumerator is on that entry. - public abstract IEnumerable<(EntryData Entry, Func Open)> Entries(); + public abstract IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false); public virtual void Dispose() { } - public static ArchiveSource Open(string path, string? password, Encoding? encoding) + public static ArchiveSource Open(string path, string? password, Encoding? encoding, long? maxBytes = null, CancellationToken cancellationToken = default) { var options = new ReaderOptions { Password = string.IsNullOrEmpty(password) ? null : password }; if (encoding is not null) @@ -44,13 +47,21 @@ public static ArchiveSource Open(string path, string? password, Encoding? encodi var name = Path.GetFileName(path).ToLowerInvariant(); var codec = TarCodec(name); if (codec is not null) - return new TarSource(path, codec.Value, options); + return new TarSource(path, codec.Value, options, maxBytes, cancellationToken); var single = SingleFileCodec(name); if (single is not null) - return new SingleFileSource(path, single.Value); + return new SingleFileSource(path, single.Value, maxBytes, cancellationToken); - return new ZipLikeSource(ArchiveFactory.Open(path, options)); + var input = OpenInput(path, maxBytes, cancellationToken); + try { return new ZipLikeSource(ArchiveFactory.Open(input, options), input); } + catch { input.Dispose(); throw; } + } + + private static Stream OpenInput(string path, long? maxBytes, CancellationToken cancellationToken) + { + var file = PreviewFile.OpenRead(path, cancellationToken); + return maxBytes is { } limit ? new PreviewReadStream(file, limit, cancellationToken) : file; } internal static Codec? TarCodec(string lowerName) @@ -105,17 +116,19 @@ internal enum Codec private sealed class ZipLikeSource : ArchiveSource { private readonly IArchive archive; + private readonly Stream input; - public ZipLikeSource(IArchive archive) + public ZipLikeSource(IArchive archive, Stream input) { this.archive = archive; + this.input = input; } public override bool IsSolid => archive.IsSolid || archive.Type == ArchiveType.SevenZip; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { - if (IsSolid) + if (IsSolid && !headersOnly) { using var reader = archive.ExtractAllEntries(); while (reader.MoveToNextEntry()) @@ -134,7 +147,11 @@ public ZipLikeSource(IArchive archive) } } - public override void Dispose() => archive.Dispose(); + public override void Dispose() + { + try { archive.Dispose(); } + finally { input.Dispose(); } + } } private sealed class TarSource : ArchiveSource @@ -142,25 +159,36 @@ private sealed class TarSource : ArchiveSource private readonly string path; private readonly Codec codec; private readonly ReaderOptions options; + private readonly long? maxBytes; + private readonly CancellationToken cancellationToken; - public TarSource(string path, Codec codec, ReaderOptions options) + public TarSource(string path, Codec codec, ReaderOptions options, long? maxBytes, CancellationToken cancellationToken) { this.path = path; this.codec = codec; this.options = options; + this.maxBytes = maxBytes; + this.cancellationToken = cancellationToken; } public override bool IsSolid => true; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { - using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - using var decompressed = Decompress(file, codec); - using var reader = ReaderFactory.Open(decompressed, options); - while (reader.MoveToNextEntry()) + using var file = OpenInput(path, maxBytes, cancellationToken); + using var codecStream = Decompress(file, codec); + using var decompressed = maxBytes is { } limit ? new PreviewReadStream(codecStream, limit, cancellationToken) : codecStream; + using var reader = new TarReader(decompressed); + TarEntry? entry; + while ((entry = reader.GetNextEntry()) is not null) { - var entry = reader.Entry; - yield return (Map(entry, true), () => reader.OpenEntryStream()); + cancellationToken.ThrowIfCancellationRequested(); + var current = entry; + var link = entry.EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink ? entry.LinkName : null; + if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory or TarEntryType.SymbolicLink or TarEntryType.HardLink)) + throw new ArchiveSecurityException("The tar contains an unsupported entry type."); + yield return (new EntryData(entry.Name, entry.EntryType == TarEntryType.Directory, entry.Length, 0, + entry.ModificationTime.UtcDateTime, false, link, 0, (int)entry.Mode, true), () => current.DataStream ?? Stream.Null); } } } @@ -170,19 +198,24 @@ private sealed class SingleFileSource : ArchiveSource private readonly string path; private readonly Codec codec; - public SingleFileSource(string path, Codec codec) + private readonly long? maxBytes; + private readonly CancellationToken cancellationToken; + + public SingleFileSource(string path, Codec codec, long? maxBytes, CancellationToken cancellationToken) { this.path = path; this.codec = codec; + this.maxBytes = maxBytes; + this.cancellationToken = cancellationToken; } public override bool IsSolid => false; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { var name = Path.GetFileNameWithoutExtension(path); var info = new FileInfo(path); - using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var file = OpenInput(path, maxBytes, cancellationToken); using var decompressed = Decompress(file, codec); yield return (new EntryData(name, false, 0, info.Length, info.LastWriteTime, false, null, 0, null, false), () => decompressed); } diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs index 2173250ca3f4..5446747714a8 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; @@ -48,7 +49,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE { ct.ThrowIfCancellationRequested(); entryCount++; - declaredBytes += Math.Max(entry.Size, 0); + declaredBytes = checked(declaredBytes + Math.Max(entry.Size, 0)); if (extract) ArchivePathValidator.NormalizeEntryName(entry.Key); @@ -57,6 +58,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE throw new ArchivePasswordException("The archive is encrypted and the password is missing."); guard.CheckEntryCount(entryCount); + guard.CheckDeclared(declaredBytes); } guard.CheckDeclared(declaredBytes); @@ -90,7 +92,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (entry.IsEncrypted && string.IsNullOrEmpty(options.Password)) throw new ArchivePasswordException("The archive is encrypted and the password is missing."); - options.Progress?.Report(new ArchiveProgress(processed, entryCount, bytesDone, declaredBytes, relative)); + options.Progress?.Report(new ArchiveProgress(processed, entryCount, bytesDone, declaredBytes, ArchivePathValidator.Printable(relative))); if (!extract) { @@ -122,8 +124,8 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (entry.LinkTarget is not null) { - // Only relative links that stay inside the extracted tree are created - if (!ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || FileSystemEntry.GetKind(full) != EntryKind.None) + // Parent segments could escape through another archive link; accept only downward relative targets. + if (!ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || FileSystemEntry.GetKind(full) != EntryKind.None) { skipped++; continue; @@ -178,7 +180,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (!extract) return new ArchiveResult(true, false, processed, skipped); - var state = new MergeState(options, ct); + var state = new MergeState(options, ct, destination!); Merge(staging!, destination!, string.Empty, state); skipped += state.Skipped; @@ -195,18 +197,18 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE } catch (ArchiveSecurityException ex) { - return new ArchiveResult(false, false, processed, skipped, ex.Message); + return new ArchiveResult(false, false, processed, skipped, ArchivePathValidator.Printable(ex.Message)); } catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) { throw new ArchivePasswordException("The password is wrong.", ex); } - catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException or ArchiveException or InvalidDataException or NotSupportedException or ArgumentException) + catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException or ArchiveException or InvalidDataException or NotSupportedException or ArgumentException or OverflowException) { if (!string.IsNullOrEmpty(options.Password) && ex is not IOException and not UnauthorizedAccessException) throw new ArchivePasswordException("The archive could not be read, the password may be wrong.", ex); - return new ArchiveResult(false, false, processed, skipped, ex.Message); + return new ArchiveResult(false, false, processed, skipped, ArchivePathValidator.Printable(ex.Message)); } finally { @@ -225,7 +227,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE private static void VerifyCrc(EntryData entry, uint actual) { if (!entry.IsTar && entry.Crc != 0 && (uint)entry.Crc != actual) - throw new InvalidDataException($"CRC mismatch, the archive is corrupt or the password is wrong: '{entry.Key}'."); + 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) @@ -256,14 +258,17 @@ private sealed class MergeState private readonly ArchiveExtractOptions options; private ConflictResolution? sticky; - public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken) + public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken, string root) { this.options = options; CancellationToken = cancellationToken; + Root = root; } public CancellationToken CancellationToken { get; } + public string Root { get; } + public long Skipped { get; set; } public ConflictAction Resolve(ArchiveConflict conflict) @@ -296,6 +301,22 @@ private static void Merge(string source, string destination, string relative, Me var targetKind = FileSystemEntry.GetKind(target); var isDirectory = sourceKind == EntryKind.Directory; + if (sourceKind == EntryKind.Symlink) + { + try + { + var linkTarget = new FileInfo(child).LinkTarget!; + var resolved = Path.GetFullPath(Path.Combine(destination, linkTarget)); + ArchivePathValidator.ResolveInside(state.Root, Path.GetRelativePath(state.Root, resolved)); + ArchivePathValidator.EnsureNoLinkInPath(state.Root, resolved); + } + catch (ArchiveSecurityException) + { + state.Skipped++; + continue; + } + } + if (targetKind == EntryKind.None) { MoveItem(child, target, isDirectory); @@ -308,7 +329,7 @@ private static void Merge(string source, string destination, string relative, Me continue; } - switch (state.Resolve(new ArchiveConflict(relativePath, target, isDirectory))) + switch (state.Resolve(new ArchiveConflict(ArchivePathValidator.Printable(relativePath), target, isDirectory))) { case ConflictAction.Cancel: throw new OperationCanceledException(); diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs new file mode 100644 index 000000000000..04f3aada72cc --- /dev/null +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs @@ -0,0 +1,88 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using SharpCompress.Common; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Linux.Archives +{ + public sealed partial class LinuxArchiveService + { + private static readonly ArchiveLimits BrowsingLimits = new(64L * 1024 * 1024, 10000, 100, 1024 * 1024); + + /// + public Task OpenEntryAsync(string archivePath, string entryPath, string? password = null, CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + var wanted = ArchivePathValidator.NormalizeEntryName(entryPath); + if (wanted.Length == 0) + throw new FileNotFoundException(); + try + { + using var archive = OpenArchive(archivePath, password, null, BrowsingLimits.MaxTotalBytes, cancellationToken); + var guard = new ExtractionGuard(BrowsingLimits, null, new FileInfo(archivePath).Length, cancellationToken); + var buffer = new byte[81920]; + long count = 0; + foreach (var (entry, open) in archive.Entries()) + { + cancellationToken.ThrowIfCancellationRequested(); + guard.CheckEntryCount(++count); + var name = ArchivePathValidator.NormalizeEntryName(entry.Key ?? GetDefaultExtractFolderName(archivePath)); + var selected = name == wanted; + if (entry.IsDirectory || IsLink(entry)) + { + if (selected) + throw new ArchiveSecurityException("Only regular archive files can be opened."); + continue; + } + if (!selected && !archive.IsSolid) + continue; + if (entry.IsEncrypted && string.IsNullOrEmpty(password)) + throw new ArchivePasswordException("The archive requires a password."); + guard.CheckDeclared(Math.Max(entry.Size, 0)); + var memberGuard = selected && entry.CompressedSize > 0 + ? new ExtractionGuard(BrowsingLimits, null, entry.CompressedSize, cancellationToken) : null; + memberGuard?.CheckDeclared(Math.Max(entry.Size, 0)); + using var input = open(); + using var output = new MemoryStream(); + uint crc = 0; + int read; + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + guard.AddBytes(read); + memberGuard?.AddBytes(read); + if (selected) + { + output.Write(buffer, 0, read); + crc = Crc32.Update(crc, buffer, read); + } + } + if (selected) + { + try { VerifyCrc(entry, crc); } + catch (InvalidDataException ex) when (entry.IsEncrypted) + { + throw new ArchivePasswordException("The archive password is wrong.", ex); + } + return new MemoryStream(output.ToArray(), writable: false); + } + } + throw new FileNotFoundException("The archive entry was not found."); + } + catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) + { + throw new ArchivePasswordException("The archive password is missing or wrong.", ex); + } + }, cancellationToken); + } + + private static bool IsLink(EntryData entry) + => entry.LinkTarget is not null || (entry.Attrib is { } attributes && ((attributes >> 16) & 0xF000) == 0xA000); + } +} diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs index 0b51bdfdb5a6..f84401629d62 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs @@ -24,7 +24,7 @@ public sealed partial class LinuxArchiveService : IArchiveService [ ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar.lz", ".tgz", ".tbz2", ".tbz", ".txz", ".tzst", - ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", + ".zip", ".jar", ".mrpack", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", ]; private readonly ISevenZipRunner sevenZip; @@ -86,14 +86,23 @@ public Task ListAsync(string archivePath, string? password = nul { return Task.Run(() => { - using var archive = OpenArchive(archivePath, password, fileNameEncoding); + using var archive = OpenArchive(archivePath, password, fileNameEncoding, BrowsingLimits.MaxTotalBytes, cancellationToken); var fallback = GetDefaultExtractFolderName(archivePath); var entries = new List(); var encrypted = false; + var guard = new ExtractionGuard(BrowsingLimits, null, new FileInfo(archivePath).Length, cancellationToken); + long bytes = 0; + var nameChars = 0; - foreach (var (entry, _) in archive.Entries()) + foreach (var (entry, _) in archive.Entries(headersOnly: true)) { cancellationToken.ThrowIfCancellationRequested(); + guard.CheckEntryCount(entries.Count + 1); + bytes = checked(bytes + Math.Max(entry.Size, 0)); + guard.CheckDeclared(bytes); + nameChars = checked(nameChars + (entry.Key?.Length ?? fallback.Length)); + if (nameChars > 1024 * 1024) + throw new ArchiveSecurityException("The archive name limit was exceeded."); encrypted |= entry.IsEncrypted; entries.Add(new ArchiveEntryInfo( (entry.Key ?? fallback).Replace('\\', '/'), @@ -102,7 +111,7 @@ public Task ListAsync(string archivePath, string? password = nul Math.Max(entry.CompressedSize, 0), entry.Modified, entry.IsEncrypted, - entry.LinkTarget)); + IsLink(entry) ? entry.LinkTarget ?? string.Empty : null)); } return new ArchiveListing(entries, encrypted, archive.IsSolid); @@ -135,11 +144,11 @@ public async Task IsEncryptedAsync(string archivePath, CancellationToken c return null; } - private static ArchiveSource OpenArchive(string path, string? password, Encoding? encoding) + private static ArchiveSource OpenArchive(string path, string? password, Encoding? encoding, long? maxBytes = null, CancellationToken cancellationToken = default) { try { - return ArchiveSource.Open(path, password, encoding); + return ArchiveSource.Open(path, password, encoding, maxBytes, cancellationToken); } catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) { diff --git a/src/Files.Shared/Helpers/FileExtensionHelpers.cs b/src/Files.Shared/Helpers/FileExtensionHelpers.cs index 3c3119ebe909..9f6d49006040 100644 --- a/src/Files.Shared/Helpers/FileExtensionHelpers.cs +++ b/src/Files.Shared/Helpers/FileExtensionHelpers.cs @@ -7,6 +7,8 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Globalization; +using System.Text; namespace Files.Shared.Helpers { @@ -133,27 +135,47 @@ public static bool IsZipFile(string? fileExtensionToCheck) public static bool IsBrowsableZipFile(string? filePath, [NotNullWhen(true)] out string? ext) { - // LINUX-TODO(archives): browsing archives as folders (ZipStorageFolder) is built on 7z.dll and '\\' paths; on Linux archives are - // plain files that the Extract actions (IArchiveService) handle. - if (!OperatingSystem.IsWindows()) + if (string.IsNullOrWhiteSpace(filePath)) { ext = null; return false; } - if (string.IsNullOrWhiteSpace(filePath)) + ext = null; + foreach (var component in filePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries)) { - ext = null; - - return false; + var candidate = Path.GetExtension(component); + if (new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".tgz", ".tbz2", ".txz", ".tzst", ".mrpack", ".jar" }.Contains(candidate, StringComparer.OrdinalIgnoreCase) || OperatingSystem.IsWindows() && candidate.Equals(".lzh", StringComparison.OrdinalIgnoreCase)) + { + ext = candidate; + break; + } } + return ext is not null; + } - // Only extensions we want to browse - ext = new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".lzh", ".mrpack", ".jar" } - .FirstOrDefault(x => filePath.Contains(x, StringComparison.OrdinalIgnoreCase)); + /// Finds a browsable archive component, skipping real directories with archive extensions. + public static string? GetArchiveContainerPath(string path) + { + for (var end = 1; end <= path.Length; end++) + { + if (end < path.Length && path[end] is not ('/' or '\\')) + continue; + var candidate = path[..end]; + var name = candidate[(Math.Max(candidate.LastIndexOf('/'), candidate.LastIndexOf('\\')) + 1)..]; + if (IsBrowsableZipFile(name, out _) && !Directory.Exists(candidate)) + return candidate; + } + return null; + } - return ext is not null; + /// Checks for an archive root or a member path using either separator. + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) + { + if (string.IsNullOrEmpty(path) || GetArchiveContainerPath(path) is not { } container) + return false; + return container.Length == path.TrimEnd('/', '\\').Length ? includeRoot : !Path.Exists(path); } /// @@ -389,4 +411,21 @@ public static bool IsImagePreviewFile(string? fileExtensionToCheck) return HasExtension(fileExtensionToCheck, ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".ico", ".webp", ".jxr"); } } + + /// Escapes untrusted archive names without changing the identifier used to open an entry. + public static class ArchiveDisplayName + { + public static string Escape(string value) + { + var result = new StringBuilder(value.Length); + foreach (var character in value) + { + if (char.IsControl(character) || char.GetUnicodeCategory(character) is UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator || character is '\\' or '\u034F' or '\u180B' or '\u180C' or '\u180D') + result.Append("\\u").Append(((int)character).ToString("X4", CultureInfo.InvariantCulture)); + else + result.Append(character); + } + return result.ToString(); + } + } } diff --git a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs new file mode 100644 index 000000000000..91fd5ebbbaa3 --- /dev/null +++ b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs @@ -0,0 +1,280 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Storage.Archives; +using Files.App.Storage.Storables; +using Files.Core.Storage.Enums; +using Files.Platform.Abstractions.Archives; +using Files.Platform.Linux.Archives; +using Files.Shared.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OwlCore.Storage; +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Archives +{ + [TestClass] + [SupportedOSPlatform("linux")] + public sealed class ArchiveBrowsingTests + { + private readonly LinuxArchiveService service = new(); + private string root = null!; + + [TestInitialize] + public void Setup() => root = Directory.CreateTempSubdirectory("files-archive-browsing-").FullName; + + [TestCleanup] + public void Cleanup() => Directory.Delete(root, true); + + private string Zip(params (string Name, string Text)[] entries) + { + var path = Path.Combine(root, "test.zip"); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var (name, text) in entries) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open()); + writer.Write(text); + } + return path; + } + + [TestMethod] + public async Task RealArchiveNamedDirectoriesFallThrough() + { + var folder = Directory.CreateDirectory(Path.Combine(root, "a.zip")); + var child = Path.Combine(folder.FullName, "real.txt"); + File.WriteAllText(child, "real"); + var route = new ArchiveStorableRoute(service); + Assert.AreEqual(StorableStatus.NotMine, (await route.TryGetAsync(folder.FullName)).Status); + Assert.AreEqual(StorableStatus.NotMine, (await route.TryGetAsync(child)).Status); + Assert.IsFalse(FileExtensionHelpers.IsZipPath(child)); + var resolver = new StorableResolver([route, new LocalStorableRoute()]); + Assert.IsInstanceOfType((await resolver.TryGetAsync(child)).Item); + } + + [TestMethod] + public async Task BrowseImplicitDirectoriesAndOpenMembers() + { + var path = Zip(("dir/sub/a.txt", "alpha"), ("empty/", ""), ("root.txt", "root")); + var route = new ArchiveStorableRoute(service); + var folder = (ArchiveFolder)(await route.TryGetAsync(path)).Item!; + var children = new List(); + await foreach (var child in folder.GetItemsAsync()) children.Add(child); + CollectionAssert.AreEquivalent(new[] { "dir", "empty", "root.txt" }, children.Select(child => child.Name).ToArray()); + Assert.IsFalse((object)folder is IModifiableFolder); + var nested = (ArchiveFolder)await folder.GetItemAsync(path + "/dir"); + Assert.AreEqual(path, (await nested.GetParentAsync())!.Id); + var member = (ArchiveEntryFile)(await route.TryGetAsync(path + "/dir/sub/a.txt")).Item!; + Assert.AreEqual(path + "/dir/sub", (await member.GetParentAsync())!.Id); + using var stream = await member.OpenStreamAsync(FileAccess.Read); + Assert.IsFalse(stream.CanWrite); + using var reader = new StreamReader(stream); + Assert.AreEqual("alpha", await reader.ReadToEndAsync()); + await Assert.ThrowsAsync(() => member.OpenStreamAsync(FileAccess.Write)); + await Assert.ThrowsAsync(() => folder.GetItemAsync(path + "/dir/sub/a.txt")); + Assert.AreEqual(StorableStatus.NotFound, (await route.TryGetAsync(path + "/missing")).Status); + Assert.IsFalse(((IArchiveService)service).CanWriteEntries); + } + + [TestMethod] + public async Task NamesAreEscapedButIdentifiersRemainExact() + { + const string name = "line\n\u202E.txt"; + var path = Zip((name, "data")); + var member = (ArchiveEntryFile)(await new ArchiveStorableRoute(service).TryGetAsync(path + "/" + name)).Item!; + Assert.AreEqual("line\\u000A\\u202E.txt", member.Name); + Assert.AreEqual(path + "/" + name, member.Id); + using var stream = await member.OpenStreamAsync(FileAccess.Read); + Assert.AreEqual(4, stream.Length); + } + + [TestMethod] + public void ArchivePathDetectionAcceptsBothSeparatorsAndRequiresBoundaries() + { + Assert.IsTrue(FileExtensionHelpers.IsZipPath("/tmp/test.zip/dir/a.txt", false)); + Assert.IsTrue(FileExtensionHelpers.IsZipPath(@"C:\temp\test.zip\dir\a.txt", false)); + Assert.IsTrue(FileExtensionHelpers.IsZipPath("/tmp/test.zip")); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zip", false)); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zip.backup/a.txt")); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zipfolder/a.txt")); + } + + [TestMethod] + public async Task TraversalAndAmbiguousArchiveTreesAreRejected() + { + var path = Zip(("../outside", "no")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "../outside")); + File.Delete(path); + path = Zip(("a", "file"), ("a/b", "file")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + File.Delete(path); + path = Zip(("a/b", "one"), ("a\\b", "two")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + } + + [TestMethod] + public async Task MemberReadsRejectMissingEntriesAndCancellation() + { + var path = Zip(("a.txt", "a")); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "missing")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "a.txt", cancellationToken: cts.Token)); + await Assert.ThrowsAsync(() => new ArchiveStorableRoute(service).TryGetAsync(path, cts.Token)); + } + + [TestMethod] + public async Task MemberReadsEnforceDeclaredRatioAndActualGzipBytes() + { + var path = Zip(("zeros", new string('0', 2 * 1024 * 1024))); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "zeros")); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Update)) + using (var padding = zip.CreateEntry("padding").Open()) + { + var buffer = new byte[2 * 1024 * 1024]; + new Random(42).NextBytes(buffer); + padding.Write(buffer); + } + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "zeros")); + var gzipPath = Path.Combine(root, "zeros.gz"); + using (var file = File.Create(gzipPath)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + gzip.Write(new byte[2 * 1024 * 1024]); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(gzipPath, "zeros")); + } + + [TestMethod] + public async Task DeclaredMemberSizeAndImplicitHierarchyAreBounded() + { + var path = Path.Combine(root, "large.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + using (var stream = zip.CreateEntry("large").Open()) + { + var buffer = new byte[1024 * 1024]; + for (var i = 0; i < 65; i++) stream.Write(buffer); + } + var violation = await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "large")); + Assert.AreEqual("bytes", violation.Violation.Limit); + path = Zip((string.Join('/', Enumerable.Repeat("a", 129)) + "/file", "data")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + File.Delete(path); + path = Zip(Enumerable.Range(0, 1100).Select(i => ($"dir{i}/a/b/c/d/e/f/g/h/i/file", "data")).ToArray()); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + } + + [TestMethod] + public async Task SolidTraversalCountsSkippedMembers() + { + var path = Path.Combine(root, "solid.tar.gz"); + using (var file = File.Create(path)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + using (var writer = new TarWriter(gzip)) + { + using var data = new MemoryStream(new byte[2 * 1024 * 1024]); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "first") { DataStream = data }); + using var last = new MemoryStream(Encoding.UTF8.GetBytes("last")); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "last") { DataStream = last }); + } + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "last")); + } + + [TestMethod] + public async Task CorruptArchivesReturnErrorAndZipLinksAreHidden() + { + var path = Path.Combine(root, "broken.zip"); + File.WriteAllText(path, "invalid"); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + path = Path.Combine(root, "link.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("link"); + entry.ExternalAttributes = unchecked((int)0xA1FF0000); + using var writer = new StreamWriter(entry.Open()); + writer.Write("/etc/passwd"); + } + Assert.AreEqual(StorableStatus.NotFound, (await new ArchiveStorableRoute(service).TryGetAsync(path + "/link")).Status); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "link")); + } + + [TestMethod] + public async Task UpwardLinkTargetsAreSkippedEvenWhenLexicallyInside() + { + var path = Path.Combine(root, "chain.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "sub/alias") { LinkName = ".." }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "sub/alias/../outside" }); + } + var destination = Path.Combine(root, "extract"); + var result = await service.ExtractAsync(path, destination); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.AreEqual(2, result.ItemsSkipped); + Assert.IsNull(new FileInfo(Path.Combine(destination, "link")).LinkTarget); + } + + [TestMethod] + public async Task ArchiveLinksCannotEscapeThroughDestinationLinks() + { + var path = Path.Combine(root, "link.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "alias/file" }); + var destination = Directory.CreateDirectory(Path.Combine(root, "out")).FullName; + var outside = Directory.CreateDirectory(Path.Combine(root, "outside")).FullName; + Directory.CreateSymbolicLink(Path.Combine(destination, "alias"), outside); + var result = await service.ExtractAsync(path, destination); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.AreEqual(1, result.ItemsSkipped); + Assert.IsNull(new FileInfo(Path.Combine(destination, "link")).LinkTarget); + } + + [TestMethod] + public async Task ExtractionConflictNamesAreEscapedWithoutChangingDestination() + { + const string name = "spoof\u202E.txt"; + var path = Zip((name, "new")); + var destination = Directory.CreateDirectory(Path.Combine(root, "out")).FullName; + var target = Path.Combine(destination, name); + File.WriteAllText(target, "old"); + ArchiveConflict? conflict = null; + var result = await service.ExtractAsync(path, destination, new ArchiveExtractOptions + { + ResolveConflict = (item, _) => + { + conflict = item; + return Task.FromResult(new Files.Platform.Abstractions.FileOperations.ConflictResolution(Files.Platform.Abstractions.FileOperations.ConflictAction.Skip)); + } + }); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.IsNotNull(conflict); + Assert.IsFalse(conflict.EntryPath.Contains('\u202E')); + Assert.AreEqual(target, conflict.DestinationPath); + Assert.AreEqual("old", File.ReadAllText(target)); + } + + [TestMethod] + public async Task LinksAreHiddenAndCannotBeOpened() + { + var path = Path.Combine(root, "links.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "/etc/passwd" }); + var folder = (ArchiveFolder)(await new ArchiveStorableRoute(service).TryGetAsync(path)).Item!; + var count = 0; + await foreach (var child in folder.GetItemsAsync()) count++; + Assert.AreEqual(0, count); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "link")); + } + } +} diff --git a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs index ea910ebdf741..cebdd6b7e3f6 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; +using Files.App.Storage.Archives; using Files.Platform.Abstractions.FileOperations; using Files.Platform.Linux.Archives; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -681,6 +682,19 @@ public async Task PasswordProtectedZip() await Assert.ThrowsExactlyAsync(() => service.ExtractAsync(archive, Out, new ArchiveExtractOptions { Password = "wrong" })); Assert.AreEqual(0, Directory.GetFileSystemEntries(Out).Length); + await Assert.ThrowsExactlyAsync(() => service.OpenEntryAsync(archive, "Documents/a.txt")); + await Assert.ThrowsExactlyAsync(() => service.OpenEntryAsync(archive, "Documents/a.txt", "wrong")); + using (var stream = await service.OpenEntryAsync(archive, "Documents/a.txt", "s3cret")) + using (var reader = new StreamReader(stream)) + Assert.AreEqual("alpha", await reader.ReadToEndAsync()); + + var prompt = new TestPasswordPrompt(); + var route = new ArchiveStorableRoute(service, prompt); + var member = (ArchiveEntryFile)(await route.TryGetAsync(archive + "/Documents/a.txt")).Item!; + using (var stream = await member.OpenStreamAsync(FileAccess.Read)) + Assert.AreEqual(5, stream.Length); + Assert.AreEqual(2, prompt.Requests); + var ok = await service.ExtractAsync(archive, Out, new ArchiveExtractOptions { Password = "s3cret" }); Assert.IsTrue(ok.Succeeded, ok.Error); Assert.AreEqual("alpha", File.ReadAllText(Path.Combine(Out, "Documents", "a.txt"))); @@ -757,6 +771,16 @@ private static bool Run(string tool, string workingDirectory, IReadOnlyList RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default) + { + Assert.AreEqual(Requests > 0, retry); + return Task.FromResult(++Requests == 1 ? "wrong" : "s3cret"); + } + } + private sealed class FakeSevenZip : ISevenZipRunner { private readonly string? binary; diff --git a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj index 88d59a4f26f4..ce2b3c263491 100644 --- a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj +++ b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj @@ -36,4 +36,8 @@ + + + + From 62c9c45aa50e3d70557f9f66ae8e7a036199f125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 21:01:05 +0300 Subject: [PATCH 2/4] Fix P4-A review: duplicate project reference, browsing-only archive limits, Windows zip semantics ListAsync/IsEncryptedAsync now read headers without browsing limits; limits apply via ListForBrowsingAsync. Restore Windows IsBrowsableZipFile behaviour, cache archive passwords per session, drop OpenEntry double buffer, refresh compat-off baseline. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/compat-off-baseline.txt | 14 +--- .../Archives/ArchiveContext.cs | 14 +++- .../Archives/ArchiveStorableRoute.cs | 5 +- .../Files.App.Storage.csproj | 3 - .../Archives/IArchiveService.cs | 4 + .../Archives/ArchiveSource.cs | 2 +- .../Archives/LinuxArchiveService.OpenEntry.cs | 2 +- .../Archives/LinuxArchiveService.cs | 77 +++++++++++++++---- .../Helpers/FileExtensionHelpers.cs | 11 ++- 9 files changed, 93 insertions(+), 39 deletions(-) diff --git a/docs/linux-port/compat-off-baseline.txt b/docs/linux-port/compat-off-baseline.txt index d584a54da734..30c8382d1b40 100644 --- a/docs/linux-port/compat-off-baseline.txt +++ b/docs/linux-port/compat-off-baseline.txt @@ -4,6 +4,7 @@ src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs: error CS0246: The type src/Files.App/Data/Items/ContextMenu.cs: error CS0246: The type or namespace name 'MENU_ITEM_TYPE' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Data/Items/ListedItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Data/Items/ShellLinkItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) +src/Files.App/Data/Items/WidgetFolderCardItem.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?) src/Files.App/Data/Items/WidgetFolderCardItem.cs: error CS0246: The type or namespace name 'IWindowsStorable' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Data/Items/WidgetRecentItem.cs: error CS0246: The type or namespace name 'IShellItem' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Data/Models/RemovableDevice.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) @@ -16,18 +17,8 @@ src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs: error CS0122: 'BOOL' is src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0103: The name 'SHOW_WINDOW_CMD' does not exist in the current context -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HRESULT' is inaccessible due to its protection level -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HWND' is inaccessible due to its protection level -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'FILEOPERATION_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'PROPERTYKEY' does not exist in the namespace 'Windows.Win32.Foundation' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'SLR_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'Disposable' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellFileOperations2' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellItem' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) +src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0246: The type or namespace name 'FindCloseSafeHandle' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0246: The type or namespace name 'WIN32_FIND_DATAW' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) @@ -38,3 +29,4 @@ src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.cs: error CS0234: Th src/Files.App/ViewModels/ShellViewModel.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?) src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?) +src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs: error CS0246: The type or namespace name 'IAgileReference' could not be found (are you missing a using directive or an assembly reference?) diff --git a/src/Files.App.Storage/Archives/ArchiveContext.cs b/src/Files.App.Storage/Archives/ArchiveContext.cs index 87681012a4f8..5dcb0925013c 100644 --- a/src/Files.App.Storage/Archives/ArchiveContext.cs +++ b/src/Files.App.Storage/Archives/ArchiveContext.cs @@ -15,9 +15,9 @@ namespace Files.App.Storage.Archives { - internal sealed class ArchiveContext(string path, IArchiveService service, IArchivePasswordPrompt? prompt) + internal sealed class ArchiveContext(string path, IArchiveService service, IArchivePasswordPrompt? prompt, System.Collections.Concurrent.ConcurrentDictionary? passwordCache = null) { - private string? password; + private string? password = passwordCache is not null && passwordCache.TryGetValue(path, out var cached) ? cached : null; private Dictionary? entries; private readonly SemaphoreSlim gate = new(1, 1); public string Path { get; } = path; @@ -31,7 +31,13 @@ public async Task WithPasswordAsync(Func> operation, Canc for (var attempt = 0; ; attempt++) { cancellationToken.ThrowIfCancellationRequested(); - try { return await operation(password).ConfigureAwait(false); } + try + { + var result = await operation(password).ConfigureAwait(false); + if (password is not null && passwordCache is not null) + passwordCache[Path] = password; + return result; + } catch (ArchivePasswordException) when (prompt is not null && attempt < 3) { password = await prompt.RequestPasswordAsync(Path, password is not null, cancellationToken).ConfigureAwait(false); @@ -48,7 +54,7 @@ public async Task> ListAsync(CancellationTo cancellationToken.ThrowIfCancellationRequested(); if (entries is { } cached) return cached; - var listing = await WithPasswordAsync(password => Service.ListAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); + var listing = await WithPasswordAsync(password => Service.ListForBrowsingAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); var result = new Dictionary(StringComparer.Ordinal); foreach (var entry in listing.Entries) { diff --git a/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs index 17b8a2485ec1..24e9e32f2427 100644 --- a/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs +++ b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs @@ -17,6 +17,9 @@ namespace Files.App.Storage.Archives /// Routes archive roots and members before the local file system route. public sealed class ArchiveStorableRoute(IArchiveService service, IArchivePasswordPrompt? passwordPrompt = null) : IStorableRoute { + // Session-only; avoids re-prompting on every navigation into an encrypted archive + private readonly System.Collections.Concurrent.ConcurrentDictionary passwords = new(StringComparer.Ordinal); + public int Order => 100; public async Task TryGetAsync(string path, CancellationToken cancellationToken = default) @@ -36,7 +39,7 @@ public async Task TryGetAsync(string path, CancellationToken can var attributes = File.GetAttributes(container); if (attributes.HasFlag(FileAttributes.Directory)) continue; - var context = new ArchiveContext(container, service, passwordPrompt); + var context = new ArchiveContext(container, service, passwordPrompt, passwords); var entryPath = ArchiveContext.Normalize(path[end..].TrimStart('/')); var listing = await context.ListAsync(cancellationToken).ConfigureAwait(false); if (entryPath.Length == 0) diff --git a/src/Files.App.Storage/Files.App.Storage.csproj b/src/Files.App.Storage/Files.App.Storage.csproj index 7d6d0dfbdfaa..ab597cc47aea 100644 --- a/src/Files.App.Storage/Files.App.Storage.csproj +++ b/src/Files.App.Storage/Files.App.Storage.csproj @@ -58,7 +58,4 @@ - - - \ No newline at end of file diff --git a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs index 87e7e0150b1e..857552927741 100644 --- a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs +++ b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs @@ -40,6 +40,10 @@ public interface IArchiveService /// Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default); + /// Lists headers for in-app browsing, applying the fixed browsing size, ratio and entry limits. Use for encryption and structure checks. + Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + => ListAsync(archivePath, password, fileNameEncoding, cancellationToken); + /// Opens one safe entry as a caller-owned, read-only stream with fixed browsing size and ratio limits. Task OpenEntryAsync(string archivePath, string entryPath, string? password = null, CancellationToken cancellationToken = default); diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index 59120f248e32..888a8d3b3c24 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -80,7 +80,7 @@ private static Stream OpenInput(string path, long? maxBytes, CancellationToken c return null; } - private static Codec? SingleFileCodec(string lowerName) + internal static Codec? SingleFileCodec(string lowerName) { if (lowerName.EndsWith(".gz", StringComparison.Ordinal)) return Codec.GZip; diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs index 04f3aada72cc..f6ecf4b5face 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs @@ -70,7 +70,7 @@ public Task OpenEntryAsync(string archivePath, string entryPath, string? { throw new ArchivePasswordException("The archive password is wrong.", ex); } - return new MemoryStream(output.ToArray(), writable: false); + return new MemoryStream(output.GetBuffer(), 0, (int)output.Length, writable: false); } } throw new FileNotFoundException("The archive entry was not found."); diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs index f84401629d62..785cb0007862 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs @@ -27,6 +27,8 @@ public sealed partial class LinuxArchiveService : IArchiveService ".zip", ".jar", ".mrpack", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", ]; + private const int MaxListedEntries = 5_000_000; + private readonly ISevenZipRunner sevenZip; /// @@ -83,6 +85,29 @@ public string GetDefaultExtractFolderName(string archivePath) /// public Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + using var archive = OpenArchive(archivePath, password, fileNameEncoding, null, cancellationToken); + var fallback = GetDefaultExtractFolderName(archivePath); + var entries = new List(); + var encrypted = false; + + foreach (var (entry, _) in archive.Entries(headersOnly: true)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entries.Count >= MaxListedEntries) + throw new ArchiveSecurityException("The archive entry count limit was exceeded."); + encrypted |= entry.IsEncrypted; + entries.Add(ToInfo(entry, fallback)); + } + + return new ArchiveListing(entries, encrypted, archive.IsSolid); + }, cancellationToken); + } + + /// + public Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) { return Task.Run(() => { @@ -104,32 +129,50 @@ public Task ListAsync(string archivePath, string? password = nul if (nameChars > 1024 * 1024) throw new ArchiveSecurityException("The archive name limit was exceeded."); encrypted |= entry.IsEncrypted; - entries.Add(new ArchiveEntryInfo( - (entry.Key ?? fallback).Replace('\\', '/'), - entry.IsDirectory, - Math.Max(entry.Size, 0), - Math.Max(entry.CompressedSize, 0), - entry.Modified, - entry.IsEncrypted, - IsLink(entry) ? entry.LinkTarget ?? string.Empty : null)); + entries.Add(ToInfo(entry, fallback)); } return new ArchiveListing(entries, encrypted, archive.IsSolid); }, cancellationToken); } + private static ArchiveEntryInfo ToInfo(EntryData entry, string fallback) => new( + (entry.Key ?? fallback).Replace('\\', '/'), + entry.IsDirectory, + Math.Max(entry.Size, 0), + Math.Max(entry.CompressedSize, 0), + entry.Modified, + entry.IsEncrypted, + IsLink(entry) ? entry.LinkTarget ?? string.Empty : null); + /// - public async Task IsEncryptedAsync(string archivePath, CancellationToken cancellationToken = default) + public Task IsEncryptedAsync(string archivePath, CancellationToken cancellationToken = default) { - try - { - return (await ListAsync(archivePath, null, null, cancellationToken).ConfigureAwait(false)).IsEncrypted; - } - catch (ArchivePasswordException) + // Tar and single-file compression formats have no encryption + var name = Path.GetFileName(archivePath).ToLowerInvariant(); + if (ArchiveSource.TarCodec(name) is not null || ArchiveSource.SingleFileCodec(name) is not null) + return Task.FromResult(false); + + return Task.Run(() => { - // Encrypted headers - return true; - } + try + { + using var archive = OpenArchive(archivePath, null, null, null, cancellationToken); + foreach (var (entry, _) in archive.Entries(headersOnly: true)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entry.IsEncrypted) + return true; + } + + return false; + } + catch (ArchivePasswordException) + { + // Encrypted headers + return true; + } + }, cancellationToken); } private static string? FindExtension(string path) diff --git a/src/Files.Shared/Helpers/FileExtensionHelpers.cs b/src/Files.Shared/Helpers/FileExtensionHelpers.cs index 9f6d49006040..da9296592ba0 100644 --- a/src/Files.Shared/Helpers/FileExtensionHelpers.cs +++ b/src/Files.Shared/Helpers/FileExtensionHelpers.cs @@ -142,11 +142,20 @@ public static bool IsBrowsableZipFile(string? filePath, [NotNullWhen(true)] out return false; } + if (OperatingSystem.IsWindows()) + { + // Windows keeps the original substring matching + ext = new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".lzh", ".mrpack", ".jar" } + .FirstOrDefault(x => filePath.Contains(x, StringComparison.OrdinalIgnoreCase)); + + return ext is not null; + } + ext = null; foreach (var component in filePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries)) { var candidate = Path.GetExtension(component); - if (new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".tgz", ".tbz2", ".txz", ".tzst", ".mrpack", ".jar" }.Contains(candidate, StringComparer.OrdinalIgnoreCase) || OperatingSystem.IsWindows() && candidate.Equals(".lzh", StringComparison.OrdinalIgnoreCase)) + if (new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".tgz", ".tbz2", ".txz", ".tzst", ".mrpack", ".jar" }.Contains(candidate, StringComparer.OrdinalIgnoreCase)) { ext = candidate; break; From 7bb4f0c45f613511e18fc3eb67b5147b83580c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 21:05:47 +0300 Subject: [PATCH 3/4] Bound archive header listing for every caller; truncated result instead of failure ListAsync/IsEncryptedAsync/HasMultipleTopLevelEntriesAsync stream headers with entry, name-byte, header-byte and time caps and report IsTruncated. Only the decompressed-size/ratio guard stays browsing-only. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Storage/StorageArchiveService.Desktop.cs | 16 +-- .../Archives/ArchiveTypes.cs | 3 +- .../Archives/IArchiveService.cs | 11 ++ .../Archives/LinuxArchiveService.cs | 109 +++++++++++++----- .../Previews/PreviewReadStream.cs | 4 +- .../Archives/ArchiveBrowsingTests.cs | 62 ++++++++++ 6 files changed, 162 insertions(+), 43 deletions(-) diff --git a/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs b/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs index 188c998e74bd..2334291ec06c 100644 --- a/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs +++ b/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs @@ -226,21 +226,7 @@ public async Task HasMultipleTopLevelEntriesAsync(string archiveFilePath, { try { - var listing = await ArchiveService.ListAsync(archiveFilePath, string.IsNullOrEmpty(password) ? null : password); - string? first = null; - foreach (var entry in listing.Entries) - { - var segment = entry.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not "."); - if (segment is null) - continue; - - if (first is null) - first = segment; - else if (segment != first) - return true; - } - - return false; + return await ArchiveService.HasMultipleTopLevelEntriesAsync(archiveFilePath, string.IsNullOrEmpty(password) ? null : password); } catch (Exception) { diff --git a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs index 23863ce77896..4d7b1497f53a 100644 --- a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs +++ b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs @@ -77,7 +77,8 @@ public sealed record ArchiveEntryInfo( /// The entries. /// Whether at least one entry is encrypted. /// Whether the archive is solid. - public sealed record ArchiveListing(IReadOnlyList Entries, bool IsEncrypted, bool IsSolid); + /// Whether listing stopped at an entry, name, byte or time cap, so is partial (too large to list). + public sealed record ArchiveListing(IReadOnlyList Entries, bool IsEncrypted, bool IsSolid, bool IsTruncated = false); /// /// Zip bomb guards. A value of zero or less disables that guard. diff --git a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs index 857552927741..4adf2766f308 100644 --- a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs +++ b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs @@ -1,7 +1,9 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.IO; using System.Threading; @@ -40,6 +42,15 @@ public interface IArchiveService /// Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default); + /// Whether the archive has more than one top-level entry, reading only the first headers. Archives too large to tell count as having several. + async Task HasMultipleTopLevelEntriesAsync(string archivePath, string? password = null, CancellationToken cancellationToken = default) + { + var listing = await ListAsync(archivePath, password, null, cancellationToken).ConfigureAwait(false); + return listing.IsTruncated || listing.Entries + .Select(e => e.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not ".")) + .Where(s => s is not null).Distinct().Skip(1).Any(); + } + /// Lists headers for in-app browsing, applying the fixed browsing size, ratio and entry limits. Use for encryption and structure checks. Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) => ListAsync(archivePath, password, fileNameEncoding, cancellationToken); diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs index 785cb0007862..3c6da80ec96a 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; +using Files.Platform.Linux.Previews; using SharpCompress.Common; using SharpCompress.Readers; @@ -27,7 +28,11 @@ public sealed partial class LinuxArchiveService : IArchiveService ".zip", ".jar", ".mrpack", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", ]; - private const int MaxListedEntries = 5_000_000; + private sealed record ScanLimits(int Entries, long NameBytes, long HeaderBytes, long StreamBytes, TimeSpan Time); + + // Header bytes bound zip/7z/rar directories; stream bytes bound the decompressed scan of tar and single-file formats + private static readonly ScanLimits ListScan = new(100_000, 16L * 1024 * 1024, 128L * 1024 * 1024, 16L * 1024 * 1024 * 1024, TimeSpan.FromSeconds(60)); + private static readonly ScanLimits QuickScan = new(10_000, 2L * 1024 * 1024, 32L * 1024 * 1024, 1L * 1024 * 1024 * 1024, TimeSpan.FromSeconds(10)); private readonly ISevenZipRunner sevenZip; @@ -84,26 +89,47 @@ public string GetDefaultExtractFolderName(string archivePath) } /// - public Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + public async Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) { - return Task.Run(() => + var fallback = GetDefaultExtractFolderName(archivePath); + var entries = new List(); + var encrypted = false; + var (truncated, solid) = await ScanHeadersAsync(archivePath, password, fileNameEncoding, entry => { - using var archive = OpenArchive(archivePath, password, fileNameEncoding, null, cancellationToken); - var fallback = GetDefaultExtractFolderName(archivePath); - var entries = new List(); - var encrypted = false; + encrypted |= entry.IsEncrypted; + entries.Add(ToInfo(entry, fallback)); + return true; + }, ListScan, cancellationToken).ConfigureAwait(false); - foreach (var (entry, _) in archive.Entries(headersOnly: true)) + return new ArchiveListing(entries, encrypted, solid, truncated); + } + + /// + public async Task HasMultipleTopLevelEntriesAsync(string archivePath, string? password = null, CancellationToken cancellationToken = default) + { + string? first = null; + var multiple = false; + try + { + var (truncated, _) = await ScanHeadersAsync(archivePath, password, null, entry => { - cancellationToken.ThrowIfCancellationRequested(); - if (entries.Count >= MaxListedEntries) - throw new ArchiveSecurityException("The archive entry count limit was exceeded."); - encrypted |= entry.IsEncrypted; - entries.Add(ToInfo(entry, fallback)); - } + var segment = (entry.Key ?? string.Empty).Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not "."); + if (segment is null) + return true; + if (first is null) + first = segment; + else if (segment != first) + multiple = true; + return !multiple; + }, QuickScan, cancellationToken).ConfigureAwait(false); - return new ArchiveListing(entries, encrypted, archive.IsSolid); - }, cancellationToken); + // Too large to tell: extracting into a child folder is the safe choice + return multiple || truncated; + } + catch (ArchivePasswordException) + { + return true; + } } /// @@ -146,31 +172,62 @@ public Task ListForBrowsingAsync(string archivePath, string? pas IsLink(entry) ? entry.LinkTarget ?? string.Empty : null); /// - public Task IsEncryptedAsync(string archivePath, CancellationToken cancellationToken = default) + public async Task IsEncryptedAsync(string archivePath, CancellationToken cancellationToken = default) { // Tar and single-file compression formats have no encryption var name = Path.GetFileName(archivePath).ToLowerInvariant(); if (ArchiveSource.TarCodec(name) is not null || ArchiveSource.SingleFileCodec(name) is not null) - return Task.FromResult(false); + return false; + var found = false; + try + { + await ScanHeadersAsync(archivePath, null, null, entry => !(found = entry.IsEncrypted), QuickScan, cancellationToken).ConfigureAwait(false); + return found; + } + catch (ArchivePasswordException) + { + // Encrypted headers + return true; + } + } + + /// Streams entry headers without buffering content. Hitting a cap ends the scan and reports it as truncated instead of failing. + private Task<(bool Truncated, bool Solid)> ScanHeadersAsync(string archivePath, string? password, Encoding? encoding, Func visit, ScanLimits limits, CancellationToken cancellationToken) + { return Task.Run(() => { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(limits.Time); + var solid = false; try { - using var archive = OpenArchive(archivePath, null, null, null, cancellationToken); + var name = Path.GetFileName(archivePath).ToLowerInvariant(); + var inputCap = ArchiveSource.TarCodec(name) is not null || ArchiveSource.SingleFileCodec(name) is not null ? limits.StreamBytes : limits.HeaderBytes; + using var archive = OpenArchive(archivePath, password, encoding, inputCap, timeout.Token); + solid = archive.IsSolid; + var count = 0; + long nameBytes = 0; foreach (var (entry, _) in archive.Entries(headersOnly: true)) { - cancellationToken.ThrowIfCancellationRequested(); - if (entry.IsEncrypted) - return true; + timeout.Token.ThrowIfCancellationRequested(); + nameBytes += Encoding.UTF8.GetByteCount(entry.Key ?? string.Empty); + if (count >= limits.Entries || nameBytes > limits.NameBytes) + return (true, solid); + count++; + if (!visit(entry)) + break; } - return false; + return (false, solid); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (true, solid); } - catch (ArchivePasswordException) + catch (InvalidDataException ex) when (ex.Message == PreviewReadStream.LimitMessage) { - // Encrypted headers - return true; + return (true, solid); } }, cancellationToken); } diff --git a/src/Files.Platform.Linux/Previews/PreviewReadStream.cs b/src/Files.Platform.Linux/Previews/PreviewReadStream.cs index e1509ffb6af9..2f21450f0501 100644 --- a/src/Files.Platform.Linux/Previews/PreviewReadStream.cs +++ b/src/Files.Platform.Linux/Previews/PreviewReadStream.cs @@ -11,6 +11,8 @@ namespace Files.Platform.Linux.Previews /// Limits cumulative reads, including rereads after seeking, from untrusted preview input. public sealed class PreviewReadStream : Stream { + public const string LimitMessage = "The preview read limit was exceeded."; + private readonly Stream source; private readonly CancellationToken cancellationToken; private long remaining; @@ -33,7 +35,7 @@ private int Limit(int count) { cancellationToken.ThrowIfCancellationRequested(); if (count > 0 && remaining == 0 && !(source.CanSeek && source.Position >= source.Length)) - throw new InvalidDataException("The preview read limit was exceeded."); + throw new InvalidDataException(LimitMessage); return (int)Math.Min(count, remaining); } diff --git a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs index 91fd5ebbbaa3..8e1556e8f5f2 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs @@ -47,6 +47,68 @@ private string Zip(params (string Name, string Text)[] entries) return path; } + [TestMethod] + public async Task ListingStopsAtEntryCapForHugeZip() + { + var path = Path.Combine(root, "many.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + for (var i = 0; i < 120_000; i++) + zip.CreateEntry("f" + i); + } + var listing = await service.ListAsync(path); + Assert.IsTrue(listing.IsTruncated); + Assert.AreEqual(100_000, listing.Entries.Count); + Assert.IsTrue(await service.HasMultipleTopLevelEntriesAsync(path)); + Assert.IsFalse(await service.IsEncryptedAsync(path)); + } + + [TestMethod] + public async Task BigTarGzListsWithinCapsButNotForBrowsing() + { + var path = Path.Combine(root, "big.tar.gz"); + var tarPath = Path.Combine(root, "big.tar"); + using (var tarFile = File.Create(tarPath)) + using (var tar = new TarWriter(tarFile)) + { + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "dir/big.bin") { DataStream = new ZeroStream(96L * 1024 * 1024) }); + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "dir/small.txt") { DataStream = new MemoryStream([1]) }); + } + using (var input = File.OpenRead(tarPath)) + using (var output = File.Create(path)) + using (var gzip = new GZipStream(output, CompressionLevel.Fastest)) + input.CopyTo(gzip); + var listing = await service.ListAsync(path); + Assert.IsFalse(listing.IsTruncated); + Assert.AreEqual(2, listing.Entries.Count); + Assert.IsFalse(await service.IsEncryptedAsync(path)); + Assert.IsFalse(await service.HasMultipleTopLevelEntriesAsync(path)); + await Assert.ThrowsAsync(() => service.ListForBrowsingAsync(path)); + } + + private sealed class ZeroStream : Stream + { + private readonly long length; + private long remaining; + public ZeroStream(long length) { this.length = length; remaining = length; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => length; + public override long Position { get => length - remaining; set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) + { + var n = (int)Math.Min(count, remaining); + Array.Clear(buffer, offset, n); + remaining -= n; + return n; + } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + [TestMethod] public async Task RealArchiveNamedDirectoriesFallThrough() { From bcd99bc1681f3e68e6d1ef838a9415b1d899eddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 22:19:39 +0300 Subject: [PATCH 4/4] Skip tar metadata entries; browsing needs only header caps Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Archives/ArchiveSource.cs | 10 +++++- .../Archives/LinuxArchiveService.cs | 31 ++++------------ .../Archives/ArchiveBrowsingTests.cs | 35 +++++++++++++++++-- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index 888a8d3b3c24..e02a0c636c4a 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -156,6 +156,8 @@ public override void Dispose() private sealed class TarSource : ArchiveSource { + private const int MaxSkippedTarEntries = 10_000; + private readonly string path; private readonly Codec codec; private readonly ReaderOptions options; @@ -180,13 +182,19 @@ public TarSource(string path, Codec codec, ReaderOptions options, long? maxBytes using var decompressed = maxBytes is { } limit ? new PreviewReadStream(codecStream, limit, cancellationToken) : codecStream; using var reader = new TarReader(decompressed); TarEntry? entry; + var skipped = 0; while ((entry = reader.GetNextEntry()) is not null) { cancellationToken.ThrowIfCancellationRequested(); var current = entry; var link = entry.EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink ? entry.LinkName : null; if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory or TarEntryType.SymbolicLink or TarEntryType.HardLink)) - throw new ArchiveSecurityException("The tar contains an unsupported entry type."); + { + // Pax global/extended headers carry metadata only; fifos, devices and the like are never created. Skip them, bounded. + if (++skipped > MaxSkippedTarEntries) + throw new ArchiveSecurityException("The tar contains too many unsupported entries."); + continue; + } yield return (new EntryData(entry.Name, entry.EntryType == TarEntryType.Directory, entry.Length, 0, entry.ModificationTime.UtcDateTime, false, link, 0, (int)entry.Mode, true), () => current.DataStream ?? Stream.Null); } diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs index 3c6da80ec96a..2be0f6dd2e58 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs @@ -133,33 +133,14 @@ public async Task HasMultipleTopLevelEntriesAsync(string archivePath, stri } /// - public Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + public async Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) { - return Task.Run(() => - { - using var archive = OpenArchive(archivePath, password, fileNameEncoding, BrowsingLimits.MaxTotalBytes, cancellationToken); - var fallback = GetDefaultExtractFolderName(archivePath); - var entries = new List(); - var encrypted = false; - var guard = new ExtractionGuard(BrowsingLimits, null, new FileInfo(archivePath).Length, cancellationToken); - long bytes = 0; - var nameChars = 0; - - foreach (var (entry, _) in archive.Entries(headersOnly: true)) - { - cancellationToken.ThrowIfCancellationRequested(); - guard.CheckEntryCount(entries.Count + 1); - bytes = checked(bytes + Math.Max(entry.Size, 0)); - guard.CheckDeclared(bytes); - nameChars = checked(nameChars + (entry.Key?.Length ?? fallback.Length)); - if (nameChars > 1024 * 1024) - throw new ArchiveSecurityException("The archive name limit was exceeded."); - encrypted |= entry.IsEncrypted; - entries.Add(ToInfo(entry, fallback)); - } + // Browsing only needs the header caps; the decompressed-size and ratio guards apply per entry in OpenEntryAsync + var listing = await ListAsync(archivePath, password, fileNameEncoding, cancellationToken).ConfigureAwait(false); + if (listing.IsTruncated) + throw new ArchiveSecurityException("The archive is too large to browse."); - return new ArchiveListing(entries, encrypted, archive.IsSolid); - }, cancellationToken); + return listing; } private static ArchiveEntryInfo ToInfo(EntryData entry, string fallback) => new( diff --git a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs index 8e1556e8f5f2..1f13e6bc5d79 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs @@ -64,7 +64,7 @@ public async Task ListingStopsAtEntryCapForHugeZip() } [TestMethod] - public async Task BigTarGzListsWithinCapsButNotForBrowsing() + public async Task BigTarGzListsWithinCapsAndForBrowsing() { var path = Path.Combine(root, "big.tar.gz"); var tarPath = Path.Combine(root, "big.tar"); @@ -83,7 +83,38 @@ public async Task BigTarGzListsWithinCapsButNotForBrowsing() Assert.AreEqual(2, listing.Entries.Count); Assert.IsFalse(await service.IsEncryptedAsync(path)); Assert.IsFalse(await service.HasMultipleTopLevelEntriesAsync(path)); - await Assert.ThrowsAsync(() => service.ListForBrowsingAsync(path)); + Assert.AreEqual(2, (await service.ListForBrowsingAsync(path)).Entries.Count); + } + + [TestMethod] + public async Task GitArchiveStyleTarWithGlobalHeaderLists() + { + var path = Path.Combine(root, "src.tar.gz"); + using (var file = File.Create(path)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + using (var tar = new TarWriter(gzip, TarEntryFormat.Pax)) + { + tar.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary { ["comment"] = "0123abcd" })); + tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, "proj/")); + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "proj/a.txt") { DataStream = new MemoryStream([1, 2]) }); + } + var listing = await service.ListForBrowsingAsync(path); + CollectionAssert.AreEquivalent(new[] { "proj/", "proj/a.txt" }, listing.Entries.Select(e => e.Path.TrimEnd('/') + (e.IsDirectory ? "/" : "")).ToArray()); + Assert.IsFalse(await service.HasMultipleTopLevelEntriesAsync(path)); + } + + [TestMethod] + public async Task LargeDeclaredSizeZipIsBrowsable() + { + var path = Path.Combine(root, "large.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + using var stream = zip.CreateEntry("zeros.bin", CompressionLevel.SmallestSize).Open(); + var block = new byte[1024 * 1024]; + for (var i = 0; i < 80; i++) + stream.Write(block); + } + Assert.AreEqual(1, (await service.ListForBrowsingAsync(path)).Entries.Count); } private sealed class ZeroStream : Stream