From 0661e0b0a295659640306662f2d38ba81fd0c95f Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 12 Nov 2026 03:54:08 +0900 Subject: [PATCH] feat(archive): read Adobe extension packages (.zxp) like JAR A .zxp, the package of an After Effects, Premiere Pro or Photoshop extension, is a signed ZIP: the bundled 7-Zip extracts it and it converts to every archive target, its META-INF/signatures.xml kept as a file. It is never a target, since Adobe's apps install only packages signed with the developer's certificate. Settings schema 82 adds .zxp to existing archives donuts without taking custom claims (80 and 81 are claimed by #1390 and #1376). Closes #1382 Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 6 +- src/Filee.Core/Formats/FormatFields.cs | 4 +- src/Filee.Core/Formats/FormatRegistry.cs | 3 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + .../Archives/ArchiveExtractor.cs | 2 +- tests/Filee.Core.Tests/FormatFieldsTests.cs | 8 +- tests/Filee.Core.Tests/ZxpFormatTests.cs | 76 +++++++++++++++++++ tests/Filee.Engines.Tests/ArchiveIsoTests.cs | 3 +- tests/Filee.Engines.Tests/ArchiveTests.cs | 43 +++++++++++ 10 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 tests/Filee.Core.Tests/ZxpFormatTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 5cea73c2f..06e57df59 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1534,7 +1534,7 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep | Format | Read | Write | |---|---|---| -| ZIP, JAR, WBPZ | 7-Zip | ZIP in-process (System.IO.Compression): UTF-8 names with the language-encoding flag, times, empty folders | +| ZIP, JAR, WBPZ, ZXP | 7-Zip | ZIP in-process (System.IO.Compression): UTF-8 names with the language-encoding flag, times, empty folders | | Minecraft MCPACK, MCADDON, MCWORLD, MCTEMPLATE; Modrinth MRPACK | 7-Zip (they are ZIPs) | ZIP in-process, with the marker file at the top (see below) | | 7Z | 7-Zip | 7-Zip | | TAR | 7-Zip | in-process (System.Formats.Tar, PAX: UTF-8 names, times) | @@ -1561,6 +1561,10 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep an archive or a folder whose only top folder holds the marker gets that folder's content, not the folder (`GamePacks.Root`); an add-on (MCADDON, several packs side by side) is packed as it is. The presets (MCPACK … MRPACK) are in the palette only. +- **Adobe extension packages** (`.zxp`, #1382, read only like JAR): the panels and plug-ins of After Effects, Premiere + Pro, Photoshop and the other Creative Cloud apps are signed ZIPs (`mimetype`, the extension's files and + `META-INF/signatures.xml`). They extract and convert to every archive target with the signature file inside. + Nothing writes `.zxp`: Adobe's apps install only packages signed with the developer's certificate (ZXPSignCmd). - **Compress into one archive** (`Archive.CombineIntoOne`, the "One ZIP" preset): `JobQueue` hands all dropped files, of any type and unconverted, to `IFileCombiner` (implemented by this engine). The archive is named after the first file with the output pattern; files with the same name become `name (2).ext`. ZIP, TAR and TAR.GZ need no 7-Zip. diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index d1640b1d1..4a3212806 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -79,7 +79,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man", "jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik", "h265", "h264", "ac3", "caf", "mp2", "dss", "wv", "tta", "w64", "eac3", "ape", "tak", "dsf", "dff", - "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "mcpack", "mcaddon", + "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "zxp", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", "cab", "cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf", "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm" @@ -107,7 +107,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow ([ "ppm", "icns", "qoi", "cur", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man", "jsonl", "yaml", "toml", "har", "plist", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", - "lz", "lzma", "jar", "cab", "cpio", "deb", "rpm" + "lz", "lzma", "jar", "zxp", "cab", "cpio", "deb", "rpm" ], ["developer"]), // Markup sources, papers and outlines, maintenance-doc drawings (CGM), Calibre's ebook formats. (["cgm", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "textile", "htmlz", "txtz"], ["writing"]), diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 6b4282b5a..296b13733 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -317,6 +317,9 @@ public static class FormatRegistry new("lzma", "LZMA", FormatCategory.Archive, ["lzma"], Writable: false), new("z", "Z", FormatCategory.Archive, ["z"], Writable: false), new("jar", "JAR", FormatCategory.Archive, ["jar"], Writable: false), + // Adobe extension packages for After Effects, Premiere Pro, Photoshop … (#1382): signed ZIPs. Never a target, + // since installing one needs the signature its developer makes with Adobe's ZXPSignCmd. + new("zxp", "ZXP (Adobe extension)", FormatCategory.Archive, ["zxp"], Writable: false), // Minecraft Bedrock packs, add-ons, worlds and world templates, and Modrinth modpacks (#824, #898): ZIP archives // under their own extensions, written with their marker file (manifest.json, level.dat, modrinth.index.json) at // the top. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 1d7e2acd3..081b7791d 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 = 79; + public const int CurrentSchemaVersion = 82; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 1311c1f3d..1e8c244b7 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1097,6 +1097,10 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id != BuiltInData.RingtoneId)) changed |= AddLikeNewLibrary(presets, BuiltInData.RingtoneId); } + // v82: Adobe extension packages (.zxp, #1382) learn the archives donut; custom claims stay put. (Schema 82 may be + // renumbered if another change takes it first.) + if (from < 82) + changed |= LearnExtensions(profiles, out _, ["zxp"]); return changed; } diff --git a/src/Filee.Engines/Archives/ArchiveExtractor.cs b/src/Filee.Engines/Archives/ArchiveExtractor.cs index 5d3a6def1..b33faee93 100644 --- a/src/Filee.Engines/Archives/ArchiveExtractor.cs +++ b/src/Filee.Engines/Archives/ArchiveExtractor.cs @@ -19,7 +19,7 @@ internal sealed class ArchiveExtractor(SevenZip? sevenZip, Func? s /// Formats read by 7-Zip. public static readonly IReadOnlyList SevenZipFormats = [ - "zip", "jar", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", + "zip", "jar", "zxp", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", "lha", "arj", "split", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", ]; diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index 23476e2c0..3601f36b4 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -36,7 +36,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", - "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xpm", "xz", "yaml", "z" + "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xpm", "xz", "yaml", "z", "zxp" ]; public static TheoryData HiddenWithOneFieldOff => new() @@ -51,7 +51,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "developer", [ "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", - "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" + "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml", "zxp" ] }, { "writing", ["adoc", "cgm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, { "video-pro", [ @@ -78,7 +78,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "developer", [ "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", - "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" + "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml", "zxp" ] }, { "writing", ["adoc", "cgm", "chm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, { "video-pro", [ @@ -145,7 +145,7 @@ public void Grade_counts_match_the_published_table() { [FormatGrade.Everyday] = 19, [FormatGrade.General] = 97, - [FormatGrade.Specialist] = 115, + [FormatGrade.Specialist] = 116, [FormatGrade.Legacy] = 51, }, counts); diff --git a/tests/Filee.Core.Tests/ZxpFormatTests.cs b/tests/Filee.Core.Tests/ZxpFormatTests.cs new file mode 100644 index 000000000..547c2e78f --- /dev/null +++ b/tests/Filee.Core.Tests/ZxpFormatTests.cs @@ -0,0 +1,76 @@ +// Adobe extension packages (.zxp, #1382): a read-only archive on the archives donut, learned by existing libraries. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class ZxpFormatTests +{ + [Fact] + public void Zxp_is_an_archive_filee_reads_but_never_writes() + { + var zxp = FormatRegistry.Detect("Panel.ZXP"); + Assert.NotNull(zxp); + Assert.Equal(("zxp", FormatCategory.Archive, false), (zxp.Id, zxp.Category, zxp.Writable)); + Assert.Equal("archives", ProfileSelector.Select(BuiltInData.CreateProfiles(), ["Panel.zxp"])?.Id); + } + + [Fact] + public void Version_81_libraries_learn_zxp_once() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var archives = profiles.Single(p => p.Id == "archives"); + archives.Extensions.Remove("zxp"); + + Assert.True(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + + Assert.Contains("zxp", archives.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + } + + [Fact] + public void A_version_81_library_leaves_zxp_on_the_users_own_donut() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var archives = profiles.Single(p => p.Id == "archives"); + archives.Extensions.Remove("zxp"); + profiles.Insert(0, new ToolbarProfile { Id = "my-extensions", Name = "Extensions", Extensions = ["ZXP"] }); + + Assert.False(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + + Assert.DoesNotContain("zxp", archives.Extensions, StringComparer.OrdinalIgnoreCase); + Assert.Equal("my-extensions", ProfileSelector.Select(profiles, ["panel.zxp"])?.Id); + } + + [Fact] + public void A_saved_version_81_library_learns_zxp_on_the_next_start_only() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Settings.SchemaVersion = 81; + foreach (var profile in store.Profiles) + profile.Extensions.Remove("zxp"); + store.SaveLibrary(); + store.SaveSettings(); + + var upgraded = new UserDataStore(dir.Path); + upgraded.Load(); + var archives = upgraded.Profiles.Single(p => p.Id == "archives").Extensions; + Assert.Contains("zxp", archives); + Assert.Equal(AppSettings.CurrentSchemaVersion, upgraded.Settings.SchemaVersion); + + // The user takes it off the archives donut again: the start after that leaves it off. + archives.Remove("zxp"); + upgraded.SaveLibrary(); + upgraded.SaveSettings(); + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + Assert.DoesNotContain("zxp", reloaded.Profiles.Single(p => p.Id == "archives").Extensions); + } +} diff --git a/tests/Filee.Engines.Tests/ArchiveIsoTests.cs b/tests/Filee.Engines.Tests/ArchiveIsoTests.cs index af984afd5..cef114909 100644 --- a/tests/Filee.Engines.Tests/ArchiveIsoTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveIsoTests.cs @@ -62,6 +62,7 @@ public void Every_supported_archive_has_a_direct_iso_route(string format) [InlineData("alz")] [InlineData("egg")] [InlineData("jar")] + [InlineData("zxp")] public async Task Archives_become_iso_with_nested_unicode_files_and_empty_directories(string format) { Assert.SkipUnless(SevenZip.Locate() is not null, "7-Zip is required."); @@ -76,7 +77,7 @@ public async Task Archives_become_iso_with_nested_unicode_files_and_empty_direct Directory.CreateDirectory(Path.Combine(tree, "nested")); Directory.CreateDirectory(Path.Combine(tree, "empty")); File.WriteAllText(Path.Combine(tree, "nested", "한글.txt"), "contents"); - await new ArchiveConverter().CombineAsync(Directory.GetFileSystemEntries(tree), input, format == "jar" ? "zip" : format, + await new ArchiveConverter().CombineAsync(Directory.GetFileSystemEntries(tree), input, format is "jar" or "zxp" ? "zip" : format, new Preset(), _root, null, TestContext.Current.CancellationToken); } var original = File.ReadAllBytes(input); diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs index b042433d5..0e50e5b44 100644 --- a/tests/Filee.Engines.Tests/ArchiveTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveTests.cs @@ -38,6 +38,7 @@ private async Task ConvertOkAsync(string source, Preset preset) [Theory] [InlineData("zip")] [InlineData("jar")] + [InlineData("zxp")] [InlineData("7z")] [InlineData("rar")] [InlineData("tgz")] @@ -829,4 +830,46 @@ public async Task A_wbpz_that_is_no_archive_fails() Assert.Equal(JobState.Failed, job.State); Assert.Empty(job.Outputs); } + + // ───────────────────────── Adobe extension packages (issue #1382) ───────────────────────── + + [Fact] + public void A_zxp_is_read_but_never_written() + { + var planner = fx.Catalog.CreatePlanner(["archive"]); + Assert.Null(planner.Plan("zip", "zxp")); + Assert.Null(planner.Plan("7z", "zxp")); + } + + [Fact] + public async Task An_adobe_zxp_extracts_and_converts_to_zip_with_its_signature_file() + { + SkipWithout7Zip(); + var input = Path.Combine(fx.NewFolder(), "panel.zxp"); + // The layout ZXPSignCmd writes: the mimetype first, the extension's files, the signature in META-INF. + var expected = new Dictionary + { + ["mimetype"] = Encoding.ASCII.GetBytes("application/vnd.adobe.air-ucf-package+zip"), + ["CSXS/manifest.xml"] = Encoding.UTF8.GetBytes(""), + ["client/index.html"] = Encoding.UTF8.GetBytes("패널"), + ["META-INF/signatures.xml"] = Encoding.UTF8.GetBytes(""), + }; + using (var archive = ZipFile.Open(input, ZipArchiveMode.Create)) + foreach (var (name, bytes) in expected) + { + using var entry = archive.CreateEntry(name, name == "mimetype" ? CompressionLevel.NoCompression : CompressionLevel.Optimal).Open(); + entry.Write(bytes); + } + var original = await File.ReadAllBytesAsync(input, Ct); + + var folder = (await ConvertOkAsync(input, Extract)).Outputs.Single(); + foreach (var (name, bytes) in expected) + Assert.Equal(bytes, await File.ReadAllBytesAsync(Path.Combine(folder, name), Ct)); + + var zip = (await ConvertOkAsync(input, new Preset { TargetFormat = "zip" })).Outputs.Single(); + Assert.EndsWith("panel.zip", zip); + using var output = ZipFile.OpenRead(zip); + Assert.Equal(expected.Keys.Order(), output.Entries.Where(e => !e.FullName.EndsWith('/')).Select(e => e.FullName).Order()); + Assert.Equal(original, await File.ReadAllBytesAsync(input, Ct)); + } }