CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary - #85
Conversation
e81fdfb to
5377b74
Compare
82f9cbc to
10ce19a
Compare
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.
…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.
…nifest, alias root-only, native null-check, FileShare.Read, reparse-point skip, DllUtils .dll filter, comments, tests
|
Edge case: ZIP with only empty directories passes the archive-not-empty check but produces no files The empty-archive guard checks if (Directory.GetFiles(tempFolder).Length == 0 &&
Directory.GetDirectories(tempFolder).Length == 0)
{
throw new InvalidOperationException(
"The library archive contains no entries.");
}A ZIP containing only empty directories (no files) passes this check because
The result: the old library content is deleted, nothing new is installed, and no manifest is written — a silent data-loss scenario during ALTER. Consider adding a check after if (extractedFiles.Count == 0)
{
throw new InvalidOperationException(
"The library archive contains no files.");
} |
|
Inner-zip path writes directly to The non-inner-zip path extracts to ZipFile.ExtractToDirectory(innerZipPath, installDir, false);This is currently safe on Windows because .NET's However, with Linux support planned (mentioned in the PR description and in the Since this PR is already adding cross-platform defense (the If that's too much churn for this PR, at least add a comment documenting why this is safe today and flagging it for re-evaluation: // SAFETY: ZipFile.ExtractToDirectory does not restore symlink entries as
// actual symlinks on any platform — they are written as regular files.
// If this changes in a future .NET version or if we switch to a different
// extraction library, this path must be routed through CopyDirectory
// (or an equivalent reparse-point filter) to match the non-inner-zip path.
ZipFile.ExtractToDirectory(innerZipPath, installDir, false); |
|
Root-level files skip the reparse-point check that
// Inside CopyDirectory — protected ✓
FileAttributes fileAttrs = File.GetAttributes(file);
if ((fileAttrs & FileAttributes.ReparsePoint) != 0)
continue;But the root-level copy loop here does not have that check: // Non-inner-zip path in InstallExternalLibrary — NOT protected ✗
foreach (string file in Directory.GetFiles(tempFolder))
{
File.Copy(file, Path.Combine(installDir, Path.GetFileName(file)), false);
}Example attack scenario on Linux (upcoming per PR description): a ZIP extracts to:
Same gap exists for the root-level directory loop: a reparse-point directory would be passed to Consider adding the same guard before both root-level copies: foreach (string file in Directory.GetFiles(tempFolder))
{
if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) != 0)
continue;
File.Copy(file, Path.Combine(installDir, Path.GetFileName(file)), false);
}
foreach (string dir in Directory.GetDirectories(tempFolder))
{
if ((File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0)
continue;
CopyDirectory(dir, Path.Combine(installDir, Path.GetFileName(dir)));
} |
|
Test coverage gaps The test suite is thorough — nice work covering both extraction paths, conflict detection, ALTER directions, alias lifecycle, zip-slip, and error propagation. A few gaps remain: 1. Raw DLL → Raw DLL ALTER 2.
These are all rejected by 3.
4. 5. Uninstall when 6. Nested-dir uninstall with shared parent directory |
|
This test only asserts that some |
|
The |
|
Manifest entry checks use if (e.find("testpackageB.dll") != string::npos) hasDll = true;This would match entries like if (e == "testpackageB.dll") hasDll = true; |
|
The test verifies the alias file |
|
Despite the name, this test doesn't exercise directory overlap. Packages A and B both have flat root-level files ( |
|
Failure modes 1 (missing file) and 2 (zip-slip) only assert |
|
Pushed two new commits addressing the third review pass:
Test results: 112/112 unit tests pass on Windows release config. Replies posted on every thread above. yaelh Justin M (@JustinMDotNet) — ready for another look. |
yaelh
left a comment
There was a problem hiding this comment.
Signing off since the remaining comments are small and trivial to implement. also since I'll be OOF I don't want to block the PR
|
Please run PVS once before merging this PR. I have shared the instructions with Justin. |
|
Acknowledged MHN (@monamaki) — coordinating with Justin M (@JustinMDotNet) to run PVS against the dotnet-core-CSharp extension. We'll post the results (and address any findings) before requesting re-review. Thanks for getting the instructions over to him. |
Production fixes (end-to-end testing against SQL Server 2025 RTM-GDR): * CSharpExtension.cs AcquireInstallLock: place install.lock under Path.Combine(installDir, "install.lock") instead of one level up so concurrent installs into different <dbid>/<langid> slots don't serialize against one another. * CSharpExtension.cs new DispatchAsZip(libName, libFilePath): install dispatch is now driven by the registered library name's extension (.zip -> ZIP, .dll -> raw DLL, otherwise fall back to libFilePath's extension). ExtHost passes a generated temp filename with no semantic suffix in production, so the previous extension-sniff on libFilePath was meaningless there; the libFilePath fallback preserves the legacy contract for test fixtures registered under a bare library name. * CSharpOutputDataSet.cs + utils/Sql.cs DotNetNVarChar plumbing: add the DotNetNVarChar row to Sql.DataTypeSize and a DotNetNVarChar case alongside DotNetWChar in ExtractColumn / GetStrLenNullMap. Previously fell through to default and threw KeyNotFoundException in DataTypeSize before the column reached the dispatch switch. * CSharpOutputDataSet.cs DotNetWChar / DotNetNVarChar Size unit: report Size in BYTES, matching the unit emitted by GetStrLenNullMap (Encoding.Unicode.GetByteCount). The previous code reported a character count, which combined with a byte-count length map caused SPEES to log "Reading one row failed for column N row M. The length information is incorrect." and reject the rowset whenever a string column contained non-ASCII data. Reviewer items (PR microsoft#85 May 13 review): * test/src/native/CMakeLists.txt: non-MSVC -std=c++17 (one dash) instead of --std=c++17 to match the documented spelling and the rest of the build tree. * include/nativecsharpextension.h: rewrite InstallExternalLibrary doc comment to spell out the new libName-based dispatch contract and the {libName}.manifest file written for every install (ZIP or raw DLL). * CSharpExtension.cs DetermineAliasSource: alias-suppression now requires an EXACT match against "{libName}.dll" at the install root. The previous prefix check ("{libName}.") suppressed alias creation for ZIPs that planted only sidecars at the root (foo.deps.json etc.) with the real binary nested under lib/net8.0/, leaving the install un-loadable. Drops the now-unused libName parameter from the signature. Pinned by AliasCreatedWhenOnlySidecarsAtRootTest + testpackageL-SIDECAR.zip fixture (build-sidecar-fixture.ps1). * test/src/native/CSharpLibraryTests.cpp new FreeLibError(SQLCHAR *) helper using LocalFree (matches the production Marshal.AllocHGlobal / LocalAlloc allocator that ExtHost uses on the consumer side). Wired into CallInstall, CallUninstall, and CallInstallCaptureError so the test harness no longer leaks the libError buffer on every failing-install assertion. Tests: 113/113 unit tests pass on Windows release config (one new TEST_F: AliasCreatedWhenOnlySidecarsAtRootTest).
SicongLiu2000
left a comment
There was a problem hiding this comment.
Review Summary — ZIP Archive Install/Uninstall Support
Excellent test coverage (2103 lines) and solid defense-in-depth (reparse point checks, manifest-based cleanup, DOS device name rejection, zip-slip prevention). One security finding that should be addressed before merge:
Must-fix (High)
- Path traversal bypass —
ValidateLibraryNamerejects literal..but not URL-encoded%2e%2eor Unicode homoglyphs. Switch to whitelist:[a-zA-Z0-9_.-]+
Should-fix (Medium)
- Infinite lock —
AcquireInstallLockblocks forever with no timeout or cancellation - TOCTOU race — gap between
CheckForConflictsandExtractContentToInstallDir(mitigated by ACLs, but undocumented) - Incomplete reparse detection —
IsReparsePointdoesn't catch hard links - Path separator mismatch — manifest entries use platform-native separators, breaking cross-platform portability
See inline comments for details and suggested fixes.
SicongLiu2000
left a comment
There was a problem hiding this comment.
Changing to Approve — the path traversal concern (Finding #1) depends on whether SQL Server normalizes the library name before passing it to the extension. Given that CREATE EXTERNAL LIBRARY likely passes the name as-is (T-SQL identifiers don't undergo URL decoding), the existing validation is sufficient in practice. The other findings are hardening suggestions, not blockers. LGTM with the inline suggestions noted.
|
MHN (@monamaki) — Understood on the PVS-Studio run. Will run it before merging and address any findings. |
7ed2e37 to
8ec1e76
Compare
…stallExternalLibrary
8ec1e76 to
3c0fe53
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restore PR #85's `e.ToString()` format in ExceptionUtils.WrapError that PR #92 had reverted, and propagate the same pattern to the other catch sites so the full exception chain (type + message + InnerException) reaches logs and -- for external library APIs -- the user-facing T-SQL diagnostic channel. PR #92 reverted the format because `CSharpExtensionApiTests.ExecuteInvalidIntegerColumnsTest` asserted on the literal `"Error: "` prefix that the old `e.StackTrace + "Error: " + e.Message` format produced. The new format preserves everything the old one did — message and stack — and also surfaces the exception type and the InnerException chain. This change relaxes the test assertion to match instead of giving up the better log. ExceptionUtils.cs Re-apply `Logging.Error(e.ToString())`. Identical to the post-PR #85 state; reverts the format-only portion of PR #92. DllUtils.cs (GetUserDll catch) Replace `e.StackTrace + "Error: " + e.Message` with `$"Failed to load or inspect '{dllPath}': {e}"`. Adds the missing context (which DLL failed in the per-DLL loop) and the inner exception chain carrying the actionable cause for Assembly.LoadFrom failures. CSharpExtension.cs (Install/UninstallExternalLibrary catches) Split the log channel from the user-facing libraryError out-parameter: - log: `Logging.Error($"...: {e}")` -- full chain + stack for SREs - user: `SetLibraryError(FlattenMessages(e), ...)` -- inner messages, no types/stack, for T-SQL CREATE/ALTER/DROP diagnostics. Adds private FlattenMessages helper; the old single-channel formatting collapsed the inner exception so DBAs saw only the generic outer message (e.g. FileLoadException's "Could not load file or assembly ..." with no reason). CSharpExecuteTests.cpp (Execute helper) Drop the literal `"Error: "` prefix from the expected substring; match on `"Unable to find user class with full name:"` which is preserved by both the old and new formats. Stream captured stdout/stderr into the EXPECT_TRUE on failure so the next regression is debuggable from the pipeline log alone.
* Revert logging format changes from PR microsoft#85 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Relax error assertion format in CSharp execute test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore legacy error-prefix logging for execute path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop CSharpExtension changes from reversion PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…oft#93) Restore PR microsoft#85's `e.ToString()` format in ExceptionUtils.WrapError that PR microsoft#92 had reverted, and propagate the same pattern to the other catch sites so the full exception chain (type + message + InnerException) reaches logs and -- for external library APIs -- the user-facing T-SQL diagnostic channel. PR microsoft#92 reverted the format because `CSharpExtensionApiTests.ExecuteInvalidIntegerColumnsTest` asserted on the literal `"Error: "` prefix that the old `e.StackTrace + "Error: " + e.Message` format produced. The new format preserves everything the old one did — message and stack — and also surfaces the exception type and the InnerException chain. This change relaxes the test assertion to match instead of giving up the better log. ExceptionUtils.cs Re-apply `Logging.Error(e.ToString())`. Identical to the post-PR microsoft#85 state; reverts the format-only portion of PR microsoft#92. DllUtils.cs (GetUserDll catch) Replace `e.StackTrace + "Error: " + e.Message` with `$"Failed to load or inspect '{dllPath}': {e}"`. Adds the missing context (which DLL failed in the per-DLL loop) and the inner exception chain carrying the actionable cause for Assembly.LoadFrom failures. CSharpExtension.cs (Install/UninstallExternalLibrary catches) Split the log channel from the user-facing libraryError out-parameter: - log: `Logging.Error($"...: {e}")` -- full chain + stack for SREs - user: `SetLibraryError(FlattenMessages(e), ...)` -- inner messages, no types/stack, for T-SQL CREATE/ALTER/DROP diagnostics. Adds private FlattenMessages helper; the old single-channel formatting collapsed the inner exception so DBAs saw only the generic outer message (e.g. FileLoadException's "Could not load file or assembly ..." with no reason). CSharpExecuteTests.cpp (Execute helper) Drop the literal `"Error: "` prefix from the expected substring; match on `"Unable to find user class with full name:"` which is preserved by both the old and new formats. Stream captured stdout/stderr into the EXPECT_TRUE on failure so the next regression is debuggable from the pipeline log alone.
Reconcile the Linux support with main, which has since added DECIMAL (microsoft#83), ZIP archive InstallExternalLibrary (microsoft#85), and SetHostCallbacks/API v3 (microsoft#90). Conflict + fallout resolutions (Linux portability for the new main code): - CMakeLists.txt: keep the WIN32/else split (Linux gtest-from-source, -fshort-wchar, dl/pthread linking); drop main''s unconditional -D_WIN64/_WINDOWS which broke Linux. - CSharpExtensionApiTests.cpp GetHandles(): resolve the InstallExternalLibrary/ UninstallExternalLibrary symbol lookups for both the Windows (GetProcAddress) and Linux (dlsym) branches. - CSharpLibraryTests.cpp (new, microsoft#85): add cross-platform GetExecutablePath() helper (readlink on Linux), guard LocalFree/HLOCAL with free() on Linux. - CSharpSetHostCallbacksTests.cpp (new, microsoft#90): make RESOLVE_SET_HOST_CALLBACKS use dlsym on Linux. - run-...-test.sh: export ENL_ROOT so the test binary can locate test_packages.
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 (or, for raw-DLL installs, the single<libName>.dllentry).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; 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.ExtractToDirectoryrejects path-traversal entries at extraction time. The single-path collapse in review pass 3 (see Update below) means the install code never sees an entry that escaped the staged tree, so there is no second-level vector to defend.CopyDirectoryskips entries with theReparsePointattribute at every recursion level ΓÇö both file and directory symlinks. Today's .NET writes symlink-mode ZIP entries as regular files on every platform; this guard is future-proofing for a runtime that materializes them as real symlinks. Pinned byInnerZipFutureSymlinkRejectedTest.ValidateLibraryNamerejects null/empty/whitespace-only names, names containing../ path separators / null characters, absolute paths, extension-only names like.dll, and Windows reserved DOS device names (CON, NUL, AUX, PRN, COM1ΓÇôCOM9, LPT1ΓÇôLPT9 ΓÇö bare or suffixed). Reserved-name rejection is enforced on every OS so libraries moved between hosts behave consistently.SetLibraryErrorin the native code allocates the error buffer withmalloc(matching the managedMarshal.AllocHGlobalcontract that ExtHost expects on the other side), so ownership transfers cleanly to the host. Pre-fix returned ac_str()pointer into a freedstd::stringΓÇö undefined behavior on the host side.Ordinalon Linux andOrdinalIgnoreCaseon Windows, so/install/Liband/install/libare correctly treated as distinct paths on Linux.Error-handling matrix
<libName>.dll; manifest written so uninstall and ALTER work uniformly<libName>.dllplanted by another library or external toolingbFailIfExists=TRUEcontract).zipextension whose bytes are not a valid ZIP<libName>.dll<libName>.dllis deleted directly (legacy-compat path for libraries installed by pre-PR builds)ALTER EXTERNAL LIBRARYwith valid new contentALTER EXTERNAL LIBRARYwith corrupt new content../path separator/null character, is an absolute path, is extension-only (.dll), or is a Windows reserved DOS device name (CON/NUL/AUX/PRN/COMn/LPTn ΓÇö bare or suffixed)IsReparsePointguard skips the entry; legitimate files still installTests
28 new
TEST_Fcases inCSharpLibraryTests.cppcovering:<libName>.dllalias naming and removal on uninstallALTER-style re-install (ZIPΓåöZIP, ZIPΓåönon-ZIP, non-ZIPΓåönon-ZIP, ALTER to empty-dirs ZIP preserving v1)libraryError<libName>.dllexists.dll-suffixed library name.zip-extension file with non-ZIP bytes fails loudly (does not silently rewrite the user's file)Update (review pass 2)
Atomicity contract clarified: Install is NOT atomic at the per-file level. A crash between
CleanupManifestand theCopyDirectoryloop can leave the install directory inconsistent. End-to-end recovery is provided by SQL Server's library management architecture: the catalog is the source of truth, and the next session re-installs from the catalog. The in-extension code is staging-validated (corrupt ZIPs cannot start a destructive cleanup) but is not crash-safe.Raw-DLL installs now write a manifest. A one-entry
{libName}.manifestlisting{libName}.dllis written for raw-DLL installs as well as ZIPs. This:CopyFileW(..., bFailIfExists=TRUE)contract: a foreign{libName}.dllplanted by another library or external tooling is no longer silently overwritten ΓÇö install fails.Other v3.8.0 hardening: native
SetLibraryErrornull-check, ZIP file opens withFileShare.Read,CopyDirectoryskipsReparsePointentries (Linux symlink defense), alias suppression now correctly counts only root-level matches (DllUtils.CreateDllListis non-recursive), various comments / doc improvements per inline review.Update (review pass 3)
Addresses 4 inline comments from JustinMDotNet and 13 from yaelh. Tip:
8ef1573.Native / managed correctness fixes:
nativecsharpextension.cppSetLibraryError: replacednew std::string(errorString)+c_str()withmalloc(len + 1)+memcpy. Buffer ownership transfers cleanly to ExtHost; OOM path returns the no-error state instead of crashing.CSharpExtension.csAcquireInstallLock: narrowed the catch viawhen (IsSharingViolation(ex))filter (HResultERROR_SHARING_VIOLATION (32)/ERROR_LOCK_VIOLATION (33)).DirectoryNotFoundException,PathTooLongException,UnauthorizedAccessException, etc. now propagate fast instead of being swallowed.DllUtils.cs: replacedDirectory.GetFiles(searchPath, "{name}.*")+.Where(...EndsWith(".dll"))withEnumerateFiles+ explicitEquals(".dll", OrdinalIgnoreCase)on extension andEquals(userLibName, OrdinalIgnoreCase)on stem. Eliminates the*.dllmatchingfoo.dllxandFoo.*matching short-name 8.3 alias quirks.CSharpExtension.csinstall path collapsed to a single code path: both inner-zip and outer-zip cases now extract intotempFolder/inner-content/first and walk the result on disk viaIsReparsePoint-guardedCopyDirectory. Removed the direct-extract-to-installDir shortcut so a future runtime that materializes Unix symlink-mode entries can't bypass the reparse-point guard.ValidateRelativePathremoved (zero callers after the collapse ΓÇö zip-slip defense is now provided byZipFile.ExtractToDirectoryplus the on-disk walk being unable to see entries that escaped the staged tree).InnerZipFutureSymlinkRejectedTest+ fixturetestpackageK-SYMLINK.zip(262 bytes, generated bybuild-symlink-fixture.ps1). Inner zip containslegitfile.dll+evil-symlink.dll(Unix mode0o120755, content/etc/passwd). Asserts: install succeeds,legitfile.dlllands in installDir, and installDir contains zero reparse points.UninstallExternalLibrary: wrappedFile.Delete(libraryFile)in anelsebranch so the manifest path exclusively owns cleanup for current-version installs. The direct delete only runs as a legacy-compat path for libraries installed by pre-PR builds with no manifest.DetermineAliasSource: lexicographically-first.dllcandidate viastring.CompareOrdinal(stable across NTFS / ext4 / XFS and re-installs).Validation / behavior tightening:
ValidateLibraryName: now rejects whitespace-only names (IsNullOrWhiteSpace) and Windows reserved DOS device names (full set: CON, NUL, AUX, PRN, COM0ΓÇôCOM9, LPT0ΓÇôLPT9). Stem is checked viaPath.GetFileNameWithoutExtension, soCON,CON.dll, andnul.manifestare all rejected. Enforced on every OS for consistency. New rows inInstallRejectsInvalidLibNameTest.IsZipFilecontent-sniff replaced by extension-basedHasZipExtension. A.zipfile whose bytes are not a valid archive now returnsSQL_ERRORinstead of being silently rewritten as<libName>.dllΓÇö the user's registered filename is opaque to the install path. The corresponding test was renamed toInstallZipExtensionWithBadContentFailsLoudlyTestand inverted to assert the loud-failure behavior.Test hardenings:
InstallZipWithManyFilesTest: per-module existence loop (Module1.dll…Module50.dll) plus a comment block with the full historical context (the originalEXPECT_EQ(dllCount, 50)agreed with the buggy install code that created the alias as{libName}with no extension — test asserted what the code did, not what it should do; fixed in commit38c553d).DirectoryOverlapAllowedTestrenamed toNonConflictingFlatFilesCoexistTestwith a tighter comment block clarifying that nested-directory overlap is covered separately.Docs / style:
IsReparsePoint<remarks>now includes the concretesneaky.dll → /etc/shadowworked example covering both file and directory reparse-point cases.CleanupManifestcatch comment to stop at the diagnostic-trail justification.}sweep applied where the pattern was genuinely two adjacent independent constructs (file/dir loops, consecutive flag-setterifs); guard-then-action}followed bycontinue;/return X;left alone..close()sites in test code so the pattern is self-documenting.Test results: 112/112 unit tests pass on Windows release config.
Update (review pass 4)
Addresses 4 inline comments from JustinMDotNet (CMake std flag, header doc contract, alias suppression for sidecar-only roots, native error-buffer ownership in tests) plus 4 production-found bugs from end-to-end testing against SQL Server 2025 RTM-GDR.
Production fixes (end-to-end testing):
CSharpExtension.csAcquireInstallLock: lock file is now created atPath.Combine(installDir, "install.lock")instead of one level up. The previous path put the lock outside the per-<dbid>/<langid>install directory, so concurrent installs into different DB/language slots serialized against each other unnecessarily and (worse) a single install could race against itself when the parent directory was on a separately-permissioned mount.CSharpExtension.csnewDispatchAsZip(libName, libFilePath)helper: install dispatch is now driven by the registered library name's extension, not the staged temp file's extension. SQL Server's ExtHost passes a generated temp file with no semantic suffix, so the previous content-/extension-sniff onlibFilePathwas meaningless in production. Order of resolution islibNameends in.zip→ ZIP,libNameends in.dll→ raw DLL, otherwise fall back tolibFilePath's extension (preserves the legacy contract for test fixtures that register libraries by bare name and pointlibraryFileat a*.zip/*.dllfixture).CSharpOutputDataSet.cs+Sql.csDotNetNVarCharplumbing: theSqlDataType.DotNetNVarCharenum value (thestringrow inSql.DataTypeMap) had no entry inSql.DataTypeSizeand no case inCSharpOutputDataSet.ExtractColumn/GetStrLenNullMap. Calls fell through todefaultand threwKeyNotFoundExceptioninDataTypeSizebefore the column ever reached the dispatch switch. Fixed by adding theMinUtf16CharSizerow toDataTypeSizeand aDotNetNVarCharcase alongsideDotNetWCharin both switches (they're SQL_C_WCHAR-shaped at the ODBC layer and share an implementation).CSharpOutputDataSet.csDotNetWChar/DotNetNVarCharSizeunit:Sizeis now reported in bytes, matching the unit emitted byGetStrLenNullMap(Encoding.Unicode.GetByteCount). The previous code divided by a UTF-16 code-unit width and reported a character count, so SPEES logged"Reading one row failed for column N row M. The length information is incorrect."and rejected the rowset whenever a string column contained non-ASCII data.Reviewer fixes:
test/src/native/CMakeLists.txt: non-MSVCtarget_compile_optionsflag changed from--std=c++17to-std=c++17(one dash). GCC and Clang accept both spellings, but the single-dash form is the documented one and matches the rest of the build tree.include/nativecsharpextension.h:InstallExternalLibrarydoc comment rewritten to spell out the new dispatch contract (libName-based, not libFile-content-based) and to document that a{libName}.manifestis written for every install (ZIP or raw DLL). The previous comment described only the legacy "raw DLL or ZIP detected from the file" behavior.CSharpExtension.csDetermineAliasSource: alias-suppression now requires an exact match againstaliasFileName("{libName}.dll") at the install root. The previous check accepted any root-level entry whose name started with"{libName}.", so a ZIP that planted only sidecars at the root (e.g.foo.deps.json,foo.runtimeconfig.json) and kept the real binary nested underlib/net8.0/foo.dllwas treated as iffoo.dllwere already loadable — alias creation was suppressed and the install was un-loadable. The unusedlibNameparameter was dropped from the signature in the same change. New regression testAliasCreatedWhenOnlySidecarsAtRootTest+ fixturetestpackageL-SIDECAR.zip(491 bytes, generated bybuild-sidecar-fixture.ps1) pins the new behavior.test/src/native/CSharpLibraryTests.cppnewFreeLibError(SQLCHAR *)helper usingLocalFree(matches the productionMarshal.AllocHGlobal/LocalAllocallocator that ExtHost expects on the consumer side). Wired intoCallInstall,CallUninstall, andCallInstallCaptureErrorso the test harness no longer leaks thelibErrorbuffer on every failing-install assertion. Pre-fix the harness allocated, ignored, and never freed — a 113-test run accumulated kilobytes of orphanlibErrorstrings on the heap.Test results: 113/113 unit tests pass on Windows release config (one new
TEST_F:AliasCreatedWhenOnlySidecarsAtRootTest).