From 6f178566040ea4d86faf78b43aa6ea88e94d7844 Mon Sep 17 00:00:00 2001 From: nilsk Date: Tue, 21 Jul 2026 15:34:49 +0200 Subject: [PATCH] extract ZIP handling logic into PluginArchive utility class for reuse and simplify PluginFactory implementation --- .../Tools/PluginSystem/PluginArchive.cs | 69 +++++++++++++++++++ .../PluginSystem/PluginFactory.Download.cs | 63 +---------------- 2 files changed, 71 insertions(+), 61 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs new file mode 100644 index 00000000..670d0289 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs @@ -0,0 +1,69 @@ +using System.IO.Compression; + +namespace AIStudio.Tools.PluginSystem; + +public static class PluginArchive +{ + // Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620). + // See documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md. + public static void Extract(string sourceArchiveFileName, string destinationDirectory) + { + using var archive = ZipFile.OpenRead(sourceArchiveFileName); + Directory.CreateDirectory(destinationDirectory); + + var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory); + if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar)) + destinationDirectoryFullPath += Path.DirectorySeparatorChar; + + foreach (var entry in archive.Entries) + { + var normalizedEntryName = NormalizeEntryName(entry.FullName); + var destinationPath = GetEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName); + + if (normalizedEntryName.EndsWith('/')) + { + if (entry.Length != 0) + throw new InvalidDataException($"The plugin archive contains a directory entry with data: '{entry.FullName}'."); + + Directory.CreateDirectory(destinationPath); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + entry.ExtractToFile(destinationPath); + } + } + + private static string NormalizeEntryName(string entryName) + { + var normalizedEntryName = entryName.Replace('\\', '/'); + if (string.IsNullOrWhiteSpace(normalizedEntryName)) + throw new InvalidDataException("The plugin archive contains an empty entry name."); + + if (normalizedEntryName.Contains('\0')) + throw new InvalidDataException($"The plugin archive contains an invalid entry name: '{entryName}'."); + + if (normalizedEntryName.StartsWith('/')) + throw new InvalidDataException($"The plugin archive contains a rooted entry name: '{entryName}'."); + + if (normalizedEntryName is [_, ':', ..]) + throw new InvalidDataException($"The plugin archive contains a drive-qualified entry name: '{entryName}'."); + + var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or "..")) + throw new InvalidDataException($"The plugin archive contains an unsafe entry name: '{entryName}'."); + + return normalizedEntryName; + } + + private static string GetEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName) + { + var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries); + var relativePath = Path.Combine(pathSegments); + var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath)); + if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal)) + throw new InvalidDataException($"The plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'."); + + return destinationPath; + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index 89dacd79..648eced1 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -1,4 +1,3 @@ -using System.IO.Compression; using System.Net.Http.Headers; namespace AIStudio.Tools.PluginSystem; @@ -130,69 +129,11 @@ public static partial class PluginFactory return wasSuccessful; } - // Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620). - // See documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md. private static void ExtractConfigPluginArchive(string sourceArchiveFileName, string destinationDirectory) { - using var archive = ZipFile.OpenRead(sourceArchiveFileName); - Directory.CreateDirectory(destinationDirectory); - - var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory); - if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar)) - destinationDirectoryFullPath += Path.DirectorySeparatorChar; - - foreach (var entry in archive.Entries) - { - var normalizedEntryName = NormalizeConfigPluginZipEntryName(entry.FullName); - var destinationPath = GetConfigPluginZipEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName); - - if (normalizedEntryName.EndsWith('/')) - { - if (entry.Length != 0) - throw new InvalidDataException($"The enterprise configuration plugin archive contains a directory entry with data: '{entry.FullName}'."); - - Directory.CreateDirectory(destinationPath); - continue; - } - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - entry.ExtractToFile(destinationPath); - } + PluginArchive.Extract(sourceArchiveFileName, destinationDirectory); if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any()) throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file."); } - - private static string NormalizeConfigPluginZipEntryName(string entryName) - { - var normalizedEntryName = entryName.Replace('\\', '/'); - if (string.IsNullOrWhiteSpace(normalizedEntryName)) - throw new InvalidDataException("The enterprise configuration plugin archive contains an empty entry name."); - - if (normalizedEntryName.Contains('\0')) - throw new InvalidDataException($"The enterprise configuration plugin archive contains an invalid entry name: '{entryName}'."); - - if (normalizedEntryName.StartsWith('/')) - throw new InvalidDataException($"The enterprise configuration plugin archive contains a rooted entry name: '{entryName}'."); - - if (normalizedEntryName is [_, ':', ..]) - throw new InvalidDataException($"The enterprise configuration plugin archive contains a drive-qualified entry name: '{entryName}'."); - - var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or "..")) - throw new InvalidDataException($"The enterprise configuration plugin archive contains an unsafe entry name: '{entryName}'."); - - return normalizedEntryName; - } - - private static string GetConfigPluginZipEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName) - { - var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries); - var relativePath = Path.Combine(pathSegments); - var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath)); - if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal)) - throw new InvalidDataException($"The enterprise configuration plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'."); - - return destinationPath; - } -} \ No newline at end of file +}