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()
{