diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50ab67e0..7e40c5e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -133,8 +133,9 @@ second with the Engines page's `TransferRate` and units, rewritten four times a - **Mark of the Web** (`Platform/MarkOfTheWeb`, Windows): every saved file gets a `Zone.Identifier` stream as a browser's download would (`ZoneId=3`, and `HostUrl` with the address a download came from, without sign-in or fragment), so SmartScreen, Office's block on macros from the internet and Protected View still apply. `JobQueue` - copies a source's mark onto the files converted from it (also for "Save only the converted file"); a folder result - is left to its engine (the archive engine marks what it unpacked). A drive without NTFS streams gets no mark, and + copies a source's mark onto the files converted from it (also for "Save only the converted file"), and onto an + archive packed from many files when any of them has one (files inside folders too, links not followed); a folder + result is left to its engine (the archive engine marks what it unpacked). A drive without NTFS streams gets no mark, and that never fails a conversion. ## Donut control diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 558435c9..b23ef387 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -663,7 +663,11 @@ private async Task RunCombinedAsync(ConversionJob job, string workDir) files.Add(file); } } + // An archive with a file from the internet in it still came from there, whichever file that was. + var mark = Platform.MarkOfTheWeb.ReadFirst(files, _log, job.CancellationToken); await _combiner!.CombineAsync(files, output, format.Id, job.Preset, workDir, progress, job.CancellationToken); + if (mark is not null && File.Exists(output)) + Platform.MarkOfTheWeb.TryWrite(output, mark, _log); inputs[0].Outputs.Add(output); foreach (var f in inputs) { diff --git a/src/Filee.Core/Platform/MarkOfTheWeb.cs b/src/Filee.Core/Platform/MarkOfTheWeb.cs index ca539363..43fba87f 100644 --- a/src/Filee.Core/Platform/MarkOfTheWeb.cs +++ b/src/Filee.Core/Platform/MarkOfTheWeb.cs @@ -49,6 +49,34 @@ public static string Internet(Uri? hostUrl = null, Uri? referrerUrl = null) } } + /// + /// The first mark among the files and the files in the folders among them, or null: for a + /// result made of all of them. Links inside a folder aren't followed, and a folder that can't be read counts as unmarked. + /// + public static string? ReadFirst(IEnumerable paths, ILogger? log = null, CancellationToken cancellationToken = default) + { + if (!OperatingSystem.IsWindows()) + return null; + var inFolder = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint }; + foreach (var path in paths) + { + try + { + foreach (var file in Directory.Exists(path) ? Directory.EnumerateFiles(path, "*", inFolder) : [path]) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Read(file) is { } mark) + return mark; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogInformation("Could not look for marks in {Folder}: {Reason}", path, ex.Message); + } + } + return null; + } + /// /// Gives the mark . False when the file or its drive can't take it; /// the file itself stays as it is. diff --git a/tests/Filee.Core.Tests/JobQueueTests.cs b/tests/Filee.Core.Tests/JobQueueTests.cs index add4df45..9272adb7 100644 --- a/tests/Filee.Core.Tests/JobQueueTests.cs +++ b/tests/Filee.Core.Tests/JobQueueTests.cs @@ -47,6 +47,72 @@ public async Task An_output_converted_from_a_file_from_the_internet_keeps_its_ma Assert.Null(MarkOfTheWeb.Read(Path.Combine(dir.Path, "b.png"))); } + [Fact] + public async Task An_archive_packed_from_a_file_from_the_internet_has_its_mark_of_the_web() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The Mark of the Web is a Windows (NTFS) stream."); + using var dir = new TempDir(); + var plain = dir.File("plain.txt"); + var marked = dir.File("marked.txt"); + var mark = MarkOfTheWeb.Internet(new Uri("https://example.com/marked.txt")); + Assert.SkipUnless(MarkOfTheWeb.TryWrite(marked, mark), "The temp drive holds no NTFS streams."); + var folder = Directory.CreateDirectory(Path.Combine(dir.Path, "folder", "inner")).Parent!.FullName; + Assert.True(MarkOfTheWeb.TryWrite(WriteFile(Path.Combine(folder, "inner", "deep.txt")), mark)); + await using var queue = new JobQueue(new ConverterCatalog([]), combiner: new FakeCombiner()); + var preset = new Preset { TargetFormat = "zip", Archive = { CombineIntoOne = true } }; + + // The marked file comes second, and the archive is named after the first. + var mixed = await Run(queue, [plain, marked], preset); + Assert.Equal(JobState.Completed, mixed.State); + Assert.Equal(mark, MarkOfTheWeb.Read(mixed.Outputs.Single())); + + var fromFolder = await Run(queue, [folder], preset); + Assert.Equal(mark, MarkOfTheWeb.Read(fromFolder.Outputs.Single())); + + var unmarked = await Run(queue, [dir.File("other.txt")], preset); + Assert.Null(MarkOfTheWeb.Read(unmarked.Outputs.Single())); + + static string WriteFile(string path) + { + File.WriteAllText(path, "x"); + return path; + } + } + + [Fact] + public async Task A_link_in_a_folder_being_packed_gives_the_archive_no_mark_of_the_web() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The Mark of the Web is a Windows (NTFS) stream."); + using var dir = new TempDir(); + using var outside = new TempDir(); + Assert.SkipUnless(MarkOfTheWeb.TryWrite(outside.File("elsewhere.txt"), MarkOfTheWeb.Internet()), "The temp drive holds no NTFS streams."); + var folder = Directory.CreateDirectory(Path.Combine(dir.Path, "folder")).FullName; + dir.File(Path.Combine("folder", "here.txt")); + var link = Path.Combine(folder, "link"); + // A junction: unlike a symbolic link, it takes no privilege, and the packer leaves both out alike. + using var mklink = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("cmd.exe", ["/c", "mklink", "/J", link, outside.Path]) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + })!; + await mklink.WaitForExitAsync(TestContext.Current.CancellationToken); + Assert.SkipUnless(mklink.ExitCode == 0, "This drive can't hold a junction."); + try + { + await using var queue = new JobQueue(new ConverterCatalog([]), combiner: new FakeCombiner()); + + var job = await Run(queue, [folder], new Preset { TargetFormat = "zip", Archive = { CombineIntoOne = true } }); + + Assert.Equal(JobState.Completed, job.State); + Assert.Null(MarkOfTheWeb.Read(job.Outputs.Single())); + } + finally + { + Directory.Delete(link); // the link only, before the folders are deleted + } + } + [Fact] public async Task Missing_fonts_from_every_step_are_combined_without_blocking_conversion() {