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")); + } +}