namespace AIStudio.Tools; /// /// Checks the names of entries in ZIP files before anything is written to the disk. /// /// /// A ZIP file comes from somewhere else: a plugin archive, the configuration of an organization, or a /// backup. An entry name such as "../settings.json" or an absolute path would make extracting it /// write outside of the target directory (zip slip). Every name is therefore checked here before it /// becomes a path.

/// /// Backslashes count as separators as well. Compatibility shim for ZIP files created on Windows /// with backslashes in their entry names (dotnet/runtime#27620); remove after dotnet/runtime#27620 /// and #41914 are fixed. See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md. ///
public static class ZipEntryNames { /// /// The separator of entry names, which the ZIP format prescribes. /// public const char SEPARATOR = '/'; /// /// Normalizes the name of an entry, and rejects a name which could lead outside of the target /// directory with an InvalidDataException: an empty, rooted, or drive-qualified name, or one which /// contains "." or "..". /// /// The name as the ZIP file holds it. /// The name with forward slashes. public static string Normalize(string entryName) { var normalizedEntryName = entryName.Replace('\\', SEPARATOR); if (string.IsNullOrWhiteSpace(normalizedEntryName)) throw new InvalidDataException("The archive contains an empty entry name."); if (normalizedEntryName.Contains('\0')) throw new InvalidDataException($"The archive contains an invalid entry name: '{entryName}'."); if (normalizedEntryName.StartsWith(SEPARATOR)) throw new InvalidDataException($"The archive contains a rooted entry name: '{entryName}'."); if (normalizedEntryName is [_, ':', ..]) throw new InvalidDataException($"The archive contains a drive-qualified entry name: '{entryName}'."); var pathSegments = normalizedEntryName.Split(SEPARATOR, StringSplitOptions.RemoveEmptyEntries); if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or "..")) throw new InvalidDataException($"The archive contains an unsafe entry name: '{entryName}'."); return normalizedEntryName; } /// /// Normalizes the name of an entry, cf. Normalize, without throwing. /// /// The name as the ZIP file holds it. /// The name with forward slashes, or an empty string when it is unsafe. /// True when the name is safe. public static bool TryNormalize(string entryName, out string normalizedEntryName) { try { normalizedEntryName = Normalize(entryName); return true; } catch (InvalidDataException) { normalizedEntryName = string.Empty; return false; } } /// /// The path at which an entry is written below the target directory. A path which would lie /// outside of it is rejected with an InvalidDataException. /// /// The target directory. /// The entry name, as Normalize returned it. /// The full path of the entry. public static string GetDestinationPath(string destinationDirectory, string normalizedEntryName) { var fullDestinationDirectory = Path.GetFullPath(destinationDirectory); if (!fullDestinationDirectory.EndsWith(Path.DirectorySeparatorChar)) fullDestinationDirectory += Path.DirectorySeparatorChar; var pathSegments = normalizedEntryName.Split(SEPARATOR, StringSplitOptions.RemoveEmptyEntries); var destinationPath = Path.GetFullPath(Path.Combine(fullDestinationDirectory, Path.Combine(pathSegments))); if (!destinationPath.StartsWith(fullDestinationDirectory, StringComparison.Ordinal)) throw new InvalidDataException($"The archive contains an entry outside the destination directory: '{normalizedEntryName}'."); return destinationPath; } }