CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary#85
Conversation
…xtension
Implement the optional InstallExternalLibrary and UninstallExternalLibrary APIs
for the .NET C# language extension, enabling CREATE/DROP EXTERNAL LIBRARY support
for both ZIP-packaged and raw DLL C# libraries in SQL Server.
Implementation:
- Native C++ entry points in nativecsharpextension.cpp with null-runtime guards
- Managed C# implementation in CSharpExtension.cs using System.IO.Compression.ZipFile
- ZIP magic byte detection (PK header) to distinguish ZIP vs raw DLL content
- ZIP path: extract to temp dir, scan for inner .zip and extract to install dir;
if no inner ZIP, copy files directly. Creates {libName}.dll alias when extracted
file names differ from the SQL library name, so DllUtils can discover them.
- Raw DLL path: copy file to install dir as {libName}.dll
- UninstallExternalLibrary clears all files/dirs from the install directory
- Unique temp folder per call (Guid-based) to prevent race conditions
- Fix DllUtils.CreateDllList to search userLibName + ".*" pattern instead of
exact name (pre-existing bug: exact name never matched files with extensions)
Testing:
- 14 new test cases in CSharpLibraryTests.cpp
- Updated ExecuteInvalidLibraryNameScriptTest for DllUtils pattern change
- 9 test packages in test/test_packages/
- All 73 tests pass (59 existing + 14 new)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e81fdfb to
5377b74
Compare
5377b74 to
82f9cbc
Compare
…fest-based cleanup, conflict detection, ALTER support - Fix build script to use VS 2022 instead of VS 2019 - Add global.json to pin .NET SDK 8.0 - Fix: use library name as-is (no unconditional .dll append) - InstallExternalLibrary: ZIP extraction with file conflict detection, manifest tracking - UninstallExternalLibrary: manifest-based targeted file deletion, bottom-up empty dir cleanup - ALTER EXTERNAL LIBRARY: clean up previous install before re-installing - CHANGES.md documenting all changes
82f9cbc to
10ce19a
Compare
…LTER New tests: - ManifestWrittenTest, ManifestListsNestedFilesTest - InstallLibNameAliasNoExtensionTest - DirectoryOverlapAllowedTest, FileConflictFailsTest - UninstallPreservesOtherLibrariesTest - UninstallRemovesEmptyNestedDirsTest - AlterExternalLibraryTest Update 3 existing tests to reflect new libName-without-extension naming: - InstallInvalidZipTest, InstallRawDllNotZipTest, InstallZipWithManyFilesTest
- ErrorMessagePopulatedOnFailureTest: verify libError is surfaced to SQL Server for non-existent file, zip-slip, and file-conflict failure modes - UninstallNonZipLibraryTest: raw-DLL install/uninstall (no-manifest path) - InnerZipFileConflictFailsTest: conflict detection in inner-zip code path - TempFolderCleanedUpAfterConflictTest: no GUID temp-dir leaks after failures - AlterFromNonZipToZipTest: ALTER from raw DLL to ZIP (missing-manifest case) - AliasFileRemovedOnUninstallTest: libName-alias file recorded in manifest and removed on uninstall
…validation, Linux case-sensitivity, cleanup
- Alias file now written as {libName}.dll so DllUtils.CreateDllList ({libName}.*) finds it (fixes feature regression on Linux)
- Raw-DLL install writes {libName}.dll; uninstall fallback updated to match
- Defense-in-depth zip-slip check on inner-ZIP entries before adding to manifest and again in CleanupManifest before deleting
- ALTER is now transactional: stage + validate new ZIP before removing old version, with manifest-aware conflict suppression
- Reject library names containing path separators, '..', null, or absolute paths
- Use OS-appropriate path comparer (Ordinal on Linux, OrdinalIgnoreCase on Windows)
- Sort dirs for bottom-up cleanup by separator count, not string length
- Extract conflict detection into single helper (removes duplicate check)
- Trim error messages and comments; update 3 tests to match new alias naming
This PR branch is based on sicongliu's branch which predates PR microsoft#83 (DECIMAL Type Support on main). PR microsoft#83 added Microsoft.Data.SqlClient 5.2.2 as a required runtime dependency for SqlDecimal handling. Without it, sp_execute_external_script fails with HRESULT 0x80004004 when any script touches DECIMAL types.
…nstall-external-library
When CREATE EXTERNAL LIBRARY is called with a name ending in .dll (e.g. [Scriptoria.dll] WITH (LANGUAGE='dotnet')), the raw-DLL install path and missing-alias fallback both unconditionally appended '.dll', producing files like 'Scriptoria.dll.dll' on disk. The CLR assembly resolver could not locate the assembly by simple name and ExtHost died with 'Could not load file or assembly Scriptoria'. Added DllFileNameFor(libName) helper that returns libName as-is if it already ends in .dll (case-insensitive on Windows via existing s_pathComparison), otherwise appends .dll. Applied at the three sites in CSharpExtension.cs: raw-DLL install path, missing-alias fallback inside the ZIP install path, and UninstallExternalLibrary raw-DLL cleanup. ZIP-based installs whose library names do not end in .dll are unaffected. Verified end-to-end on consumer side: Test-SpAiTaskSkills.ps1 reports 4 passed, 0 failed.
…-install-external-library
InstallRawDllWithDllSuffixedLibNameTest covers the case where the library is created via CREATE EXTERNAL LIBRARY [foo.dll] (libName already ending in .dll). Asserts that the raw DLL is written as 'foo.dll' (not 'foo.dll.dll') and that uninstall removes it from the same single-.dll path. Pairs with the DllFileNameFor helper added in this PR.
There was a problem hiding this comment.
Pull request overview
Adds ZIP-archive support (including nested file trees) for the .NET Core C# language extension’s InstallExternalLibrary / UninstallExternalLibrary, aiming to make installs idempotent and uninstalls precise via a per-library manifest.
Changes:
- Implemented managed
InstallExternalLibrary/UninstallExternalLibrarywith manifest-driven uninstall, conflict detection, and temp-folder staging. - Updated DLL discovery logic in
DllUtilsand added native exports/wiring for the new APIs. - Added extensive native tests plus new ZIP/DLL test packages for edge cases (zip-slip, empty zips, nested trees, many files, etc.).
Reviewed changes
Copilot reviewed 9 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs | Implements managed install/uninstall logic (ZIP extraction, manifests, conflict checks, alias handling). |
| language-extensions/dotnet-core-CSharp/src/managed/utils/DllUtils.cs | Adjusts library DLL discovery pattern matching. |
| language-extensions/dotnet-core-CSharp/src/native/nativecsharpextension.cpp | Exposes native InstallExternalLibrary / UninstallExternalLibrary forwarding into managed code. |
| language-extensions/dotnet-core-CSharp/include/nativecsharpextension.h | Declares the new native library-management API exports. |
| language-extensions/dotnet-core-CSharp/test/include/CSharpExtensionApiTests.h | Adds function pointer typedefs and fixture members for install/uninstall APIs. |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpExtensionApiTests.cpp | Loads the install/uninstall exports for tests. |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp | Adds comprehensive unit tests for the new behaviors (manifest uninstall, conflicts, aliasing, ALTER-like reinstall, zip-slip, etc.). |
| language-extensions/dotnet-core-CSharp/test/src/native/CSharpExecuteTests.cpp | Tweaks invalid-library-name test input. |
| language-extensions/dotnet-core-CSharp/test/test_packages/*.zip / *.dll | Adds new fixture packages (nested layout, many files, zip-slip, bad zip, raw dll, etc.). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Search for files matching the library name pattern (e.g. "regex.*") | ||
| if (!string.IsNullOrEmpty(privatePath)) | ||
| { | ||
| dllList.AddRange(Directory.GetFiles(privatePath, userLibName)); | ||
| dllList.AddRange(Directory.GetFiles(privatePath, userLibName + ".*")); | ||
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(publicPath)) | ||
| { | ||
| dllList.AddRange(Directory.GetFiles(publicPath, userLibName)); | ||
| dllList.AddRange(Directory.GetFiles(publicPath, userLibName + ".*")); | ||
| } |
There was a problem hiding this comment.
CreateDllList now appends ".*" to userLibName unconditionally. If callers pass an explicit filename like "Microsoft.SqlServer.CSharpExtensionTest.dll", the search pattern becomes "Microsoft.SqlServer.CSharpExtensionTest.dll.*" and will not match the actual DLL, causing runtime failures. Consider searching both the exact name and the wildcard variant (e.g., try userLibName first; if no matches, fall back to userLibName + ".*").
There was a problem hiding this comment.
Addressed in 246067b. Extracted the match logic into a helper AddMatches that tries the exact name first and falls back to the userLibName + ".*" wildcard only when no exact match exists. Filenames like "Foo.dll" now resolve correctly while bare names like "regex" still match "regex.dll", "regex.runtimeconfig.json", etc.
|
|
||
| if (Directory.Exists(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)) | ||
| { | ||
| CleanupManifest(manifestPath, installDir); | ||
| } | ||
|
|
||
| // Non-ZIP installs write a single "{libName}.dll" file and | ||
| // no manifest; remove that file directly. | ||
| string libraryFile = Path.Combine(installDir, DllFileNameFor(libName)); |
There was a problem hiding this comment.
UninstallExternalLibrary does not call ValidateLibraryName before using libName to build manifestPath / libraryFile. A malicious or legacy library name containing path separators could make Path.Combine(installDir, libName + ".manifest") resolve outside installDir, allowing unintended file reads/deletes. Validate libName (or otherwise enforce that the computed paths stay under installDir) before touching the filesystem.
| if (Directory.Exists(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)) | |
| { | |
| CleanupManifest(manifestPath, installDir); | |
| } | |
| // Non-ZIP installs write a single "{libName}.dll" file and | |
| // no manifest; remove that file directly. | |
| string libraryFile = Path.Combine(installDir, DllFileNameFor(libName)); | |
| string fullInstallDir = Path.GetFullPath(installDir); | |
| string installDirPrefix = fullInstallDir.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) | |
| ? fullInstallDir | |
| : fullInstallDir + Path.DirectorySeparatorChar; | |
| StringComparison pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) | |
| ? StringComparison.OrdinalIgnoreCase | |
| : StringComparison.Ordinal; | |
| string EnsurePathUnderInstallDir(string candidatePath) | |
| { | |
| string fullCandidatePath = Path.GetFullPath(candidatePath); | |
| if (!fullCandidatePath.StartsWith(installDirPrefix, pathComparison)) | |
| { | |
| throw new ArgumentException("Invalid library name."); | |
| } | |
| return fullCandidatePath; | |
| } | |
| if (Directory.Exists(fullInstallDir)) | |
| { | |
| // Check for a manifest written during install that lists | |
| // all files extracted from the library's ZIP content. | |
| string manifestPath = EnsurePathUnderInstallDir(Path.Combine(fullInstallDir, libName + ".manifest")); | |
| if (File.Exists(manifestPath)) | |
| { | |
| CleanupManifest(manifestPath, fullInstallDir); | |
| } | |
| // Non-ZIP installs write a single "{libName}.dll" file and | |
| // no manifest; remove that file directly. | |
| string libraryFile = EnsurePathUnderInstallDir(Path.Combine(fullInstallDir, DllFileNameFor(libName))); |
There was a problem hiding this comment.
Addressed in 246067b. UninstallExternalLibrary now calls ValidateLibraryName(libName) immediately after unmarshalling the parameters and before any path construction. ValidateLibraryName already rejects names containing /, \, \0, .., or any absolute-path form. Added regression test UninstallRejectsPathTraversalLibNameTest that plants a sentinel outside installDir and asserts it survives an uninstall call with libName "../do-not-delete".
| // If no file in installDir matches "{libName}.*", copy the first .dll | ||
| // found as "{libName}.dll" so DllUtils.CreateDllList can discover it. | ||
| string aliasFileName = DllFileNameFor(libName); | ||
| if (Directory.GetFiles(installDir, libName + ".*").Length == 0 && | ||
| !File.Exists(Path.Combine(installDir, aliasFileName))) | ||
| { | ||
| string[] dlls = Directory.GetFiles(installDir, "*.dll"); | ||
| if (dlls.Length > 0) | ||
| { | ||
| string alias = Path.Combine(installDir, aliasFileName); | ||
| File.Copy(dlls[0], alias, false); | ||
| extractedFiles.Add(aliasFileName); | ||
| } |
There was a problem hiding this comment.
Alias creation is not robust for nested ZIP layouts and can break the “arbitrary file tree” goal: Directory.GetFiles(installDir, "*.dll") only searches the top-level, so packages with DLLs under lib/… won’t get a {libName}.dll alias and won’t be discoverable by DllUtils. Also, the alias path isn’t included in conflict detection; if {libName}.dll already exists (from another library), File.Copy will throw after extraction, leaving partial state. Consider (1) searching for DLLs recursively and (2) pre-checking / including the alias path in the same conflict-validation phase before writing into installDir.
There was a problem hiding this comment.
Addressed in 246067b. Alias handling now: (1) scans the full extractedFiles list (the entire tree, not just top-level) to see whether any file named {libName}.* or exactly {libName}.dll is already present, in which case no alias is created; (2) when an alias IS needed, it is appended to extractedFiles BEFORE CheckForConflicts runs, so an {libName}.dll collision with another library fails fast with no content written to installDir. Added regression test AliasConflictDetectedBeforeExtractionTest that plants shared.dll from library A, tries to install library B with libName "shared", and asserts: (a) install returns SQL_ERROR, (b) no new files landed in installDir (byte-identical file count before/after).
| foreach (string file in Directory.GetFiles(sourceDir)) | ||
| { | ||
| string destFile = Path.Combine(destDir, Path.GetFileName(file)); | ||
| File.Copy(file, destFile, true); |
There was a problem hiding this comment.
CopyDirectory uses File.Copy(..., overwrite: true), which can overwrite existing files in shared install directories if the filesystem state changes after conflict-checking (TOCTOU), and it weakens the “no overwrites” guarantee this PR is trying to enforce. Prefer overwrite: false (and rely on the explicit conflict check) so an unexpected existing file causes a controlled failure rather than silent replacement.
| File.Copy(file, destFile, true); | |
| File.Copy(file, destFile, false); |
There was a problem hiding this comment.
Addressed in 246067b. CopyDirectory now uses File.Copy(file, destFile, false). CheckForConflicts already runs before any copy, so overwrite:false is the correct TOCTOU-safe choice: an unexpected existing file causes a loud IOException rather than silently replacing another library's content.
…e validation, recursive/conflict-checked alias, CopyDirectory no-overwrite
DllUtils.CreateDllList: try exact userLibName first; fall back to userLibName + '.*' only if no exact match. Extracted into AddMatches helper. Fixes callers that pass an explicit filename like 'Foo.dll' which previously became 'Foo.dll.*' and matched nothing.
CSharpExtension.UninstallExternalLibrary: call ValidateLibraryName before building manifestPath / libraryFile. Prevents malicious or legacy names with path separators from resolving outside installDir.
CSharpExtension.InstallExternalLibrary alias creation: (a) search the full extracted tree (not just top-level) for an existing '{libName}.*' before deciding to create an alias; (b) include the alias path in the conflict-check input so a collision fails BEFORE any content is written to installDir. Prevents partial-state failures when another library already owns '{libName}.dll' at the root.
CSharpExtension.CopyDirectory: use File.Copy overwrite:false (was overwrite:true) so TOCTOU changes between conflict-check and write fail loud rather than silently clobbering another library's files.
Tests: added UninstallRejectsPathTraversalLibNameTest and AliasConflictDetectedBeforeExtractionTest to cover the new behaviors.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 17 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Raw DLL. Clean up any previous manifest-based install of the same | ||
| // library, then copy the file as "{libName}.dll" so DllUtils can find it. | ||
| if (File.Exists(manifestPath)) | ||
| { | ||
| CleanupManifest(manifestPath, installDir); | ||
| } | ||
|
|
||
| File.Copy(libFilePath, Path.Combine(installDir, DllFileNameFor(libName)), true); |
There was a problem hiding this comment.
Raw-DLL install unconditionally copies to {installDir}\{libName}.dll with overwrite: true and does not run conflict detection. In a shared install directory this can overwrite a file installed by another library (e.g., an existing ZIP-based library extracted foo.dll, then installing a raw DLL with libName foo replaces it). Consider performing conflict checks here and avoiding overwrite unless you can prove the existing file is owned by the same library (or emit a manifest for raw installs).
| // Raw DLL. Clean up any previous manifest-based install of the same | |
| // library, then copy the file as "{libName}.dll" so DllUtils can find it. | |
| if (File.Exists(manifestPath)) | |
| { | |
| CleanupManifest(manifestPath, installDir); | |
| } | |
| File.Copy(libFilePath, Path.Combine(installDir, DllFileNameFor(libName)), true); | |
| // Raw DLL. Only replace an existing file when ownership can be proven | |
| // via this library's manifest. Otherwise, fail rather than overwrite a | |
| // potentially unrelated library installed into the shared directory. | |
| string dllFileName = DllFileNameFor(libName); | |
| string installedDllPath = Path.Combine(installDir, dllFileName); | |
| if (File.Exists(manifestPath)) | |
| { | |
| CleanupManifest(manifestPath, installDir); | |
| } | |
| else if (File.Exists(installedDllPath)) | |
| { | |
| throw new IOException( | |
| "A library file already exists at the target path, but it is not owned by the library being installed."); | |
| } | |
| File.Copy(libFilePath, installedDllPath, false); | |
| File.WriteAllLines(manifestPath, new[] { dllFileName }); |
| string manifestPath = Path.Combine(installDir, libName + ".manifest"); | ||
| HashSet<string> oldManifestEntries = ReadManifestEntries(manifestPath); | ||
|
|
There was a problem hiding this comment.
ALTER-style reinstall from a previous raw-DLL install to a ZIP install with the same libName isn’t accounted for in the “previous ownership” set: ReadManifestEntries only tracks prior ZIP installs. If {libName}.dll exists from a prior raw install, the ZIP path’s alias creation + CheckForConflicts will treat it as an external conflict (or, if alias is suppressed, DllUtils will keep discovering the stale raw DLL). Consider tracking raw installs in a manifest too, or explicitly treating an existing {libName}.dll as owned-by-previous for the same libName and deleting/replacing it atomically once staging validates.
| // DllUtils.CreateDllList can discover the library. Examine the | ||
| // entire extracted tree (not just the top level) so packages | ||
| // laid out under e.g. lib/... do not generate a redundant alias | ||
| // when a DLL named "{libName}.dll" (or "{libName}.*") is already | ||
| // present deeper in the tree. If an alias IS needed, include it | ||
| // in extractedFiles now so CheckForConflicts fails BEFORE we | ||
| // start writing to installDir. | ||
| string aliasFileName = DllFileNameFor(libName); | ||
| string aliasSourceRelPath = null; | ||
| bool libNameAlreadyPresent = false; | ||
| foreach (string relPath in extractedFiles) | ||
| { | ||
| string name = Path.GetFileName(relPath); | ||
| if (name.StartsWith(libName + ".", s_pathComparison) || | ||
| name.Equals(aliasFileName, s_pathComparison)) |
There was a problem hiding this comment.
Alias suppression treats a nested match (e.g., lib/net8.0/{libName}.dll) as sufficient to skip creating a root {libName}.dll alias. But DllUtils.CreateDllList searches only the top-level of the library path (non-recursive), so a nested {libName}.dll won’t be discoverable and execution will fail. Consider only counting root-level {libName}.* as “already present” (and still generate an alias when the match is nested), or update DllUtils to search recursively and add a regression test.
| // DllUtils.CreateDllList can discover the library. Examine the | |
| // entire extracted tree (not just the top level) so packages | |
| // laid out under e.g. lib/... do not generate a redundant alias | |
| // when a DLL named "{libName}.dll" (or "{libName}.*") is already | |
| // present deeper in the tree. If an alias IS needed, include it | |
| // in extractedFiles now so CheckForConflicts fails BEFORE we | |
| // start writing to installDir. | |
| string aliasFileName = DllFileNameFor(libName); | |
| string aliasSourceRelPath = null; | |
| bool libNameAlreadyPresent = false; | |
| foreach (string relPath in extractedFiles) | |
| { | |
| string name = Path.GetFileName(relPath); | |
| if (name.StartsWith(libName + ".", s_pathComparison) || | |
| name.Equals(aliasFileName, s_pathComparison)) | |
| // DllUtils.CreateDllList can discover the library. Since DLL | |
| // discovery only checks the top level of the extracted library | |
| // path, only a root-level "{libName}.dll" (or "{libName}.*") | |
| // should suppress alias creation. If an alias IS needed, include | |
| // it in extractedFiles now so CheckForConflicts fails BEFORE we | |
| // start writing to installDir. | |
| string aliasFileName = DllFileNameFor(libName); | |
| string aliasSourceRelPath = null; | |
| bool libNameAlreadyPresent = false; | |
| foreach (string relPath in extractedFiles) | |
| { | |
| bool isRootLevelPath = | |
| relPath.IndexOf('/') < 0 && | |
| relPath.IndexOf('\\') < 0; | |
| string name = Path.GetFileName(relPath); | |
| if (isRootLevelPath && | |
| (name.StartsWith(libName + ".", s_pathComparison) || | |
| name.Equals(aliasFileName, s_pathComparison))) |
| SQLCHAR **libraryError, | ||
| SQLINTEGER *libraryErrorLength) | ||
| { | ||
| if (!errorString.empty()) |
There was a problem hiding this comment.
Add a null-pointer guard consistent with the managed version:
if (!errorString.empty() && libraryError != nullptr && libraryErrorLength != nullptr)The managed SetLibraryError (CSharpExtension.cs) guards with if (libraryError != null && libraryErrorLength != null), but the native helper does not. If a caller ever passes null libraryError or libraryErrorLength, the native path dereferences null pointers (*libraryErrorLength = ...), causing an access violation / crash.
| // The library file is expected to be a zip. If it contains an inner zip, | ||
| // that zip is extracted to the install directory. Otherwise, all files |
There was a problem hiding this comment.
does it mean that it will recursively unzip everything if a zip has a zip in it?
| { | ||
| CleanupManifest(manifestPath, installDir); | ||
| } | ||
|
|
There was a problem hiding this comment.
It seems that we are bypassing conflict detection here. A different library's file could be silently overwritten.
This is what was used for the CSharp extension before this PR (since CSharp didn't implement InstallExternalLibrary): [please see engine code for this]
// ExthostLibraryUtils::InstallLibrary in exthostlibraryutils_win.cpp
if (!CopyFileW(lib->pathToFile.m_string, libraryPath, true /* bFailIfExists */))
{
hr = HRESULT_FROM_WIN32(GetLastError());
}CopyFileW(..., true) means bFailIfExists = TRUE — the copy fails if the destination already exists. This is the opposite of overwrite.
Even in th exthost test extension (CFunctionTestExtension::InstallExternalLibrary) — **fail if exists.
In ALTER, however, it seems we call Deletes ALL files in install dir.
ExtHost's ALTER path nukes the entire install directory and re-installs all libraries from scratch. Individual install calls then use bFailIfExists = TRUE, which succeeds because the directory was just emptied.
Before this PR, when a user ran CREATE EXTERNAL LIBRARY [shared] with a raw DLL, if shared already existed in the install directory, ExtHost's default CopyFileW(..., true) would fail the operation. The PR changes this to silently overwrite.
The overwrite-vs-fail difference only matters when:
- Two different libraries share a filename in the same install directory, OR
- Something goes wrong and a stale file is left behind
I think it would better to keep the same behavior for non-Zip files.
Please double check this with UC and also with a manual test.
| /// <returns> | ||
| /// SQL_SUCCESS(0), SQL_ERROR(-1) | ||
| /// </returns> | ||
| public static short InstallExternalLibrary( |
There was a problem hiding this comment.
Consider AlterFromNonZipToZipTest: v1 is raw-DLL myLib, writing myLib.dll. v2 is ZIP testpackageB-DLL.zip (containing testpackageB.dll, testpackageB.deps.json) with the same libName = "myLib".
- v1 raw-DLL install writes myLib.dll, no manifest
- v2 ZIP install needs alias myLib.dll → adds to extractedFiles
- CheckForConflicts finds myLib.dll on disk, it's NOT in ownedByPrevious (empty set) → throws
- Test expects SQL_SUCCESS but gets SQL_ERROR
Can you please check if AlterFromNonZipToZipTest is really passing?
If it's failing: the fix is to detect the "manifest-less raw-DLL upgrade" case: if no manifest exists but DllFileNameFor(libName) exists on disk, add it to ownedByPrevious so the conflict check allows the overwrite.
| // A ZIP file of any variant begins with the 'PK' magic bytes (0x50 0x4B). | ||
| private static bool IsZipFile(string path) | ||
| { | ||
| using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) |
There was a problem hiding this comment.
Problem:
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))The default FileShare is FileShare.None, acquiring an exclusive lock. If any other process has the file open (anti-virus scanner, another SQL session, backup agent), this throws IOException.
Recommendation:
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))| // 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";; |
There was a problem hiding this comment.
nit: while you're here, please remove the extra ;
| static_cast<SQLINTEGER>(libFilePath.length()), | ||
| reinterpret_cast<SQLCHAR *>(const_cast<char *>(installDir.c_str())), | ||
| static_cast<SQLINTEGER>(installDir.length()), | ||
| &libError, |
There was a problem hiding this comment.
this is just more of a fyi since this is test.
CallInstall and CallUninstall helpers never free libError
| "testpackageA", packagePath, installDir); | ||
| EXPECT_EQ(result, SQL_SUCCESS); | ||
|
|
||
| EXPECT_TRUE(DirectoryHasFiles(installDir)) |
There was a problem hiding this comment.
Not a blocker; but worth noting.
Problem: Both tests assert DirectoryHasFiles(installDir) — "at least one file exists." This is weak:
- If the install succeeds but extracts wrong files, the test passes.
- If a stale file from a previous test remains, the test passes even if install failed.
Later tests (e.g., ManifestWrittenTest, InstallZipWithNestedDirectoriesTest) use strong assertions checking specific file names. These early tests should do the same.
Recommendation: Replace DirectoryHasFiles with assertions on specific expected output files, consistent with the more thorough tests later in the file.
| break; | ||
| } | ||
| } | ||
| EXPECT_TRUE(hasDll) << "DLL from second package not found after reinstall"; |
There was a problem hiding this comment.
Problem: The test installs package A as "testpackage", then package B as "testpackage" (ALTER). It only checks that package B's DLL exists:
EXPECT_TRUE(hasDll) << "DLL from second package not found after reinstall";But does not verify that package A's unique files (testpackageA.dll, testpackageA.txt) were removed. The test could pass even if cleanup is broken.
The later AlterExternalLibraryTest does verify cleanup thoroughly, but ReinstallLibraryTest should also be self-contained.
Recommendation: Add:
EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.dll"));
EXPECT_FALSE(fs::exists(fs::path(installDir) / "testpackageA.txt"));| SQLCHAR **libraryError, | ||
| SQLINTEGER *libraryErrorLength) | ||
| { | ||
| if (!errorString.empty()) |
There was a problem hiding this comment.
Problem: If errorString is empty, the function does nothing — *libraryError and *libraryErrorLength remain at whatever the caller initialized them to. ExtHost initializes them to nullptr / 0, so this is fine in the current call pattern. But a future caller could pass uninitialized values.
if (!errorString.empty())
{
// Only set on non-empty — otherwise output params are untouched
}Recommendation: Consider explicitly setting *libraryErrorLength = 0 in the else branch for robustness. This is a nit — not a bug in current usage.
Summary
Extends
InstallExternalLibrary/UninstallExternalLibraryin the .NET Core CSharp Language Extension to support ZIP archives with arbitrary file trees (e.g. packages with nested folders), not just flat DLL files. Also makesInstallExternalLibraryidempotent soALTER EXTERNAL LIBRARYworks correctly.What changed vs. sicongliu's base branch
1. Manifest-based uninstall
UninstallExternalLibraryonly receivesLibraryName+LibraryInstallDirectory— noLibraryFile, so we cannot re-read ZIP entries fromsys.external_librariesat drop time. To work around this,InstallExternalLibrarynow writes a<libName>.manifestfile listing the relative paths of every extracted file.UninstallExternalLibraryreads that manifest and deletes exactly those files — no more, no less.The previous uninstall implementation deleted everything in the shared install directory, which would wipe out unrelated libraries' files.
2. File-level conflict detection on install
Before extracting a ZIP, every entry is checked against the install directory. If a file of the same name already exists (from another library), install fails with a clear error:
Directory (folder) overlaps are allowed — multiple libraries can share a parent folder like, they just can't overwrite each other's files.
3. Empty-directory cleanup on uninstall
After a manifest-driven delete, parent directories of removed files are walked deepest-first (sorted by separator count) and removed only if empty. Shared parent folders survive as long as any other library still has content in them.
4.
ALTER EXTERNAL LIBRARYsupport (transactional re-install)SQL Server may call
InstallExternalLibraryagain for the same library name duringALTER EXTERNAL LIBRARYwithout first callingUninstallExternalLibrary. The install now:If the new ZIP is corrupt or conflicts with another library, the old version is left intact — the install is atomic from the caller's perspective.
5. Defense in depth
ZipFile.ExtractToDirectoryalready guards against zip-slip on extract; this closes the secondary vector where a malicious entry path could be persisted and later deleted on uninstall..., path separators, null characters, and absolute paths inlibNamebefore using it to build on-disk paths.Ordinalon Linux andOrdinalIgnoreCaseon Windows, so/install/Liband/install/libare correctly treated as distinct paths on Linux.Error-handling matrix
<libName>.dll; no manifest written<libName>.dllis deleted directlyALTER EXTERNAL LIBRARYwith valid new contentALTER EXTERNAL LIBRARYwith corrupt new content..or path separatorTests
14 new
TEST_Fcases inCSharpLibraryTests.cppcovering:<libName>.dllalias namingALTER-style re-installlibraryErrorThree existing tests updated to assert the corrected
<libName>.dllnaming.