diff --git a/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h b/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h
index 8a1a3412..0cb369d9 100644
--- a/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h
+++ b/language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h
@@ -142,6 +142,46 @@ SQLEXTENSION_INTERFACE SQLRETURN CleanupSession(SQLGUID sessionId, SQLUSMALLINT
//
SQLEXTENSION_INTERFACE SQLRETURN Cleanup();
+// Installs an external library to the specified directory.
+//
+// Dispatch is by the registered library name (libraryName), not the
+// contents of libraryFile:
+// - libraryName ending in ".zip" -> ZIP archive install. If the archive
+// contains a single inner zip, that inner zip is extracted to the
+// install directory; otherwise the outer archive's files are copied
+// directly. A "{libName}.manifest" listing every extracted file is
+// written so UninstallExternalLibrary can clean up exactly what was
+// installed.
+// - libraryName ending in ".dll" -> raw DLL install. libraryFile is
+// copied verbatim to "{installDir}\{libraryName}" and a one-entry
+// manifest is written.
+// - libraryName with neither extension -> falls back to libraryFile's
+// extension (preserves the legacy contract for callers that register
+// libraries by bare name and point libraryFile at a "*.zip" or
+// "*.dll" fixture).
+//
+SQLEXTENSION_INTERFACE SQLRETURN InstallExternalLibrary(
+ SQLGUID setupSessionId,
+ SQLCHAR *libraryName,
+ SQLINTEGER libraryNameLength,
+ SQLCHAR *libraryFile,
+ SQLINTEGER libraryFileLength,
+ SQLCHAR *libraryInstallDirectory,
+ SQLINTEGER libraryInstallDirectoryLength,
+ SQLCHAR **libraryError,
+ SQLINTEGER *libraryErrorLength);
+
+// Uninstalls an external library from the specified directory.
+//
+SQLEXTENSION_INTERFACE SQLRETURN UninstallExternalLibrary(
+ SQLGUID setupSessionId,
+ SQLCHAR *libraryName,
+ SQLINTEGER libraryNameLength,
+ SQLCHAR *libraryInstallDirectory,
+ SQLINTEGER libraryInstallDirectoryLength,
+ SQLCHAR **libraryError,
+ SQLINTEGER *libraryErrorLength);
+
// Dotnet environment pointer
//
static DotnetEnvironment* g_dotnet_runtime = nullptr;
\ No newline at end of file
diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs
index 0f3be1f3..ca7c3865 100644
--- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs
+++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs
@@ -9,10 +9,13 @@
//
//*********************************************************************
using System;
+using System.IO;
+using System.IO.Compression;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Collections.Generic;
using System.Runtime.InteropServices;
+using System.Threading;
using static Microsoft.SqlServer.CSharpExtension.Sql;
namespace Microsoft.SqlServer.CSharpExtension
@@ -47,6 +50,43 @@ public static unsafe class CSharpExtension
///
private static string _languageParams;
+ ///
+ /// Case-sensitivity comparer matching the host OS's filesystem
+ /// semantics. Used for set keys that contain on-disk file paths.
+ ///
+ private static readonly StringComparer s_pathComparer =
+ OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
+ ///
+ /// Case-sensitivity rule matching the host OS's filesystem semantics.
+ /// Used for string-comparison APIs (StartsWith / EndsWith / Equals)
+ /// that operate on on-disk file paths.
+ ///
+ private static readonly StringComparison s_pathComparison =
+ OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ ///
+ /// Sleep interval (in milliseconds) between attempts to acquire the
+ /// per-installDir install lock when another process holds it. See
+ /// .
+ ///
+ private const int s_lockRetryDelayMs = 100;
+
+ ///
+ /// Windows reserved DOS device names. Files and directories with
+ /// these stems behave specially even on modern NTFS (CreateFile maps
+ /// "CON" / "C:\path\CON.txt" to the console device, etc.), so we
+ /// reject them as library names regardless of host OS to keep
+ /// behavior consistent.
+ ///
+ private static readonly HashSet s_reservedDeviceNames =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+ };
+
///
/// This delegate declares the delegate type of Init.
///
@@ -637,5 +677,1174 @@ public static short CleanupSession(
_currentSession = null;
});
}
+
+ ///
+ /// This delegate declares the delegate type of InstallExternalLibrary.
+ ///
+ public delegate short InstallExternalLibraryDelegate(
+ Guid setupSessionId,
+ char *libraryName,
+ int libraryNameLength,
+ char *libraryFile,
+ int libraryFileLength,
+ char *libraryInstallDirectory,
+ int libraryInstallDirectoryLength,
+ char **libraryError,
+ int *libraryErrorLength);
+
+ ///
+ /// This method implements InstallExternalLibrary API.
+ /// Installs an external library to the specified directory.
+ /// The library file MAY be a ZIP archive (with optional inner ZIP and
+ /// arbitrary nested file tree) OR a raw DLL. Raw-DLL support matches
+ /// the pre-PR ExtHost behavior: the file is copied as "{libName}.dll".
+ /// ZIP support is the new capability added by this PR; it allows
+ /// libraries to ship multiple DLLs, runtime config, or supporting
+ /// files in a single archive.
+ ///
+ ///
+ /// NOTE: Unlike most other extension APIs, Install/Uninstall report
+ /// errors via the libraryError out-parameter rather than through
+ /// ExceptionUtils.WrapError. This matches the sqlexternallibrary.h
+ /// contract: the host expects the error string to come back through
+ /// libraryError / libraryErrorLength so it can surface it as part of
+ /// CREATE/ALTER/DROP EXTERNAL LIBRARY diagnostics.
+ ///
+ ///
+ /// Session GUID supplied by ExtHost. Currently unused by the CSharp
+ /// extension; logged downstream for trace correlation only.
+ ///
+ ///
+ /// UTF-8 buffer holding the library name from CREATE EXTERNAL LIBRARY.
+ /// NOT null-terminated -- read exactly libraryNameLength bytes.
+ ///
+ ///
+ /// Byte length of .
+ ///
+ ///
+ /// UTF-8 buffer holding the absolute path to the library content file
+ /// (a raw DLL or a ZIP archive). NOT null-terminated -- read exactly
+ /// libraryFileLength bytes.
+ ///
+ ///
+ /// Byte length of .
+ ///
+ ///
+ /// UTF-8 buffer holding the absolute path to the install directory.
+ /// May be the public or private external library path. Created if
+ /// it doesn't exist. NOT null-terminated.
+ ///
+ ///
+ /// Byte length of .
+ ///
+ ///
+ /// On failure, set to a freshly-allocated UTF-8 error string for
+ /// ExtHost to surface to the user. ExtHost takes ownership of the
+ /// allocation. On success, set to nullptr.
+ ///
+ ///
+ /// On failure, set to the byte length of
+ /// (excluding the null terminator -- ExtHost adds +1 when copying).
+ /// On success, set to 0.
+ ///
+ ///
+ /// SQL_SUCCESS(0), SQL_ERROR(-1)
+ ///
+ public static short InstallExternalLibrary(
+ Guid setupSessionId,
+ char *libraryName,
+ int libraryNameLength,
+ char *libraryFile,
+ int libraryFileLength,
+ char *libraryInstallDirectory,
+ int libraryInstallDirectoryLength,
+ char **libraryError,
+ int *libraryErrorLength)
+ {
+ Logging.Trace("CSharpExtension::InstallExternalLibrary");
+
+ short result = SQL_SUCCESS;
+ string tempFolder = null;
+
+ try
+ {
+ string libFilePath = Interop.UTF8PtrToStr(libraryFile, (ulong)libraryFileLength);
+ string installDir = Interop.UTF8PtrToStr(libraryInstallDirectory, (ulong)libraryInstallDirectoryLength);
+ string libName = Interop.UTF8PtrToStr(libraryName, (ulong)libraryNameLength);
+
+ ValidateLibraryName(libName);
+
+ // Serialize against any other concurrent Install/Uninstall on
+ // the same installDir. See AcquireInstallLock remarks for why:
+ // without serialization, two installs can both pass
+ // CheckForConflicts against pre-cleanup state, then one's
+ // CleanupManifest destroys its previous version while the
+ // other's File.Copy collides on overwrite:false -- silent
+ // data loss for one of them.
+ using (FileStream installLock = AcquireInstallLock(installDir))
+ {
+ if (!Directory.Exists(installDir))
+ {
+ Directory.CreateDirectory(installDir);
+ }
+
+ string manifestPath = Path.Combine(installDir, libName + ".manifest");
+ HashSet oldManifestEntries = ReadManifestEntries(manifestPath);
+
+ // Dispatch on the user-registered library name's
+ // extension first. SQL Server hands the extension a
+ // staged temp file with a generated name (typically no
+ // .zip / .dll extension), so libFilePath is not a
+ // reliable signal of user intent. The only reliable
+ // signal is libName, which the engine forwards verbatim
+ // from CREATE EXTERNAL LIBRARY [] -- e.g. a user
+ // who wrote CREATE EXTERNAL LIBRARY [Foo.dll] gets
+ // libName = "Foo.dll" and expects a raw-DLL install.
+ //
+ // When libName carries no extension (some test fixtures
+ // register libraries by bare name, pointing libFilePath
+ // at "foo-DLL.zip" or "foo-RAWDLL.dll"), fall back to
+ // libFilePath's extension so legacy callers continue to
+ // work without modification.
+ if (DispatchAsZip(libName, libFilePath))
+ {
+ InstallZipPackage(libFilePath, installDir, libName,
+ manifestPath, oldManifestEntries, out tempFolder);
+ }
+ else
+ {
+ InstallRawDll(libFilePath, installDir, libName, manifestPath);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ string stackTracePart = string.IsNullOrEmpty(e.StackTrace) ? string.Empty : e.StackTrace + Environment.NewLine;
+ Logging.Error(stackTracePart + "Error: " + e.Message);
+ SetLibraryError(e.Message, libraryError, libraryErrorLength);
+ result = SQL_ERROR;
+ }
+ finally
+ {
+ if (tempFolder != null && Directory.Exists(tempFolder))
+ {
+ try { Directory.Delete(tempFolder, true); }
+ catch { /* best-effort */ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Installs a raw DLL (non-ZIP) library file. Copies the file as
+ /// "{libName}.dll" into and writes a
+ /// single-entry manifest so future ALTER calls can clean it up.
+ ///
+ ///
+ /// Matches the pre-PR ExtHost CopyFileW(..., bFailIfExists=TRUE)
+ /// contract: if "{libName}.dll" already exists at the target path AND
+ /// it is NOT owned by this library (i.e. not listed in our manifest),
+ /// throws rather than silently overwriting a file that may belong to
+ /// another library.
+ ///
+ private static void InstallRawDll(
+ string libFilePath,
+ string installDir,
+ string libName,
+ string manifestPath)
+ {
+ string dllFileName = DllFileNameFor(libName);
+ string installedDllPath = Path.Combine(installDir, dllFileName);
+
+ if (File.Exists(manifestPath))
+ {
+ // Prior manifest-based install of the SAME library: clean it up
+ // first so the upcoming copy has a free slot.
+ CleanupManifest(manifestPath, installDir);
+ }
+ else if (File.Exists(installedDllPath))
+ {
+ // No manifest: we don't own this file. Fail rather than overwrite.
+ throw new IOException(
+ $"Cannot install library '{libName}': file '{dllFileName}' " +
+ "already exists in the install directory and is not owned by this library.");
+ }
+
+ File.Copy(libFilePath, installedDllPath, false);
+
+ // Track the raw-DLL install in a manifest too. This is what makes
+ // ALTER from raw-DLL to ZIP work: the ZIP path's CheckForConflicts
+ // sees "{libName}.dll" in oldManifestEntries and treats it as
+ // owned-by-previous, allowing the upgrade to proceed cleanly.
+ File.WriteAllLines(manifestPath, new[] { dllFileName });
+ Logging.Trace(
+ $"Wrote manifest: {manifestPath} with 1 entry (raw-DLL install)");
+ }
+
+ ///
+ /// Installs a ZIP-archive library. Stages the new content to a temp
+ /// folder, validates it (zip-slip, empty-archive, conflict checks),
+ /// then cleans up the previous version and copies the new content
+ /// into .
+ ///
+ ///
+ /// Out-parameter set to the staging tempFolder path AS SOON AS it is
+ /// chosen, BEFORE any extraction is attempted. This lets the caller
+ /// clean it up in its outer finally even if the extraction or
+ /// any subsequent step throws -- otherwise a half-extracted tempFolder
+ /// would leak inside installDir.
+ ///
+ ///
+ /// A corrupt ZIP leaves the existing install intact (validation runs
+ /// against the staged copy in tempFolder, before we touch installDir).
+ /// On-disk replacement is NOT atomic at the per-file level. A crash
+ /// between CleanupManifest and the copy phase can leave the install
+ /// directory inconsistent; SQL Server's catalog-based recovery
+ /// re-installs from the catalog on the next session.
+ ///
+ private static void InstallZipPackage(
+ string libFilePath,
+ string installDir,
+ string libName,
+ string manifestPath,
+ HashSet oldManifestEntries,
+ out string tempFolder)
+ {
+ // Publish tempFolder to the caller BEFORE doing any work that
+ // could throw, so the caller's finally can clean it up regardless
+ // of where we fail (extract throw, conflict throw, copy throw).
+ tempFolder = Path.Combine(installDir, Guid.NewGuid().ToString());
+ ZipFile.ExtractToDirectory(libFilePath, tempFolder);
+
+ if (Directory.GetFiles(tempFolder).Length == 0 &&
+ Directory.GetDirectories(tempFolder).Length == 0)
+ {
+ throw new InvalidOperationException(
+ "The library archive contains no entries.");
+ }
+
+ // Pick the staging directory whose contents will be copied into
+ // installDir. If the outer ZIP wraps a single inner ZIP at its
+ // top level (the engine-wrapped pattern), extract that inner ZIP
+ // into a sibling subfolder of tempFolder and treat IT as the
+ // content root. The inner ZIP is then walked and copied via the
+ // same IsReparsePoint-guarded path as the no-inner-zip case --
+ // so a future .NET runtime that honors symlink entries on Linux
+ // (or a switch to a different extraction library) cannot smuggle
+ // a symlink into installDir, even from an attacker-controlled
+ // inner ZIP. The sub-folder lives inside tempFolder, so the
+ // outer caller's finally cleans it up alongside the rest.
+ string innerZipPath = FindInnerZip(tempFolder);
+ string contentRoot;
+ if (innerZipPath != null)
+ {
+ contentRoot = Path.Combine(tempFolder, "inner-content");
+ Directory.CreateDirectory(contentRoot);
+ ZipFile.ExtractToDirectory(innerZipPath, contentRoot);
+ }
+ else
+ {
+ contentRoot = tempFolder;
+ }
+
+ List extractedFiles = CollectStagedFiles(contentRoot);
+
+ // Reject archives that contain only empty directories (no files).
+ // The earlier "no entries" guard checks for an entirely-empty
+ // tempFolder, but a ZIP whose entries are all directory markers
+ // (e.g. "lib/", "lib/net8.0/") passes that check while leaving
+ // CollectStagedFiles with zero file entries. Without this guard,
+ // ALTER would silently destroy the previous version: CleanupManifest
+ // deletes its content, nothing is copied (extractedFiles is empty),
+ // and the manifest write is skipped. The library would end up GONE
+ // with no replacement and no manifest tracking what was lost.
+ if (extractedFiles.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "The library archive contains no files.");
+ }
+
+ string aliasFileName = DllFileNameFor(libName);
+ string aliasSourceRelPath = DetermineAliasSource(aliasFileName, extractedFiles);
+ if (aliasSourceRelPath != null)
+ {
+ // Append the alias to extractedFiles BEFORE CheckForConflicts
+ // so any "{libName}.dll" collision with another library fails
+ // fast with no content written to installDir.
+ extractedFiles.Add(aliasFileName);
+ }
+
+ CheckForConflicts(installDir, libName, extractedFiles, oldManifestEntries);
+
+ // All checks passed. Remove the previous version's files (if any),
+ // then extract / copy the new content into installDir.
+ if (File.Exists(manifestPath))
+ {
+ CleanupManifest(manifestPath, installDir);
+ }
+
+ ExtractContentToInstallDir(installDir, contentRoot);
+
+ if (aliasSourceRelPath != null)
+ {
+ CreateAlias(installDir, aliasSourceRelPath, aliasFileName);
+ }
+
+ File.WriteAllLines(manifestPath, extractedFiles);
+ Logging.Trace(
+ $"Wrote manifest: {manifestPath} with {extractedFiles.Count} entries");
+ }
+
+ ///
+ /// Finds the FIRST top-level ".zip" entry inside the staged
+ /// , or null if none is present.
+ ///
+ ///
+ /// If the outer ZIP contains exactly one inner .zip at its top level,
+ /// it is treated as the real package and extracted in place of the
+ /// outer. This matches the way the SQL Server engine wraps
+ /// user-supplied archives. Multiple inner zips are unsupported; any
+ /// after the first are extracted as opaque files (callers that need
+ /// to ship multiple zips should pack them inside subdirectories).
+ ///
+ private static string FindInnerZip(string tempFolder)
+ {
+ foreach (string file in Directory.GetFiles(tempFolder))
+ {
+ if (Path.GetExtension(file).Equals(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ return file;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Builds the list of relative paths under
+ /// that will be installed into installDir. Reparse points are skipped
+ /// (CollectRelativeFiles enforces this) so manifest entries always
+ /// correspond to a regular file that CopyDirectory will physically copy.
+ ///
+ ///
+ /// Both the "outer ZIP only" and "outer ZIP wrapping inner ZIP" code
+ /// paths route through this single on-disk walk. For the inner-zip
+ /// case, contentRoot is the sub-folder into which InstallZipPackage
+ /// extracted the inner ZIP; for the no-inner-zip case it IS tempFolder
+ /// itself. Walking the on-disk tree (rather than enumerating ZIP
+ /// entries) keeps the manifest aligned with what
+ /// ExtractContentToInstallDir will actually copy after IsReparsePoint
+ /// filtering.
+ ///
+ private static List CollectStagedFiles(string contentRoot)
+ {
+ List extractedFiles = new List();
+ CollectRelativeFiles(contentRoot, "", extractedFiles);
+ return extractedFiles;
+ }
+
+ ///
+ /// Decides which extracted file (if any) should be cloned as the
+ /// "{libName}.dll" alias so DllUtils.CreateDllList can discover the
+ /// library. Returns the relative path of the source DLL to clone, or
+ /// null if no alias is needed (a root-level "{libName}.*" is already
+ /// present, or no DLL exists to clone from).
+ ///
+ ///
+ /// DllUtils.CreateDllList searches only the TOP LEVEL of the library
+ /// path (non-recursive). So a deeply-nested DLL like
+ /// "lib/net8.0/{libName}.dll" does NOT make the library discoverable,
+ /// and we still need to create a root-level alias.
+ ///
+ /// Suppression is intentionally narrow: only a root-level file whose
+ /// name EXACTLY equals (i.e.
+ /// "{libName}.dll" -- the file the loader will actually try to map)
+ /// counts as "already discoverable". An earlier, looser check used
+ /// name.StartsWith("{libName}."), which incorrectly treated
+ /// sidecars such as "{libName}.deps.json", "{libName}.runtimeconfig.json",
+ /// or (in the libName-with-".dll"-suffix case) "{libName}.dll.config"
+ /// as if they made the library loadable. They do not: the loader
+ /// resolves "{libName}" to a PE binary by exact filename, so a
+ /// sidecar at the root with no real DLL there would suppress alias
+ /// creation and leave the install un-loadable. The exact-match rule
+ /// keeps suppression aligned with what DllUtils can actually load.
+ ///
+ /// When more than one candidate DLL exists (no root-level
+ /// "{aliasFileName}" but, say, both "lib/net8.0/foo.dll" and
+ /// "lib/net6.0/bar.dll" are present), we deterministically pick the
+ /// first by ordinal sort of the relative path. Without this sort the
+ /// pick depends on Directory.GetFiles order, which is
+ /// filesystem-defined: NTFS returns name-sorted, ext4 / XFS return
+ /// inode-creation-order, and the same ZIP can yield different alias
+ /// sources on different hosts. Ordinal sort gives stable, repeatable
+ /// behavior across platforms and across re-installs.
+ ///
+ private static string DetermineAliasSource(
+ string aliasFileName,
+ List extractedFiles)
+ {
+ foreach (string relPath in extractedFiles)
+ {
+ bool isRootLevel =
+ relPath.IndexOf('/') < 0 &&
+ relPath.IndexOf('\\') < 0;
+ if (!isRootLevel)
+ {
+ continue;
+ }
+ string name = Path.GetFileName(relPath);
+ if (name.Equals(aliasFileName, s_pathComparison))
+ {
+ // The file the loader will actually map is already at
+ // the root -- no alias needed. Sidecars matching
+ // "{libName}.*" are intentionally NOT treated as
+ // suppressing: see above.
+ return null;
+ }
+ }
+ // Pick the lexicographically-first .dll candidate so the choice
+ // is stable across hosts/runs (see above).
+ string chosen = null;
+ foreach (string relPath in extractedFiles)
+ {
+ if (!Path.GetExtension(relPath).Equals(".dll", s_pathComparison))
+ {
+ continue;
+ }
+ if (chosen == null ||
+ string.CompareOrdinal(relPath, chosen) < 0)
+ {
+ chosen = relPath;
+ }
+ }
+ return chosen;
+ }
+
+ ///
+ /// Copies the staged content from
+ /// into the live , skipping reparse
+ /// points at every level.
+ ///
+ ///
+ /// Both the "outer ZIP only" and "outer ZIP wrapping inner ZIP" code
+ /// paths converge here. Inner-ZIP content has already been extracted
+ /// to a sub-folder of tempFolder by InstallZipPackage; this method
+ /// then copies it into installDir using the same IsReparsePoint
+ /// guard at every recursion level. The guards must live at the call
+ /// site for the top-level entries because CopyDirectory only checks
+ /// the children it iterates, not its top-level sourceDir argument:
+ /// a root-level reparse-point file could cause File.Copy to read
+ /// data from outside the staged tree, and a root-level reparse-point
+ /// directory could make CopyDirectory recurse out of contentRoot.
+ ///
+ private static void ExtractContentToInstallDir(
+ string installDir,
+ string contentRoot)
+ {
+ foreach (string file in Directory.GetFiles(contentRoot))
+ {
+ if (IsReparsePoint(file))
+ {
+ continue;
+ }
+ File.Copy(file, Path.Combine(installDir, Path.GetFileName(file)), false);
+ }
+
+ foreach (string dir in Directory.GetDirectories(contentRoot))
+ {
+ if (IsReparsePoint(dir))
+ {
+ continue;
+ }
+ CopyDirectory(dir, Path.Combine(installDir, Path.GetFileName(dir)));
+ }
+ }
+
+ ///
+ /// Creates the "{libName}.dll" alias by cloning the source DLL chosen
+ /// by . Caller must have verified
+ /// the alias is needed and conflict-checked before this is invoked.
+ ///
+ private static void CreateAlias(
+ string installDir,
+ string aliasSourceRelPath,
+ string aliasFileName)
+ {
+ string aliasSrc = Path.Combine(installDir, aliasSourceRelPath);
+ string alias = Path.Combine(installDir, aliasFileName);
+ if (File.Exists(aliasSrc))
+ {
+ File.Copy(aliasSrc, alias, false);
+ }
+ }
+
+ ///
+ /// This delegate declares the delegate type of UninstallExternalLibrary.
+ ///
+ public delegate short UninstallExternalLibraryDelegate(
+ Guid setupSessionId,
+ char *libraryName,
+ int libraryNameLength,
+ char *libraryInstallDirectory,
+ int libraryInstallDirectoryLength,
+ char **libraryError,
+ int *libraryErrorLength);
+
+ ///
+ /// This method implements UninstallExternalLibrary API.
+ /// Uninstalls an external library from the specified directory by
+ /// reading "{libName}.manifest" and deleting each listed file, then
+ /// pruning newly-empty subdirectories. Files belonging to other
+ /// libraries (not listed in this manifest) are left intact.
+ ///
+ ///
+ /// See InstallExternalLibrary remarks: errors are reported via the
+ /// libraryError out-parameter rather than ExceptionUtils.WrapError,
+ /// per the sqlexternallibrary.h contract. A missing installDir or
+ /// missing manifest is treated as a no-op success (the library is
+ /// already in the desired state).
+ ///
+ ///
+ /// Session GUID supplied by ExtHost. Currently unused.
+ ///
+ ///
+ /// UTF-8 buffer holding the library name from DROP EXTERNAL LIBRARY.
+ /// NOT null-terminated -- read exactly libraryNameLength bytes.
+ ///
+ ///
+ /// Byte length of .
+ ///
+ ///
+ /// UTF-8 buffer holding the absolute path to the install directory.
+ /// May be the public or private external library path. NOT
+ /// null-terminated.
+ ///
+ ///
+ /// Byte length of .
+ ///
+ ///
+ /// On failure, set to a freshly-allocated UTF-8 error string for
+ /// ExtHost to surface to the user. ExtHost takes ownership of the
+ /// allocation. On success, set to nullptr.
+ ///
+ ///
+ /// On failure, set to the byte length of
+ /// (excluding the null terminator). On success, set to 0.
+ ///
+ ///
+ /// SQL_SUCCESS(0), SQL_ERROR(-1)
+ ///
+ public static short UninstallExternalLibrary(
+ Guid setupSessionId,
+ char *libraryName,
+ int libraryNameLength,
+ char *libraryInstallDirectory,
+ int libraryInstallDirectoryLength,
+ char **libraryError,
+ int *libraryErrorLength)
+ {
+ Logging.Trace("CSharpExtension::UninstallExternalLibrary");
+
+ short result = SQL_SUCCESS;
+
+ try
+ {
+ string installDir = Interop.UTF8PtrToStr(libraryInstallDirectory, (ulong)libraryInstallDirectoryLength);
+ string libName = Interop.UTF8PtrToStr(libraryName, (ulong)libraryNameLength);
+
+ // Reject names containing path separators etc. before they are used
+ // to build manifestPath / libraryFile via Path.Combine. Without this,
+ // a malicious libName could resolve outside installDir.
+ ValidateLibraryName(libName);
+
+ if (Directory.Exists(installDir))
+ {
+ // Serialize against any other concurrent Install/Uninstall
+ // on the same installDir. An uninstall that races an install
+ // of a different library can otherwise see CleanupManifest
+ // delete its own files between the install's CheckForConflicts
+ // and File.Copy, leaving the install with a stale view of disk
+ // state. See AcquireInstallLock remarks for the full threat
+ // model.
+ using (FileStream installLock = AcquireInstallLock(installDir))
+ {
+ // Check for a manifest written during install that lists
+ // all files extracted from the library's ZIP content.
+ string manifestPath = Path.Combine(installDir, libName + ".manifest");
+ if (File.Exists(manifestPath))
+ {
+ // Manifest covers everything we own: ZIP-extracted
+ // files AND -- since C3 -- the single "{libName}.dll"
+ // entry written by a raw-DLL install. CleanupManifest
+ // deletes them all.
+ CleanupManifest(manifestPath, installDir);
+ }
+ else
+ {
+ // Legacy fallback: a library installed by a pre-PR
+ // version of the extension would have been a raw DLL
+ // with no manifest. Remove "{libName}.dll" directly
+ // so older installs can still be uninstalled cleanly.
+ string libraryFile = Path.Combine(installDir, DllFileNameFor(libName));
+ if (File.Exists(libraryFile))
+ {
+ File.Delete(libraryFile);
+ }
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ string stackTracePart = string.IsNullOrEmpty(e.StackTrace) ? string.Empty : e.StackTrace + Environment.NewLine;
+ Logging.Error(stackTracePart + "Error: " + e.Message);
+ SetLibraryError(e.Message, libraryError, libraryErrorLength);
+ result = SQL_ERROR;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Allocates an unmanaged error string and populates the error output parameters.
+ ///
+ private static void SetLibraryError(string errorMessage, char **libraryError, int *libraryErrorLength)
+ {
+ if (libraryError != null && libraryErrorLength != null)
+ {
+ byte[] errorBytes = System.Text.Encoding.UTF8.GetBytes(errorMessage);
+ IntPtr errorPtr = Marshal.AllocHGlobal(errorBytes.Length + 1);
+ Marshal.Copy(errorBytes, 0, errorPtr, errorBytes.Length);
+ ((byte*)errorPtr)[errorBytes.Length] = 0;
+ *libraryError = (char*)errorPtr;
+ // Length excludes the null terminator -- ExtHost adds +1 when
+ // copying via Utf8ToNullTerminatedUtf16Le / strcpy_s.
+ *libraryErrorLength = errorBytes.Length;
+ }
+ }
+
+ ///
+ /// Recursively copies a directory and its contents.
+ ///
+ private static void CopyDirectory(string sourceDir, string destDir)
+ {
+ Directory.CreateDirectory(destDir);
+
+ foreach (string file in Directory.GetFiles(sourceDir))
+ {
+ if (IsReparsePoint(file))
+ {
+ continue;
+ }
+ string destFile = Path.Combine(destDir, Path.GetFileName(file));
+ // overwrite: false so that if filesystem state changed between the
+ // conflict check and here (TOCTOU), we fail loud rather than silently
+ // replacing a file belonging to another library.
+ File.Copy(file, destFile, false);
+ }
+
+ foreach (string dir in Directory.GetDirectories(sourceDir))
+ {
+ if (IsReparsePoint(dir))
+ {
+ continue;
+ }
+ string destSubDir = Path.Combine(destDir, Path.GetFileName(dir));
+ CopyDirectory(dir, destSubDir);
+ }
+ }
+
+ ///
+ /// Returns true if is a reparse point
+ /// (symbolic link, junction, mount point).
+ ///
+ ///
+ /// We treat all reparse points discovered in the staged tempFolder as
+ /// untrusted. On Linux a ZIP archive can carry symlink entries that
+ /// survive extraction; following them while copying into installDir
+ /// could (a) copy data from outside the staged tree (information leak)
+ /// or (b) cause the recursive copy to escape the staged area entirely
+ /// and write into arbitrary filesystem locations.
+ ///
+ /// Concrete attack scenario: a malicious ZIP entry named
+ /// "sneaky.dll" is packed with Unix mode 0o120755 (symbolic link)
+ /// and content "/etc/shadow". On a Linux runtime that materializes
+ /// such entries as real symlinks during extraction, sneaky.dll
+ /// becomes a symlink in tempFolder pointing at /etc/shadow.
+ /// Without an IsReparsePoint guard, the subsequent
+ /// File.Copy(sneaky.dll, installDir/sneaky.dll, false)
+ /// would follow the link, READ /etc/shadow with the SQL Server
+ /// service account's privileges, and write its contents into
+ /// installDir/sneaky.dll -- which is then world-readable to any
+ /// principal with read access to the library directory. With the
+ /// guard, the symlink is skipped, the manifest does not list it,
+ /// and the installed library directory contains only files that
+ /// originated inside the ZIP. The same reasoning applies at the
+ /// directory level: a reparse-point directory could redirect
+ /// CopyDirectory's recursion into /, /home, or any other tree the
+ /// service account can read, exfiltrating arbitrary file content
+ /// into installDir.
+ ///
+ /// Today's .NET ZipFile.ExtractToDirectory writes Unix-symlink-mode
+ /// entries as regular files containing the link target text on
+ /// every platform, so this scenario is theoretical against the
+ /// current runtime; the guard is in place for the case where a
+ /// future runtime starts honoring the bits, or we switch to a
+ /// different extraction library. Regression test:
+ /// in
+ /// CSharpLibraryTests.cpp.
+ ///
+ private static bool IsReparsePoint(string path)
+ {
+ return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
+ }
+
+ // Block forever in AcquireInstallLock -- match the engine's
+ // "this install will eventually finish" contract. SQL Server's outer
+ // statement-level cancellation is the right place to interrupt a
+ // pathologically-stuck install, not an arbitrary timeout in here.
+ // (s_lockRetryDelayMs is declared at the top of the class with the
+ // other class members.)
+
+ ///
+ /// Acquires an exclusive cross-process lock for operations on
+ /// . Returns a disposable handle that
+ /// releases the lock when disposed.
+ ///
+ ///
+ /// Two concurrent CSharp extension processes (e.g. two SQL sessions
+ /// each running CREATE/ALTER/DROP EXTERNAL LIBRARY against the same
+ /// install directory) must not be allowed to interleave the
+ /// CheckForConflicts / CleanupManifest / copy / WriteAllLines
+ /// sequence. Without serialization, both can pass CheckForConflicts
+ /// against pre-cleanup state, then one's CleanupManifest destroys
+ /// its previous version's content while the other's File.Copy
+ /// collides on overwrite:false -- leaving the second library GONE
+ /// with no replacement and no manifest.
+ ///
+ /// Implementation: open the file "{installDir}\install.lock"
+ /// (INSIDE installDir) with FileShare.None. The lock must live
+ /// inside installDir, not as a sibling of it, because SQL Server's
+ /// per-database / per-language ExternalLibraries hierarchy ACLs the
+ /// satellite's AppContainer SID with Modify on the per-user leaf
+ /// directory only; the parent (e.g.
+ /// "ExternalLibraries\<dbid>\<langid>\") grants the
+ /// AppContainer Read access only, so a sibling placement
+ /// "ExternalLibraries\<dbid>\<langid>\1.install.lock"
+ /// fails with UnauthorizedAccessException at FileStream creation
+ /// and the install path can never start.
+ ///
+ /// The OS releases the handle on process crash so there is no
+ /// stale-lock risk. FileOptions.DeleteOnClose removes the lock
+ /// file when the holder closes its handle, keeping the install
+ /// area clean across runs.
+ ///
+ /// Library names that would collide with the lock filename
+ /// ("install.lock", or "install" producing an "install.manifest")
+ /// are not reserved here -- callers should not register such a
+ /// name. The collision surfaces as a sharing violation at
+ /// install time rather than silent overwrite.
+ ///
+ /// Acquisition blocks indefinitely with a 100ms retry interval.
+ /// In the uncontended common case (single session), acquisition
+ /// completes on the first attempt with no measurable overhead.
+ ///
+ private static FileStream AcquireInstallLock(string installDir)
+ {
+ // Ensure installDir exists -- the lock file lives inside it.
+ Directory.CreateDirectory(installDir);
+
+ // Lock file lives INSIDE installDir. See remarks for the ACL
+ // rationale (the satellite's AppContainer SID has write access
+ // on installDir only, not on its parent).
+ string lockPath = Path.Combine(installDir, "install.lock");
+
+ while (true)
+ {
+ try
+ {
+ return new FileStream(
+ lockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.DeleteOnClose);
+ }
+ catch (IOException ex) when (IsSharingViolation(ex))
+ {
+ // Another holder has the lock; wait and retry. Any other
+ // IOException subtype (DirectoryNotFoundException,
+ // PathTooLongException, IO failure mid-creation, etc.)
+ // and anything outside IOException
+ // (UnauthorizedAccessException, ArgumentException,
+ // SecurityException, ...) propagates so that
+ // non-transient failures fail fast rather than spinning.
+ Thread.Sleep(s_lockRetryDelayMs);
+ }
+ }
+ }
+
+ ///
+ /// Returns true when 's HResult indicates that
+ /// the underlying file is held by another process with an exclusive
+ /// share -- i.e. the only situation in which retrying acquisition
+ /// of the install lock makes sense.
+ ///
+ ///
+ /// Win32 maps two errors here:
+ ///
+ /// - ERROR_SHARING_VIOLATION (32, 0x80070020) -- another open
+ /// handle's FileShare flags exclude the requested access.
+ /// - ERROR_LOCK_VIOLATION (33, 0x80070021) -- a byte-range lock
+ /// conflicts with the requested access.
+ ///
+ /// .NET on Linux maps EAGAIN/EWOULDBLOCK/EBUSY to the same HResults
+ /// when fileshare flags conflict, so the same constants work
+ /// cross-platform.
+ ///
+ private static bool IsSharingViolation(IOException ex)
+ {
+ int hr = ex.HResult & 0xFFFF;
+ return hr == 32 || hr == 33;
+ }
+
+ ///
+ /// Recursively collects all file paths relative to the root directory.
+ ///
+ ///
+ /// Reparse points (symlinks, junctions) are skipped at both the file
+ /// and directory level. The result of this walk feeds CheckForConflicts
+ /// and the on-disk manifest, both of which assume every entry will be
+ /// physically copied by CopyDirectory. Since CopyDirectory skips
+ /// reparse points, recording them here would create phantom manifest
+ /// entries that point at nothing on disk.
+ ///
+ private static void CollectRelativeFiles(string directory, string prefix, List results)
+ {
+ foreach (string file in Directory.GetFiles(directory))
+ {
+ if (IsReparsePoint(file))
+ {
+ continue;
+ }
+ string relPath = string.IsNullOrEmpty(prefix)
+ ? Path.GetFileName(file)
+ : Path.Combine(prefix, Path.GetFileName(file));
+ results.Add(relPath);
+ }
+
+ foreach (string dir in Directory.GetDirectories(directory))
+ {
+ if (IsReparsePoint(dir))
+ {
+ continue;
+ }
+ string dirName = Path.GetFileName(dir);
+ string newPrefix = string.IsNullOrEmpty(prefix)
+ ? dirName
+ : Path.Combine(prefix, dirName);
+ CollectRelativeFiles(dir, newPrefix, results);
+ }
+ }
+
+ ///
+ /// Returns the on-disk file name for a raw-DLL install of
+ /// .
+ ///
+ ///
+ /// If the library was registered with a name that already ends in
+ /// ".dll" (e.g. CREATE EXTERNAL LIBRARY [Scriptoria.dll]), we must
+ /// not append a second ".dll" -- that produced "Scriptoria.dll.dll"
+ /// files that the CLR assembly resolver could not locate.
+ ///
+ ///
+ /// The library name as supplied via libraryName to InstallExternalLibrary.
+ ///
+ ///
+ /// "{libName}.dll" if libName does not already end in ".dll",
+ /// otherwise libName unchanged.
+ ///
+ private static string DllFileNameFor(string libName)
+ {
+ string result;
+ if (!string.IsNullOrEmpty(libName) &&
+ libName.EndsWith(".dll", s_pathComparison))
+ {
+ result = libName;
+ }
+ else
+ {
+ result = libName + ".dll";
+ }
+
+ return result;
+ }
+
+ ///
+ /// Validates that is safe to use in path
+ /// composition. Throws on rejection.
+ ///
+ ///
+ /// Without this check, a malicious or legacy libName like "../foo"
+ /// or "/etc/foo" could make
+ /// Path.Combine(installDir, libName + ".manifest") resolve
+ /// outside installDir, allowing unintended file reads / writes /
+ /// deletes. Also rejects names that are only an extension
+ /// (e.g. ".dll") because the resulting "{libName}.manifest" paths
+ /// would be hidden dotfiles on Linux and opaque on both platforms;
+ /// rejects whitespace-only names because Windows trims trailing
+ /// whitespace from filenames silently and Linux behavior is
+ /// surprising at best; and rejects Windows reserved DOS device
+ /// names (CON, PRN, NUL, AUX, COM1-9, LPT1-9) because CreateFile
+ /// maps any path ending in those stems to a device handle even on
+ /// modern NTFS.
+ ///
+ ///
+ /// The library name as supplied via libraryName to
+ /// InstallExternalLibrary or UninstallExternalLibrary.
+ ///
+ private static void ValidateLibraryName(string libName)
+ {
+ if (string.IsNullOrWhiteSpace(libName))
+ {
+ throw new ArgumentException("Library name must not be empty or whitespace.");
+ }
+
+ if (libName.IndexOfAny(new[] { '/', '\\', '\0' }) >= 0 ||
+ libName.Contains("..") ||
+ Path.IsPathRooted(libName))
+ {
+ throw new ArgumentException(
+ $"Library name '{libName}' contains invalid characters.");
+ }
+
+ // Reject names that are only an extension (e.g. ".dll", ".txt").
+ // Path.GetFileNameWithoutExtension returns "" for these, meaning
+ // the name has no stem -- DllFileNameFor would return the bare
+ // extension and the resulting "{libName}.manifest" / "{libName}.dll"
+ // paths would be hidden dotfiles on Linux and opaque on both
+ // platforms.
+ string stem = Path.GetFileNameWithoutExtension(libName);
+ if (string.IsNullOrEmpty(stem))
+ {
+ throw new ArgumentException(
+ $"Library name '{libName}' must not be only an extension.");
+ }
+
+ // Reject Windows reserved DOS device names. CreateFile interprets
+ // any path whose final stem matches one of these as a handle to
+ // the corresponding device, which makes "{installDir}/CON.dll"
+ // and "{installDir}/CON.manifest" both behave unexpectedly. We
+ // reject on every OS so behavior is consistent for libraries
+ // moved between hosts.
+ if (s_reservedDeviceNames.Contains(stem))
+ {
+ throw new ArgumentException(
+ $"Library name '{libName}' uses a reserved device name.");
+ }
+ }
+
+ ///
+ /// Returns true if 's filename ends with the
+ /// ".zip" extension (case-insensitive). Dispatch is by extension --
+ /// not by inspecting file content -- so the registered filename's
+ /// intent is honored: a file named "foo.zip" is always treated as a
+ /// ZIP archive (and fails loudly via ZipFile.ExtractToDirectory if
+ /// the bytes are not a valid archive), and a file named "foo.dll"
+ /// is always treated as a raw DLL (and copied as-is even if its
+ /// bytes happen to start with "PK").
+ ///
+ ///
+ /// We previously sniffed the leading two "magic bytes" ("PK") to
+ /// decide. That silently rewrote a malformed "foo.zip" upload into
+ /// "foo.dll" on disk, hiding upload-corruption bugs from the caller
+ /// and surprising readers of the install directory. Extension-based
+ /// dispatch trades one form of robustness (tolerating misnamed
+ /// files) for a more important one (predictable failures and no
+ /// silent renaming of user-registered filenames).
+ ///
+ private static bool HasZipExtension(string path)
+ {
+ return string.Equals(Path.GetExtension(path), ".zip",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Decides whether to dispatch /
+ /// through the ZIP install path or
+ /// the raw-DLL install path.
+ ///
+ ///
+ /// Precedence:
+ ///
+ /// - If ends in ".zip", dispatch as
+ /// a ZIP archive. The user wrote
+ /// CREATE EXTERNAL LIBRARY [Foo.zip]; their intent is
+ /// unambiguous.
+ /// - If ends in ".dll", dispatch
+ /// as a raw DLL. The user wrote
+ /// CREATE EXTERNAL LIBRARY [Foo.dll]; their intent is
+ /// unambiguous and the staged temp file contents must be a PE
+ /// binary (any attempt to parse it as a ZIP throws
+ /// InvalidDataException).
+ /// - Otherwise, fall back to 's
+ /// extension. SQL Server's ExtHost passes a generated temp file
+ /// name with no semantic extension; some test fixtures, however,
+ /// register libraries under a bare name (e.g.
+ /// "testpackageB") and point libFilePath at a fixture file
+ /// that does carry a meaningful ".zip" / ".dll" suffix. The
+ /// fallback preserves backward compatibility for those callers.
+ ///
+ ///
+ ///
+ private static bool DispatchAsZip(string libName, string libFilePath)
+ {
+ string libNameExt = Path.GetExtension(libName);
+ if (string.Equals(libNameExt, ".zip",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(libNameExt, ".dll",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return HasZipExtension(libFilePath);
+ }
+
+ ///
+ /// Reads an existing manifest file into a set of relative paths.
+ /// Returns an empty set if the manifest does not exist.
+ ///
+ ///
+ /// Absolute path to the "{libName}.manifest" file. Safe to pass a
+ /// non-existent path.
+ ///
+ private static HashSet ReadManifestEntries(string manifestPath)
+ {
+ HashSet set = new HashSet(s_pathComparer);
+ if (File.Exists(manifestPath))
+ {
+ foreach (string line in File.ReadAllLines(manifestPath))
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ set.Add(line);
+ }
+ }
+ }
+
+ return set;
+ }
+
+ // Throws if any staged relative path collides with an existing file that is not
+ // owned by the previous install of this same library.
+ private static void CheckForConflicts(
+ string installDir,
+ string libName,
+ List relPaths,
+ HashSet ownedByPrevious)
+ {
+ foreach (string relPath in relPaths)
+ {
+ if (ownedByPrevious.Contains(relPath))
+ {
+ continue;
+ }
+ if (File.Exists(Path.Combine(installDir, relPath)))
+ {
+ throw new InvalidOperationException(
+ $"Cannot install library '{libName}': file '{relPath}' already exists in the install directory.");
+ }
+ }
+ }
+
+ ///
+ /// Reads a manifest, deletes each listed file, removes any directories
+ /// that become empty (bottom-up), then deletes the manifest itself.
+ ///
+ private static void CleanupManifest(string manifestPath, string installDir)
+ {
+ string fullInstall = Path.GetFullPath(installDir);
+ string sep = Path.DirectorySeparatorChar.ToString();
+ string prefix = fullInstall.EndsWith(sep) ? fullInstall : fullInstall + sep;
+
+ string[] entries = File.ReadAllLines(manifestPath);
+ HashSet parentDirs = new HashSet(s_pathComparer);
+
+ foreach (string relPath in entries)
+ {
+ if (string.IsNullOrWhiteSpace(relPath))
+ {
+ continue;
+ }
+
+ string fullPath;
+ try
+ {
+ fullPath = Path.GetFullPath(Path.Combine(fullInstall, relPath));
+ }
+ catch (Exception ex)
+ {
+ // A malformed manifest entry shouldn't abort the rest of
+ // the cleanup, but it must leave a diagnostic trail so
+ // orphaned files don't disappear silently.
+ Logging.Error(
+ $"CleanupManifest: skipping manifest entry '{relPath}': {ex.Message}");
+ continue;
+ }
+
+ // Defense in depth: skip any entry that resolves outside installDir.
+ if (!fullPath.StartsWith(prefix, s_pathComparison))
+ {
+ continue;
+ }
+
+ if (File.Exists(fullPath))
+ {
+ File.Delete(fullPath);
+ }
+
+ string dir = Path.GetDirectoryName(fullPath);
+ while (!string.IsNullOrEmpty(dir) &&
+ !dir.Equals(fullInstall, s_pathComparison))
+ {
+ parentDirs.Add(dir);
+ dir = Path.GetDirectoryName(dir);
+ }
+ }
+
+ // Remove empty directories deepest first.
+ List sortedDirs = new List(parentDirs);
+ sortedDirs.Sort((a, b) => SeparatorCount(b).CompareTo(SeparatorCount(a)));
+ foreach (string dir in sortedDirs)
+ {
+ if (Directory.Exists(dir) &&
+ Directory.GetFiles(dir).Length == 0 &&
+ Directory.GetDirectories(dir).Length == 0)
+ {
+ Directory.Delete(dir, false);
+ }
+ }
+
+ File.Delete(manifestPath);
+ }
+
+ private static int SeparatorCount(string path)
+ {
+ int count = 0;
+ for (int i = 0; i < path.Length; i++)
+ {
+ if (path[i] == Path.DirectorySeparatorChar)
+ {
+ count++;
+ }
+ }
+ return count;
+ }
}
}
diff --git a/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs b/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs
index eeb116e9..bd53e5c4 100644
--- a/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs
+++ b/language-extensions/dotnet-core-CSharp/src/managed/CSharpOutputDataSet.cs
@@ -190,13 +190,31 @@ DataFrameColumn column
SetDataPtrs(columnNumber, GetStringArray(column));
break;
case SqlDataType.DotNetWChar:
+ case SqlDataType.DotNetNVarChar:
// Calculate column size from actual data.
- // columnSize = max character count (UTF-16 byte length / 2).
- // Minimum size is 1 character (nchar(0) is illegal in SQL).
+ // GetStrLenNullMap returns the per-row byte length
+ // (Encoding.Unicode.GetByteCount) and the SQL_C_WCHAR
+ // ODBC contract requires the column Size and the
+ // strLenOrNullMap entries to use the same unit. Report
+ // Size in BYTES; do NOT convert to a character count
+ // here -- a character-count Size combined with a
+ // byte-count length map causes SPEES to log
+ // "Reading one row failed for column N row M. The
+ // length information is incorrect." and reject the
+ // rowset.
+ //
+ // DotNetNVarChar is treated as an alias of DotNetWChar
+ // (both are SQL_C_WCHAR-shaped at the ODBC layer).
+ // Without this case, callers who set
+ // DataTypeMap[typeof(string)] = SqlDataType.DotNetNVarChar
+ // hit a KeyNotFoundException in DataTypeSize and the
+ // column never reaches the dispatch switch.
+ //
+ // Minimum size is 2 bytes (one UTF-16 code unit -- nchar(0)
+ // is illegal in SQL).
//
int maxUnicodeByteLen = colMap.Length > 0 ? colMap.Where(x => x > 0).DefaultIfEmpty(0).Max() : 0;
- int maxCharCount = maxUnicodeByteLen / sizeof(char);
- _columns[columnNumber].Size = (ulong)Math.Max(maxCharCount, MinUtf16CharSize);
+ _columns[columnNumber].Size = (ulong)Math.Max(maxUnicodeByteLen, MinUtf16CharSize);
SetDataPtrs(columnNumber, GetUnicodeStringArray(column));
break;
@@ -429,8 +447,11 @@ private int[] GetStrLenNullMap(ushort columnNumber, DataFrameColumn column)
Logging.Trace($"GetStrLenNullMap: Row {rowNumber}, Value='{column[rowNumber]}', ByteLen={colMap[rowNumber]}");
break;
case SqlDataType.DotNetWChar:
+ case SqlDataType.DotNetNVarChar:
// Report the byte length of the UTF-16 encoded string (2 bytes per code unit).
- // This must match the buffer size emitted by GetUnicodeStringArray().
+ // This must match the buffer size emitted by GetUnicodeStringArray()
+ // and the column Size set in ExtractColumn (also bytes for SQL_C_WCHAR).
+ // DotNetNVarChar is an alias of DotNetWChar at the ODBC layer.
//
colMap[rowNumber] = Encoding.Unicode.GetByteCount((string)column[rowNumber]);
Logging.Trace($"GetStrLenNullMap: Row {rowNumber}, Value='{column[rowNumber]}', ByteLen={colMap[rowNumber]}");
diff --git a/language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs b/language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs
index 9d6f9165..9abe008c 100644
--- a/language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs
+++ b/language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs
@@ -91,15 +91,12 @@ public static List CreateDllList(
}
else
{
- if (!string.IsNullOrEmpty(privatePath))
- {
- dllList.AddRange(Directory.GetFiles(privatePath, userLibName));
- }
-
- if (!string.IsNullOrEmpty(publicPath))
- {
- dllList.AddRange(Directory.GetFiles(publicPath, userLibName));
- }
+ // Callers may pass either a bare library name ("regex") or an explicit
+ // filename ("Foo.dll"). Try the exact name first so filenames with
+ // extensions resolve correctly; fall back to the "{name}.*" wildcard
+ // for bare names.
+ AddMatches(privatePath, userLibName, dllList);
+ AddMatches(publicPath, userLibName, dllList);
}
if (dllList.Count == 0)
@@ -110,6 +107,63 @@ public static List CreateDllList(
return dllList;
}
+ ///
+ /// Adds DLL matches for under
+ /// . Tries the exact name first (so callers
+ /// that pass "Foo.dll" resolve correctly) and falls back to all .dll
+ /// files whose stem equals (so callers
+ /// that pass "Foo" still match "Foo.dll").
+ ///
+ ///
+ /// Directory to search. Returns immediately if null/empty or absent;
+ /// missing public/private library paths are not an error -- the other
+ /// path may still yield matches.
+ ///
+ ///
+ /// Library name to match. May be a bare stem ("regex") or include a
+ /// .dll suffix ("Foo.dll"). Wildcards / path separators are not
+ /// expected -- the caller is responsible for validation.
+ ///
+ ///
+ /// Output list to append discovered .dll paths to. Caller-owned;
+ /// AddMatches never clears or replaces.
+ ///
+ ///
+ /// We deliberately avoid 's
+ /// search-pattern argument and its Win32 FindFirstFile
+ /// wildcard semantics, which over-match in two well-known ways on
+ /// Windows: "*.dll" matches files like "foo.dllx"
+ /// (3-char-extension prefix-match quirk inherited from FAT), and
+ /// "Foo.*" can spuriously match short-name (8.3) aliases of
+ /// long-named files. Enumerating all entries and filtering with
+ /// equality on
+ /// both the extension and the stem gives exact, predictable
+ /// matches and avoids loading anything we did not intend.
+ ///
+ private static void AddMatches(string searchPath, string userLibName, List dllList)
+ {
+ if (string.IsNullOrEmpty(searchPath) || !Directory.Exists(searchPath))
+ {
+ return;
+ }
+
+ string exactPath = Path.Combine(searchPath, userLibName);
+ if (File.Exists(exactPath))
+ {
+ dllList.Add(exactPath);
+ return;
+ }
+
+ foreach (string f in Directory.EnumerateFiles(searchPath))
+ {
+ if (Path.GetExtension(f).Equals(".dll", StringComparison.OrdinalIgnoreCase) &&
+ Path.GetFileNameWithoutExtension(f).Equals(userLibName, StringComparison.OrdinalIgnoreCase))
+ {
+ dllList.Add(f);
+ }
+ }
+ }
+
///
/// This method finds the corresponding loaded dll for user dll's dependencies.
/// It searches for the corresponding loaded dll that matches args.Name.
diff --git a/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs b/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs
index 78ee0a9c..491c6935 100644
--- a/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs
+++ b/language-extensions/dotnet-core-CSharp/src/managed/utils/Sql.cs
@@ -102,6 +102,7 @@ public enum SqlDataType: short
{SqlDataType.DotNetBit, sizeof(bool)},
{SqlDataType.DotNetChar, MinUtf8CharSize},
{SqlDataType.DotNetWChar, MinUtf16CharSize},
+ {SqlDataType.DotNetNVarChar, MinUtf16CharSize},
{SqlDataType.DotNetNumeric, SqlNumericStructSize}
};
diff --git a/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp b/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp
index b70831d7..6985d144 100644
--- a/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp
+++ b/language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp
@@ -340,4 +340,157 @@ SQLRETURN Cleanup()
LOG("nativecsharpextension::Cleanup");
delete g_dotnet_runtime;
return SQL_SUCCESS;
+}
+
+//--------------------------------------------------------------------------------------------------
+// Name: SetLibraryError
+//
+// Description:
+// Helper to populate the library error output parameters.
+//
+static void SetLibraryError(
+ const std::string &errorString,
+ SQLCHAR **libraryError,
+ SQLINTEGER *libraryErrorLength)
+{
+ // Guard against null out-parameters. The managed SetLibraryError
+ // (CSharpExtension.cs) does the same null-check; without it, a caller
+ // passing null libraryError / libraryErrorLength would dereference null
+ // and crash the host process.
+ if (libraryError == nullptr || libraryErrorLength == nullptr)
+ {
+ return;
+ }
+
+ if (!errorString.empty())
+ {
+ // Length excludes null terminator -- ExtHost adds +1 when copying
+ // (see Utf8ToNullTerminatedUtf16Le / strcpy_s in the host).
+ *libraryErrorLength = static_cast(errorString.length());
+
+ // Ownership of the buffer transfers to ExtHost; it frees the pointer
+ // via the C runtime (free()) after consuming the message. Mirrors
+ // the managed SetLibraryError (CSharpExtension.cs), which uses
+ // Marshal.AllocHGlobal for the same reason. The previous
+ // implementation -- new std::string(errorString) + returning
+ // c_str() -- leaked the string AND handed back a pointer into a
+ // std::string's internal buffer, which is undefined behavior the
+ // moment ExtHost calls free() on it.
+ size_t bufLen = errorString.length() + 1;
+ char *buf = static_cast(malloc(bufLen));
+ if (buf == nullptr)
+ {
+ // Out of memory; surface a "no error" state rather than crash
+ // (the original failure will still be logged by the caller).
+ *libraryError = nullptr;
+ *libraryErrorLength = 0;
+ return;
+ }
+ memcpy(buf, errorString.c_str(), bufLen);
+ *libraryError = reinterpret_cast(buf);
+ }
+ else
+ {
+ // Explicitly clear the out-parameters so callers that don't
+ // pre-initialize them see a well-defined "no error" state.
+ *libraryError = nullptr;
+ *libraryErrorLength = 0;
+ }
+}
+
+//--------------------------------------------------------------------------------------------------
+// Name: InstallExternalLibrary
+//
+// Description:
+// Installs an external library to the specified directory.
+// The library file may be a ZIP archive or a raw DLL.
+// If a ZIP, and it contains an inner zip, that inner zip is extracted to the
+// install directory. Otherwise, all files are copied directly.
+//
+// Returns:
+// SQL_SUCCESS on success, else SQL_ERROR
+//
+SQLRETURN InstallExternalLibrary(
+ SQLGUID setupSessionId,
+ SQLCHAR *libraryName,
+ SQLINTEGER libraryNameLength,
+ SQLCHAR *libraryFile,
+ SQLINTEGER libraryFileLength,
+ SQLCHAR *libraryInstallDirectory,
+ SQLINTEGER libraryInstallDirectoryLength,
+ SQLCHAR **libraryError,
+ SQLINTEGER *libraryErrorLength)
+{
+ LOG("nativecsharpextension::InstallExternalLibrary");
+
+ SQLRETURN result = SQL_ERROR;
+
+ if (g_dotnet_runtime == nullptr)
+ {
+ SetLibraryError(
+ "Extension not initialized. Call Init before InstallExternalLibrary.",
+ libraryError,
+ libraryErrorLength);
+ }
+ else
+ {
+ result = g_dotnet_runtime->call_managed_method(
+ nameof(InstallExternalLibrary),
+ setupSessionId,
+ libraryName,
+ libraryNameLength,
+ libraryFile,
+ libraryFileLength,
+ libraryInstallDirectory,
+ libraryInstallDirectoryLength,
+ libraryError,
+ libraryErrorLength);
+ }
+
+ return result;
+}
+
+//--------------------------------------------------------------------------------------------------
+// Name: UninstallExternalLibrary
+//
+// Description:
+// Uninstalls an external library from the specified directory.
+//
+// Returns:
+// SQL_SUCCESS on success, else SQL_ERROR
+//
+SQLRETURN UninstallExternalLibrary(
+ SQLGUID setupSessionId,
+ SQLCHAR *libraryName,
+ SQLINTEGER libraryNameLength,
+ SQLCHAR *libraryInstallDirectory,
+ SQLINTEGER libraryInstallDirectoryLength,
+ SQLCHAR **libraryError,
+ SQLINTEGER *libraryErrorLength)
+{
+ LOG("nativecsharpextension::UninstallExternalLibrary");
+
+ SQLRETURN result = SQL_ERROR;
+
+ if (g_dotnet_runtime == nullptr)
+ {
+ SetLibraryError(
+ "Extension not initialized. Call Init before UninstallExternalLibrary.",
+ libraryError,
+ libraryErrorLength);
+ }
+ else
+ {
+ result = g_dotnet_runtime->call_managed_method(
+ nameof(UninstallExternalLibrary),
+ setupSessionId,
+ libraryName,
+ libraryNameLength,
+ libraryInstallDirectory,
+ libraryInstallDirectoryLength,
+ libraryError,
+ libraryErrorLength);
+ }
+
+ return result;
}
\ No newline at end of file
diff --git a/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h b/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h
index c9ac6569..c99e537c 100644
--- a/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h
+++ b/language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h
@@ -99,6 +99,26 @@ typedef SQLRETURN FN_cleanupSession(
typedef SQLRETURN FN_cleanup();
+typedef SQLRETURN FN_installExternalLibrary(
+ SQLGUID, // setupSessionId
+ SQLCHAR *, // libraryName
+ SQLINTEGER, // libraryNameLength
+ SQLCHAR *, // libraryFile
+ SQLINTEGER, // libraryFileLength
+ SQLCHAR *, // libraryInstallDirectory
+ SQLINTEGER, // libraryInstallDirectoryLength
+ SQLCHAR **, // libraryError
+ SQLINTEGER *);// libraryErrorLength
+
+typedef SQLRETURN FN_uninstallExternalLibrary(
+ SQLGUID, // setupSessionId
+ SQLCHAR *, // libraryName
+ SQLINTEGER, // libraryNameLength
+ SQLCHAR *, // libraryInstallDirectory
+ SQLINTEGER, // libraryInstallDirectoryLength
+ SQLCHAR **, // libraryError
+ SQLINTEGER *);// libraryErrorLength
+
namespace ExtensionApiTest
{
// Forward declaration
@@ -341,7 +361,7 @@ namespace ExtensionApiTest
// User library name and class full name
// The name of the library is same as the dll file name.
//
- const std::string m_UserLibName = "Microsoft.SqlServer.CSharpExtensionTest.dll";;
+ const std::string m_UserLibName = "Microsoft.SqlServer.CSharpExtensionTest.dll";
const std::string m_UserClassFullName = "Microsoft.SqlServer.CSharpExtensionTest.CSharpTestExecutor";
const std::string m_Separator = ";";
@@ -451,6 +471,14 @@ namespace ExtensionApiTest
// Pointer to the Cleanup function
//
static FN_cleanup *sm_cleanupFuncPtr;
+
+ // Pointer to the InstallExternalLibrary function
+ //
+ static FN_installExternalLibrary *sm_installExternalLibraryFuncPtr;
+
+ // Pointer to the UninstallExternalLibrary function
+ //
+ static FN_uninstallExternalLibrary *sm_uninstallExternalLibraryFuncPtr;
};
// ColumnInfo template class to store information
diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CMakeLists.txt b/language-extensions/dotnet-core-CSharp/test/src/native/CMakeLists.txt
index 4a10e9db..952df261 100644
--- a/language-extensions/dotnet-core-CSharp/test/src/native/CMakeLists.txt
+++ b/language-extensions/dotnet-core-CSharp/test/src/native/CMakeLists.txt
@@ -23,7 +23,10 @@ add_executable(dotnet-core-CSharp-extension-test
${DOTNETCORE_CSHARP_EXTENSION_TEST_SOURCE_FILES}
)
-target_compile_options(dotnet-core-CSharp-extension-test PRIVATE --std=c++17)
+target_compile_options(dotnet-core-CSharp-extension-test PRIVATE
+ "$<$:/std:c++17>"
+ "$<$>:-std=c++17>"
+)
# Set the DLLEXPORT variable to export symbols
#
diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp
index 4ca858b4..f7d492ed 100644
--- a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp
+++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp
@@ -103,9 +103,12 @@ namespace ExtensionApiTest
//
TEST_F(CSharpExtensionApiTests, ExecuteInvalidLibraryNameScriptTest)
{
- // Unmatched library name with the dll file name.
+ // Use a library name that cannot resolve to any DLL on the search path.
+ // The pre-PR literal "Microsoft.SqlServer.CSharpExtensionTest" is the basename of
+ // m_UserLibName ("Microsoft.SqlServer.CSharpExtensionTest.dll"), so the loader now
+ // resolves it successfully and the test would fail to observe the expected error.
//
- string userLibName = "Microsoft.SqlServer.CSharpExtensionTest";
+ string userLibName = "NonExistentLibrary";
string scriptString = userLibName + m_Separator + m_UserClassFullName;
InitializeSession(
0, // inputSchemaColumnsNumber
diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp
index 33672e4a..150167f6 100644
--- a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp
+++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp
@@ -31,6 +31,8 @@ namespace ExtensionApiTest
FN_getOutputParam *CSharpExtensionApiTests::sm_getOutputParamFuncPtr = nullptr;
FN_cleanupSession *CSharpExtensionApiTests::sm_cleanupSessionFuncPtr = nullptr;
FN_cleanup *CSharpExtensionApiTests::sm_cleanupFuncPtr = nullptr;
+ FN_installExternalLibrary *CSharpExtensionApiTests::sm_installExternalLibraryFuncPtr = nullptr;
+ FN_uninstallExternalLibrary *CSharpExtensionApiTests::sm_uninstallExternalLibraryFuncPtr = nullptr;
//----------------------------------------------------------------------------------------------
// Name: CSharpExtensionApiTest::SetUpTestCase
@@ -250,6 +252,14 @@ namespace ExtensionApiTest
sm_cleanupFuncPtr = reinterpret_cast(GetProcAddress(sm_libHandle, "Cleanup"));
EXPECT_TRUE(sm_cleanupFuncPtr != nullptr);
+
+ sm_installExternalLibraryFuncPtr = reinterpret_cast(
+ GetProcAddress(sm_libHandle, "InstallExternalLibrary"));
+ EXPECT_TRUE(sm_installExternalLibraryFuncPtr != nullptr);
+
+ sm_uninstallExternalLibraryFuncPtr = reinterpret_cast(
+ GetProcAddress(sm_libHandle, "UninstallExternalLibrary"));
+ EXPECT_TRUE(sm_uninstallExternalLibraryFuncPtr != nullptr);
}
//----------------------------------------------------------------------------------------------
diff --git a/language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp
new file mode 100644
index 00000000..ea163807
--- /dev/null
+++ b/language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp
@@ -0,0 +1,2103 @@
+//*********************************************************************
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+//
+// @File: CSharpLibraryTests.cpp
+//
+// Purpose:
+// Tests the .NET Core CSharpExtension's implementation of the
+// InstallExternalLibrary and UninstallExternalLibrary APIs.
+// Uses the CSharpExtensionApiTests fixture to share the single
+// .NET runtime initialization.
+//
+//*********************************************************************
+#include "CSharpExtensionApiTests.h"
+#include
+#include
+#include
+
+using namespace std;
+namespace fs = std::filesystem;
+
+namespace ExtensionApiTest
+{
+ // Helper: get path to test_packages directory
+ //
+ static string GetPackagesPath()
+ {
+ string result;
+ const char *enlRoot = getenv("ENL_ROOT");
+
+ if (enlRoot != nullptr)
+ {
+ result = (fs::path(enlRoot) /
+ "language-extensions" / "dotnet-core-CSharp" / "test" / "test_packages").string();
+ }
+ else
+ {
+ // Fallback: navigate from executable path
+ char path[MAX_PATH + 1] = { 0 };
+ GetModuleFileName(NULL, path, MAX_PATH);
+ fs::path buildOutputPath = fs::path(path).parent_path().parent_path().parent_path().parent_path();
+ result = (buildOutputPath.parent_path().parent_path() /
+ "language-extensions" / "dotnet-core-CSharp" / "test" / "test_packages").string();
+ }
+
+ return result;
+ }
+
+ // Helper: create a clean temporary install directory
+ //
+ static string CreateInstallDir()
+ {
+ char path[MAX_PATH + 1] = { 0 };
+ GetModuleFileName(NULL, path, MAX_PATH);
+ string installDir = (fs::path(path).parent_path() / "testInstallLibs").string();
+
+ if (fs::exists(installDir))
+ {
+ fs::remove_all(installDir);
+ }
+ fs::create_directories(installDir);
+
+ return installDir;
+ }
+
+ // Helper: clean up install directory
+ //
+ static void CleanupInstallDir(const string &installDir)
+ {
+ if (fs::exists(installDir))
+ {
+ fs::remove_all(installDir);
+ }
+ }
+
+ // Helper: release an unmanaged error buffer returned from Install/
+ // Uninstall External Library. The managed implementation allocates
+ // these via Marshal.AllocHGlobal (see CSharpExtension.cs::SetLibraryError),
+ // which on Windows is backed by LocalAlloc -- the matching deallocator
+ // is LocalFree. Production ExtHost frees the buffer the same way; the
+ // tests must do so too, otherwise every SQL_ERROR path (and every
+ // success path, since a null libError is just a no-op LocalFree) leaks
+ // unmanaged memory. With 113 tests, many of which intentionally trigger
+ // SQL_ERROR, the leak accumulation was non-trivial across a single
+ // gtest run.
+ //
+ // The native pre-flight path in nativecsharpextension.cpp also allocates
+ // libError, but uses malloc() and not LocalAlloc. On Windows, LocalFree
+ // on a malloc'd pointer is undefined behavior. The tests in this file
+ // only exercise the managed entry points (Install/UninstallExternalLibrary
+ // in Microsoft.SqlServer.CSharpExtension.dll), so every libError they
+ // ever see is AllocHGlobal/LocalAlloc'd and LocalFree is correct here.
+ //
+ static void FreeLibError(SQLCHAR *libError)
+ {
+ if (libError != nullptr)
+ {
+ LocalFree(reinterpret_cast(libError));
+ }
+ }
+
+ // Helper: call InstallExternalLibrary and check result
+ //
+ static SQLRETURN CallInstall(
+ FN_installExternalLibrary *installFunc,
+ const string &libName,
+ const string &libFilePath,
+ const string &installDir)
+ {
+ SQLCHAR *libError = nullptr;
+ SQLINTEGER libErrorLength = 0;
+
+ SQLRETURN result = (*installFunc)(
+ SQLGUID(),
+ reinterpret_cast(const_cast(libName.c_str())),
+ static_cast(libName.length()),
+ reinterpret_cast(const_cast(libFilePath.c_str())),
+ static_cast(libFilePath.length()),
+ reinterpret_cast(const_cast(installDir.c_str())),
+ static_cast(installDir.length()),
+ &libError,
+ &libErrorLength);
+
+ // Release the unmanaged error buffer (if any) before returning, so
+ // the SQL_ERROR-path tests don't accumulate unmanaged allocations
+ // across the run. See FreeLibError above for rationale.
+ FreeLibError(libError);
+ return result;
+ }
+
+ // Helper: call UninstallExternalLibrary and check result
+ //
+ static SQLRETURN CallUninstall(
+ FN_uninstallExternalLibrary *uninstallFunc,
+ const string &libName,
+ const string &installDir)
+ {
+ SQLCHAR *libError = nullptr;
+ SQLINTEGER libErrorLength = 0;
+
+ SQLRETURN result = (*uninstallFunc)(
+ SQLGUID(),
+ reinterpret_cast(const_cast(libName.c_str())),
+ static_cast(libName.length()),
+ reinterpret_cast(const_cast(installDir.c_str())),
+ static_cast(installDir.length()),
+ &libError,
+ &libErrorLength);
+
+ FreeLibError(libError);
+ return result;
+ }
+
+ // Helper: check if directory has any files or subdirectories
+ //
+ static bool DoesDirectoryHaveFiles(const string &dir)
+ {
+ bool hasFiles = false;
+
+ for (const auto &entry : fs::directory_iterator(dir))
+ {
+ hasFiles = true;
+ break;
+ }
+
+ return hasFiles;
+ }
+
+ // Helper: call InstallExternalLibrary and capture the error message (if any).
+ // Returns SQL result; populates errorMessage with the UTF-8 error text.
+ //
+ static SQLRETURN CallInstallCaptureError(
+ FN_installExternalLibrary *installFunc,
+ const string &libName,
+ const string &libFilePath,
+ const string &installDir,
+ string &errorMessage)
+ {
+ SQLCHAR *libError = nullptr;
+ SQLINTEGER libErrorLength = 0;
+
+ SQLRETURN result = (*installFunc)(
+ SQLGUID(),
+ reinterpret_cast(const_cast(libName.c_str())),
+ static_cast(libName.length()),
+ reinterpret_cast(const_cast(libFilePath.c_str())),
+ static_cast(libFilePath.length()),
+ reinterpret_cast(const_cast(installDir.c_str())),
+ static_cast(installDir.length()),
+ &libError,
+ &libErrorLength);
+
+ errorMessage.clear();
+ if (libError != nullptr && libErrorLength > 0)
+ {
+ errorMessage.assign(reinterpret_cast(libError),
+ static_cast(libErrorLength));
+ }
+
+ // Release the unmanaged error buffer AFTER copying its contents into
+ // the std::string. See FreeLibError above for the allocator-pairing
+ // rationale.
+ FreeLibError(libError);
+ return result;
+ }
+
+ // Helper: count GUID-shaped subdirectories in a directory (temp folders
+ // created by the install code during ZIP extraction).
+ //
+ static int CountGuidTempDirs(const string &dir)
+ {
+ int count = 0;
+ if (!fs::exists(dir))
+ {
+ return 0;
+ }
+ for (const auto &entry : fs::directory_iterator(dir))
+ {
+ if (!fs::is_directory(entry.path()))
+ {
+ continue;
+ }
+ string name = entry.path().filename().string();
+ // GUID format: 8-4-4-4-12 = 36 chars with hyphens at 8,13,18,23
+ if (name.length() == 36 &&
+ name[8] == '-' && name[13] == '-' &&
+ name[18] == '-' && name[23] == '-')
+ {
+ ++count;
+ }
+ }
+ return count;
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipContainingZipTest
+ //
+ // Description:
+ // Tests installing an outer zip that contains an inner zip.
+ // Verifies that files from the inner zip are extracted to the install directory.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipContainingZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackageA", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Assert on the specific files we expect from the inner-zip
+ // contents rather than just "directory is non-empty" -- the weaker
+ // form passes even if the install extracts the wrong package or
+ // leaves stale files behind from a prior test run.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.dll"))
+ << "Expected testpackageA.dll not extracted from inner zip";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.txt"))
+ << "Expected testpackageA.txt not extracted from inner zip";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InnerZipFutureSymlinkRejectedTest
+ //
+ // Description:
+ // Regression for PR #85 / option α. The inner-zip install path must
+ // route through tempFolder + IsReparsePoint-guarded CopyDirectory --
+ // NOT a direct ZipFile.ExtractToDirectory(innerZip, installDir) call.
+ // testpackageK-SYMLINK.zip is an outer zip whose inner zip contains:
+ //
+ // legitfile.dll regular file
+ // evil-symlink.dll Unix mode 0o120755 (symbolic link, target
+ // "/etc/passwd")
+ //
+ // Today's .NET ZipFile.ExtractToDirectory ignores Unix mode bits on
+ // every platform: it writes evil-symlink.dll as a regular file
+ // containing the literal text "/etc/passwd". A future .NET runtime
+ // (or a switch to a different extraction library) might honor those
+ // bits and create a real symlink in the staged tree -- at which point
+ // a direct extract-to-installDir would silently land an attacker-
+ // controlled symlink in the live library directory.
+ //
+ // This test asserts the future-proofing invariant: regardless of how
+ // the runtime materializes the symlink-mode entry, installDir must
+ // contain NO reparse points after install. If the runtime starts
+ // honoring the bits, the IsReparsePoint guard in CopyDirectory must
+ // skip evil-symlink.dll; if the runtime keeps writing it as a regular
+ // file, the test still passes (no reparse points exist). The test
+ // fails only if BOTH (a) the runtime honors the bits AND (b) the
+ // IsReparsePoint guard regresses.
+ //
+ // Also asserts that install completes successfully and that the
+ // legitimate file lands in installDir, so a regression that simply
+ // fails the install on encountering a symlink-mode entry is also
+ // caught.
+ //
+ TEST_F(CSharpExtensionApiTests, InnerZipFutureSymlinkRejectedTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageK-SYMLINK.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath))
+ << "Symlink fixture missing -- regenerate with build-symlink-fixture.ps1";
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "symlinklib", packagePath, installDir);
+ ASSERT_EQ(result, SQL_SUCCESS)
+ << "Install must succeed -- symlink-mode entries are skipped, not fatal";
+
+ // The legitimate file must be present (proves install actually ran).
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "legitfile.dll"))
+ << "Expected legitfile.dll missing -- inner-zip path may not have run";
+
+ // The future-proofing invariant: nothing in installDir is a reparse
+ // point. If a future runtime materializes evil-symlink.dll as a
+ // real symlink in tempFolder, the IsReparsePoint guard in
+ // CopyDirectory must skip it so installDir stays clean.
+ std::error_code ec;
+ for (const auto &entry : fs::recursive_directory_iterator(installDir, ec))
+ {
+ // is_symlink follows std::filesystem semantics: returns true
+ // for both file and directory symlinks on Linux, and for
+ // SYMLINK / SYMLINKD reparse points on Windows.
+ EXPECT_FALSE(fs::is_symlink(entry.symlink_status()))
+ << "installDir contains a symlink (regression): " << entry.path();
+ }
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipContainingDllsTest
+ //
+ // Description:
+ // Tests installing an outer zip that contains DLLs directly (no inner zip).
+ // Verifies that the specific expected DLLs are extracted to the install
+ // directory.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipContainingDllsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackageB", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Assert on the specific files we expect from package B's contents.
+ // "any DLL exists" is too weak: it would pass even if the install
+ // extracted the wrong package or a stale file from a prior test
+ // survived.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"))
+ << "Expected testpackageB.dll not extracted";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"))
+ << "Expected testpackageB.deps.json not extracted";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipExtensionWithBadContentFailsLoudlyTest
+ //
+ // Description:
+ // Tests that installing a file with the .zip extension whose bytes
+ // are NOT a valid ZIP archive returns SQL_ERROR. Dispatch is by
+ // extension (not by content sniff), so a file the user named
+ // "bad-package-ZIP.zip" must be treated as a ZIP -- and if its bytes
+ // are not a real archive, ZipFile.ExtractToDirectory throws and the
+ // install must fail loudly. We must NOT silently rewrite the user's
+ // upload into a "{libName}.dll" raw install (which the previous
+ // content-sniff implementation did).
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipExtensionWithBadContentFailsLoudlyTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "bad-package-ZIP.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "bad-package", packagePath, installDir);
+ EXPECT_EQ(result, SQL_ERROR);
+
+ // The user's file must NOT have been silently installed under a
+ // ".dll" rename. Pre-fix behavior was to copy bad-package-ZIP.zip
+ // to "bad-package.dll" -- assert that did not happen.
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "bad-package.dll"))
+ << "Bad ZIP should not be silently rewritten as bad-package.dll";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallNonExistentFileTest
+ //
+ // Description:
+ // Tests that installing a non-existent file returns SQL_ERROR.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallNonExistentFileTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "nonexistent.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "nonexistent", packagePath, installDir);
+ EXPECT_EQ(result, SQL_ERROR);
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallLibraryTest
+ //
+ // Description:
+ // Tests installing a library, then uninstalling it, and verifying the
+ // install directory is empty.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallLibraryTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ // Install first
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackageB", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+ EXPECT_TRUE(DoesDirectoryHaveFiles(installDir)) << "No files found after installation";
+
+ // Uninstall
+ result = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "testpackageB", installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Verify directory is empty
+ EXPECT_FALSE(DoesDirectoryHaveFiles(installDir)) << "Files still present after uninstall";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: ReinstallLibraryTest
+ //
+ // Description:
+ // Tests installing a library, then reinstalling with a different package
+ // to verify overwrite behavior works correctly.
+ //
+ TEST_F(CSharpExtensionApiTests, ReinstallLibraryTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePathA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ string packagePathB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(packagePathA)) << "Test package not found: " << packagePathA;
+ ASSERT_TRUE(fs::exists(packagePathB)) << "Test package not found: " << packagePathB;
+
+ string installDir = CreateInstallDir();
+
+ // Install first package
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackage", packagePathA, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Install second package (overwrite)
+ result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackage", packagePathB, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Verify v1's unique files were removed by the manifest cleanup,
+ // and v2's expected file is present. The earlier "any .dll exists"
+ // loop is redundant given these explicit checks.
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.dll"))
+ << "Stale testpackageA.dll left behind after reinstall";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.txt"))
+ << "Stale testpackageA.txt left behind after reinstall";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"))
+ << "Expected testpackageB.dll not present after reinstall";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallEmptyZipTest
+ //
+ // Description:
+ // Tests that installing an empty zip (zero entries) returns SQL_ERROR.
+ // The install logic detects that the archive contains no entries and
+ // explicitly fails the operation rather than proceeding with an empty
+ // set of files.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallEmptyZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageC-EMPTY.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "emptypackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_ERROR);
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AlterToEmptyDirsZipPreservesV1Test
+ //
+ // Description:
+ // Regression test for the silent-data-loss scenario on ALTER. A ZIP
+ // containing only directory entries (no file entries) used to slip
+ // past the empty-archive guard because Directory.GetDirectories on
+ // the extracted tempFolder is non-empty. CollectRelativeFiles then
+ // returns 0 entries; CleanupManifest deletes the previous version's
+ // content; nothing is copied; and the manifest write is skipped
+ // because it is gated on extractedFiles.Count > 0. The library would
+ // end up GONE with no replacement and no manifest.
+ //
+ // After the fix, install fails with SQL_ERROR before any cleanup
+ // runs, so v1 must remain byte-for-byte intact.
+ //
+ TEST_F(CSharpExtensionApiTests, AlterToEmptyDirsZipPreservesV1Test)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ string emptyDirsZip = (fs::path(packagesPath) / "testpackageI-EMPTYDIRS.zip").string();
+ ASSERT_TRUE(fs::exists(pkgB));
+ ASSERT_TRUE(fs::exists(emptyDirsZip));
+
+ string installDir = CreateInstallDir();
+
+ // v1: install a real ZIP package as "myLib".
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", pkgB, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+
+ // v2: attempt ALTER with a ZIP whose only entries are empty
+ // directories. Must FAIL -- and v1 must survive intact.
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", emptyDirsZip, installDir);
+ EXPECT_EQ(r, SQL_ERROR)
+ << "ALTER to empty-dirs ZIP must fail rather than silently delete v1";
+
+ // v1's content must be untouched.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"))
+ << "v1's testpackageB.dll was deleted by failed ALTER";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"))
+ << "v1's testpackageB.deps.json was deleted by failed ALTER";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"))
+ << "v1's manifest was deleted by failed ALTER";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipWithNestedDirectoriesTest
+ //
+ // Description:
+ // Tests that installing a zip with nested subdirectories (e.g.,
+ // lib/net8.0/, runtimes/win-x64/) preserves the full directory tree.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipWithNestedDirectoriesTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageD-NESTED.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "nestedpackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Verify subdirectories were preserved
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0" / "MyLib.dll"))
+ << "Nested file lib/net8.0/MyLib.dll not found";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "runtimes" / "win-x64" / "native.dll"))
+ << "Nested file runtimes/win-x64/native.dll not found";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "MyLib.deps.json"))
+ << "Root file MyLib.deps.json not found";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallRawDllNotZipTest
+ //
+ // Description:
+ // Tests that installing a raw DLL file (not a zip) copies it to the
+ // install directory named after the library and returns SQL_SUCCESS.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallRawDllNotZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "rawdllpackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // The raw DLL should be copied using the library name with .dll extension.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "rawdllpackage.dll"))
+ << "Raw DLL not found in install directory as rawdllpackage.dll";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipWithSpacesInFilenamesTest
+ //
+ // Description:
+ // Tests that installing a zip whose entries contain spaces in filenames
+ // extracts correctly.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipWithSpacesInFilenamesTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageF-SPACES.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "spacespackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "My Library.dll"))
+ << "File with spaces 'My Library.dll' not found";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "config file.json"))
+ << "File with spaces 'config file.json' not found";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipWithManyFilesTest
+ //
+ // Description:
+ // Tests that installing a ZIP containing many files extracts all of
+ // them correctly AND that the alias is created on top.
+ //
+ // Package contents: testpackageG-MANYFILES.zip contains exactly 50
+ // DLLs (Module1.dll .. Module50.dll) and no other files. None of
+ // them matches "manyfilespackage.*", so install must clone the first
+ // DLL as an alias named "manyfilespackage.dll". The DLL count in
+ // installDir is therefore 50 (extracted) + 1 (alias) = 51.
+ //
+ // Historical note: a pre-PR version of this test asserted
+ // EXPECT_EQ(dllCount, 50) and EXPECT_TRUE(fs::exists(... /
+ // "manyfilespackage")) (alias with no .dll extension). It passed
+ // legitimately at the time because the install code created the
+ // alias as "{libName}" with no extension -- so dllCount stayed at 50
+ // and the no-extension EXPECT_TRUE matched. Test and code agreed,
+ // but the alias was un-loadable as a DLL on Windows. The test
+ // caught no regression in alias naming because its assertions were
+ // written to match the buggy behavior. Current assertions
+ // (51 + .dll extension + per-module name check below) pin the
+ // correct shape so a future regression toward "alias with wrong
+ // extension" or "extracted file silently dropped" is caught.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipWithManyFilesTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageG-MANYFILES.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "manyfilespackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Count DLL files in installDir. 50 from the package + 1 alias = 51.
+ int dllCount = 0;
+ for (const fs::directory_entry &entry : fs::directory_iterator(installDir))
+ {
+ if (entry.path().extension() == ".dll")
+ {
+ dllCount++;
+ }
+ }
+ EXPECT_EQ(dllCount, 51)
+ << "Expected 51 DLL files (50 extracted + 1 alias), found " << dllCount;
+
+ // Per-module existence check. Catches a "silently dropped one
+ // extracted file but added another spurious .dll so the count
+ // still totals 51" regression that the bare count above would miss.
+ for (int i = 1; i <= 50; ++i)
+ {
+ string moduleName = "Module" + std::to_string(i) + ".dll";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / moduleName))
+ << "Expected extracted module missing: " << moduleName;
+ }
+
+ // Verify the alias exists with the .dll extension so DllUtils can
+ // discover the library by name. The .dll extension is critical:
+ // an alias without it would still be findable by DllUtils's
+ // "{libName}.*" wildcard but would be un-loadable as a DLL on
+ // Windows, which is exactly the bug the historical test missed.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "manyfilespackage.dll"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipSlipTest
+ //
+ // Description:
+ // Tests that installing a zip containing path-traversal entries (e.g., "../../malicious.dll")
+ // returns SQL_ERROR. .NET 8's ZipFile.ExtractToDirectory has built-in protection against
+ // zip-slip attacks and throws IOException for such entries.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipSlipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageH-ZIPSLIP.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "zipslippackage", packagePath, installDir);
+ EXPECT_EQ(result, SQL_ERROR);
+
+ // Verify no files were written outside the install directory
+ fs::path parentDir = fs::path(installDir).parent_path();
+ EXPECT_FALSE(fs::exists(parentDir / "malicious.dll"))
+ << "Zip-slip attack: file escaped install directory";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: DoubleUninstallTest
+ //
+ // Description:
+ // Tests that uninstalling a library twice is idempotent. The second
+ // uninstall should succeed even though the library is already gone.
+ //
+ TEST_F(CSharpExtensionApiTests, DoubleUninstallTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ // Install, then uninstall twice
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "doubleuninstall", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ result = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "doubleuninstall", installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ result = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "doubleuninstall", installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallNonExistentLibraryTest
+ //
+ // Description:
+ // Tests that uninstalling a library that was never installed succeeds.
+ // The install directory may or may not exist; either way the operation
+ // should not fail.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallNonExistentLibraryTest)
+ {
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "neverinstalled", installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Helper: read manifest file into a vector of relative paths
+ //
+ static vector ReadManifest(const string &manifestPath)
+ {
+ vector lines;
+ ifstream f(manifestPath);
+ string line;
+ while (getline(f, line))
+ {
+ if (!line.empty() && line.back() == '\r')
+ {
+ line.pop_back();
+ }
+ if (!line.empty())
+ {
+ lines.push_back(line);
+ }
+ }
+ return lines;
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: ManifestWrittenTest
+ //
+ // Description:
+ // Verifies that after installing a ZIP package, a manifest file named
+ // "{libName}.manifest" is written in the install directory and lists
+ // every file extracted from the package.
+ //
+ TEST_F(CSharpExtensionApiTests, ManifestWrittenTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath));
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "testpackageB", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ string manifestPath = (fs::path(installDir) / "testpackageB.manifest").string();
+ ASSERT_TRUE(fs::exists(manifestPath)) << "Manifest file not created";
+
+ vector entries = ReadManifest(manifestPath);
+ EXPECT_GE(entries.size(), 2u) << "Manifest should list at least 2 extracted files";
+
+ // Manifest entries are EXACT relative paths -- assert equality, not
+ // substring containment. Substring match would accept "x.dll",
+ // "testpackageB.dll.bak", etc. and miss real bugs.
+ bool hasDll = false;
+ bool hasDeps = false;
+ for (const string &e : entries)
+ {
+ if (e == "testpackageB.dll") { hasDll = true; }
+ if (e == "testpackageB.deps.json") { hasDeps = true; }
+ }
+ EXPECT_TRUE(hasDll) << "Manifest missing exact entry 'testpackageB.dll'";
+ EXPECT_TRUE(hasDeps) << "Manifest missing exact entry 'testpackageB.deps.json'";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: ManifestListsNestedFilesTest
+ //
+ // Description:
+ // Verifies that the manifest records nested file paths using the
+ // relative path form so that uninstall can locate the files.
+ //
+ TEST_F(CSharpExtensionApiTests, ManifestListsNestedFilesTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageD-NESTED.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath));
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "nestedlib", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ string manifestPath = (fs::path(installDir) / "nestedlib.manifest").string();
+ ASSERT_TRUE(fs::exists(manifestPath));
+
+ vector entries = ReadManifest(manifestPath);
+
+ bool hasNestedDll = false;
+ bool hasRuntimeDll = false;
+ for (const auto &e : entries)
+ {
+ // Accept either separator for cross-platform resilience
+ if (e.find("MyLib.dll") != string::npos &&
+ (e.find("net8.0") != string::npos))
+ {
+ hasNestedDll = true;
+ }
+
+ if (e.find("native.dll") != string::npos &&
+ e.find("win-x64") != string::npos)
+ {
+ hasRuntimeDll = true;
+ }
+ }
+ EXPECT_TRUE(hasNestedDll) << "Manifest missing lib/net8.0/MyLib.dll entry";
+ EXPECT_TRUE(hasRuntimeDll) << "Manifest missing runtimes/win-x64/native.dll entry";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallLibNameAliasTest
+ //
+ // Description:
+ // When the ZIP does not contain a file matching "{libName}.*", the
+ // install routine creates an alias named "{libName}.dll" so that
+ // DllUtils.CreateDllList (which searches "{libName}.*") can find it.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallLibNameAliasTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath));
+
+ string installDir = CreateInstallDir();
+
+ // Library name "myAlias" does not match the package's testpackageB.*
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myAlias", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ fs::path aliasFile = fs::path(installDir) / "myAlias.dll";
+ fs::path sourceDll = fs::path(installDir) / "testpackageB.dll";
+ ASSERT_TRUE(fs::exists(aliasFile))
+ << "Expected alias file 'myAlias.dll' not found";
+ ASSERT_TRUE(fs::exists(sourceDll))
+ << "Expected source file 'testpackageB.dll' not extracted";
+
+ // Alias must be a byte-for-byte copy of the source DLL (the first
+ // .dll discovered in the package). A zero-length alias, a copy of
+ // the wrong file, or a partial write would all silently pass the
+ // "file exists" check above without this content check.
+ EXPECT_EQ(fs::file_size(aliasFile), fs::file_size(sourceDll))
+ << "Alias file size differs from source DLL";
+
+ std::ifstream aliasStream(aliasFile.string(), std::ios::binary);
+ std::ifstream sourceStream(sourceDll.string(), std::ios::binary);
+ std::string aliasContents(
+ (std::istreambuf_iterator(aliasStream)),
+ std::istreambuf_iterator());
+ std::string sourceContents(
+ (std::istreambuf_iterator(sourceStream)),
+ std::istreambuf_iterator());
+ // Explicit close (rather than relying on RAII at end-of-scope) so
+ // the file handles are released before the EXPECT_EQ comparison
+ // -- gtest macros that fail can run arbitrary code in the
+ // diagnostic path, and the comparison itself is cheaper to debug
+ // when the streams are known-closed. Same pattern is used at
+ // every read/write site below.
+ aliasStream.close();
+ sourceStream.close();
+ EXPECT_EQ(aliasContents, sourceContents)
+ << "Alias file contents differ from source DLL";
+
+ // Manifest should include the alias.
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "myAlias.manifest").string());
+ bool hasAlias = false;
+ for (const string &e : entries)
+ {
+ if (e == "myAlias.dll")
+ {
+ hasAlias = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(hasAlias) << "Manifest missing alias entry 'myAlias.dll'";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AliasCreatedWhenOnlySidecarsAtRootTest
+ //
+ // Description:
+ // Regression test for the alias-suppression tightening in
+ // DetermineAliasSource. The fixture testpackageL-SIDECAR.zip contains
+ // ONLY two root-level sidecars (foo.deps.json, foo.runtimeconfig.json)
+ // and the actual DLL nested at lib/net8.0/foo.dll.
+ //
+ // Pre-fix, DetermineAliasSource matched any root file whose name
+ // began with "{libName}." (StartsWith). The two sidecars satisfied
+ // that check, so alias creation was suppressed -- the install ended
+ // up with no root-level "foo.dll" and DllUtils.CreateDllList (which
+ // walks only the top level) could not find the library at load time.
+ //
+ // Post-fix, suppression requires an EXACT match against the alias
+ // filename ("foo.dll"). The sidecars no longer suppress, so the
+ // nested DLL is cloned to the root as the alias and the library is
+ // loadable.
+ //
+ TEST_F(CSharpExtensionApiTests, AliasCreatedWhenOnlySidecarsAtRootTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageL-SIDECAR.zip").string();
+ ASSERT_TRUE(fs::exists(packagePath))
+ << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "foo", packagePath, installDir);
+ EXPECT_EQ(result, SQL_SUCCESS);
+
+ // Both sidecars must have been extracted to the root unchanged.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "foo.deps.json"))
+ << "Sidecar foo.deps.json missing from installDir";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "foo.runtimeconfig.json"))
+ << "Sidecar foo.runtimeconfig.json missing from installDir";
+
+ // The nested DLL must have been extracted intact.
+ fs::path nestedDll = fs::path(installDir) / "lib" / "net8.0" / "foo.dll";
+ ASSERT_TRUE(fs::exists(nestedDll))
+ << "Nested DLL lib/net8.0/foo.dll missing from installDir";
+
+ // Core regression assertion: the alias MUST exist at the root
+ // (post-fix). Pre-fix, this file was absent and the test would
+ // fail here, demonstrating the bug.
+ fs::path aliasFile = fs::path(installDir) / "foo.dll";
+ ASSERT_TRUE(fs::exists(aliasFile))
+ << "Expected root-level alias 'foo.dll' was not created -- "
+ << "alias suppression incorrectly fired on a sidecar match";
+
+ // Alias must be a byte-for-byte copy of the nested DLL.
+ EXPECT_EQ(fs::file_size(aliasFile), fs::file_size(nestedDll))
+ << "Alias size differs from nested DLL";
+
+ std::ifstream aliasStream(aliasFile.string(), std::ios::binary);
+ std::ifstream nestedStream(nestedDll.string(), std::ios::binary);
+ std::string aliasContents(
+ (std::istreambuf_iterator(aliasStream)),
+ std::istreambuf_iterator());
+ std::string nestedContents(
+ (std::istreambuf_iterator(nestedStream)),
+ std::istreambuf_iterator());
+ // Explicit close before EXPECT_EQ so the file handles are released
+ // before any failure-diagnostic code runs (same pattern as
+ // InstallLibNameAliasTest above).
+ aliasStream.close();
+ nestedStream.close();
+ EXPECT_EQ(aliasContents, nestedContents)
+ << "Alias contents differ from nested DLL";
+
+ // Manifest should include the alias.
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "foo.manifest").string());
+ bool hasAlias = false;
+ for (const string &e : entries)
+ {
+ if (e == "foo.dll")
+ {
+ hasAlias = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(hasAlias) << "Manifest missing alias entry 'foo.dll'";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: NonConflictingFlatFilesCoexistTest
+ //
+ // Description:
+ // Installing two libraries into the same install directory succeeds
+ // as long as they do not share any filenames. Both packages used here
+ // are flat (no nested directories), so the test exercises the
+ // flat-file coexistence case only -- nested-directory overlap is
+ // covered separately by ManifestListsNestedFilesTest +
+ // InnerZipFileConflictFailsTest.
+ //
+ TEST_F(CSharpExtensionApiTests, NonConflictingFlatFilesCoexistTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(pkgA));
+ ASSERT_TRUE(fs::exists(pkgB));
+
+ string installDir = CreateInstallDir();
+
+ // Install lib1 from package A (contents: testpackageA.dll, testpackageA.txt)
+ SQLRETURN r1 = CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgA, installDir);
+ EXPECT_EQ(r1, SQL_SUCCESS);
+
+ // Install lib2 from package B (contents: testpackageB.dll, testpackageB.deps.json)
+ // No filename conflict => succeeds
+ SQLRETURN r2 = CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgB, installDir);
+ EXPECT_EQ(r2, SQL_SUCCESS);
+
+ // Both libraries' files coexist
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib1.manifest"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib2.manifest"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: FileConflictFailsTest
+ //
+ // Description:
+ // Installing a second library that would overwrite a file already
+ // installed by another library must fail with SQL_ERROR. The first
+ // library's files must remain intact.
+ //
+ TEST_F(CSharpExtensionApiTests, FileConflictFailsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(pkgB));
+
+ string installDir = CreateInstallDir();
+
+ // Install "lib1" from package B
+ SQLRETURN r1 = CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgB, installDir);
+ EXPECT_EQ(r1, SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+
+ // Install a DIFFERENT library "lib2" from the same package.
+ // Both would write testpackageB.dll => conflict, must fail.
+ SQLRETURN r2 = CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgB, installDir);
+ EXPECT_EQ(r2, SQL_ERROR) << "Expected conflict error on duplicate filename";
+
+ // lib1's files must survive
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib1.manifest"));
+ // lib2 must not have written a manifest
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib2.manifest"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallPreservesOtherLibrariesTest
+ //
+ // Description:
+ // Uninstalling one library must only delete that library's files
+ // (as listed in its manifest). Other libraries' files in the same
+ // directory must remain untouched.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallPreservesOtherLibrariesTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgA, installDir), SQL_SUCCESS);
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgB, installDir), SQL_SUCCESS);
+
+ // Uninstall lib1 only
+ SQLRETURN r = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "lib1", installDir);
+ EXPECT_EQ(r, SQL_SUCCESS);
+
+ // lib1's files + manifest gone
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.txt"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib1.manifest"));
+
+ // lib2's files + manifest intact
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib2.manifest"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallRemovesEmptyNestedDirsTest
+ //
+ // Description:
+ // After uninstalling a library whose files lived in nested
+ // subdirectories, the now-empty nested directories are removed
+ // bottom-up.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallRemovesEmptyNestedDirsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgD = (fs::path(packagesPath) / "testpackageD-NESTED.zip").string();
+ ASSERT_TRUE(fs::exists(pkgD));
+
+ string installDir = CreateInstallDir();
+
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "nestedlib", pkgD, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "runtimes" / "win-x64"));
+
+ // Uninstall
+ SQLRETURN r = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "nestedlib", installDir);
+ EXPECT_EQ(r, SQL_SUCCESS);
+
+ // Nested directories should have been removed when they became empty
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib" / "net8.0"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "runtimes" / "win-x64"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "runtimes"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AlterExternalLibraryTest
+ //
+ // Description:
+ // Installing a library a second time with the same libName (simulating
+ // ALTER EXTERNAL LIBRARY) removes the old content tracked by the
+ // previous manifest before extracting the new package, even when the
+ // new package would otherwise conflict with leftover files.
+ //
+ TEST_F(CSharpExtensionApiTests, AlterExternalLibraryTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ // v1: install as "myLib" from package A => testpackageA.dll, testpackageA.txt
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", pkgA, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+
+ // v2: install again as "myLib" from package B (ALTER)
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", pkgB, installDir);
+ EXPECT_EQ(r, SQL_SUCCESS) << "ALTER-style reinstall should succeed";
+
+ // v1's unique files gone
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.txt"));
+
+ // v2's files present
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"));
+
+ // Manifest reflects v2 content only
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "myLib.manifest").string());
+ bool hasA = false, hasB = false;
+ for (const string &e : entries)
+ {
+ if (e.find("testpackageA") != string::npos)
+ {
+ hasA = true;
+ }
+
+ if (e.find("testpackageB") != string::npos)
+ {
+ hasB = true;
+ }
+ }
+ EXPECT_FALSE(hasA) << "Manifest still references v1 files";
+ EXPECT_TRUE(hasB) << "Manifest missing v2 files";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: ErrorMessagePopulatedOnFailureTest
+ //
+ // Description:
+ // SQL Server surfaces the libraryError string to end users. Every failure
+ // path must populate libError with a non-empty, UTF-8-decodable message.
+ // Validates this for three distinct failure modes: non-existent source file,
+ // zip-slip attack, and file-level conflict.
+ //
+ TEST_F(CSharpExtensionApiTests, ErrorMessagePopulatedOnFailureTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string installDir = CreateInstallDir();
+ string msg;
+
+ // Failure mode 1: non-existent source file
+ string missingPath = "C:\\does\\not\\exist.zip";
+ SQLRETURN r = CallInstallCaptureError(sm_installExternalLibraryFuncPtr,
+ "missing", missingPath, installDir, msg);
+ EXPECT_EQ(r, SQL_ERROR);
+ EXPECT_FALSE(msg.empty()) << "No error message populated for missing file";
+ // Message should mention the path that was not found, so the user
+ // can fix the input rather than guess.
+ EXPECT_NE(msg.find("exist.zip"), string::npos)
+ << "Missing-file message should reference the path. Got: " << msg;
+
+ // Failure mode 2: zip-slip attack
+ string zipSlip = (fs::path(packagesPath) / "testpackageH-ZIPSLIP.zip").string();
+ r = CallInstallCaptureError(sm_installExternalLibraryFuncPtr,
+ "slip", zipSlip, installDir, msg);
+ EXPECT_EQ(r, SQL_ERROR);
+ EXPECT_FALSE(msg.empty()) << "No error message populated for zip-slip";
+ // Message should describe the rejection. The exception comes from
+ // .NET's ZipFile.ExtractToDirectory built-in zip-slip guard, which
+ // throws with "outside the specified destination directory". Our
+ // own ValidateRelativePath defense-in-depth check ("invalid path")
+ // is never reached because the .NET extractor catches it first.
+ // Either substring is acceptable -- assert the .NET form.
+ EXPECT_NE(msg.find("outside"), string::npos)
+ << "Zip-slip message should describe the rejection. Got: " << msg;
+
+ // Failure mode 3: file-level conflict
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "libA", pkgB, installDir), SQL_SUCCESS);
+ r = CallInstallCaptureError(sm_installExternalLibraryFuncPtr,
+ "libB", pkgB, installDir, msg);
+ EXPECT_EQ(r, SQL_ERROR);
+ EXPECT_FALSE(msg.empty()) << "No error message populated for conflict";
+ // Message should mention the conflicting library name for diagnosability
+ EXPECT_NE(msg.find("libB"), string::npos)
+ << "Conflict message should include library name. Got: " << msg;
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallNonZipLibraryTest
+ //
+ // Description:
+ // Raw-DLL (non-ZIP) installs write a single-entry manifest tracking
+ // "{libName}.dll" so that ALTER from raw->ZIP can clean up the prior
+ // install. Uninstall must remove both the file and its manifest.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallNonZipLibraryTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(rawDll));
+
+ string installDir = CreateInstallDir();
+
+ // Install the raw DLL as "rawlib" — manifest must be written so that
+ // ALTER scenarios can track ownership of the {libName}.dll file.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "rawlib", rawDll, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "rawlib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "rawlib.manifest"))
+ << "Raw-DLL install should write a single-entry manifest";
+
+ // Uninstall must delete both the file and the manifest.
+ SQLRETURN r = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "rawlib", installDir);
+ EXPECT_EQ(r, SQL_SUCCESS);
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "rawlib.dll"))
+ << "Raw library file not removed by uninstall";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "rawlib.manifest"))
+ << "Raw-DLL manifest file not removed by uninstall";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InnerZipFileConflictFailsTest
+ //
+ // Description:
+ // FileConflictFailsTest exercises the direct-files code path (package B,
+ // no inner zip). This test exercises the inner-zip code path (package A)
+ // which has its own separate conflict-detection loop. Regressing only
+ // one path must be caught.
+ //
+ TEST_F(CSharpExtensionApiTests, InnerZipFileConflictFailsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ ASSERT_TRUE(fs::exists(pkgA));
+
+ string installDir = CreateInstallDir();
+
+ // Install "lib1" from package A (inner-zip path) => testpackageA.dll + .txt
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgA, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "lib1.manifest"));
+
+ // Install "lib2" from the same package — inner-zip conflict must fail
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgA, installDir);
+ EXPECT_EQ(r, SQL_ERROR) << "Inner-zip path must detect filename conflict";
+
+ // lib1's state must be untouched
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib1.manifest"));
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib2.manifest"));
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: TempFolderCleanedUpAfterConflictTest
+ //
+ // Description:
+ // After a failed install (conflict or otherwise), the GUID-named temp
+ // folder used for outer-zip extraction must be cleaned up by the finally
+ // block. Regression would slowly leak disk space on every failed install.
+ //
+ TEST_F(CSharpExtensionApiTests, TempFolderCleanedUpAfterConflictTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ // Trigger a conflict: install then reinstall same package under different names
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgB, installDir), SQL_SUCCESS);
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgB, installDir), SQL_ERROR);
+
+ // Also trigger inner-zip path conflict
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "libA", pkgA, installDir), SQL_SUCCESS);
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "libB", pkgA, installDir), SQL_ERROR);
+
+ // Also trigger zip-slip failure
+ string zipSlip = (fs::path(packagesPath) / "testpackageH-ZIPSLIP.zip").string();
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "slip", zipSlip, installDir), SQL_ERROR);
+
+ EXPECT_EQ(CountGuidTempDirs(installDir), 0)
+ << "GUID-named temp folder leaked after failed install(s)";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AlterFromNonZipToZipTest
+ //
+ // Description:
+ // ALTER EXTERNAL LIBRARY scenario where v1 was a raw DLL and v2 is a
+ // ZIP package. Raw-DLL installs now write a manifest with one entry
+ // ("{libName}.dll"), so the v2 ZIP install can detect and clean up the
+ // v1 file before extracting. Without that manifest tracking, the ZIP
+ // install would either silently overwrite v1's DLL or trip the alias
+ // conflict check on the pre-existing "{libName}.dll".
+ //
+ TEST_F(CSharpExtensionApiTests, AlterFromNonZipToZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ // v1: raw DLL install. The new behavior writes a manifest tracking
+ // the single "{libName}.dll" entry, so ALTER can clean it up below.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", rawDll, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"))
+ << "Raw-DLL install should write a manifest so ALTER can track it";
+
+ // v2: ZIP install of the same libName. The pre-existing myLib.dll
+ // must NOT trip the alias conflict check -- it is tracked in v1's
+ // manifest as owned-by-previous and gets cleaned up first.
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", pkgB, installDir);
+ EXPECT_EQ(r, SQL_SUCCESS)
+ << "ALTER from non-ZIP to ZIP should succeed";
+
+ // v2's content must be present + manifest exists.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"));
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+
+ // v1's raw DLL bytes must be GONE. Both the file and the alias for
+ // v2 are named myLib.dll, so a broken cleanup (overwrite-instead-of-
+ // clean-then-install) would be masked by mere existence of the file.
+ // Compare bytes against testpackageB.dll: if the alias was created
+ // correctly from v2's content, it must equal the source DLL.
+ fs::path myLibDll = fs::path(installDir) / "myLib.dll";
+ fs::path sourceDll = fs::path(installDir) / "testpackageB.dll";
+ ASSERT_TRUE(fs::exists(myLibDll))
+ << "v2's alias myLib.dll missing";
+ EXPECT_EQ(fs::file_size(myLibDll), fs::file_size(sourceDll))
+ << "myLib.dll size differs from v2's source -- likely still v1's bytes";
+
+ std::ifstream myLibStream(myLibDll.string(), std::ios::binary);
+ std::ifstream sourceStream(sourceDll.string(), std::ios::binary);
+ std::string myLibBytes(
+ (std::istreambuf_iterator(myLibStream)),
+ std::istreambuf_iterator());
+ std::string sourceBytes(
+ (std::istreambuf_iterator(sourceStream)),
+ std::istreambuf_iterator());
+ myLibStream.close();
+ sourceStream.close();
+ EXPECT_EQ(myLibBytes, sourceBytes)
+ << "myLib.dll content differs from v2's source -- v1's raw DLL was not cleaned up";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AlterFromZipToNonZipTest
+ //
+ // Description:
+ // ALTER EXTERNAL LIBRARY scenario where v1 was a ZIP package and v2 is
+ // a raw DLL with the same libName. The v1 manifest must be cleaned up
+ // before v2 writes "{libName}.dll", and v2 must end up with its own
+ // one-entry manifest. This is the inverse of AlterFromNonZipToZipTest.
+ //
+ TEST_F(CSharpExtensionApiTests, AlterFromZipToNonZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+
+ string installDir = CreateInstallDir();
+
+ // v1: install ZIP package B as "myLib". This drops testpackageB.dll,
+ // testpackageB.deps.json, an alias myLib.dll, and a myLib.manifest.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", pkgB, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "testpackageB.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+
+ // v2: raw-DLL install of the same libName.
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", rawDll, installDir);
+ EXPECT_EQ(r, SQL_SUCCESS)
+ << "ALTER from ZIP to non-ZIP should succeed";
+
+ // v1's ZIP-only files must be gone (cleaned up via v1's manifest
+ // before v2 was written).
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageB.deps.json"))
+ << "Stale v1 deps.json left behind after ALTER zip->raw";
+
+ // v2's file must be present and its manifest must list exactly that file.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "myLib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "myLib.manifest").string());
+ EXPECT_EQ(entries.size(), 1u) << "Raw-DLL manifest should list exactly one entry";
+ if (!entries.empty())
+ {
+ EXPECT_EQ(entries[0], "myLib.dll");
+ }
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AliasFileRemovedOnUninstallTest
+ //
+ // Description:
+ // When a ZIP contains DLLs whose names don't match the library name,
+ // the install code creates an alias file named {libName}.dll so
+ // DllUtils.CreateDllList can discover it. This alias must be recorded
+ // in the manifest and removed on uninstall — otherwise orphaned alias
+ // files accumulate over install/uninstall cycles.
+ //
+ TEST_F(CSharpExtensionApiTests, AliasFileRemovedOnUninstallTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ // Install package A (contains testpackageA.dll) under a different libName.
+ // Since no file matches "aliaslib.*", the install code must create an
+ // "aliaslib.dll" alias file copied from the first DLL.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "aliaslib", pkgA, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "aliaslib.dll"))
+ << "Alias file not created";
+
+ // The alias must also be listed in the manifest.
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "aliaslib.manifest").string());
+ bool hasAlias = false;
+ for (const auto &e : entries)
+ {
+ if (e == "aliaslib.dll")
+ {
+ hasAlias = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(hasAlias) << "Alias file not recorded in manifest";
+
+ // Uninstall must remove both the content and the alias.
+ ASSERT_EQ(CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "aliaslib", installDir), SQL_SUCCESS);
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "aliaslib.dll"))
+ << "Alias file leaked after uninstall";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.dll"))
+ << "Content file leaked after uninstall";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "aliaslib.manifest"))
+ << "Manifest file leaked after uninstall";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallRawDllWithDllSuffixedLibNameTest
+ //
+ // Description:
+ // Regression test for the double-".dll" bug. When a caller invokes
+ // CREATE EXTERNAL LIBRARY [foo.dll] (i.e. the registered library name
+ // already ends in ".dll") with raw-DLL CONTENT, the install code must
+ // write the file as "foo.dll" — NOT "foo.dll.dll". The CLR assembly
+ // resolver looks up assemblies by simple name and will not find
+ // "foo.dll.dll" when asked to load "foo".
+ //
+ // Symmetric behavior is required on uninstall: the file removed must
+ // be "foo.dll", not "foo.dll.dll".
+ //
+ TEST_F(CSharpExtensionApiTests, InstallRawDllWithDllSuffixedLibNameTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string packagePath = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(packagePath)) << "Test package not found: " << packagePath;
+
+ string installDir = CreateInstallDir();
+
+ // libName already ends in ".dll" — must NOT get a second ".dll" appended.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "rawdllpackage.dll", packagePath, installDir), SQL_SUCCESS);
+
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "rawdllpackage.dll"))
+ << "Raw DLL not found at expected single-.dll path";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "rawdllpackage.dll.dll"))
+ << "Raw DLL incorrectly written with double-.dll extension; "
+ "CLR assembly resolver would fail to locate it.";
+
+ // Uninstall must also use the single-.dll path.
+ ASSERT_EQ(CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "rawdllpackage.dll", installDir), SQL_SUCCESS);
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "rawdllpackage.dll"))
+ << "Raw DLL not removed by uninstall";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallRejectsPathTraversalLibNameTest
+ //
+ // Description:
+ // UninstallExternalLibrary must reject libNames that contain path
+ // separators or traversal segments before using them to build
+ // manifestPath / libraryFile via Path.Combine. Without validation,
+ // a malicious libName like "../foo" would resolve outside installDir
+ // and allow unintended file reads/deletes.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallRejectsPathTraversalLibNameTest)
+ {
+ string installDir = CreateInstallDir();
+
+ // Create a sentinel file OUTSIDE installDir that uninstall must not touch.
+ fs::path sentinelDir = fs::path(installDir).parent_path();
+ fs::path sentinel = sentinelDir / "do-not-delete.manifest";
+ std::ofstream sentinelStream(sentinel.string());
+ sentinelStream << "sentinel";
+ // Close before fs::exists -- the assertion only checks the dir
+ // entry, but planting the file via an unflushed stream and then
+ // exercising uninstall would race against the OS commit. Keep
+ // the explicit close-before-assert pattern at every write site
+ // for consistency with the byte-comparison sites further down.
+ sentinelStream.close();
+ ASSERT_TRUE(fs::exists(sentinel));
+
+ // Attempt uninstall with a traversal libName.
+ SQLRETURN result = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "../do-not-delete", installDir);
+ EXPECT_EQ(result, SQL_ERROR) << "Uninstall must reject libName with traversal";
+ EXPECT_TRUE(fs::exists(sentinel)) << "Sentinel file outside installDir was deleted";
+
+ fs::remove(sentinel);
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallRejectsExtensionOnlyLibNameTest
+ //
+ // Description:
+ // ValidateLibraryName must reject libNames that are only an extension
+ // (e.g. ".dll"). Without this check, DllFileNameFor(".dll") returns
+ // ".dll", producing hidden dotfiles like "{installDir}/.dll" and
+ // "{installDir}/.dll.manifest" that are opaque on Windows and hidden
+ // on Linux.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallRejectsExtensionOnlyLibNameTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(rawDll));
+
+ string installDir = CreateInstallDir();
+
+ // libName is just an extension -- no stem. Must be rejected before
+ // any filesystem operation.
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ ".dll", rawDll, installDir);
+ EXPECT_EQ(result, SQL_ERROR) << "Install must reject extension-only libName";
+
+ // No file should have been created at "installDir/.dll" or its manifest.
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / ".dll"))
+ << "Hidden dotfile created from extension-only libName";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / ".dll.manifest"))
+ << "Hidden dotfile manifest created from extension-only libName";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AliasConflictDetectedBeforeExtractionTest
+ //
+ // Description:
+ // If library A has already installed a file "shared.dll" at the root of
+ // installDir, and library B (whose package contains DLLs but none named
+ // "shared.*") is then installed with libName "shared", the install code
+ // would normally create a "shared.dll" alias. That alias now collides
+ // with A's file. Install must fail during the conflict-check phase
+ // BEFORE any of B's content is written into installDir (no partial
+ // state left behind).
+ //
+ TEST_F(CSharpExtensionApiTests, AliasConflictDetectedBeforeExtractionTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgA = (fs::path(packagesPath) / "testpackageA-ZIP.zip").string();
+
+ string installDir = CreateInstallDir();
+
+ // Install library A normally.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "libA", pkgA, installDir), SQL_SUCCESS);
+
+ // Plant a "shared.dll" file in installDir (simulates ownership by another library).
+ fs::path squatter = fs::path(installDir) / "shared.dll";
+ std::ofstream squatterStream(squatter.string());
+ squatterStream << "squatter";
+ squatterStream.close();
+ ASSERT_TRUE(fs::exists(squatter));
+
+ // Count files currently in installDir; install of B must not add any.
+ size_t fileCountBefore = 0;
+ for (const fs::directory_entry &p : fs::recursive_directory_iterator(installDir))
+ {
+ if (fs::is_regular_file(p))
+ {
+ ++fileCountBefore;
+ }
+ }
+
+ // Install B with libName "shared" - it has no shared.* file, so install
+ // would create a "shared.dll" alias, which collides with squatter.
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ "shared", pkgA, installDir);
+ EXPECT_EQ(result, SQL_ERROR) << "Alias conflict must be detected and install must fail";
+
+ // No B content should have been written.
+ size_t fileCountAfter = 0;
+ for (const fs::directory_entry &p : fs::recursive_directory_iterator(installDir))
+ {
+ if (fs::is_regular_file(p))
+ {
+ ++fileCountAfter;
+ }
+ }
+ EXPECT_EQ(fileCountBefore, fileCountAfter)
+ << "Failed install left partial state in installDir";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: RawDllInstallFailsIfForeignFileExistsTest
+ //
+ // Description:
+ // Restores the pre-PR ExtHost CopyFileW(..., bFailIfExists=TRUE)
+ // contract for raw-DLL installs. If "{libName}.dll" already exists in
+ // installDir AND we cannot prove ownership via this library's manifest
+ // (e.g. another library or an external process planted the file),
+ // install must FAIL rather than silently overwrite. This prevents one
+ // library from clobbering another's content in a shared install dir.
+ //
+ TEST_F(CSharpExtensionApiTests, RawDllInstallFailsIfForeignFileExistsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(rawDll));
+
+ string installDir = CreateInstallDir();
+
+ // Plant a foreign file at the target path with no matching manifest
+ // -- simulates ownership by another library or external tooling.
+ fs::path foreign = fs::path(installDir) / "owned.dll";
+ std::ofstream foreignStream(foreign.string());
+ foreignStream << "FOREIGN-CONTENT";
+ foreignStream.close();
+ ASSERT_TRUE(fs::exists(foreign));
+
+ // Install must FAIL rather than overwrite the foreign file.
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "owned", rawDll, installDir);
+ EXPECT_EQ(r, SQL_ERROR)
+ << "Raw-DLL install must fail when {libName}.dll already exists "
+ "and is not owned by this library";
+
+ // Foreign file content must be byte-for-byte unchanged.
+ ASSERT_TRUE(fs::exists(foreign));
+ std::ifstream ifs(foreign.string());
+ std::string contents((std::istreambuf_iterator(ifs)),
+ std::istreambuf_iterator());
+ ifs.close();
+ EXPECT_EQ(contents, "FOREIGN-CONTENT")
+ << "Foreign file was overwritten by failed raw-DLL install";
+
+ // No manifest should have been written for the failed install.
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "owned.manifest"))
+ << "Manifest leaked from a failed raw-DLL install";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: AlterFromNonZipToNonZipTest
+ //
+ // Description:
+ // ALTER EXTERNAL LIBRARY scenario where both v1 and v2 are raw DLLs
+ // with the same libName. v1 writes a single-entry manifest; v2 must
+ // see that manifest, treat the existing "{libName}.dll" as
+ // owned-by-previous, clean it up, then copy v2's bytes into place.
+ // Completes the ALTER coverage matrix (ZIP->ZIP, raw->ZIP, ZIP->raw,
+ // and now raw->raw).
+ //
+ TEST_F(CSharpExtensionApiTests, AlterFromNonZipToNonZipTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(rawDll));
+
+ string installDir = CreateInstallDir();
+
+ // v1: raw DLL install. Writes myLib.dll plus myLib.manifest.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", rawDll, installDir), SQL_SUCCESS);
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+
+ // v2: raw DLL install of the same libName (same source bytes is fine
+ // -- the test exercises the manifest cleanup + re-install path).
+ SQLRETURN r = CallInstall(sm_installExternalLibraryFuncPtr,
+ "myLib", rawDll, installDir);
+ EXPECT_EQ(r, SQL_SUCCESS) << "ALTER raw->raw should succeed";
+
+ // The file and manifest must still be present, and the manifest
+ // should still contain exactly one entry (no duplication from a
+ // missed cleanup of the previous manifest).
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "myLib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "myLib.manifest"));
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "myLib.manifest").string());
+ EXPECT_EQ(entries.size(), 1u)
+ << "Raw->raw ALTER must not duplicate manifest entries";
+ if (!entries.empty())
+ {
+ EXPECT_EQ(entries[0], "myLib.dll");
+ }
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallRejectsInvalidLibNameTest
+ //
+ // Description:
+ // ValidateLibraryName must reject all of the following on the install
+ // side (uninstall is covered separately by
+ // UninstallRejectsPathTraversalLibNameTest):
+ // - empty string
+ // - whitespace-only
+ // - path-traversal segment (".." resolved against installDir)
+ // - embedded null character
+ // - absolute path
+ // - extension-only (covered also by InstallRejectsExtensionOnlyLibNameTest)
+ // - Windows reserved DOS device names (CON, NUL, COM1, LPT1, ...)
+ // A regression in ValidateLibraryName must trip at least one of these
+ // cases.
+ //
+ TEST_F(CSharpExtensionApiTests, InstallRejectsInvalidLibNameTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string rawDll = (fs::path(packagesPath) / "testpackageE-RAWDLL.dll").string();
+ ASSERT_TRUE(fs::exists(rawDll));
+
+ struct Case
+ {
+ string libName;
+ const char *label;
+ };
+ // Note: "foo\0bar" must be constructed via the (data, length) ctor
+ // so the embedded NUL survives. CallInstall forwards libName.length().
+ Case cases[] = {
+ { string(""), "empty" },
+ { string(" "), "whitespace-only" },
+ { string("../escape"), "path-traversal" },
+ { string("foo\0bar", 7), "null-character" },
+#ifdef _WIN32
+ { string("C:\\Windows\\foo"), "absolute-path-windows" },
+#else
+ { string("/etc/foo"), "absolute-path-posix" },
+#endif
+ { string(".dll"), "extension-only" },
+ // Reserved DOS device names. ValidateLibraryName checks the
+ // stem (Path.GetFileNameWithoutExtension), so both bare "CON"
+ // and "CON.dll" / "nul.txt" must be rejected. Mixed case must
+ // also be rejected (s_reservedDeviceNames uses
+ // OrdinalIgnoreCase).
+ { string("CON"), "reserved-device-CON" },
+ { string("nul"), "reserved-device-nul-lower" },
+ { string("Aux"), "reserved-device-Aux-mixed" },
+ { string("PRN"), "reserved-device-PRN" },
+ { string("COM1"), "reserved-device-COM1" },
+ { string("LPT9"), "reserved-device-LPT9" },
+ { string("CON.dll"), "reserved-device-CON-with-ext" },
+ { string("nul.manifest"), "reserved-device-nul-with-ext" },
+ };
+
+ for (const Case &c : cases)
+ {
+ string installDir = CreateInstallDir();
+
+ SQLRETURN result = CallInstall(sm_installExternalLibraryFuncPtr,
+ c.libName, rawDll, installDir);
+ EXPECT_EQ(result, SQL_ERROR)
+ << "ValidateLibraryName should reject case: " << c.label;
+
+ CleanupInstallDir(installDir);
+ }
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: InstallZipWithDllSuffixedLibNameTest
+ //
+ // Description:
+ // Symmetric coverage with InstallRawDllWithDllSuffixedLibNameTest. When
+ // CREATE EXTERNAL LIBRARY [foo.dll] is paired with a ZIP package whose
+ // contents do NOT include a "foo.dll" file at the root, install must
+ // create a single "foo.dll" alias (NOT "foo.dll.dll") and the alias
+ // must be tracked in the manifest as "foo.dll".
+ //
+ TEST_F(CSharpExtensionApiTests, InstallZipWithDllSuffixedLibNameTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgB = (fs::path(packagesPath) / "testpackageB-DLL.zip").string();
+ ASSERT_TRUE(fs::exists(pkgB));
+
+ string installDir = CreateInstallDir();
+
+ // libName ends in .dll. The package contains testpackageB.dll +
+ // testpackageB.deps.json -- nothing matching "foo.*" -- so the
+ // install code must create an alias.
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "foo.dll", pkgB, installDir), SQL_SUCCESS);
+
+ // Single .dll suffix -- not "foo.dll.dll".
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "foo.dll"))
+ << "Alias not created at expected single-.dll path";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "foo.dll.dll"))
+ << "Alias incorrectly written with double-.dll extension";
+
+ // Manifest tracks the alias under its single-.dll name.
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "foo.dll.manifest"));
+ vector entries = ReadManifest(
+ (fs::path(installDir) / "foo.dll.manifest").string());
+ bool aliasInManifest = false;
+ for (const string &e : entries)
+ {
+ if (e == "foo.dll")
+ {
+ aliasInManifest = true;
+ break;
+ }
+ }
+ EXPECT_TRUE(aliasInManifest)
+ << "Manifest must list the alias under its single-.dll name";
+
+ CleanupInstallDir(installDir);
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallWithMissingInstallDirTest
+ //
+ // Description:
+ // Uninstall must succeed (no-op) when installDir does not exist on
+ // disk at all. The Directory.Exists guard in UninstallExternalLibrary
+ // short-circuits manifest cleanup; this test pins that contract so a
+ // future change can't accidentally throw on a missing directory.
+ // Distinct from UninstallNonExistentLibraryTest which creates the
+ // installDir first.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallWithMissingInstallDirTest)
+ {
+ // Construct a path that does NOT exist. Use a sibling of the
+ // standard testInstallLibs path so we know the parent directory
+ // is writable but the target itself is absent.
+ char path[MAX_PATH + 1] = { 0 };
+ GetModuleFileName(NULL, path, MAX_PATH);
+ fs::path missing = fs::path(path).parent_path() / "testInstallLibs-missing";
+ if (fs::exists(missing))
+ {
+ fs::remove_all(missing);
+ }
+ ASSERT_FALSE(fs::exists(missing))
+ << "Test setup error: chosen installDir must not exist";
+
+ SQLRETURN r = CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "anything", missing.string());
+ EXPECT_EQ(r, SQL_SUCCESS)
+ << "Uninstall against a missing installDir must succeed (no-op)";
+
+ // Should NOT have created the directory as a side effect.
+ EXPECT_FALSE(fs::exists(missing))
+ << "Uninstall must not create installDir";
+ }
+
+ //----------------------------------------------------------------------------------------------
+ // Name: UninstallPreservesSharedNestedDirsTest
+ //
+ // Description:
+ // Two libraries can share a nested parent directory (e.g. both contribute
+ // files under "lib/net8.0/"). Uninstalling one must leave the other's
+ // files AND the shared parent directory itself intact.
+ //
+ // Library 1 = testpackageD-NESTED.zip -> lib/net8.0/MyLib.dll, runtimes/win-x64/native.dll, MyLib.deps.json
+ // Library 2 = testpackageJ-NESTED2.zip -> lib/net8.0/Other.dll, Other.deps.json
+ //
+ // After installing both: lib/net8.0/ contains MyLib.dll AND Other.dll.
+ // After uninstalling library 1: lib/net8.0/Other.dll AND lib/net8.0/
+ // AND lib/ must all still exist.
+ //
+ TEST_F(CSharpExtensionApiTests, UninstallPreservesSharedNestedDirsTest)
+ {
+ string packagesPath = GetPackagesPath();
+ string pkgD = (fs::path(packagesPath) / "testpackageD-NESTED.zip").string();
+ string pkgJ = (fs::path(packagesPath) / "testpackageJ-NESTED2.zip").string();
+ ASSERT_TRUE(fs::exists(pkgD));
+ ASSERT_TRUE(fs::exists(pkgJ));
+
+ string installDir = CreateInstallDir();
+
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib1", pkgD, installDir), SQL_SUCCESS);
+ ASSERT_EQ(CallInstall(sm_installExternalLibraryFuncPtr,
+ "lib2", pkgJ, installDir), SQL_SUCCESS);
+
+ // Pre-uninstall sanity: both libraries' files coexist in lib/net8.0/.
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0" / "MyLib.dll"));
+ ASSERT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0" / "Other.dll"));
+
+ // Uninstall library 1.
+ ASSERT_EQ(CallUninstall(sm_uninstallExternalLibraryFuncPtr,
+ "lib1", installDir), SQL_SUCCESS);
+
+ // Library 1's unique files must be gone.
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "lib" / "net8.0" / "MyLib.dll"))
+ << "lib1's MyLib.dll leaked after uninstall";
+ EXPECT_FALSE(fs::exists(fs::path(installDir) / "MyLib.deps.json"))
+ << "lib1's MyLib.deps.json leaked after uninstall";
+
+ // Library 2's files must be untouched.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0" / "Other.dll"))
+ << "lib2's Other.dll was wrongly removed by lib1's uninstall";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "Other.deps.json"))
+ << "lib2's Other.deps.json was wrongly removed by lib1's uninstall";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib2.manifest"))
+ << "lib2's manifest was wrongly removed by lib1's uninstall";
+
+ // The shared parent directory MUST still exist (lib2 still has
+ // content there). Empty-dir cleanup must only fire on dirs that
+ // become empty, not on dirs that still have content from another
+ // library.
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib" / "net8.0"))
+ << "Shared parent directory lib/net8.0/ wrongly removed by lib1's uninstall";
+ EXPECT_TRUE(fs::exists(fs::path(installDir) / "lib"))
+ << "Shared parent directory lib/ wrongly removed by lib1's uninstall";
+
+ CleanupInstallDir(installDir);
+ }
+}
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/bad-package-ZIP.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/bad-package-ZIP.zip
new file mode 100644
index 00000000..cf898e03
--- /dev/null
+++ b/language-extensions/dotnet-core-CSharp/test/test_packages/bad-package-ZIP.zip
@@ -0,0 +1 @@
+This is not a valid zip file
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/build-sidecar-fixture.ps1 b/language-extensions/dotnet-core-CSharp/test/test_packages/build-sidecar-fixture.ps1
new file mode 100644
index 00000000..508ee130
--- /dev/null
+++ b/language-extensions/dotnet-core-CSharp/test/test_packages/build-sidecar-fixture.ps1
@@ -0,0 +1,80 @@
+# Builds testpackageL-SIDECAR.zip -- a regression fixture for PR #85.
+#
+# Layout:
+#
+# foo.deps.json root-level sidecar (matches "{libName}." prefix
+# but is NOT a loadable DLL)
+# foo.runtimeconfig.json root-level sidecar (same shape)
+# lib/net8.0/foo.dll the actual DLL, NESTED -- not at root, so
+# DllUtils.CreateDllList (top-level only) cannot
+# find it without an alias
+#
+# Regression scenario: with libName="foo", the loader needs a root-level
+# "foo.dll". Pre-fix, DetermineAliasSource matched any root file starting
+# with "foo." (StartsWith), so the sidecars caused alias creation to be
+# suppressed and the install ended up with no loadable DLL at the root.
+# Post-fix, DetermineAliasSource only suppresses on EXACT match against
+# "foo.dll" (the alias filename) -- so the sidecars no longer suppress, and
+# the nested "lib/net8.0/foo.dll" is cloned to the root as the alias.
+#
+# The companion test (AliasCreatedWhenOnlySidecarsAtRootTest) asserts that
+# after install:
+# - SQL_SUCCESS,
+# - root-level "foo.dll" alias EXISTS,
+# - alias is a byte-for-byte copy of "lib/net8.0/foo.dll",
+# - both sidecars were extracted to the root,
+# - the nested "lib/net8.0/foo.dll" was extracted intact.
+#
+# Run once from the test_packages directory; checks the fixture in.
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Add-Type -AssemblyName System.IO.Compression
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$outPath = Join-Path $here 'testpackageL-SIDECAR.zip'
+
+if (Test-Path $outPath) { Remove-Item $outPath -Force }
+
+# Helper: write a single string entry into the archive at $relPath (forward
+# slashes; .NET ZipArchive normalizes correctly).
+function Add-TextEntry {
+ param(
+ [System.IO.Compression.ZipArchive]$Archive,
+ [string]$RelPath,
+ [string]$Text
+ )
+ $entry = $Archive.CreateEntry($RelPath)
+ $writer = New-Object System.IO.StreamWriter($entry.Open())
+ try { $writer.Write($Text) } finally { $writer.Dispose() }
+}
+
+$stream = [System.IO.File]::Create($outPath)
+try {
+ $archive = New-Object System.IO.Compression.ZipArchive(
+ $stream, [System.IO.Compression.ZipArchiveMode]::Create)
+ try {
+ # Two root-level sidecars. Both match "foo." prefix; neither is a
+ # loadable DLL. The pre-fix StartsWith check would treat either of
+ # them as proof the library is "already discoverable" and skip
+ # alias creation -- producing an install with no root-level DLL.
+ Add-TextEntry -Archive $archive -RelPath 'foo.deps.json' `
+ -Text '{"runtimeTarget":{"name":".NETCoreApp,Version=v8.0"}}'
+ Add-TextEntry -Archive $archive -RelPath 'foo.runtimeconfig.json' `
+ -Text '{"runtimeOptions":{"tfm":"net8.0"}}'
+
+ # The actual DLL, NESTED. DllUtils.CreateDllList only walks the top
+ # level, so without an alias clone at the root this DLL is
+ # undiscoverable to the loader.
+ Add-TextEntry -Archive $archive -RelPath 'lib/net8.0/foo.dll' `
+ -Text 'fake DLL content for foo'
+ } finally {
+ $archive.Dispose()
+ }
+} finally {
+ $stream.Dispose()
+}
+
+Write-Host "Wrote: $outPath ($((Get-Item $outPath).Length) bytes)"
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/build-symlink-fixture.ps1 b/language-extensions/dotnet-core-CSharp/test/test_packages/build-symlink-fixture.ps1
new file mode 100644
index 00000000..947b8f84
--- /dev/null
+++ b/language-extensions/dotnet-core-CSharp/test/test_packages/build-symlink-fixture.ps1
@@ -0,0 +1,92 @@
+# Builds testpackageK-SYMLINK.zip -- a regression fixture for PR #85.
+#
+# Layout: outer zip wrapping a single inner zip ("inner.zip" at root). The
+# inner zip contains:
+#
+# legitfile.dll regular file, plain content
+# evil-symlink.dll Unix mode 0o120755 (symbolic link), payload "/etc/passwd"
+#
+# The Unix-mode bits live in the central directory entry's external
+# attributes. Today's .NET ZipFile.ExtractToDirectory ignores those bits on
+# every platform and writes evil-symlink.dll as a regular file containing
+# the literal text "/etc/passwd". The test that uses this fixture
+# (InnerZipFutureSymlinkRejectedTest) asserts a future-proofing invariant:
+# regardless of how a future .NET runtime decides to materialize that entry,
+# the installed library directory must contain NO reparse points.
+#
+# Run once from the test_packages directory; checks the fixture in.
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Add-Type -AssemblyName System.IO.Compression
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$outerPath = Join-Path $here 'testpackageK-SYMLINK.zip'
+$innerTempPath = [System.IO.Path]::Combine(
+ [System.IO.Path]::GetTempPath(),
+ [System.Guid]::NewGuid().ToString() + '.zip')
+
+# Unix mode 0o120755 (symbolic link, owner rwx, group rx, other rx) packed
+# into the high 16 bits of the 32-bit external attributes field. The .NET
+# ExternalAttributes property is a signed Int32. We parse the hex value as
+# Int32 directly so PowerShell's "literal must fit in target type" rule is
+# never triggered: 0xA1ED0000 has its sign bit set, so it parses to the
+# negative value -1578237952, which is the correct two's-complement
+# representation in the central directory.
+[int]$externalAttrsSymlink = [int]::Parse(
+ 'A1ED0000',
+ [System.Globalization.NumberStyles]::HexNumber)
+
+# --- Build the inner zip ---
+$innerStream = [System.IO.File]::Create($innerTempPath)
+try {
+ $innerArchive = New-Object System.IO.Compression.ZipArchive(
+ $innerStream, [System.IO.Compression.ZipArchiveMode]::Create)
+ try {
+ # Regular file -- proves install completed (it must end up in installDir).
+ $regular = $innerArchive.CreateEntry('legitfile.dll')
+ $regWriter = New-Object System.IO.StreamWriter($regular.Open())
+ try { $regWriter.Write('regular DLL content') } finally { $regWriter.Dispose() }
+
+ # Unix-symlink-mode entry. Today's .NET writes this as a regular
+ # file; a future .NET that honors the mode bits would write a real
+ # symlink. The IsReparsePoint guard in CopyDirectory must skip it
+ # in the latter case.
+ $symlink = $innerArchive.CreateEntry('evil-symlink.dll')
+ $symlink.ExternalAttributes = $externalAttrsSymlink
+ $symWriter = New-Object System.IO.StreamWriter($symlink.Open())
+ try { $symWriter.Write('/etc/passwd') } finally { $symWriter.Dispose() }
+ } finally {
+ $innerArchive.Dispose()
+ }
+} finally {
+ $innerStream.Dispose()
+}
+
+# --- Build the outer zip wrapping inner.zip ---
+if (Test-Path $outerPath) { Remove-Item $outerPath -Force }
+$outerStream = [System.IO.File]::Create($outerPath)
+try {
+ $outerArchive = New-Object System.IO.Compression.ZipArchive(
+ $outerStream, [System.IO.Compression.ZipArchiveMode]::Create)
+ try {
+ $entry = $outerArchive.CreateEntry('inner.zip')
+ $entryStream = $entry.Open()
+ try {
+ $innerBytes = [System.IO.File]::ReadAllBytes($innerTempPath)
+ $entryStream.Write($innerBytes, 0, $innerBytes.Length)
+ } finally {
+ $entryStream.Dispose()
+ }
+ } finally {
+ $outerArchive.Dispose()
+ }
+} finally {
+ $outerStream.Dispose()
+}
+
+Remove-Item $innerTempPath -Force
+
+Write-Host "Wrote: $outerPath ($((Get-Item $outerPath).Length) bytes)"
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageA-ZIP.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageA-ZIP.zip
new file mode 100644
index 00000000..e6e2b1cb
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageA-ZIP.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageB-DLL.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageB-DLL.zip
new file mode 100644
index 00000000..0ac2d94d
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageB-DLL.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageC-EMPTY.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageC-EMPTY.zip
new file mode 100644
index 00000000..15cb0ecb
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageC-EMPTY.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageD-NESTED.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageD-NESTED.zip
new file mode 100644
index 00000000..5ecc5158
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageD-NESTED.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageE-RAWDLL.dll b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageE-RAWDLL.dll
new file mode 100644
index 00000000..9215d5d1
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageE-RAWDLL.dll differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageF-SPACES.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageF-SPACES.zip
new file mode 100644
index 00000000..a73bdebd
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageF-SPACES.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageG-MANYFILES.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageG-MANYFILES.zip
new file mode 100644
index 00000000..e53dfca8
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageG-MANYFILES.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageH-ZIPSLIP.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageH-ZIPSLIP.zip
new file mode 100644
index 00000000..e453322c
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageH-ZIPSLIP.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageI-EMPTYDIRS.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageI-EMPTYDIRS.zip
new file mode 100644
index 00000000..5ad591fc
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageI-EMPTYDIRS.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageJ-NESTED2.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageJ-NESTED2.zip
new file mode 100644
index 00000000..38cf0cbe
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageJ-NESTED2.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageK-SYMLINK.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageK-SYMLINK.zip
new file mode 100644
index 00000000..f52cd3fd
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageK-SYMLINK.zip differ
diff --git a/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageL-SIDECAR.zip b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageL-SIDECAR.zip
new file mode 100644
index 00000000..c9fef8ef
Binary files /dev/null and b/language-extensions/dotnet-core-CSharp/test/test_packages/testpackageL-SIDECAR.zip differ