diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index d1e5507b..89dacd79 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -65,7 +65,7 @@ public static partial class PluginFactory await response.Content.CopyToAsync(tempFileStream, cancellationToken); } - ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory); + ExtractConfigPluginArchive(tempDownloadFile, stagedDirectory); var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); if (Directory.Exists(configDirectory)) @@ -129,4 +129,70 @@ 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); + } + + 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 diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index 29fc7499..3b08da60 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -1,2 +1,3 @@ # v26.7.3, build 245 (2026-07-xx xx:xx UTC) -- Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks. \ No newline at end of file +- Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks. +- Fixed enterprise configuration plugins from Windows-created ZIP files not loading correctly on Linux when the ZIP contained plugin files inside a folder. \ No newline at end of file diff --git a/documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md b/documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md new file mode 100644 index 00000000..2cfc77f9 --- /dev/null +++ b/documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md @@ -0,0 +1,28 @@ +# Enterprise Configuration ZIP Backslashes + +- Status: Active +- Introduced: 2026-07-09 +- Remove after: when Microsoft fixes dotnet/runtime#27620 and dotnet/runtime#41914 +- Code references: + - `app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs` + +## User Impact + +Some enterprise administrators create configuration plugin ZIP files on Windows. Depending on the packaging tool, entries inside the ZIP may use Windows-style backslashes, for example `O\plugin.lua`. + +Without this shim, Unix systems extract those entries as files whose names contain literal backslash characters. The plugin loader then cannot find `plugin.lua`, so the enterprise configuration plugin is not activated. + +## Compatibility Behavior + +AI Studio manually extracts downloaded enterprise configuration plugin ZIP files. During extraction, entry names are normalized so both `/` and `\` are treated as archive path separators. + +The extraction still preserves the archive structure and validates each entry before writing it to disk. Rooted paths, drive-qualified paths, and parent-directory traversal paths are rejected. + +This works around the behavior described in dotnet/runtime#27620. A related upstream context for ZIP entry creation is dotnet/runtime#41914, where the ZIP specification requirement for forward slashes is discussed. + +## Removal Checklist + +- Confirm supported .NET runtimes and administrator packaging guidance no longer require accepting backslashes in enterprise ZIP entry names. +- Replace the manual enterprise configuration plugin ZIP extraction with `ZipFile.ExtractToDirectory(...)`. +- Remove `ExtractConfigPluginArchive(...)`, `NormalizeConfigPluginZipEntryName(...)`, and `GetConfigPluginZipEntryDestinationPath(...)`. +- Update this document's status to `Removed`.