diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index b95a12d14..c7aea17f0 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -1520,6 +1520,13 @@ minutes, Ghostscript 5, Pandoc 2 and calibre 10 (it made the 640-page novel's PD
## Archives
+Folders can be packed directly as ZIP, 7Z, TAR, TAR.GZ, TAR.BZ2 or TAR.XZ. The Folders donut offers the existing
+ZIP preset (one archive per folder) and One ZIP (one archive containing all selected files/folders), alongside ISO.
+Other container formats can be added from the palette; single-file GZ/BZ2/XZ are not offered for folders.
+Folder contents are stored under the selected folder's name, retaining Unicode names and empty directories.
+7Z stages directory inputs through the same link-skipping copy used for renamed items, so nested symbolic links
+and junctions cannot add files outside the selected tree. Custom folder donut layouts are preserved on upgrade.
+
The archive engine (`Archives/ArchiveConverter.cs`, id `archive`) uses Filee's own copy of the 7-Zip console program
(`7z.exe` + `7z.dll`, taken from the official x64 MSI with `msiexec /a` by `build/fetch-engines.ps1 -Only 7zip` and
bundled with the installer), never a 7-Zip installed on the PC. It runs as a separate process (LGPL).
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index 8485eeb00..d37911419 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -630,11 +630,10 @@ private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.
}
///
- /// What converts of : folders only go into "Compress into one
- /// archive"; any other preset converts the files beside them (issue #186).
+ /// Keeps folders for direct folder actions and combined archives; other presets convert only the files beside them.
///
internal static IReadOnlyList SourcesFor(Preset preset, IReadOnlyList files) =>
- preset.PacksIntoOneArchive || preset.IsCopyToFolder || preset.TargetFormat == "iso" ? files : [.. files.Where(path => !Directory.Exists(path))];
+ preset.PacksIntoOneArchive || BuiltInData.AcceptsFolders(preset) ? files : [.. files.Where(path => !Directory.Exists(path))];
// ───────── Progress ring ─────────
diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs
index f675ca0e3..5865375e5 100644
--- a/src/Filee.Cli/Cli.cs
+++ b/src/Filee.Cli/Cli.cs
@@ -62,7 +62,7 @@ private static async Task ConvertAsync(CliOptions options, CliHost host, Te
var missing = new List();
var files = ExpandInputs(options.Inputs, options.Recursive, missing,
- foldersAsInputs: preset.TargetFormat == "iso" || preset.PacksIntoOneArchive);
+ foldersAsInputs: BuiltInData.AcceptsFolders(preset) || preset.PacksIntoOneArchive);
foreach (var path in missing)
errors.WriteLine($"filee: not found: {path}");
if (missing.Count > 0)
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index ae1b7b038..9f9fc2574 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -12,7 +12,7 @@ public static class BuiltInData
public const string SameAsSource = "*";
/// Whether a conversion target accepts a folder directly.
- public static bool SupportsFolderTarget(string target) => target == "iso";
+ public static bool SupportsFolderTarget(string target) => target is "iso" or "zip" or "7z" or "tar" or "tgz" or "tbz2" or "txz";
/// Whether a preset accepts folders without converting their individual files.
public static bool AcceptsFolders(Preset preset) => !preset.IsGroup
@@ -769,7 +769,7 @@ public static List CreateProfiles()
Id = "folders",
NameKey = "profile.folders",
Extensions = [],
- PresetIds = ["to-iso"],
+ PresetIds = ["to-iso", "to-zip", "zip-all"],
BackPresetIds = [],
};
profiles.Insert(profiles.FindIndex(p => p.Id == "archives") + 1, folders);
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 3089b2e56..a9067c42e 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -139,7 +139,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 78;
+ public const int CurrentSchemaVersion = 79;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index d8d7825a8..2dbc4949d 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -1089,6 +1089,13 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "folders") is { } folders
+ && folders.PresetIds.SequenceEqual(new[] { "to-iso" }) && (folders.BackPresetIds?.Count ?? 0) == 0)
+ {
+ foreach (var id in new[] { "to-zip", "zip-all" })
+ if (presets.Any(p => p.Id == id)) { folders.PresetIds.Add(id); changed = true; }
+ }
return changed;
}
diff --git a/src/Filee.Engines/Archives/ArchiveConverter.cs b/src/Filee.Engines/Archives/ArchiveConverter.cs
index e531306cd..1ec9258aa 100644
--- a/src/Filee.Engines/Archives/ArchiveConverter.cs
+++ b/src/Filee.Engines/Archives/ArchiveConverter.cs
@@ -6,6 +6,7 @@
using Filee.Core.Conversion;
using Filee.Core.Formats;
+using Filee.Core.Platform;
using Filee.Core.Presets;
using Filee.Engines.Infrastructure;
@@ -37,6 +38,8 @@ .. from source in SingleFileSources
from target in ArchivePacker.SingleFileFormats
select new ConversionEdge(source, target),
.. ArchiveExtractor.ReadableFormats.Select(source => new ConversionEdge(source, "iso", DirectOnly: true)),
+ .. new[] { "zip", "7z", "tar", "tgz", "tbz2", "txz" }
+ .Select(target => new ConversionEdge(FormatRegistry.Folder, target, DirectOnly: true)),
];
public EngineStatus GetStatus()
@@ -47,6 +50,19 @@ public EngineStatus GetStatus()
public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken)
{
+ if (step.From == FormatRegistry.Folder)
+ {
+ var folder = new DirectoryInfo(Path.GetFullPath(Path.TrimEndingDirectorySeparator(step.InputPath)));
+ if (!folder.Exists) throw new DirectoryNotFoundException(folder.FullName);
+ if (folder.Parent is null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ throw new ConversionRefusedException("error.unsupported_source", folder.FullName);
+ var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
+ if (output is null) return [];
+ var mark = MarkOfTheWeb.ReadFirst([folder.FullName], cancellationToken: cancellationToken);
+ await CombineAsync([folder.FullName], output, step.To, step.Preset, step.WorkDirectory, progress, cancellationToken);
+ if (mark is not null) MarkOfTheWeb.TryWrite(output, mark);
+ return [output];
+ }
var sevenZip = _sevenZip ??= SevenZip.Locate();
var extractor = new ArchiveExtractor(sevenZip);
if (step.To == FormatRegistry.Folder)
diff --git a/src/Filee.Engines/Archives/ArchivePacker.cs b/src/Filee.Engines/Archives/ArchivePacker.cs
index 333af2f68..442d1fe26 100644
--- a/src/Filee.Engines/Archives/ArchivePacker.cs
+++ b/src/Filee.Engines/Archives/ArchivePacker.cs
@@ -105,7 +105,8 @@ private static async Task> NamedAsAsync(IReadOnlyList();
foreach (var item in items)
{
- if (string.Equals(Path.GetFileName(item.SourcePath), item.EntryName, StringComparison.Ordinal))
+ // 7-Zip follows junctions inside directories. Always stage folders through the link-skipping copy.
+ if (!Directory.Exists(item.SourcePath) && string.Equals(Path.GetFileName(item.SourcePath), item.EntryName, StringComparison.Ordinal))
{
paths.Add(item.SourcePath);
continue;
@@ -133,9 +134,13 @@ private static async Task CopyFileAsync(string source, string target, Cancellati
/// Copies a folder tree without following links.
private static async Task CopyFolderAsync(DirectoryInfo source, string target, CancellationToken cancellationToken)
{
+ cancellationToken.ThrowIfCancellationRequested();
+ if (source.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ throw new IOException($"Cannot pack a linked folder: {source.FullName}");
Directory.CreateDirectory(target);
foreach (var info in source.EnumerateFileSystemInfos())
{
+ cancellationToken.ThrowIfCancellationRequested();
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint))
continue;
var destination = Path.Combine(target, info.Name);
diff --git a/tests/Filee.App.Tests/FolderArchives1345Tests.cs b/tests/Filee.App.Tests/FolderArchives1345Tests.cs
new file mode 100644
index 000000000..f3b9ee891
--- /dev/null
+++ b/tests/Filee.App.Tests/FolderArchives1345Tests.cs
@@ -0,0 +1,45 @@
+using Avalonia.Headless.XUnit;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.ViewModels.Pages;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+
+namespace Filee.App.Tests;
+
+public sealed class FolderArchives1345Tests
+{
+ [AvaloniaFact]
+ public void Folder_wheel_and_editor_offer_zip_and_one_zip()
+ {
+ TestServices.EnsureInitialized("zh-TW");
+ var root = Directory.CreateTempSubdirectory("filee-folder-1345-ui-").FullName;
+ try
+ {
+ var folder = Directory.CreateDirectory(Path.Combine(root, "photos.jpg")).FullName;
+ var store = new UserDataStore(Path.Combine(root, "settings"));
+ store.Load();
+ var loc = AppHost.Get();
+ var radial = new RadialViewModel(store, loc, AppHost.Get());
+ radial.Load([folder], clickMode: false);
+ Assert.Equal(["to-iso", "to-zip", "zip-all"], radial.Items.Select(item => item.Id));
+ var archiveAvailable = new Filee.Engines.Archives.ArchiveConverter().GetStatus().IsAvailable;
+ foreach (var id in new[] { "to-zip", "zip-all" })
+ {
+ var preset = store.Presets.Single(p => p.Id == id);
+ var item = radial.Items.Single(i => i.Id == id);
+ Assert.Equal(id == "zip-all" || archiveAvailable, item.IsEnabled);
+ Assert.Equal([folder], RadialController.SourcesFor(preset, [folder]));
+ var editor = new PresetEditorViewModel(preset, loc, folderInput: true);
+ Assert.Equal("zip", editor.Target.Value);
+ Assert.True(editor.ShowArchive);
+ Assert.False(editor.ShowReplaceOriginal);
+ Assert.Equal(id == "zip-all", editor.CombineIntoOne);
+ Assert.DoesNotContain(editor.Targets, target => target.Value is "gz" or "bz2" or "xz" or "png");
+ Assert.Contains(editor.Targets, target => target.Value == "7z");
+ }
+ }
+ finally { Directory.Delete(root, true); }
+ }
+}
diff --git a/tests/Filee.App.Tests/FolderIso632Tests.cs b/tests/Filee.App.Tests/FolderIso632Tests.cs
index a73a96999..ab5f67522 100644
--- a/tests/Filee.App.Tests/FolderIso632Tests.cs
+++ b/tests/Filee.App.Tests/FolderIso632Tests.cs
@@ -171,7 +171,7 @@ public async Task Folder_toolbar_passes_folder_context_to_new_and_existing_prese
var dialog = Assert.Single(windows.Main!.OwnedWindows.OfType());
var editor = Assert.IsType(dialog.DataContext);
Assert.True(editor.FolderInput);
- Assert.Single(editor.Targets);
+ Assert.Equal(7, editor.Targets.Count);
dialog.Close();
await editing.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
@@ -185,13 +185,14 @@ public async Task Folder_toolbar_passes_folder_context_to_new_and_existing_prese
}
[AvaloniaFact]
- public void Folder_editor_only_offers_iso_without_compression_or_filesystem_options()
+ public void Folder_editor_offers_folder_archives_but_keeps_iso_compression_hidden()
{
TestServices.EnsureInitialized("en");
var loc = AppHost.Get();
var preset = new Preset { Name = "Folder action", TargetFormat = "iso", Output = { ReplaceOriginal = true } };
var editor = new PresetEditorViewModel(preset, loc, folderInput: true);
- Assert.Equal("iso", Assert.Single(editor.Targets).Value);
+ Assert.Equal("iso", editor.Target.Value);
+ Assert.Equal(7, editor.Targets.Count);
Assert.False(editor.ShowReplaceOriginal);
// Cancel has not changed the saved preset.
Assert.Equal("iso", preset.TargetFormat);
@@ -209,7 +210,7 @@ public void Folder_editor_only_offers_iso_without_compression_or_filesystem_opti
var newFolder = new PresetEditorViewModel(new Preset { TargetFormat = "png" }, loc, folderInput: true);
Assert.Equal("iso", newFolder.Target.Value);
var availability = AppHost.Get();
- foreach (var target in new[] { "zip", "7z", "tar", "tgz", "tbz2", "txz", "gz", "bz2", "xz", "w64", "eac3" })
+ foreach (var target in new[] { "gz", "bz2", "xz", "w64", "eac3" })
{
var incompatible = new Preset { TargetFormat = target, Archive = { CombineIntoOne = true } };
Assert.Equal(PresetUse.Unusable,
@@ -230,7 +231,7 @@ public void Folder_group_editor_only_offers_folder_actions_without_removing_exis
var editor = new GroupEditorViewModel(group, store, AppHost.Get(), folderInput: true);
Assert.Single(editor.Members);
Assert.Contains(editor.Available, t => t.Value == "to-iso");
- Assert.DoesNotContain(editor.Available, t => t.Value == "zip-all");
+ Assert.Contains(editor.Available, t => t.Value == "zip-all");
Assert.All(editor.Available, t => Assert.True(BuiltInData.AcceptsFolders(store.FindPreset(t.Value)!)));
editor.ToAdd = editor.Available.Single(t => t.Value == "to-iso");
editor.AddCommand.Execute(null);
@@ -308,7 +309,7 @@ public void Folder_input_opens_the_iso_donut_and_editor_shows_no_compression()
var vm = new RadialViewModel(store, AppHost.Get(), AppHost.Get());
vm.Load([folder], clickMode: false);
Assert.Equal("Folders", AppHost.Get()[store.Profiles.Single(p => p.Id == "folders").NameKey!]);
- Assert.Single(vm.Items);
+ Assert.Equal(3, vm.Items.Count);
var item = Assert.Single(vm.Items, i => i.Id == "to-iso");
Assert.Equal("to-iso", item.Id);
Assert.True(item.IsEnabled, item.DisabledReason);
@@ -324,7 +325,7 @@ public void Folder_input_opens_the_iso_donut_and_editor_shows_no_compression()
Assert.Null(view.FindControl("IsoCard"));
Assert.False(view.FindControl("ArchiveCard")!.IsVisible);
var targets = view.FindControl("TargetBox")!;
- Assert.Equal(1, targets.ItemCount);
+ Assert.Equal(7, targets.ItemCount);
Assert.DoesNotContain(targets.Items.OfType>(), t => t.Value is "w64" or "eac3" or "gz" or "bz2" or "xz");
Assert.Null(view.FindControl("IsoStandardBox"));
RenderTests.Pump();
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
index fa74b2169..1c5341a70 100644
--- a/tests/Filee.Cli.Tests/CliTests.cs
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -16,6 +16,21 @@ namespace Filee.Cli.Tests;
[Collection("Cli")] // shares EngineEnvironment state with EnginesCommandTests
public sealed class CliTests : IDisposable
{
+ [Fact]
+ public async Task Folder_to_zip_keeps_the_folder_tree_instead_of_converting_its_individual_files()
+ {
+ Assert.SkipUnless(new Filee.Engines.Archives.ArchiveConverter().GetStatus().IsAvailable, "Bundled 7-Zip is required.");
+ var folder = Directory.CreateDirectory(Path.Combine(_folder, "photos.jpg")).FullName;
+ Directory.CreateDirectory(Path.Combine(folder, "empty"));
+ await File.WriteAllTextAsync(Path.Combine(folder, "input.txt"), "folder contents", TestContext.Current.CancellationToken);
+ var result = await Run("convert", folder, "--to", "zip", "--output", _folder);
+ Assert.True(result.Code == 0, result.Errors + result.Output);
+ using var archive = System.IO.Compression.ZipFile.OpenRead(folder + ".zip");
+ Assert.NotNull(archive.GetEntry("photos.jpg/empty/"));
+ using var reader = new StreamReader(archive.GetEntry("photos.jpg/input.txt")!.Open());
+ Assert.Equal("folder contents", reader.ReadToEnd());
+ }
+
[Fact]
public async Task Folder_to_iso_uses_the_folder_itself_and_preserves_a_dotted_name()
{
diff --git a/tests/Filee.Core.Tests/BackFaceTests.SlotTable.cs b/tests/Filee.Core.Tests/BackFaceTests.SlotTable.cs
index a867d7a46..59afd050c 100644
--- a/tests/Filee.Core.Tests/BackFaceTests.SlotTable.cs
+++ b/tests/Filee.Core.Tests/BackFaceTests.SlotTable.cs
@@ -22,7 +22,7 @@ public partial class BackFaceTests
["subtitles"] = (["to-srt", "to-vtt"], ["zip-all"]),
["vector"] = (["to-png", "to-pdf", "to-svg", "to-jpg", "to-webp"], ["to-tiff", "to-ico", "to-icns", "to-dxf", "zip-all"]),
["archives"] = (["extract", "zip-all", "to-zip", "to-7z"], []),
- ["folders"] = (["to-iso"], []),
+ ["folders"] = (["to-iso", "to-zip", "zip-all"], []),
["cad"] = (["to-pdf", "to-png", "to-svg", "to-dxf", "to-dwg"], ["to-jpg", "merge-pdf", "zip-all"]),
["fonts"] = (["to-woff2", "to-woff", "to-ttf", "to-otf"], ["zip-all"]),
["data"] = (["to-json", "to-yaml", "to-xml", "to-csv", "to-xlsx"], ["to-ods", "to-html", "to-md", "zip-all"]),
@@ -69,7 +69,7 @@ public void A_new_library_has_only_conversions_on_its_fronts()
foreach (var profile in BuiltInData.CreateProfiles())
foreach (var id in profile.PresetIds)
foreach (var work in WorkOf(profile.Id, id, presets))
- Assert.True(work == 'V' || work == 'B' && profile.Id is "archives" or "mixed", $"{profile.Id}/front: {id} is {work}");
+ Assert.True(work == 'V' || work == 'B' && profile.Id is "archives" or "folders" or "mixed", $"{profile.Id}/front: {id} is {work}");
}
[Fact]
diff --git a/tests/Filee.Core.Tests/FolderArchives1345Tests.cs b/tests/Filee.Core.Tests/FolderArchives1345Tests.cs
new file mode 100644
index 000000000..fdacca2d1
--- /dev/null
+++ b/tests/Filee.Core.Tests/FolderArchives1345Tests.cs
@@ -0,0 +1,49 @@
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+
+namespace Filee.Core.Tests;
+
+public sealed class FolderArchives1345Tests
+{
+ [Theory]
+ [InlineData("zip")]
+ [InlineData("7z")]
+ [InlineData("tar")]
+ [InlineData("tgz")]
+ [InlineData("tbz2")]
+ [InlineData("txz")]
+ public void Folder_presets_accept_only_container_archives(string target)
+ {
+ Assert.True(BuiltInData.AcceptsFolders(new Preset { TargetFormat = target }));
+ foreach (var single in new[] { "gz", "bz2", "xz", "pdf" })
+ Assert.False(BuiltInData.AcceptsFolders(new Preset { TargetFormat = single }));
+ }
+
+ [Fact]
+ public void Default_and_existing_iso_only_folders_offer_zip_and_one_zip()
+ {
+ var presets = BuiltInData.CreatePresets();
+ var profiles = BuiltInData.CreateProfiles();
+ var folders = profiles.Single(p => p.Id == "folders");
+ Assert.Equal(["to-iso", "to-zip", "zip-all"], folders.PresetIds);
+ folders.PresetIds = ["to-iso"];
+ Assert.True(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
+ Assert.Equal(["to-iso", "to-zip", "zip-all"], folders.PresetIds);
+ Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
+ Assert.Single(presets, p => p.Id == "to-zip");
+ }
+
+ [Fact]
+ public void Migration_preserves_custom_or_deleted_folder_profiles()
+ {
+ var presets = BuiltInData.CreatePresets();
+ var profiles = BuiltInData.CreateProfiles();
+ var folders = profiles.Single(p => p.Id == "folders");
+ folders.PresetIds = ["zip-all", "to-iso"];
+ Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
+ Assert.Equal(["zip-all", "to-iso"], folders.PresetIds);
+ profiles.Remove(folders);
+ Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles));
+ Assert.DoesNotContain(profiles, p => p.Id == "folders");
+ }
+}
diff --git a/tests/Filee.Core.Tests/FolderIso632Tests.cs b/tests/Filee.Core.Tests/FolderIso632Tests.cs
index 472cca230..50cb8d5f2 100644
--- a/tests/Filee.Core.Tests/FolderIso632Tests.cs
+++ b/tests/Filee.Core.Tests/FolderIso632Tests.cs
@@ -56,7 +56,7 @@ public void Migration_adds_the_folder_donut_without_enabling_iso_on_archives_or_
Assert.True(SettingsMigrations.ApplyToLibrary(73, presets, profiles));
Assert.Equal(presets.FindIndex(p => p.Id == "zip-all") - 1, presets.FindIndex(p => p.Id == "to-iso"));
Assert.Equal(original, archives.PresetIds);
- Assert.Equal(["to-iso"],
+ Assert.Equal(["to-iso", "to-zip", "zip-all"],
profiles.Single(p => p.Id == "folders").PresetIds);
presets.Single(p => p.Id == "to-iso").Name = "My ISO";
Assert.False(SettingsMigrations.ApplyToLibrary(73, presets, profiles));
diff --git a/tests/Filee.Engines.Tests/FolderArchives1345Tests.cs b/tests/Filee.Engines.Tests/FolderArchives1345Tests.cs
new file mode 100644
index 000000000..07ccfb044
--- /dev/null
+++ b/tests/Filee.Engines.Tests/FolderArchives1345Tests.cs
@@ -0,0 +1,150 @@
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Platform;
+using Filee.Core.Presets;
+using Filee.Engines.Archives;
+
+namespace Filee.Engines.Tests;
+
+public sealed class FolderArchives1345Tests : IDisposable
+{
+ private readonly string _root = Directory.CreateTempSubdirectory("filee-folder-archives-").FullName;
+ public void Dispose() => Directory.Delete(_root, true);
+
+ private string Folder(string name)
+ {
+ var folder = Directory.CreateDirectory(Path.Combine(_root, name)).FullName;
+ Directory.CreateDirectory(Path.Combine(folder, "empty"));
+ File.WriteAllText(Path.Combine(folder, "日本語.txt"), name);
+ return folder;
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Zip_creates_one_archive_per_folder_or_one_for_the_whole_selection(bool combine)
+ {
+ Assert.SkipUnless(SevenZip.Locate() is not null, "The archive converter uses bundled 7-Zip.");
+ string[] folders = [Folder("first"), Folder("second")];
+ var converter = new ArchiveConverter();
+ await using var queue = new JobQueue(new ConverterCatalog([converter]), combiner: converter);
+ var job = new ConversionJob(folders, new Preset
+ {
+ TargetFormat = "zip",
+ Archive = { CombineIntoOne = combine },
+ Output = { Location = OutputLocation.SameFolder }
+ }, "ZIP", folderSources: folders.ToHashSet());
+ await queue.RunAsync(job);
+ Assert.True(job.State == JobState.Completed, string.Join("\n", job.Files.Select(file => file.ErrorDetail)));
+ Assert.Equal(combine ? 1 : 2, job.Outputs.Count());
+ }
+
+ [Fact]
+ public async Task Folder_zip_keeps_the_mark_of_an_included_downloaded_file()
+ {
+ Assert.SkipUnless(OperatingSystem.IsWindows(), "Mark of the Web is Windows-only.");
+ Assert.SkipUnless(SevenZip.Locate() is not null, "The archive converter uses bundled 7-Zip.");
+ var folder = Folder("downloaded");
+ var source = Path.Combine(folder, "日本語.txt");
+ MarkOfTheWeb.TryWrite(source, MarkOfTheWeb.Internet());
+ Assert.SkipUnless(MarkOfTheWeb.Read(source) is not null, "Filesystem must support alternate data streams.");
+ var converter = new ArchiveConverter();
+ await using var queue = new JobQueue(new ConverterCatalog([converter]), combiner: converter);
+ var job = new ConversionJob([folder], new Preset { TargetFormat = "zip", Output = { Location = OutputLocation.SameFolder } },
+ "ZIP", folderSources: new HashSet { folder });
+ await queue.RunAsync(job);
+ Assert.Equal(JobState.Completed, job.State);
+ Assert.Equal(MarkOfTheWeb.Internet(), MarkOfTheWeb.Read(Assert.Single(job.Outputs)));
+ }
+
+ [Theory]
+ [InlineData("zip", false)]
+ [InlineData("7z", false)]
+ [InlineData("tar", false)]
+ [InlineData("tgz", false)]
+ [InlineData("tbz2", false)]
+ [InlineData("txz", false)]
+ [InlineData("zip", true)]
+ public async Task Folder_archives_round_trip_names_contents_and_empty_folders(string format, bool combine)
+ {
+ var sevenZip = SevenZip.Locate();
+ Assert.SkipUnless(sevenZip is not null, "Bundled 7-Zip is required for the independent archive check.");
+ var one = Folder("photos.jpg");
+ var two = Folder("second");
+ string[] sources = combine ? [one, two] : [one];
+ var converter = new ArchiveConverter();
+ var catalog = new ConverterCatalog([converter]);
+ Assert.True(converter.GetStatus().IsAvailable);
+ await using var queue = new JobQueue(catalog, combiner: converter);
+ var preset = new Preset
+ {
+ TargetFormat = format,
+ Archive = { CombineIntoOne = combine },
+ Output = { Location = OutputLocation.SameFolder }
+ };
+ var job = new ConversionJob(sources, preset, "archive", folderSources: sources.ToHashSet());
+ await queue.RunAsync(job);
+ Assert.True(job.State == JobState.Completed, string.Join("\n", job.Files.Select(f => f.ErrorDetail)));
+ var output = Assert.Single(job.Outputs);
+ if (!combine) Assert.EndsWith("photos.jpg." + FormatRegistry.Get(format).PrimaryExtension, output);
+ var extracted = await new ArchiveExtractor(sevenZip).ExtractAsync(output, format, _root, TestContext.Current.CancellationToken);
+ foreach (var folder in sources)
+ {
+ var name = Path.GetFileName(folder);
+ Assert.Equal(name, File.ReadAllText(Path.Combine(extracted, name, "日本語.txt")));
+ Assert.True(Directory.Exists(Path.Combine(extracted, name, "empty")));
+ Assert.Equal(name, File.ReadAllText(Path.Combine(folder, "日本語.txt")));
+ }
+ }
+
+ [Theory]
+ [InlineData("zip")]
+ [InlineData("7z")]
+ [InlineData("tar")]
+ [InlineData("tgz")]
+ [InlineData("tbz2")]
+ [InlineData("txz")]
+ public async Task Nested_links_never_pack_files_outside_the_selected_folder(string format)
+ {
+ var sevenZip = SevenZip.Locate();
+ Assert.SkipUnless(sevenZip is not null, "Bundled 7-Zip is required to inspect all archive formats.");
+ var folder = Folder("selected");
+ var outside = Directory.CreateDirectory(Path.Combine(_root, "outside")).FullName;
+ File.WriteAllText(Path.Combine(outside, "private.txt"), "do not archive");
+ var link = Path.Combine(folder, "link");
+ if (OperatingSystem.IsWindows())
+ {
+ using var junction = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd.exe", ["/c", "mklink", "/J", link, outside])
+ { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true })!;
+ junction.WaitForExit();
+ Assert.Equal(0, junction.ExitCode);
+ }
+ else Directory.CreateSymbolicLink(link, outside);
+ try
+ {
+ var archive = Path.Combine(_root, "output." + format);
+ var work = Directory.CreateDirectory(Path.Combine(_root, "work")).FullName;
+ await new ArchivePacker(sevenZip).PackAsync([new PackItem(folder, "selected")], archive, format,
+ ArchiveLevel.Fast, work, null, TestContext.Current.CancellationToken);
+ var extracted = await new ArchiveExtractor(sevenZip).ExtractAsync(archive, format, work, TestContext.Current.CancellationToken);
+ Assert.False(Directory.Exists(Path.Combine(extracted, "selected", "link")));
+ Assert.DoesNotContain(Directory.GetFiles(extracted, "*", SearchOption.AllDirectories), file => Path.GetFileName(file) == "private.txt");
+ Assert.True(File.Exists(Path.Combine(extracted, "selected", "日本語.txt")));
+ }
+ finally { Directory.Delete(link); }
+ }
+
+ [Fact]
+ public async Task Cancellation_preserves_an_existing_destination()
+ {
+ var folder = Folder("selected");
+ var output = Path.Combine(_root, "output.zip");
+ File.WriteAllText(output, "previous archive");
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => new ArchivePacker(null).PackAsync(
+ [new PackItem(folder, "selected")], output, "zip", ArchiveLevel.Normal, _root, null, cancellation.Token));
+ Assert.Equal("previous archive", File.ReadAllText(output));
+ Assert.Empty(Directory.GetFiles(_root, "*.tmp"));
+ }
+}