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;
}
}