Skip to content
Merged
100 changes: 100 additions & 0 deletions src/Files.App.Storage/Archives/ArchiveContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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, System.Collections.Concurrent.ConcurrentDictionary<string, string>? passwordCache = null)
{
private string? password = passwordCache is not null && passwordCache.TryGetValue(path, out var cached) ? cached : null;
private Dictionary<string, ArchiveEntryInfo>? entries;
private readonly SemaphoreSlim gate = new(1, 1);
public string Path { get; } = path;
public IArchiveService Service { get; } = service;

public async Task<T> WithPasswordAsync<T>(Func<string?, Task<T>> operation, CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
for (var attempt = 0; ; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
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);
if (password is null)
throw new OperationCanceledException(cancellationToken);
}
}
}
finally { gate.Release(); }
}

public async Task<Dictionary<string, ArchiveEntryInfo>> ListAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (entries is { } cached)
return cached;
var listing = await WithPasswordAsync(password => Service.ListForBrowsingAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false);
var result = new Dictionary<string, ArchiveEntryInfo>(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 != "."));
}
}
}
49 changes: 49 additions & 0 deletions src/Files.App.Storage/Archives/ArchiveEntryFile.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>A regular archive member. The caller owns the returned bounded stream.</summary>
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<IFolder?> GetParentAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<IFolder?>(parent);
}

public Task<Stream> 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);
}
}
}
73 changes: 73 additions & 0 deletions src/Files.App.Storage/Archives/ArchiveFolder.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>A read-only view of an archive directory, including directories implied by entry names.</summary>
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<IFolder?> 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<IFolder?>(parent);
}

public async IAsyncEnumerable<IStorableChild> 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<IStorableChild> 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<IFolderWatcher> GetFolderWatcherAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromException<IFolderWatcher>(new NotSupportedException());
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Registers archive routing; the host provides IArchiveService and an optional IArchivePasswordPrompt.</summary>
public static IServiceCollection AddArchiveStorables(this IServiceCollection services)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStorableRoute, ArchiveStorableRoute>());
return services;
}
}
}
62 changes: 62 additions & 0 deletions src/Files.App.Storage/Archives/ArchiveStorableRoute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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
{
/// <summary>Routes archive roots and members before the local file system route.</summary>
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<string, string> passwords = new(StringComparer.Ordinal);

public int Order => 100;

public async Task<StorableResult> 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, passwords);
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; }
}
}
}
26 changes: 26 additions & 0 deletions src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs
Original file line number Diff line number Diff line change
@@ -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<string?> 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<IDialogService>();
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Files.Platform.Abstractions.Archives.IArchiveService>().CanWriteEntries
#endif
) &&
context.PageType != ContentPageTypes.ReleaseNotes &&
context.PageType != ContentPageTypes.Settings &&
context.PageType != ContentPageTypes.None;
Expand Down
Loading
Loading