Skip to content

CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary#85

Open
stuartpa wants to merge 12 commits intomicrosoft:mainfrom
stuartpa:dev/stuartpa/dotnet-install-external-library
Open

CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary#85
stuartpa wants to merge 12 commits intomicrosoft:mainfrom
stuartpa:dev/stuartpa/dotnet-install-external-library

Conversation

@stuartpa
Copy link
Copy Markdown

@stuartpa stuartpa commented Apr 17, 2026

Summary

Extends InstallExternalLibrary / UninstallExternalLibrary in 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 makes InstallExternalLibrary idempotent so ALTER EXTERNAL LIBRARY works correctly.

What changed vs. sicongliu's base branch

1. Manifest-based uninstall

UninstallExternalLibrary only receives LibraryName + LibraryInstallDirectory — no LibraryFile, so we cannot re-read ZIP entries from sys.external_libraries at drop time. To work around this, InstallExternalLibrary now writes a <libName>.manifest file listing the relative paths of every extracted file. UninstallExternalLibrary reads 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:

Cannot install library '<X>': file '<Y>' already exists in the install directory.

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 LIBRARY support (transactional re-install)

SQL Server may call InstallExternalLibrary again for the same library name during ALTER EXTERNAL LIBRARY without first calling UninstallExternalLibrary. The install now:

  1. Extracts and conflict-checks the new content into a temporary staging folder first
  2. Only cleans up the previous version's manifest after the new content has validated successfully
  3. Suppresses false-positive conflicts between the new version and the old version's own files

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

  • Zip-slip: every extracted path is canonicalized and verified to resolve under the install directory before it is written to the manifest or deleted. ZipFile.ExtractToDirectory already guards against zip-slip on extract; this closes the secondary vector where a malicious entry path could be persisted and later deleted on uninstall.
  • Library name validation: rejects .., path separators, null characters, and absolute paths in libName before using it to build on-disk paths.
  • Linux case-sensitivity: path comparisons use Ordinal on Linux and OrdinalIgnoreCase on Windows, so /install/Lib and /install/lib are correctly treated as distinct paths on Linux.

Error-handling matrix

Scenario Behavior
ZIP contains a file that already exists on disk Install fails with explicit error; no partial state
Two libraries extract to the same folder Allowed (folder overlap is fine)
Non-ZIP file (raw DLL) Copied directly as <libName>.dll; no manifest written
Drop library with no manifest <libName>.dll is deleted directly
ALTER EXTERNAL LIBRARY with valid new content Old content replaced atomically via manifest
ALTER EXTERNAL LIBRARY with corrupt new content Old content preserved; error returned
ZIP entry path escapes install dir (zip-slip) Install fails with explicit error
Library name contains .. or path separator Install fails with explicit error

Tests

14 new TEST_F cases in CSharpLibraryTests.cpp covering:

  • Manifest creation and content (flat + nested paths)
  • <libName>.dll alias naming
  • Directory overlap allowed, file conflict rejected
  • Uninstall preserves unrelated libraries
  • Empty nested directory cleanup
  • ALTER-style re-install
  • Error message surfacing through libraryError
  • Non-ZIP uninstall path
  • Conflict in inner-ZIP code path
  • Temp-folder cleanup on failure
  • ALTER from non-ZIP to ZIP
  • Alias removed on uninstall

Three existing tests updated to assert the corrected <libName>.dll naming.

…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>
@stuartpa stuartpa force-pushed the dev/stuartpa/dotnet-install-external-library branch from e81fdfb to 5377b74 Compare April 17, 2026 11:46
@stuartpa stuartpa changed the base branch from main to dev/sicongliu/dotnet-install-external-library April 17, 2026 11:47
@stuartpa stuartpa force-pushed the dev/stuartpa/dotnet-install-external-library branch from 5377b74 to 82f9cbc Compare April 17, 2026 11:53
…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
@stuartpa stuartpa force-pushed the dev/stuartpa/dotnet-install-external-library branch from 82f9cbc to 10ce19a Compare April 17, 2026 12:09
@stuartpa stuartpa changed the title Add InstallExternalLibrary/UninstallExternalLibrary for .NET extension + build toolchain updates CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary Apr 17, 2026
Stuart Padley added 4 commits April 17, 2026 05:18
…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
@stuartpa stuartpa marked this pull request as ready for review April 17, 2026 14:49
stuartpa added 2 commits April 17, 2026 09:07
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.
Stuart Padley added 2 commits April 21, 2026 07:23
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.
@stuartpa stuartpa changed the base branch from dev/sicongliu/dotnet-install-external-library to main April 21, 2026 14:41
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.
Copilot AI review requested due to automatic review settings April 21, 2026 14:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / UninstallExternalLibrary with manifest-driven uninstall, conflict detection, and temp-folder staging.
  • Updated DLL discovery logic in DllUtils and 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.

Comment on lines 94 to 103
// 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 + ".*"));
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + ".*").

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +858 to +871

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));
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)));

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment on lines +783 to +795
// 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);
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
File.Copy(file, destFile, true);
File.Copy(file, destFile, false);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +701 to +708
// 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);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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 });

Copilot uses AI. Check for mistakes.
Comment on lines +696 to +698
string manifestPath = Path.Combine(installDir, libName + ".manifest");
HashSet<string> oldManifestEntries = ReadManifestEntries(manifestPath);

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +759 to +773
// 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))
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)))

Copilot uses AI. Check for mistakes.
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength)
{
if (!errorString.empty())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +146 to +147
// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it mean that it will recursively unzip everything if a zip has a zip in it?

{
CleanupManifest(manifestPath, installDir);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants