Skip to content

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

Open
stuartpa wants to merge 6 commits intomicrosoft:dev/sicongliu/dotnet-install-external-libraryfrom
stuartpa:dev/stuartpa/dotnet-install-external-library
Open

CSharp extension: ZIP archive support for InstallExternalLibrary/UninstallExternalLibrary#85
stuartpa wants to merge 6 commits intomicrosoft:dev/sicongliu/dotnet-install-external-libraryfrom
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. skill packages with nested folders), not just flat DLL files. Also makes InstallExternalLibrary idempotent so ALTER EXTERNAL LIBRARY works correctly.

+938 / -81 across two files:

  • language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs — implementation
  • language-extensions/dotnet-core-CSharp/test/src/native/CSharpLibraryTests.cpp — tests (28 total, 14 new)

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 skills/, 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.

Multi-library lifecycle example

CREATE EXTERNAL LIBRARY [skills-what-is-sql]   FROM (CONTENT = ...) WITH (LANGUAGE = 'dotnet');
CREATE EXTERNAL LIBRARY [skills-explain-query] FROM (CONTENT = ...) WITH (LANGUAGE = 'dotnet');

After both installs:

ExternalLibraries/1/65548/1/
├── Scriptoria.dll                       (other library, untouched)
├── skills-what-is-sql.manifest          → skills\what-is-sql\SKILL.md
├── skills-explain-query.manifest        → skills\explain-query\SKILL.md
└── skills/
    ├── what-is-sql/SKILL.md
    └── explain-query/SKILL.md
DROP EXTERNAL LIBRARY [skills-what-is-sql];

skills/what-is-sql/SKILL.md and its manifest are deleted. The skills/ folder and skills-explain-query's content are preserved because the folder is still non-empty.

DROP EXTERNAL LIBRARY [skills-explain-query];

Last library using skills/ is dropped; the now-empty skills/ folder is removed. Scriptoria.dll and other unrelated libraries are untouched.

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.

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

1 participant