From 4e7b4e75a54ab1269ee6eaf1928180f4449f7257 Mon Sep 17 00:00:00 2001 From: David Coe <> Date: Wed, 11 Mar 2026 10:43:38 -0400 Subject: [PATCH 1/5] initial check-in --- .../Apache.Arrow.Adbc.csproj | 5 + .../DriverManager/AdbcDriverManager.cs | 736 +++++++++++++ .../DriverManager/AdbcLoadFlags.cs | 63 ++ .../FilesystemProfileProvider.cs | 137 +++ .../DriverManager/IConnectionProfile.cs | 72 ++ .../IConnectionProfileProvider.cs | 44 + .../DriverManager/TomlConnectionProfile.cs | 225 ++++ .../DriverManager/TomlParser.cs | 157 +++ .../Apache.Arrow.Adbc/DriverManager/readme.md | 51 + .../DriverManager/ColocatedManifestTests.cs | 255 +++++ .../DriverManager/FakeAdbcDriver.cs | 51 + .../TomlConnectionProfileTests.cs | 993 ++++++++++++++++++ 12 files changed, 2789 insertions(+) create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcLoadFlags.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfile.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfileProvider.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/FakeAdbcDriver.cs create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs diff --git a/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj b/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj index 1a6e3ec653..cfe7084b0e 100644 --- a/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj +++ b/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj @@ -16,6 +16,11 @@ + + \ + PreserveNewest + true + true \ diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs new file mode 100644 index 0000000000..d24759484c --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs @@ -0,0 +1,736 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using Apache.Arrow.Adbc.C; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Provides methods to locate and load ADBC drivers, optionally using TOML manifest + /// files and connection profiles. + /// Mirrors the free functions declared in adbc_driver_manager.h: + /// AdbcLoadDriver and AdbcFindLoadDriver. + /// + public static class AdbcDriverManager + { + /// + /// The environment variable that specifies additional driver search paths. + /// + public const string DriverPathEnvVar = "ADBC_DRIVER_PATH"; + + private static readonly string[] s_nativeExtensions = { ".so", ".dll", ".dylib" }; + + // ----------------------------------------------------------------------- + // AdbcLoadDriver – load a driver directly from an absolute or relative path + // ----------------------------------------------------------------------- + + /// + /// Loads an ADBC driver from an explicit file path. + /// Mirrors AdbcLoadDriver in adbc_driver_manager.h. + /// + /// + /// + /// If a TOML manifest file with the same base name exists in the same directory + /// as the driver file, it will be automatically detected and used. For example, + /// loading libadbc_driver_snowflake.dll will automatically use + /// libadbc_driver_snowflake.toml if present. + /// + /// + /// The co-located manifest can specify default options, override the driver path, + /// or provide additional metadata. The parameter + /// always takes precedence over any entrypoint derived from the manifest or filename. + /// + /// + /// + /// The absolute or relative path to the driver shared library. + /// + /// + /// The symbol name of the driver initialisation function. When null the + /// driver manager derives a candidate entrypoint from the file name (see + /// ), falling back to AdbcDriverInit. + /// + /// The loaded . + public static AdbcDriver LoadDriver(string driverPath, string? entrypoint = null) + { + if (string.IsNullOrEmpty(driverPath)) + throw new ArgumentException("Driver path must not be null or empty.", nameof(driverPath)); + + // Check for co-located TOML manifest + string? colocatedManifest = TryFindColocatedManifest(driverPath); + if (colocatedManifest != null) + { + return LoadFromManifest(colocatedManifest, entrypoint); + } + + string resolvedEntrypoint = entrypoint ?? DeriveEntrypoint(driverPath); + return CAdbcDriverImporter.Load(driverPath, resolvedEntrypoint); + } + + // ----------------------------------------------------------------------- + // AdbcFindLoadDriver – locate a driver by name using configurable search rules + // ----------------------------------------------------------------------- + + /// + /// Searches for an ADBC driver by name and loads it. + /// Mirrors AdbcFindLoadDriver in adbc_driver_manager.h. + /// + /// + /// + /// If is an absolute path: + /// + /// A .toml extension triggers manifest loading. + /// Any other extension loads the path as a shared library directly. + /// + /// + /// + /// If is a bare name (no extension, not absolute): + /// + /// Each configured search directory is checked in order. + /// + /// For each directory, <dir>/<name>.toml is attempted first; + /// if found the manifest is parsed and the driver loaded. + /// + /// + /// Then <dir>/<name>.<ext> for each platform extension + /// (.dll, .so, .dylib) is attempted. + /// + /// + /// + /// + /// + /// A driver identifier: an absolute path (with or without extension) or a bare + /// driver name to be resolved by directory search. + /// + /// + /// The symbol name of the driver initialisation function, or null to + /// derive it automatically. + /// + /// + /// Flags controlling which directories are searched. + /// + /// + /// An optional OS path-list-separator-delimited list of extra directories to + /// search before the standard ones, or null. + /// + /// The loaded . + /// Thrown when the driver cannot be found or loaded. + public static AdbcDriver FindLoadDriver( + string driverName, + string? entrypoint = null, + AdbcLoadFlags loadOptions = AdbcLoadFlags.Default, + string? additionalSearchPathList = null) + { + if (string.IsNullOrEmpty(driverName)) + throw new ArgumentException("Driver name must not be null or empty.", nameof(driverName)); + + // Absolute path – load directly. + if (Path.IsPathRooted(driverName)) + return LoadFromAbsolutePath(driverName, entrypoint); + + // Bare name with an extension but not rooted – relative path case. + string ext = Path.GetExtension(driverName); + if (!string.IsNullOrEmpty(ext) && loadOptions.HasFlag(AdbcLoadFlags.AllowRelativePaths)) + { + if (string.Equals(ext, ".toml", StringComparison.OrdinalIgnoreCase)) + return LoadFromManifest(driverName, entrypoint); + return LoadDriver(driverName, entrypoint); + } + + // Bareword – search configured directories. + foreach (string dir in BuildSearchDirectories(loadOptions, additionalSearchPathList)) + { + AdbcDriver? found = TryLoadFromDirectory(dir, driverName, entrypoint); + if (found != null) + return found; + } + + throw new AdbcException( + $"Could not find ADBC driver '{driverName}' in any configured search path.", + AdbcStatusCode.NotFound); + } + + // ----------------------------------------------------------------------- + // LoadDriverFromProfile – load a driver as specified by a connection profile + // ----------------------------------------------------------------------- + + /// + /// Loads an ADBC driver as specified by an . + /// + /// The profile that specifies the driver to load. + /// + /// An optional override for the driver entrypoint symbol. When null the + /// entrypoint is derived from the driver name in the profile. + /// + /// Flags controlling directory search behaviour. + /// + /// An optional additional search path list, overriding any search paths that may + /// be embedded in the profile. + /// + /// The loaded . + /// + /// Thrown when the profile does not specify a driver, or the driver cannot be found. + /// + public static AdbcDriver LoadDriverFromProfile( + IConnectionProfile profile, + string? entrypoint = null, + AdbcLoadFlags loadOptions = AdbcLoadFlags.Default, + string? additionalSearchPathList = null) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + + if (string.IsNullOrEmpty(profile.DriverName)) + throw new AdbcException( + "The connection profile does not specify a driver name.", + AdbcStatusCode.InvalidArgument); + + return FindLoadDriver(profile.DriverName!, entrypoint, loadOptions, additionalSearchPathList); + } + + // ----------------------------------------------------------------------- + // LoadManagedDriver – load a managed .NET AdbcDriver via reflection + // ----------------------------------------------------------------------- + + /// + /// Loads a managed (pure .NET) ADBC driver from a .NET assembly using reflection. + /// + /// + /// Use this instead of when the driver is a .NET assembly + /// (e.g. Apache.Arrow.Adbc.Drivers.BigQuery.dll) rather than a native shared + /// library. The assembly is loaded via and the + /// specified type is instantiated with a public parameterless constructor. + /// + /// + /// The absolute or relative path to the .NET assembly containing the driver. + /// + /// + /// The fully-qualified name of a concrete class that derives from + /// and has a public parameterless constructor + /// (e.g. Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver). + /// + /// An instance of the specified subclass. + /// + /// Thrown when the assembly cannot be loaded, the type is not found, or the type + /// does not derive from . + /// + public static AdbcDriver LoadManagedDriver(string assemblyPath, string typeName) + { + if (string.IsNullOrEmpty(assemblyPath)) + throw new ArgumentException("Assembly path must not be null or empty.", nameof(assemblyPath)); + if (string.IsNullOrEmpty(typeName)) + throw new ArgumentException("Type name must not be null or empty.", nameof(typeName)); + + Assembly assembly; + try + { + assembly = Assembly.LoadFrom(assemblyPath); + } + catch (Exception ex) + { + throw new AdbcException( + $"Failed to load managed driver assembly '{assemblyPath}': {ex.Message}", + AdbcStatusCode.IOError); + } + + Type? driverType = assembly.GetType(typeName, throwOnError: false); + if (driverType == null) + throw new AdbcException( + $"Type '{typeName}' was not found in assembly '{assemblyPath}'.", + AdbcStatusCode.NotFound); + + if (!typeof(AdbcDriver).IsAssignableFrom(driverType)) + throw new AdbcException( + $"Type '{typeName}' does not derive from {nameof(AdbcDriver)}.", + AdbcStatusCode.InvalidArgument); + + object? instance; + try + { + instance = Activator.CreateInstance(driverType); + } + catch (Exception ex) + { + throw new AdbcException( + $"Failed to instantiate driver type '{typeName}': {ex.Message}", + AdbcStatusCode.InternalError); + } + + if (instance == null) + throw new AdbcException( + $"Activator returned null for driver type '{typeName}'.", + AdbcStatusCode.InternalError); + + return (AdbcDriver)instance; + } + + // ----------------------------------------------------------------------- + // OpenDatabaseFromProfile – load driver + open database in one step + // ----------------------------------------------------------------------- + + /// + /// Loads the driver specified by and opens a database, + /// applying all options from the profile as connection parameters. + /// + /// + /// + /// If the profile has a non-null , the + /// driver is loaded as a managed .NET assembly via and + /// is used as the assembly path. + /// + /// + /// Otherwise the driver is loaded as a native shared library via + /// . + /// + /// + /// All options (string, integer, and double) are merged into a single + /// string → string dictionary. Integer and double values are formatted + /// using . The merged dictionary is + /// passed to . + /// + /// + /// Call on the profile before + /// passing it here if you want env_var(NAME) values expanded first. + /// + /// + /// The connection profile specifying the driver and options. + /// Flags controlling directory search for native drivers. + /// + /// An optional extra search path list for native driver discovery. + /// + /// An open . + public static AdbcDatabase OpenDatabaseFromProfile( + IConnectionProfile profile, + AdbcLoadFlags loadOptions = AdbcLoadFlags.Default, + string? additionalSearchPathList = null) + { + return OpenDatabaseFromProfile(profile, null, loadOptions, additionalSearchPathList); + } + + /// + /// Loads the driver specified by and opens a database, + /// merging profile options with explicitly provided options. + /// + /// + /// + /// This overload allows specifying additional options that will be merged with the + /// profile options. Per the ADBC specification, profile options are applied first, + /// then explicit options. If the same key appears in both the profile and explicit + /// options, the explicit value takes precedence. + /// + /// + /// If the profile has a non-null , the + /// driver is loaded as a managed .NET assembly via and + /// is used as the assembly path. + /// + /// + /// Otherwise the driver is loaded as a native shared library via + /// . + /// + /// + /// All options are merged into a single string → string dictionary in the + /// following order (later values override earlier ones for the same key): + /// + /// Profile integer options (formatted as strings) + /// Profile double options (formatted as strings) + /// Profile string options + /// Explicit options from + /// + /// The merged dictionary is passed to . + /// + /// + /// The connection profile specifying the driver and options. + /// + /// Additional options to merge with profile options. Explicit options override profile + /// options for the same key. May be null or empty. + /// + /// Flags controlling directory search for native drivers. + /// + /// An optional extra search path list for native driver discovery. + /// + /// An open . + public static AdbcDatabase OpenDatabaseFromProfile( + IConnectionProfile profile, + IReadOnlyDictionary? explicitOptions, + AdbcLoadFlags loadOptions = AdbcLoadFlags.Default, + string? additionalSearchPathList = null) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + + AdbcDriver driver; + + if (!string.IsNullOrEmpty(profile.DriverTypeName)) + { + // Managed .NET driver path + if (string.IsNullOrEmpty(profile.DriverName)) + throw new AdbcException( + "The connection profile specifies a driver_type but no driver assembly path (driver field).", + AdbcStatusCode.InvalidArgument); + + driver = LoadManagedDriver(profile.DriverName!, profile.DriverTypeName!); + } + else + { + // Native shared-library path + driver = LoadDriverFromProfile(profile, null, loadOptions, additionalSearchPathList); + } + + return driver.Open(BuildStringOptions(profile, explicitOptions)); + } + + /// + /// Merges all options from a profile into a flat string → string dictionary + /// suitable for passing to . + /// Integer and double values are formatted with . + /// String options take precedence if the same key appears in multiple option sets. + /// + public static IReadOnlyDictionary BuildStringOptions(IConnectionProfile profile) + { + return BuildStringOptions(profile, null); + } + + /// + /// Merges options from a profile with explicitly provided options into a flat + /// string → string dictionary suitable for passing to + /// . + /// + /// + /// + /// Options are merged in the following order (later values override earlier ones): + /// + /// Profile integer options (formatted as strings) + /// Profile double options (formatted as strings) + /// Profile string options + /// Explicit options from + /// + /// + /// + /// This ordering ensures that explicit options always take precedence over profile + /// options, as required by the ADBC specification. + /// + /// + /// The connection profile containing base options. + /// + /// Additional options that override profile options. May be null or empty. + /// + /// A merged dictionary of all options. + public static IReadOnlyDictionary BuildStringOptions( + IConnectionProfile profile, + IReadOnlyDictionary? explicitOptions) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + + var merged = new Dictionary(StringComparer.Ordinal); + + // Profile options first (in order: int, double, string) + foreach (var kv in profile.IntOptions) + merged[kv.Key] = kv.Value.ToString(CultureInfo.InvariantCulture); + + foreach (var kv in profile.DoubleOptions) + merged[kv.Key] = kv.Value.ToString(CultureInfo.InvariantCulture); + + foreach (var kv in profile.StringOptions) + merged[kv.Key] = kv.Value; + + // Explicit options last – they override profile options + if (explicitOptions != null) + { + foreach (var kv in explicitOptions) + merged[kv.Key] = kv.Value; + } + + return merged; + } + + // ----------------------------------------------------------------------- + // Helpers – derive entrypoint, search directories, manifest loading + // ----------------------------------------------------------------------- + + /// + /// Derives a candidate entrypoint symbol name from a driver file path. + /// + /// + /// The convention used by the Go-based ADBC drivers is to strip leading + /// lib and any extension, then append Init. For example, + /// libadbc_driver_postgresql.so yields AdbcDriverPostgresqlInit. + /// Falls back to AdbcDriverInit when no better candidate can be formed. + /// + public static string DeriveEntrypoint(string driverPath) + { + string baseName = Path.GetFileNameWithoutExtension(driverPath); + + // Strip leading "lib" prefix common on POSIX platforms. + if (baseName.StartsWith("lib", StringComparison.OrdinalIgnoreCase)) + baseName = baseName.Substring(3); + + // Strip "adbc_driver_" or "adbc_" prefix to get a shorter stem. + const string adbcDriverPrefix = "adbc_driver_"; + const string adbcPrefix = "adbc_"; + + if (baseName.StartsWith(adbcDriverPrefix, StringComparison.OrdinalIgnoreCase)) + baseName = baseName.Substring(adbcDriverPrefix.Length); + else if (baseName.StartsWith(adbcPrefix, StringComparison.OrdinalIgnoreCase)) + baseName = baseName.Substring(adbcPrefix.Length); + + if (string.IsNullOrEmpty(baseName)) + return "AdbcDriverInit"; + + // Convert snake_case to PascalCase. + string pascal = ToPascalCase(baseName); + return $"AdbcDriver{pascal}Init"; + } + + private static AdbcDriver LoadFromAbsolutePath(string path, string? entrypoint) + { + string ext = Path.GetExtension(path); + if (string.Equals(ext, ".toml", StringComparison.OrdinalIgnoreCase)) + return LoadFromManifest(path, entrypoint); + + // Check for co-located TOML manifest (e.g., libadbc_driver_snowflake.toml + // alongside libadbc_driver_snowflake.dll) + string? colocatedManifest = TryFindColocatedManifest(path); + if (colocatedManifest != null) + { + return LoadFromManifest(colocatedManifest, entrypoint); + } + + return LoadDriver(path, entrypoint); + } + + /// + /// Checks for a TOML manifest file co-located with a driver file. + /// + /// + /// + /// Given a driver path like C:\drivers\libadbc_driver_snowflake.dll, + /// this checks for C:\drivers\libadbc_driver_snowflake.toml. + /// + /// + /// Co-located manifests allow drivers to ship with metadata about how they should + /// be loaded (e.g., specifying they're managed .NET drivers via driver_type, + /// or redirecting to the actual driver location via the driver field). + /// + /// + /// Important: Options specified in co-located manifests are NOT automatically + /// applied to database connections. The manifest is used solely for driver loading. + /// To use manifest options, explicitly load the profile with + /// and use + /// . + /// + /// + /// The path to the driver file. + /// + /// The full path to the co-located manifest if found; otherwise null. + /// + private static string? TryFindColocatedManifest(string driverPath) + { + try + { + string fullPath = Path.GetFullPath(driverPath); + string? directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directory)) + return null; + + string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fullPath); + string manifestPath = Path.Combine(directory, fileNameWithoutExt + ".toml"); + + return File.Exists(manifestPath) ? manifestPath : null; + } + catch + { + // If path resolution fails, just return null + return null; + } + } + + private static AdbcDriver LoadFromManifest(string manifestPath, string? entrypoint) + { + if (!File.Exists(manifestPath)) + throw new AdbcException( + $"Driver manifest file not found: '{manifestPath}'.", + AdbcStatusCode.NotFound); + + TomlConnectionProfile manifest = TomlConnectionProfile.FromFile(manifestPath); + + if (string.IsNullOrEmpty(manifest.DriverName)) + throw new AdbcException( + $"Driver manifest '{manifestPath}' does not specify a 'driver' field.", + AdbcStatusCode.InvalidArgument); + + // Check if this is a managed driver + if (!string.IsNullOrEmpty(manifest.DriverTypeName)) + { + // Managed .NET driver - resolve relative path relative to manifest directory + string driverPath = manifest.DriverName!; + if (!Path.IsPathRooted(driverPath)) + { + string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + if (!string.IsNullOrEmpty(manifestDir)) + driverPath = Path.Combine(manifestDir, driverPath); + } + return LoadManagedDriver(driverPath, manifest.DriverTypeName!); + } + + // Native driver - resolve entrypoint and path + string resolvedEntrypoint = entrypoint ?? DeriveEntrypoint(manifest.DriverName!); + + // Resolve relative driver path relative to the manifest directory. + string resolvedDriverPath = manifest.DriverName!; + if (!Path.IsPathRooted(resolvedDriverPath)) + { + string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + if (!string.IsNullOrEmpty(manifestDir)) + resolvedDriverPath = Path.Combine(manifestDir, resolvedDriverPath); + } + + return CAdbcDriverImporter.Load(resolvedDriverPath, resolvedEntrypoint); + } + + private static AdbcDriver? TryLoadFromDirectory(string dir, string driverName, string? entrypoint) + { + // 1. Try manifest + string manifestPath = Path.Combine(dir, driverName + ".toml"); + if (File.Exists(manifestPath)) + { + try { return LoadFromManifest(manifestPath, entrypoint); } + catch (AdbcException) { throw; } // surface manifest parse errors + } + + // 2. Try native library extensions + foreach (string nativeExt in GetPlatformExtensions()) + { + string libPath = Path.Combine(dir, driverName + nativeExt); + if (File.Exists(libPath)) + { + try + { + string ep = entrypoint ?? DeriveEntrypoint(libPath); + return CAdbcDriverImporter.Load(libPath, ep); + } + catch (AdbcException) { /* try next extension */ } + } + } + + return null; + } + + private static IEnumerable BuildSearchDirectories( + AdbcLoadFlags flags, + string? additionalSearchPathList) + { + // 1. Caller-supplied additional paths + if (!string.IsNullOrEmpty(additionalSearchPathList)) + { + foreach (string p in SplitPathList(additionalSearchPathList!)) + yield return p; + } + + // 2. ADBC_DRIVER_PATH environment variable + if (flags.HasFlag(AdbcLoadFlags.SearchEnv)) + { + string? envPath = Environment.GetEnvironmentVariable(DriverPathEnvVar); + if (!string.IsNullOrEmpty(envPath)) + { + foreach (string p in SplitPathList(envPath!)) + yield return p; + } + } + + // 3. User-level directory + if (flags.HasFlag(AdbcLoadFlags.SearchUser)) + { + string userDir = GetUserDriverDirectory(); + if (!string.IsNullOrEmpty(userDir)) + yield return userDir; + } + + // 4. System-level directory + if (flags.HasFlag(AdbcLoadFlags.SearchSystem)) + { + string sysDir = GetSystemDriverDirectory(); + if (!string.IsNullOrEmpty(sysDir)) + yield return sysDir; + } + } + + private static string[] GetPlatformExtensions() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return new[] { ".dll" }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return new[] { ".dylib", ".so" }; + return new[] { ".so", ".dylib" }; + } + + private static string GetUserDriverDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string? appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return string.IsNullOrEmpty(appData) ? string.Empty : Path.Combine(appData, "adbc", "drivers"); + } + else + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrEmpty(home) ? string.Empty : Path.Combine(home, ".config", "adbc", "drivers"); + } + } + + private static string GetSystemDriverDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string? sysRoot = Environment.GetEnvironmentVariable("ProgramData"); + return string.IsNullOrEmpty(sysRoot) ? string.Empty : Path.Combine(sysRoot, "adbc", "drivers"); + } + else + { + return "/usr/lib/adbc"; + } + } + + private static string[] SplitPathList(string pathList) => + pathList.Split(new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); + + private static string ToPascalCase(string snake) + { + var sb = new System.Text.StringBuilder(); + bool upperNext = true; + foreach (char c in snake) + { + if (c == '_') + { + upperNext = true; + } + else if (upperNext) + { + sb.Append(char.ToUpperInvariant(c)); + upperNext = false; + } + else + { + sb.Append(char.ToLowerInvariant(c)); + } + } + return sb.ToString(); + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcLoadFlags.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcLoadFlags.cs new file mode 100644 index 0000000000..9812b2e00e --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcLoadFlags.cs @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Flags to control how the ADBC driver manager searches for and loads drivers. + /// Mirrors the AdbcLoadFlags type and ADBC_LOAD_FLAG_* constants + /// defined in adbc_driver_manager.h. + /// + [Flags] + public enum AdbcLoadFlags : uint + { + /// No search flags set. + None = 0, + + /// + /// Search the ADBC_DRIVER_PATH environment variable for drivers. + /// Corresponds to ADBC_LOAD_FLAG_SEARCH_ENV. + /// + SearchEnv = 1, + + /// + /// Search the user-level configuration directory for drivers. + /// Corresponds to ADBC_LOAD_FLAG_SEARCH_USER. + /// + SearchUser = 2, + + /// + /// Search the system-level configuration directory for drivers. + /// Corresponds to ADBC_LOAD_FLAG_SEARCH_SYSTEM. + /// + SearchSystem = 4, + + /// + /// Allow loading drivers from relative paths. + /// Corresponds to ADBC_LOAD_FLAG_ALLOW_RELATIVE_PATHS. + /// + AllowRelativePaths = 8, + + /// + /// Default flags: search env, user, system, and allow relative paths. + /// Corresponds to ADBC_LOAD_FLAG_DEFAULT. + /// + Default = SearchEnv | SearchUser | SearchSystem | AllowRelativePaths, + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs new file mode 100644 index 0000000000..0e92dd290c --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// The default filesystem-based . + /// Mirrors AdbcProfileProviderFilesystem in adbc_driver_manager.h. + /// + /// + /// Profiles are searched in the following order: + /// + /// + /// + /// If is an absolute path, it is used directly + /// (with a .toml extension appended if none is present). + /// + /// + /// + /// + /// The directories supplied in additionalSearchPathList (delimited by the + /// OS path-list separator, e.g. ; on Windows, : on POSIX). + /// + /// + /// + /// + /// The directories listed in the ADBC_PROFILE_PATH environment variable. + /// + /// + /// + /// The user-level configuration directory (e.g. ~/.config/adbc/profiles). + /// + /// + /// + public sealed class FilesystemProfileProvider : IConnectionProfileProvider + { + /// + /// The environment variable that specifies additional profile search paths. + /// + public const string ProfilePathEnvVar = "ADBC_PROFILE_PATH"; + + /// + /// Initializes a new instance of . + /// + public FilesystemProfileProvider() { } + + /// + public IConnectionProfile? GetProfile(string profileName, string? additionalSearchPathList = null) + { + if (profileName == null) throw new ArgumentNullException(nameof(profileName)); + + // If already an absolute path, load directly. + if (Path.IsPathRooted(profileName)) + { + string candidate = EnsureTomlExtension(profileName); + if (File.Exists(candidate)) + return TomlConnectionProfile.FromFile(candidate); + return null; + } + + // Build the ordered search directories. + var dirs = new List(); + + if (!string.IsNullOrEmpty(additionalSearchPathList)) + dirs.AddRange(SplitPathList(additionalSearchPathList!)); + + string? profilePathEnv = Environment.GetEnvironmentVariable(ProfilePathEnvVar); + if (!string.IsNullOrEmpty(profilePathEnv)) + dirs.AddRange(SplitPathList(profilePathEnv!)); + + string userDir = GetUserProfileDirectory(); + if (!string.IsNullOrEmpty(userDir)) + dirs.Add(userDir); + + string fileName = EnsureTomlExtension(profileName); + + foreach (string dir in dirs) + { + string candidate = Path.Combine(dir, fileName); + if (File.Exists(candidate)) + return TomlConnectionProfile.FromFile(candidate); + } + + return null; + } + + private static string EnsureTomlExtension(string name) + { + string ext = Path.GetExtension(name); + return string.IsNullOrEmpty(ext) + ? name + ".toml" + : name; + } + + private static string[] SplitPathList(string pathList) => + pathList.Split(new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); + + private static string GetUserProfileDirectory() + { + // ~/.config/adbc/profiles (Linux / macOS) + // %APPDATA%\adbc\profiles (Windows) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string? appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return string.IsNullOrEmpty(appData) + ? string.Empty + : Path.Combine(appData, "adbc", "profiles"); + } + else + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrEmpty(home) + ? string.Empty + : Path.Combine(home, ".config", "adbc", "profiles"); + } + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfile.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfile.cs new file mode 100644 index 0000000000..e48680aba3 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfile.cs @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Represents an ADBC connection profile, which specifies a driver and a set of + /// options to apply when initializing a database connection. + /// Mirrors the AdbcConnectionProfile struct defined in adbc_driver_manager.h. + /// + /// + /// The profile specifies a driver (optionally) and key/value options of three types: + /// string, 64-bit integer, and double. String option values of the form + /// env_var(ENV_VAR_NAME) are expanded from the named environment variable + /// before being applied to the database. + /// + public interface IConnectionProfile : IDisposable + { + /// + /// Gets the name of the driver specified by this profile, or null if the + /// profile does not specify a driver. + /// For native drivers this is the path to a shared library or a bare driver name. + /// For managed drivers this is the path to the .NET assembly. + /// + string? DriverName { get; } + + /// + /// Gets the fully-qualified .NET type name of the subclass + /// to instantiate for managed (pure .NET) drivers, or null for native drivers. + /// When set, is used instead of + /// the native NativeLibrary path. + /// + /// + /// Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver + /// + string? DriverTypeName { get; } + + /// + /// Gets the string options specified by this profile. Values of the form + /// env_var(ENV_VAR_NAME) will be expanded from the named environment + /// variable when applied. + /// + IReadOnlyDictionary StringOptions { get; } + + /// + /// Gets the 64-bit integer options specified by this profile. + /// + IReadOnlyDictionary IntOptions { get; } + + /// + /// Gets the double-precision floating-point options specified by this profile. + /// + IReadOnlyDictionary DoubleOptions { get; } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfileProvider.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfileProvider.cs new file mode 100644 index 0000000000..e3cf1b4811 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/IConnectionProfileProvider.cs @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Provides instances by name. + /// Mirrors the AdbcConnectionProfileProvider function type defined in + /// adbc_driver_manager.h. + /// + public interface IConnectionProfileProvider + { + /// + /// Loads a connection profile by name. + /// + /// + /// The name of the profile to load. May be an absolute file path or a bare name + /// to be resolved by searching the configured directories. + /// + /// + /// An optional OS path-list-separator-delimited list of additional directories to + /// search for profiles, or null to use only the default locations. + /// + /// + /// The loaded , or null if no profile with + /// was found. + /// + IConnectionProfile? GetProfile(string profileName, string? additionalSearchPathList = null); + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs new file mode 100644 index 0000000000..453f9c0cde --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// An loaded from a TOML file. + /// + /// + /// The expected file format is: + /// + /// version = 1 + /// driver = "driver_name" + /// + /// [options] + /// option1 = "value1" + /// option2 = 42 + /// option3 = 3.14 + /// + /// Boolean option values are converted to the string equivalents "true" or + /// "false" and placed in . + /// Integer values are placed in and double values in + /// (integer values are also reflected in + /// ). + /// Values of the form env_var(ENV_VAR_NAME) are expanded from the named + /// environment variable when is called. + /// + public sealed class TomlConnectionProfile : IConnectionProfile + { + private const string OptionsSection = "options"; + private const string EnvVarPrefix = "env_var("; + + private readonly Dictionary _stringOptions; + private readonly Dictionary _intOptions; + private readonly Dictionary _doubleOptions; + + private TomlConnectionProfile( + string? driverName, + string? driverTypeName, + Dictionary stringOptions, + Dictionary intOptions, + Dictionary doubleOptions) + { + DriverName = driverName; + DriverTypeName = driverTypeName; + _stringOptions = stringOptions; + _intOptions = intOptions; + _doubleOptions = doubleOptions; + } + + /// + public string? DriverName { get; } + + /// + public string? DriverTypeName { get; } + + /// + public IReadOnlyDictionary StringOptions => _stringOptions; + + /// + public IReadOnlyDictionary IntOptions => _intOptions; + + /// + public IReadOnlyDictionary DoubleOptions => _doubleOptions; + + /// + /// Loads a from the given TOML content string. + /// + /// The raw TOML text to parse. + /// A new . + /// + /// Thrown when the TOML content is missing a required version field or + /// has an unsupported version number. + /// + public static TomlConnectionProfile FromContent(string tomlContent) + { + if (tomlContent == null) throw new ArgumentNullException(nameof(tomlContent)); + + var sections = TomlParser.Parse(tomlContent); + + var root = sections.TryGetValue("", out var r) ? r : new Dictionary(); + + ValidateVersion(root); + + string? driverName = null; + if (root.TryGetValue("driver", out object? driverObj) && driverObj is string driverStr) + driverName = driverStr; + + string? driverTypeName = null; + if (root.TryGetValue("driver_type", out object? driverTypeObj) && driverTypeObj is string driverTypeStr) + driverTypeName = driverTypeStr; + + var stringOpts = new Dictionary(StringComparer.Ordinal); + var intOpts = new Dictionary(StringComparer.Ordinal); + var doubleOpts = new Dictionary(StringComparer.Ordinal); + + if (sections.TryGetValue(OptionsSection, out var optSection)) + { + foreach (var kv in optSection) + { + string key = kv.Key; + object val = kv.Value; + + switch (val) + { + case long lv: + intOpts[key] = lv; + break; + case double dv: + doubleOpts[key] = dv; + break; + case bool bv: + stringOpts[key] = bv ? "true" : "false"; + break; + default: + stringOpts[key] = Convert.ToString(val, CultureInfo.InvariantCulture) ?? string.Empty; + break; + } + } + } + + return new TomlConnectionProfile(driverName, driverTypeName, stringOpts, intOpts, doubleOpts); + } + + /// + /// Loads a from the given TOML file path. + /// + /// Absolute or relative path to the .toml file. + /// A new . + public static TomlConnectionProfile FromFile(string filePath) + { + if (filePath == null) throw new ArgumentNullException(nameof(filePath)); + string content = System.IO.File.ReadAllText(filePath, System.Text.Encoding.UTF8); + return FromContent(content); + } + + /// + /// Returns a new profile with any env_var(NAME) values in + /// replaced by the value of the corresponding + /// environment variable. + /// + /// + /// Thrown when a referenced environment variable is not set. + /// + public TomlConnectionProfile ResolveEnvVars() + { + var resolved = new Dictionary(StringComparer.Ordinal); + foreach (var kv in _stringOptions) + { + string value = kv.Value; + if (value.StartsWith(EnvVarPrefix, StringComparison.Ordinal) && + value.EndsWith(")", StringComparison.Ordinal)) + { + string varName = value.Substring(EnvVarPrefix.Length, value.Length - EnvVarPrefix.Length - 1); + string? envValue = Environment.GetEnvironmentVariable(varName); + if (envValue == null) + throw new AdbcException( + $"Environment variable '{varName}' required by profile option '{kv.Key}' is not set.", + AdbcStatusCode.InvalidState); + resolved[kv.Key] = envValue; + } + else + { + resolved[kv.Key] = value; + } + } + return new TomlConnectionProfile(DriverName, DriverTypeName, resolved, _intOptions, _doubleOptions); + } + + /// + public void Dispose() + { + } + + private static void ValidateVersion(Dictionary root) + { + if (!root.TryGetValue("version", out object? versionObj)) + throw new AdbcException( + "TOML profile is missing the required 'version' field.", + AdbcStatusCode.InvalidArgument); + + long version; + if (versionObj is long lv) + { + version = lv; + } + else + { + try + { + version = Convert.ToInt64(versionObj, CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { + throw new AdbcException( + $"The 'version' field has an invalid value '{versionObj}'. It must be an integer.", + AdbcStatusCode.InvalidArgument); + } + } + + if (version != 1) + throw new AdbcException( + $"Unsupported profile version '{version}'. Only version 1 is supported.", + AdbcStatusCode.NotImplemented); + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs new file mode 100644 index 0000000000..a78c350709 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// A minimal TOML parser that handles the subset of TOML used by ADBC driver + /// manifests and connection profiles: + /// + /// Root-level key = value assignments + /// Table section headers: [section] + /// String values (double-quoted), integer values, floating-point + /// values, and boolean values (true/false) + /// Line comments beginning with # + /// + /// This parser intentionally does not support the full TOML specification. + /// A full-featured TOML library (e.g. Tomlyn) was considered but cannot be used here + /// because the assembly is strongly-named and Tomlyn does not publish a strongly-named + /// package that is compatible with the project's pinned dependency versions. + /// + internal static class TomlParser + { + private const string RootSection = ""; + + /// + /// Parses and returns a dictionary keyed by section name. + /// Root-level keys are stored under the empty string key. + /// Values are typed as , , , + /// or . + /// + internal static Dictionary> Parse(string content) + { + if (content == null) throw new ArgumentNullException(nameof(content)); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [RootSection] = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + + string currentSection = RootSection; + + foreach (string rawLine in content.Split('\n')) + { + string line = StripComment(rawLine).Trim(); + + if (line.Length == 0) + continue; + + if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal)) + { + currentSection = line.Substring(1, line.Length - 2).Trim(); + if (!result.ContainsKey(currentSection)) + result[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase); + continue; + } + + int eqIndex = line.IndexOf('='); + if (eqIndex <= 0) + continue; + + string key = line.Substring(0, eqIndex).Trim(); + string valueRaw = line.Substring(eqIndex + 1).Trim(); + + object value = ParseValue(valueRaw); + result[currentSection][key] = value; + } + + return result; + } + + private static object ParseValue(string raw) + { + // Double-quoted string + if (raw.StartsWith("\"", StringComparison.Ordinal) && raw.EndsWith("\"", StringComparison.Ordinal) && raw.Length >= 2) + { + string inner = raw.Substring(1, raw.Length - 2); + return UnescapeString(inner); + } + + // Boolean + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase)) + return false; + + // Integer (try before float, since integers are a subset) + if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long intValue)) + return intValue; + + // Float + if (double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out double dblValue)) + return dblValue; + + // Fallback: treat as unquoted string + return raw; + } + + private static string UnescapeString(string s) + { + var sb = new System.Text.StringBuilder(s.Length); + for (int i = 0; i < s.Length; i++) + { + if (s[i] == '\\' && i + 1 < s.Length) + { + i++; + switch (s[i]) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + default: sb.Append('\\'); sb.Append(s[i]); break; + } + } + else + { + sb.Append(s[i]); + } + } + return sb.ToString(); + } + + private static string StripComment(string line) + { + // Only strip # that is not inside a quoted string + bool inString = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '"' && (i == 0 || line[i - 1] != '\\')) + inString = !inString; + if (c == '#' && !inString) + return line.Substring(0, i); + } + return line; + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md new file mode 100644 index 0000000000..3c8c1cdba2 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md @@ -0,0 +1,51 @@ +# Apache.Arrow.Adbc.DriverManager + +A .NET implementation of the ADBC Driver Manager, based on the C interface defined in `adbc_driver_manager.h`. + +## Features + +- **Driver discovery**: search for ADBC drivers by name across configurable directories (environment variable, user-level, system-level). +- **TOML manifest loading**: locate drivers via `.toml` manifest files that specify the shared library path. +- **Connection profiles**: load reusable connection configurations (driver + options) from `.toml` profile files. +- **Custom profile providers**: plug in your own `IConnectionProfileProvider` implementation. + +## TOML Manifest / Profile Format + +### Unmanaged Driver Example (Snowflake) + +For unmanaged drivers loaded from native shared libraries: + +```toml +version = 1 +driver = "libadbc_driver_snowflake" +entrypoint = "AdbcDriverSnowflakeInit" + +[options] +adbc.snowflake.sql.account = "myaccount" +adbc.snowflake.sql.warehouse = "mywarehouse" +adbc.snowflake.sql.auth_type = "auth_snowflake" +username = "myuser" +password = "env_var(SNOWFLAKE_PASSWORD)" +``` + +### Managed Driver Example (BigQuery) + +For managed .NET drivers: + +```toml +version = 1 +driver = "C:\\path\\to\\Apache.Arrow.Adbc.Drivers.BigQuery.dll" +driver_type = "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" + +[options] +adbc.bigquery.project_id = "my-project" +adbc.bigquery.auth_type = "service" +adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" +``` + +### Format Notes + +- Boolean option values are converted to the string equivalents `"true"` or `"false"`. +- Values of the form `env_var(ENV_VAR_NAME)` are expanded from the named environment variable at connection time. +- For unmanaged drivers, use `driver` for the library path and `entrypoint` for the initialization function. +- For managed drivers, use `driver` for the assembly path and `driver_type` for the fully-qualified type name. diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs new file mode 100644 index 0000000000..708f2b3bd7 --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using Apache.Arrow.Adbc.DriverManager; +using Xunit; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Tests for co-located manifest file auto-discovery. + /// + public class ColocatedManifestTests : IDisposable + { + private readonly List _tempFiles = new List(); + private readonly List _tempDirs = new List(); + + private (string dllPath, string tomlPath) CreateTestFilePair(string baseName, string tomlContent) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string dllPath = Path.Combine(tempDir, baseName + ".dll"); + string tomlPath = Path.Combine(tempDir, baseName + ".toml"); + + File.WriteAllText(dllPath, "fake dll content"); + File.WriteAllText(tomlPath, tomlContent); + + _tempFiles.Add(dllPath); + _tempFiles.Add(tomlPath); + + return (dllPath, tomlPath); + } + + public void Dispose() + { + foreach (string f in _tempFiles) + { + try { if (File.Exists(f)) File.Delete(f); } catch { } + } + foreach (string d in _tempDirs) + { + try { if (Directory.Exists(d)) Directory.Delete(d, true); } catch { } + } + } + + [Fact] + public void LoadDriver_WithColocatedManifest_LoadsFromManifest() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "from_manifest = \"true\"\n" + + "manifest_version = \"1.0\"\n"; + + var (dllPath, tomlPath) = CreateTestFilePair("test_driver", toml); + + // LoadDriver should auto-detect the co-located manifest and use it to determine: + // - The actual driver location (from the 'driver' field) + // - Whether it's a managed driver (from 'driver_type') + // - The entrypoint (if specified) + AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); + Assert.NotNull(driver); + Assert.IsType(driver); + + // NOTE: Manifest options are stored in the profile but NOT automatically applied here. + // To use manifest options, explicitly load the profile and use OpenDatabaseFromProfile, + // or pass options when opening the database. + var db = driver.Open(new Dictionary { { "test_key", "test_value" } }); + FakeAdbcDatabase fakeDb = Assert.IsType(db); + Assert.Equal("test_value", fakeDb.Parameters["test_key"]); + } + + [Fact] + public void LoadDriver_WithoutColocatedManifest_FailsAsExpected() + { + // Create a DLL without a co-located manifest + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string dllPath = Path.Combine(tempDir, "no_manifest.dll"); + File.WriteAllText(dllPath, "fake dll"); + _tempFiles.Add(dllPath); + + // Should fail because the DLL doesn't exist as a real native library + // (but importantly, it should NOT fail looking for a manifest) + Assert.ThrowsAny(() => AdbcDriverManager.LoadDriver(dllPath)); + } + + [Fact] + public void FindLoadDriver_WithColocatedManifest_UsesManifest() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" // Important: Must specify driver_type for managed drivers + + "\n[options]\n" + + "auto_discovered = \"yes\"\n"; + + var (dllPath, tomlPath) = CreateTestFilePair("my_driver", toml); + + // FindLoadDriver should auto-detect co-located manifest and use it to load the driver + // NOTE: Options from the manifest are NOT automatically applied - they're only available + // when using OpenDatabaseFromProfile. The manifest is primarily for specifying HOW to + // load the driver (driver path, driver_type, entrypoint), not database configuration. + AdbcDriver driver = AdbcDriverManager.FindLoadDriver(dllPath); + Assert.NotNull(driver); + Assert.IsType(driver); + + // To actually use the manifest options, you would need to: + // 1. Load the profile separately with TomlConnectionProfile.FromFile(tomlPath) + // 2. Use AdbcDriverManager.OpenDatabaseFromProfile(profile) + // Or just pass options when opening the database + var db = driver.Open(new Dictionary { { "manual_option", "value" } }); + FakeAdbcDatabase fakeDb = Assert.IsType(db); + Assert.Equal("value", fakeDb.Parameters["manual_option"]); + } + + [Fact] + public void LoadDriver_ManifestCanOverrideDriverPath() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + // Manifest points to the actual driver assembly + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n"; + + // DLL file is just a placeholder - the manifest redirects to the real driver + var (dllPath, tomlPath) = CreateTestFilePair("placeholder", toml); + + AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); + Assert.NotNull(driver); + Assert.IsType(driver); + } + + [Fact] + public void LoadDriver_ExplicitEntrypointStillWorks() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n"; + + var (dllPath, tomlPath) = CreateTestFilePair("entrypoint_test", toml); + + // Even with a manifest, explicit entrypoint parameter should work + // (though for managed drivers, entrypoint doesn't apply - it's ignored) + AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath, "CustomEntrypoint"); + Assert.NotNull(driver); + Assert.IsType(driver); + } + + [Fact] + public void LoadDriver_RelativePathInManifest_ResolvedCorrectly() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + string assemblyFileName = Path.GetFileName(assemblyPath); + + // Copy the assembly to the test directory so we can use a relative path + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string localAssemblyPath = Path.Combine(tempDir, assemblyFileName); + File.Copy(assemblyPath, localAssemblyPath, overwrite: true); + _tempFiles.Add(localAssemblyPath); + + // Manifest uses relative path to the driver + string toml = "version = 1\n" + + "driver = \"" + assemblyFileName + "\"\n" + + "driver_type = \"" + typeName + "\"\n"; + + string dllPath = Path.Combine(tempDir, "wrapper.dll"); + string tomlPath = Path.Combine(tempDir, "wrapper.toml"); + + File.WriteAllText(dllPath, "fake"); + File.WriteAllText(tomlPath, toml); + + _tempFiles.Add(dllPath); + _tempFiles.Add(tomlPath); + + AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); + Assert.NotNull(driver); + Assert.IsType(driver); + } + + [Fact] + public void LoadDriver_DifferentExtensions_AllDetectManifest() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n"; + + // Test with .dll extension + var (dll1, _) = CreateTestFilePair("test.driver", toml); + AdbcDriver driver1 = AdbcDriverManager.LoadDriver(dll1); + Assert.NotNull(driver1); + Assert.IsType(driver1); + + // Test with .so extension + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string soPath = Path.Combine(tempDir, "test.driver.so"); + string soToml = Path.Combine(tempDir, "test.driver.toml"); + File.WriteAllText(soPath, "fake"); + File.WriteAllText(soToml, toml); + _tempFiles.Add(soPath); + _tempFiles.Add(soToml); + + // Should auto-detect manifest even with .so extension + AdbcDriver driver2 = AdbcDriverManager.LoadDriver(soPath); + Assert.NotNull(driver2); + Assert.IsType(driver2); + } + } +} diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/FakeAdbcDriver.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/FakeAdbcDriver.cs new file mode 100644 index 0000000000..efe5404dd0 --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/FakeAdbcDriver.cs @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Minimal in-memory AdbcDriver used to exercise the managed-driver loading path + /// without requiring a real driver assembly on disk. + /// + public sealed class FakeAdbcDriver : AdbcDriver + { + /// The parameters passed to the most recent call. + public IReadOnlyDictionary? LastOpenParameters { get; private set; } + + public override AdbcDatabase Open(IReadOnlyDictionary parameters) + { + LastOpenParameters = parameters; + return new FakeAdbcDatabase(parameters); + } + } + + /// Minimal in-memory AdbcDatabase returned by . + public sealed class FakeAdbcDatabase : AdbcDatabase + { + public IReadOnlyDictionary Parameters { get; } + + public FakeAdbcDatabase(IReadOnlyDictionary parameters) + { + Parameters = parameters; + } + + public override AdbcConnection Connect(IReadOnlyDictionary? options) => + throw new System.NotImplementedException(); + } +} diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs new file mode 100644 index 0000000000..a0e4700a46 --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs @@ -0,0 +1,993 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using Apache.Arrow.Adbc.DriverManager; +using Xunit; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Tests for and the internal . + /// + public class TomlConnectionProfileTests : IDisposable + { + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private readonly List _tempFiles = new List(); + + private string WriteTempToml(string content) + { + string path = Path.GetTempFileName(); + // Rename so it has the .toml extension (required by load helpers). + string tomlPath = Path.ChangeExtension(path, ".toml"); + File.Move(path, tomlPath); + File.WriteAllText(tomlPath, content, System.Text.Encoding.UTF8); + _tempFiles.Add(tomlPath); + return tomlPath; + } + + public void Dispose() + { + foreach (string f in _tempFiles) + { + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + } + + // ----------------------------------------------------------------------- + // Positive: basic profile with all option types + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_WithAllOptionTypes_SetsCorrectValues() + { + const string toml = @" +version = 1 +driver = ""libadbc_driver_postgresql"" + +[options] +uri = ""postgresql://localhost/mydb"" +port = 5432 +timeout = 30.5 +use_ssl = true +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + Assert.Equal("libadbc_driver_postgresql", profile.DriverName); + Assert.Equal("postgresql://localhost/mydb", profile.StringOptions["uri"]); + Assert.Equal(5432L, profile.IntOptions["port"]); + Assert.Equal(30.5, profile.DoubleOptions["timeout"]); + // Booleans are converted to string + Assert.Equal("true", profile.StringOptions["use_ssl"]); + } + + [Fact] + public void ParseProfile_WithFalseBoolean_SetsStringFalse() + { + const string toml = @" +version = 1 +driver = ""mydriver"" + +[options] +verify_cert = false +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("false", profile.StringOptions["verify_cert"]); + } + + [Fact] + public void ParseProfile_WithoutDriver_DriverNameIsNull() + { + const string toml = @" +version = 1 + +[options] +uri = ""jdbc:something"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Null(profile.DriverName); + Assert.Equal("jdbc:something", profile.StringOptions["uri"]); + } + + [Fact] + public void ParseProfile_WithoutOptionsSection_OptionDictionariesAreEmpty() + { + const string toml = @" +version = 1 +driver = ""mydriver"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("mydriver", profile.DriverName); + Assert.Empty(profile.StringOptions); + Assert.Empty(profile.IntOptions); + Assert.Empty(profile.DoubleOptions); + } + + [Fact] + public void ParseProfile_WithLineComments_CommentsAreIgnored() + { + const string toml = @" +# This is a comment +version = 1 +driver = ""mydriver"" # inline comment + +[options] +# another comment +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("mydriver", profile.DriverName); + Assert.Equal("value", profile.StringOptions["key"]); + } + + [Fact] + public void ParseProfile_WithNegativeInteger_ParsedCorrectly() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +offset = -100 +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal(-100L, profile.IntOptions["offset"]); + } + + [Fact] + public void ParseProfile_WithNegativeDouble_ParsedCorrectly() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +delta = -0.5 +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal(-0.5, profile.DoubleOptions["delta"]); + } + + [Fact] + public void ParseProfile_FromFile_LoadsCorrectly() + { + const string toml = @" +version = 1 +driver = ""file_driver"" + +[options] +server = ""localhost"" +"; + string path = WriteTempToml(toml); + TomlConnectionProfile profile = TomlConnectionProfile.FromFile(path); + + Assert.Equal("file_driver", profile.DriverName); + Assert.Equal("localhost", profile.StringOptions["server"]); + } + + [Fact] + public void ParseProfile_EscapedQuotesInString_ParsedCorrectly() + { + const string toml = "version = 1\ndriver = \"d\"\n\n[options]\nmsg = \"say \\\"hello\\\"\"\n"; + + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("say \"hello\"", profile.StringOptions["msg"]); + } + + // ----------------------------------------------------------------------- + // Positive: env_var expansion + // ----------------------------------------------------------------------- + + [Fact] + public void ResolveEnvVars_ExpandsEnvVarValues() + { + const string varName = "ADBC_TEST_PASSWORD_TOML"; + Environment.SetEnvironmentVariable(varName, "secret123"); + try + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +password = ""env_var(ADBC_TEST_PASSWORD_TOML)"" +plain = ""notanenvvar"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml).ResolveEnvVars(); + + Assert.Equal("secret123", profile.StringOptions["password"]); + Assert.Equal("notanenvvar", profile.StringOptions["plain"]); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Fact] + public void ResolveEnvVars_NoEnvVarValues_ReturnsSameProfile() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml).ResolveEnvVars(); + Assert.Equal("value", profile.StringOptions["key"]); + } + + // ----------------------------------------------------------------------- + // Positive: AdbcDriverManager DeriveEntrypoint + // ----------------------------------------------------------------------- + + [Theory] + [InlineData("libadbc_driver_postgresql.so", "AdbcDriverPostgresqlInit")] + [InlineData("libadbc_driver_snowflake.dll", "AdbcDriverSnowflakeInit")] + [InlineData("libadbc_driver_flightsql.dylib", "AdbcDriverFlightsqlInit")] + [InlineData("adbc_driver_sqlite.dll", "AdbcDriverSqliteInit")] + [InlineData("mydriver.dll", "AdbcDriverMydriverInit")] + [InlineData("unknown", "AdbcDriverUnknownInit")] + public void DeriveEntrypoint_ReturnsExpectedSymbol(string driverPath, string expected) + { + string actual = AdbcDriverManager.DeriveEntrypoint(driverPath); + Assert.Equal(expected, actual); + } + + // ----------------------------------------------------------------------- + // Positive: AdbcLoadFlags values + // ----------------------------------------------------------------------- + + [Fact] + public void AdbcLoadFlags_DefaultIncludesAllSearchFlags() + { + AdbcLoadFlags flags = AdbcLoadFlags.Default; + Assert.True(flags.HasFlag(AdbcLoadFlags.SearchEnv)); + Assert.True(flags.HasFlag(AdbcLoadFlags.SearchUser)); + Assert.True(flags.HasFlag(AdbcLoadFlags.SearchSystem)); + Assert.True(flags.HasFlag(AdbcLoadFlags.AllowRelativePaths)); + } + + [Fact] + public void AdbcLoadFlags_NoneHasNoFlags() + { + AdbcLoadFlags flags = AdbcLoadFlags.None; + Assert.False(flags.HasFlag(AdbcLoadFlags.SearchEnv)); + Assert.False(flags.HasFlag(AdbcLoadFlags.SearchUser)); + Assert.False(flags.HasFlag(AdbcLoadFlags.SearchSystem)); + Assert.False(flags.HasFlag(AdbcLoadFlags.AllowRelativePaths)); + } + + // ----------------------------------------------------------------------- + // Negative: missing version field + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_MissingVersion_ThrowsAdbcException() + { + const string toml = @" +driver = ""mydriver"" + +[options] +key = ""value"" +"; + AdbcException ex = Assert.Throws(() => TomlConnectionProfile.FromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ----------------------------------------------------------------------- + // Negative: unsupported version + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_UnsupportedVersion_ThrowsAdbcException() + { + const string toml = @" +version = 99 +driver = ""mydriver"" +"; + AdbcException ex = Assert.Throws(() => TomlConnectionProfile.FromContent(toml)); + Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status); + Assert.Contains("99", ex.Message); + } + + // ----------------------------------------------------------------------- + // Negative: null / empty content + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_NullContent_ThrowsArgumentNullException() + { + Assert.Throws(() => TomlConnectionProfile.FromContent(null!)); + } + + [Fact] + public void ParseProfile_EmptyContent_ThrowsAdbcException() + { + AdbcException ex = Assert.Throws(() => TomlConnectionProfile.FromContent(string.Empty)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Negative: file not found + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_FileNotFound_ThrowsIOException() + { + string fakePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".toml"); + Assert.ThrowsAny( + () => TomlConnectionProfile.FromFile(fakePath)); + } + + // ----------------------------------------------------------------------- + // Negative: env_var expansion – variable not set + // ----------------------------------------------------------------------- + + [Fact] + public void ResolveEnvVars_MissingEnvVar_ThrowsAdbcException() + { + const string varName = "ADBC_TEST_DEFINITELY_NOT_SET_XYZ"; + Environment.SetEnvironmentVariable(varName, null); + + const string toml = @" +version = 1 +driver = ""d"" + +[options] +password = ""env_var(ADBC_TEST_DEFINITELY_NOT_SET_XYZ)"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcException ex = Assert.Throws(() => profile.ResolveEnvVars()); + Assert.Equal(AdbcStatusCode.InvalidState, ex.Status); + Assert.Contains(varName, ex.Message); + } + + // ----------------------------------------------------------------------- + // Negative: AdbcDriverManager argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void LoadDriver_NullPath_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.LoadDriver(null!)); + } + + [Fact] + public void LoadDriver_EmptyPath_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.LoadDriver(string.Empty)); + } + + [Fact] + public void FindLoadDriver_NullName_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.FindLoadDriver(null!)); + } + + [Fact] + public void FindLoadDriver_EmptyName_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.FindLoadDriver(string.Empty)); + } + + [Fact] + public void FindLoadDriver_NonExistentBareName_ThrowsAdbcException() + { + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.FindLoadDriver( + "driver_that_does_not_exist_anywhere", + loadOptions: AdbcLoadFlags.None)); + Assert.Equal(AdbcStatusCode.NotFound, ex.Status); + } + + [Fact] + public void FindLoadDriver_AbsolutePathNotFound_ThrowsException() + { + // Absolute non-toml path that does not exist. + string fakePath = Path.Combine(Path.GetTempPath(), "fake_adbc_driver_notexist.dll"); + Assert.ThrowsAny(() => AdbcDriverManager.FindLoadDriver(fakePath)); + } + + [Fact] + public void FindLoadDriver_AbsoluteTomlPathNotFound_ThrowsAdbcException() + { + string fakePath = Path.Combine(Path.GetTempPath(), "fake_manifest_notexist.toml"); + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.FindLoadDriver(fakePath)); + Assert.Equal(AdbcStatusCode.NotFound, ex.Status); + } + + [Fact] + public void LoadDriverFromProfile_NullProfile_ThrowsArgumentNullException() + { + Assert.Throws(() => AdbcDriverManager.LoadDriverFromProfile(null!)); + } + + [Fact] + public void LoadDriverFromProfile_ProfileWithNoDriver_ThrowsAdbcException() + { + const string toml = @" +version = 1 + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.LoadDriverFromProfile(profile)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Negative: FilesystemProfileProvider with no candidate + // ----------------------------------------------------------------------- + + [Fact] + public void FilesystemProfileProvider_UnknownProfile_ReturnsNull() + { + var provider = new FilesystemProfileProvider(); + IConnectionProfile? result = provider.GetProfile( + "definitely_not_a_real_profile_xyz", + additionalSearchPathList: Path.GetTempPath()); + Assert.Null(result); + } + + [Fact] + public void FilesystemProfileProvider_NullName_ThrowsArgumentNullException() + { + var provider = new FilesystemProfileProvider(); + Assert.Throws(() => provider.GetProfile(null!)); + } + + // ----------------------------------------------------------------------- + // Positive: FilesystemProfileProvider finds a profile on disk + // ----------------------------------------------------------------------- + + [Fact] + public void FilesystemProfileProvider_ProfileExistsInAdditionalPath_ReturnsProfile() + { + const string toml = @" +version = 1 +driver = ""test_driver"" + +[options] +key = ""found"" +"; + string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, "myprofile.toml"); + File.WriteAllText(filePath, toml, System.Text.Encoding.UTF8); + try + { + var provider = new FilesystemProfileProvider(); + IConnectionProfile? profile = provider.GetProfile("myprofile", additionalSearchPathList: dir); + + Assert.NotNull(profile); + Assert.Equal("test_driver", profile!.DriverName); + Assert.Equal("found", profile.StringOptions["key"]); + } + finally + { + try { File.Delete(filePath); } catch { } + try { Directory.Delete(dir); } catch { } + } + } + + [Fact] + public void FilesystemProfileProvider_AbsoluteTomlPath_LoadsDirectly() + { + const string toml = @" +version = 1 +driver = ""abs_driver"" +"; + string path = WriteTempToml(toml); + var provider = new FilesystemProfileProvider(); + IConnectionProfile? profile = provider.GetProfile(path); + + Assert.NotNull(profile); + Assert.Equal("abs_driver", profile!.DriverName); + } + + // ----------------------------------------------------------------------- + // Positive: driver_type field in TOML profile + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_WithDriverType_ParsedCorrectly() + { + const string toml = @" +version = 1 +driver = ""Apache.Arrow.Adbc.Tests.dll"" +driver_type = ""Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("Apache.Arrow.Adbc.Tests.dll", profile.DriverName); + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver", profile.DriverTypeName); + } + + [Fact] + public void ParseProfile_WithoutDriverType_DriverTypeNameIsNull() + { + const string toml = @" +version = 1 +driver = ""mydriver"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Null(profile.DriverTypeName); + } + + // ----------------------------------------------------------------------- + // Positive: LoadManagedDriver loads a managed .NET driver by reflection + // ----------------------------------------------------------------------- + + [Fact] + public void LoadManagedDriver_ValidType_ReturnsDriverInstance() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + AdbcDriver driver = AdbcDriverManager.LoadManagedDriver(assemblyPath, typeName); + + Assert.NotNull(driver); + Assert.IsType(driver); + } + + // ----------------------------------------------------------------------- + // Positive: BuildStringOptions merges all option types into string dictionary + // ----------------------------------------------------------------------- + + [Fact] + public void BuildStringOptions_MergesAllOptionTypes() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +str_key = ""hello"" +int_key = 42 +dbl_key = 1.5 +bool_key = true +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + IReadOnlyDictionary opts = AdbcDriverManager.BuildStringOptions(profile); + + Assert.Equal("hello", opts["str_key"]); + Assert.Equal("42", opts["int_key"]); + Assert.Equal("1.5", opts["dbl_key"]); + Assert.Equal("true", opts["bool_key"]); + } + + [Fact] + public void BuildStringOptions_NoOptions_ReturnsEmptyDictionary() + { + const string toml = @" +version = 1 +driver = ""d"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + IReadOnlyDictionary opts = AdbcDriverManager.BuildStringOptions(profile); + + Assert.Empty(opts); + } + + // ----------------------------------------------------------------------- + // Positive: OpenDatabaseFromProfile end-to-end with managed driver + // ----------------------------------------------------------------------- + + [Fact] + public void OpenDatabaseFromProfile_ManagedDriver_OpensDatabase() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + // Build TOML content; escape any backslashes in the Windows assembly path. + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "project_id = \"my-project\"\n" + + "region = \"us-east1\"\n"; + + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); + + FakeAdbcDatabase fakeDb = Assert.IsType(db); + Assert.Equal("my-project", fakeDb.Parameters["project_id"]); + Assert.Equal("us-east1", fakeDb.Parameters["region"]); + } + + // ----------------------------------------------------------------------- + // Negative: LoadManagedDriver argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void LoadManagedDriver_NullAssemblyPath_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.LoadManagedDriver(null!, "SomeType")); + } + + [Fact] + public void LoadManagedDriver_EmptyAssemblyPath_ThrowsArgumentException() + { + Assert.Throws(() => AdbcDriverManager.LoadManagedDriver(string.Empty, "SomeType")); + } + + [Fact] + public void LoadManagedDriver_NullTypeName_ThrowsArgumentException() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + Assert.Throws(() => AdbcDriverManager.LoadManagedDriver(assemblyPath, null!)); + } + + [Fact] + public void LoadManagedDriver_TypeNotFound_ThrowsAdbcExceptionWithNotFoundStatus() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.LoadManagedDriver(assemblyPath, "NoSuchType.DoesNotExist")); + Assert.Equal(AdbcStatusCode.NotFound, ex.Status); + } + + [Fact] + public void LoadManagedDriver_TypeNotAdbcDriver_ThrowsAdbcExceptionWithInvalidArgumentStatus() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string nonDriverTypeName = typeof(TomlConnectionProfileTests).FullName!; + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.LoadManagedDriver(assemblyPath, nonDriverTypeName)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Negative: OpenDatabaseFromProfile managed path validation + // ----------------------------------------------------------------------- + + [Fact] + public void OpenDatabaseFromProfile_NullProfile_ThrowsArgumentNullException() + { + Assert.Throws(() => AdbcDriverManager.OpenDatabaseFromProfile(null!)); + } + + [Fact] + public void OpenDatabaseFromProfile_ManagedDriverMissingAssemblyPath_ThrowsAdbcException() + { + // driver_type is set but the driver (assembly path) field is omitted. + const string toml = @" +version = 1 +driver_type = ""Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.OpenDatabaseFromProfile(profile)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Negative: BuildStringOptions null validation + // ----------------------------------------------------------------------- + + [Fact] + public void BuildStringOptions_NullProfile_ThrowsArgumentNullException() + { + Assert.Throws(() => AdbcDriverManager.BuildStringOptions(null!)); + } + + // ----------------------------------------------------------------------- + // Bad driver: LoadManagedDriver with assembly file that does not exist + // ----------------------------------------------------------------------- + + [Fact] + public void LoadManagedDriver_NonExistentAssemblyFile_ThrowsAdbcExceptionWithIOErrorStatus() + { + string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".dll"); + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.LoadManagedDriver(missingPath, "Some.Type")); + Assert.Equal(AdbcStatusCode.IOError, ex.Status); + } + + // ----------------------------------------------------------------------- + // Invalid types: version field is an unquoted non-numeric string + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_VersionIsNonNumericString_ThrowsAdbcException() + { + // The parser falls back to treating unquoted, non-bool, non-numeric tokens as + // raw strings. ValidateVersion must not leak a raw FormatException. + const string toml = "version = notanumber\ndriver = \"d\"\n"; + AdbcException ex = Assert.Throws( + () => TomlConnectionProfile.FromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + [Fact] + public void ParseProfile_VersionIsQuotedNonNumericString_ThrowsAdbcException() + { + // A quoted string that cannot be converted to long must not leak FormatException. + const string toml = "version = \"abc\"\ndriver = \"d\"\n"; + AdbcException ex = Assert.Throws( + () => TomlConnectionProfile.FromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Unknown TOML sections are silently ignored + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_UnknownSection_SectionIsIgnored() + { + const string toml = @" +version = 1 +driver = ""d"" + +[metadata] +author = ""someone"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + // [metadata] must not bleed into options + Assert.Equal("value", profile.StringOptions["key"]); + Assert.False(profile.StringOptions.ContainsKey("author")); + } + + // ----------------------------------------------------------------------- + // Duplicate keys: last value wins + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_DuplicateOptionKey_LastValueWins() + { + const string toml = "version = 1\ndriver = \"d\"\n\n[options]\nkey = \"first\"\nkey = \"second\"\n"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + Assert.Equal("second", profile.StringOptions["key"]); + } + + // ----------------------------------------------------------------------- + // ResolveEnvVars preserves DriverTypeName + // ----------------------------------------------------------------------- + + [Fact] + public void ResolveEnvVars_DriverTypeNameIsPreserved() + { + const string varName = "ADBC_TEST_RESOLVE_ENVVAR_HOST"; + Environment.SetEnvironmentVariable(varName, "myhost"); + try + { + const string toml = @" +version = 1 +driver = ""MyDriver.dll"" +driver_type = ""My.Namespace.MyDriver"" + +[options] +host = ""env_var(ADBC_TEST_RESOLVE_ENVVAR_HOST)"" +"; + TomlConnectionProfile resolved = TomlConnectionProfile.FromContent(toml).ResolveEnvVars(); + Assert.Equal("My.Namespace.MyDriver", resolved.DriverTypeName); + Assert.Equal("myhost", resolved.StringOptions["host"]); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + // ----------------------------------------------------------------------- + // Extra options not known to the driver pass through transparently + // ----------------------------------------------------------------------- + + [Fact] + public void OpenDatabaseFromProfile_UnknownOptionsPassThroughToDriver() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "known_key = \"hello\"\n" + + "unknown_widget = \"ignored_by_driver\"\n"; + + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); + + FakeAdbcDatabase fakeDb = Assert.IsType(db); + // Both keys arrive at the driver; the driver decides what to do with them. + Assert.Equal("hello", fakeDb.Parameters["known_key"]); + Assert.Equal("ignored_by_driver", fakeDb.Parameters["unknown_widget"]); + } + + // ----------------------------------------------------------------------- + // Positive: BuildStringOptions with explicit options merges correctly + // ----------------------------------------------------------------------- + + [Fact] + public void BuildStringOptions_WithExplicitOptions_MergesCorrectly() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +profile_key = ""from_profile"" +shared_key = ""profile_value"" +port = 5432 +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + var explicitOptions = new Dictionary + { + { "explicit_key", "from_explicit" }, + { "shared_key", "explicit_value" } + }; + + IReadOnlyDictionary merged = AdbcDriverManager.BuildStringOptions(profile, explicitOptions); + + // Profile-only option should be present + Assert.Equal("from_profile", merged["profile_key"]); + Assert.Equal("5432", merged["port"]); + + // Explicit-only option should be present + Assert.Equal("from_explicit", merged["explicit_key"]); + + // Shared key: explicit should override profile + Assert.Equal("explicit_value", merged["shared_key"]); + } + + [Fact] + public void BuildStringOptions_NullExplicitOptions_UsesOnlyProfileOptions() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + IReadOnlyDictionary opts = AdbcDriverManager.BuildStringOptions(profile, null); + + Assert.Equal("value", opts["key"]); + } + + [Fact] + public void BuildStringOptions_EmptyExplicitOptions_UsesOnlyProfileOptions() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + var empty = new Dictionary(); + IReadOnlyDictionary opts = AdbcDriverManager.BuildStringOptions(profile, empty); + + Assert.Equal("value", opts["key"]); + } + + [Fact] + public void BuildStringOptions_ExplicitOptionsOverrideAllProfileTypes() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +str_key = ""profile_string"" +int_key = 100 +dbl_key = 2.5 +bool_key = false +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + var explicitOptions = new Dictionary + { + { "str_key", "explicit_string" }, + { "int_key", "999" }, + { "dbl_key", "9.9" }, + { "bool_key", "true" } + }; + + IReadOnlyDictionary merged = AdbcDriverManager.BuildStringOptions(profile, explicitOptions); + + // All explicit values should override profile values + Assert.Equal("explicit_string", merged["str_key"]); + Assert.Equal("999", merged["int_key"]); + Assert.Equal("9.9", merged["dbl_key"]); + Assert.Equal("true", merged["bool_key"]); + } + + // ----------------------------------------------------------------------- + // Positive: OpenDatabaseFromProfile with explicit options + // ----------------------------------------------------------------------- + + [Fact] + public void OpenDatabaseFromProfile_WithExplicitOptions_MergesCorrectly() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "profile_option = \"from_profile\"\n" + + "shared_option = \"profile_value\"\n"; + + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + var explicitOptions = new Dictionary + { + { "explicit_option", "from_explicit" }, + { "shared_option", "explicit_value" } + }; + + AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, explicitOptions); + + FakeAdbcDatabase fakeDb = Assert.IsType(db); + + // Profile-only option should be present + Assert.Equal("from_profile", fakeDb.Parameters["profile_option"]); + + // Explicit-only option should be present + Assert.Equal("from_explicit", fakeDb.Parameters["explicit_option"]); + + // Shared option: explicit should override profile + Assert.Equal("explicit_value", fakeDb.Parameters["shared_option"]); + } + + [Fact] + public void OpenDatabaseFromProfile_NullExplicitOptions_UsesOnlyProfile() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + string escapedPath = assemblyPath.Replace("\\", "\\\\"); + string toml = "version = 1\n" + + "driver = \"" + escapedPath + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "key = \"value\"\n"; + + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, null); + + FakeAdbcDatabase fakeDb = Assert.IsType(db); + Assert.Equal("value", fakeDb.Parameters["key"]); + } + } +} From 3d64362eb6b76709c18c756a229dfd29ffefc268 Mon Sep 17 00:00:00 2001 From: David Coe <> Date: Wed, 11 Mar 2026 17:10:47 -0400 Subject: [PATCH 2/5] update readme header --- .../Apache.Arrow.Adbc/DriverManager/readme.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md index 3c8c1cdba2..324fbf0bad 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md @@ -1,3 +1,21 @@ + # Apache.Arrow.Adbc.DriverManager A .NET implementation of the ADBC Driver Manager, based on the C interface defined in `adbc_driver_manager.h`. From 6d48037d6aa4a666383dfacdf70fd07a41d5ec4f Mon Sep 17 00:00:00 2001 From: David Coe <> Date: Thu, 12 Mar 2026 22:59:47 -0400 Subject: [PATCH 3/5] refactor; add security --- .../DriverManager/AdbcDriverManager.cs | 42 +- .../DriverManager/DriverManagerSecurity.cs | 497 +++++++++++++++ .../FilesystemProfileProvider.cs | 17 +- .../DriverManager/TomlConnectionProfile.cs | 38 +- .../DriverManager/TomlParser.cs | 27 +- .../Apache.Arrow.Adbc/DriverManager/readme.md | 74 +++ .../DriverManager/ColocatedManifestTests.cs | 195 +++--- .../DriverManagerSecurityTests.cs | 575 ++++++++++++++++++ .../TomlConnectionProfileTests.cs | 16 +- 9 files changed, 1377 insertions(+), 104 deletions(-) create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManagerSecurity.cs create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs index d24759484c..23fcd97544 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs @@ -275,9 +275,11 @@ public static AdbcDriver LoadManagedDriver(string assemblyPath, string typeName) } if (instance == null) + { throw new AdbcException( $"Activator returned null for driver type '{typeName}'.", AdbcStatusCode.InternalError); + } return (AdbcDriver)instance; } @@ -564,27 +566,40 @@ private static AdbcDriver LoadFromAbsolutePath(string path, string? entrypoint) private static AdbcDriver LoadFromManifest(string manifestPath, string? entrypoint) { if (!File.Exists(manifestPath)) + { throw new AdbcException( - $"Driver manifest file not found: '{manifestPath}'.", + $"Driver manifest file not found.", AdbcStatusCode.NotFound); + } TomlConnectionProfile manifest = TomlConnectionProfile.FromFile(manifestPath); if (string.IsNullOrEmpty(manifest.DriverName)) + { throw new AdbcException( - $"Driver manifest '{manifestPath}' does not specify a 'driver' field.", + $"Driver manifest does not specify a 'driver' field.", AdbcStatusCode.InvalidArgument); + } + + string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); // Check if this is a managed driver if (!string.IsNullOrEmpty(manifest.DriverTypeName)) { - // Managed .NET driver - resolve relative path relative to manifest directory + // Managed .NET driver - resolve path string driverPath = manifest.DriverName!; - if (!Path.IsPathRooted(driverPath)) + if (Path.IsPathRooted(driverPath)) { - string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + // Absolute path - validate it doesn't contain path traversal + DriverManagerSecurity.ValidatePathSecurity(driverPath, "manifest driver path"); + } + else + { + // Relative path - validate it doesn't escape the manifest directory if (!string.IsNullOrEmpty(manifestDir)) - driverPath = Path.Combine(manifestDir, driverPath); + { + driverPath = DriverManagerSecurity.ValidateAndResolveManifestPath(manifestDir, driverPath); + } } return LoadManagedDriver(driverPath, manifest.DriverTypeName!); } @@ -592,13 +607,20 @@ private static AdbcDriver LoadFromManifest(string manifestPath, string? entrypoi // Native driver - resolve entrypoint and path string resolvedEntrypoint = entrypoint ?? DeriveEntrypoint(manifest.DriverName!); - // Resolve relative driver path relative to the manifest directory. + // Resolve driver path string resolvedDriverPath = manifest.DriverName!; - if (!Path.IsPathRooted(resolvedDriverPath)) + if (Path.IsPathRooted(resolvedDriverPath)) + { + // Absolute path - validate it doesn't contain path traversal + DriverManagerSecurity.ValidatePathSecurity(resolvedDriverPath, "manifest driver path"); + } + else { - string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + // Relative path - validate it doesn't escape the manifest directory if (!string.IsNullOrEmpty(manifestDir)) - resolvedDriverPath = Path.Combine(manifestDir, resolvedDriverPath); + { + resolvedDriverPath = DriverManagerSecurity.ValidateAndResolveManifestPath(manifestDir, resolvedDriverPath); + } } return CAdbcDriverImporter.Load(resolvedDriverPath, resolvedEntrypoint); diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManagerSecurity.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManagerSecurity.cs new file mode 100644 index 0000000000..9e57a44328 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManagerSecurity.cs @@ -0,0 +1,497 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Provides security-related functionality for the ADBC driver manager, + /// including path validation, allowlist management, and audit logging. + /// + public static class DriverManagerSecurity + { + private static readonly object s_lock = new object(); + private static IDriverLoadAuditLogger? s_auditLogger; + private static IDriverAllowlist? s_allowlist; + + /// + /// Gets or sets the audit logger for driver loading operations. + /// When set, all driver load attempts will be logged. + /// + /// + /// This property is thread-safe. Set to null to disable audit logging. + /// + public static IDriverLoadAuditLogger? AuditLogger + { + get + { + lock (s_lock) + { + return s_auditLogger; + } + } + set + { + lock (s_lock) + { + s_auditLogger = value; + } + } + } + + /// + /// Gets or sets the driver allowlist. When set, only drivers matching + /// the allowlist criteria will be permitted to load. + /// + /// + /// + /// This property is thread-safe. Set to null to disable allowlist + /// validation (all drivers will be permitted). + /// + /// + /// Security Note: In production environments, consider using an + /// allowlist to restrict which drivers can be loaded, preventing + /// potential arbitrary code execution attacks. + /// + /// + public static IDriverAllowlist? Allowlist + { + get + { + lock (s_lock) + { + return s_allowlist; + } + } + set + { + lock (s_lock) + { + s_allowlist = value; + } + } + } + + /// + /// Validates that a path does not contain path traversal sequences + /// that could escape intended directories. + /// + /// The path to validate. + /// The parameter name for error messages. + /// + /// Thrown when the path contains potentially dangerous sequences. + /// + public static void ValidatePathSecurity(string path, string paramName) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + // Check for path traversal attempts + if (path.Contains("..")) + { + throw new AdbcException( + $"Path '{paramName}' contains potentially dangerous path traversal sequences (..).", + AdbcStatusCode.InvalidArgument); + } + + // Check for null bytes (can be used to truncate paths in some systems) + if (path.Contains("\0")) + { + throw new AdbcException( + $"Path '{paramName}' contains invalid null characters.", + AdbcStatusCode.InvalidArgument); + } + } + + /// + /// Validates that a relative path in a manifest does not attempt to + /// escape the manifest's directory. + /// + /// The directory containing the manifest. + /// The relative path specified in the manifest. + /// The validated, canonicalized full path. + /// + /// Thrown when the resolved path escapes the manifest directory. + /// + public static string ValidateAndResolveManifestPath(string manifestDirectory, string relativePath) + { + ValidatePathSecurity(relativePath, "manifest driver path"); + + // Resolve the full path + string fullPath = Path.GetFullPath(Path.Combine(manifestDirectory, relativePath)); + string canonicalManifestDir = Path.GetFullPath(manifestDirectory); + + // Ensure the resolved path is within or below the manifest directory + // This prevents path traversal attacks via symbolic links or other means + if (!fullPath.StartsWith(canonicalManifestDir, StringComparison.OrdinalIgnoreCase)) + { + throw new AdbcException( + $"Manifest driver path resolves outside the manifest directory. " + + $"This may indicate a path traversal attack.", + AdbcStatusCode.InvalidArgument); + } + + return fullPath; + } + + /// + /// Logs a driver load attempt if an audit logger is configured. + /// + /// The driver load attempt details to log. + public static void LogDriverLoadAttempt(DriverLoadAttempt attempt) + { + IDriverLoadAuditLogger? logger = AuditLogger; + if (logger != null) + { + try + { + logger.LogDriverLoadAttempt(attempt); + } + catch + { + // Don't let logging failures affect driver loading + } + } + } + + /// + /// Checks if a driver is allowed to be loaded based on the configured allowlist. + /// + /// The path to the driver. + /// The type name for managed drivers, or null for native drivers. + /// + /// Thrown when the driver is not permitted by the allowlist. + /// + public static void ValidateAllowlist(string driverPath, string? typeName) + { + IDriverAllowlist? allowlist = Allowlist; + if (allowlist == null) + { + return; // No allowlist configured, all drivers permitted + } + + if (!allowlist.IsDriverAllowed(driverPath, typeName)) + { + string driverDescription = typeName != null + ? $"managed driver '{typeName}' from '{driverPath}'" + : $"native driver '{driverPath}'"; + + throw new AdbcException( + $"Loading {driverDescription} is not permitted by the configured allowlist.", + AdbcStatusCode.Unauthorized); + } + } + } + + /// + /// Represents an attempt to load a driver, for audit logging purposes. + /// + public sealed class DriverLoadAttempt + { + /// + /// Gets the UTC timestamp when the load attempt occurred. + /// + public DateTime TimestampUtc { get; } + + /// + /// Gets the path to the driver being loaded. + /// + public string DriverPath { get; } + + /// + /// Gets the type name for managed drivers, or null for native drivers. + /// + public string? TypeName { get; } + + /// + /// Gets the manifest path if a manifest was used, or null otherwise. + /// + public string? ManifestPath { get; } + + /// + /// Gets a value indicating whether the load attempt succeeded. + /// + public bool Success { get; } + + /// + /// Gets the error message if the load attempt failed, or null if it succeeded. + /// + public string? ErrorMessage { get; } + + /// + /// Gets the method used to load the driver (e.g., "LoadDriver", "LoadManagedDriver", "FindLoadDriver"). + /// + public string LoadMethod { get; } + + /// + /// Initializes a new instance of . + /// + public DriverLoadAttempt( + string driverPath, + string? typeName, + string? manifestPath, + bool success, + string? errorMessage, + string loadMethod) + { + TimestampUtc = DateTime.UtcNow; + DriverPath = driverPath ?? throw new ArgumentNullException(nameof(driverPath)); + TypeName = typeName; + ManifestPath = manifestPath; + Success = success; + ErrorMessage = errorMessage; + LoadMethod = loadMethod ?? throw new ArgumentNullException(nameof(loadMethod)); + } + } + + /// + /// Interface for audit logging of driver load attempts. + /// + /// + /// Implement this interface to receive notifications about all driver + /// loading operations for security monitoring and compliance purposes. + /// + public interface IDriverLoadAuditLogger + { + /// + /// Logs a driver load attempt. + /// + /// Details about the load attempt. + void LogDriverLoadAttempt(DriverLoadAttempt attempt); + } + + /// + /// Interface for validating whether a driver is allowed to be loaded. + /// + /// + /// + /// Implement this interface to restrict which drivers can be loaded. + /// This is useful for preventing arbitrary code execution attacks where + /// a malicious manifest could redirect to a malicious driver. + /// + /// + /// Example implementations: + /// + /// Allow only drivers from specific directories + /// Allow only drivers with specific file names + /// Allow only signed assemblies + /// Allow only specific type names for managed drivers + /// + /// + /// + public interface IDriverAllowlist + { + /// + /// Determines whether a driver is allowed to be loaded. + /// + /// The full path to the driver file. + /// + /// For managed drivers, the fully-qualified type name. For native drivers, null. + /// + /// + /// true if the driver is allowed to be loaded; otherwise, false. + /// + bool IsDriverAllowed(string driverPath, string? typeName); + } + + /// + /// A simple directory-based driver allowlist that permits drivers + /// only from specific directories. + /// + /// + /// + /// This allowlist implementation checks whether the driver path starts + /// with one of the allowed directory paths. It uses case-insensitive + /// comparison on Windows and case-sensitive on other platforms. + /// + /// + /// Example usage: + /// + /// var allowlist = new DirectoryAllowlist(new[] + /// { + /// @"C:\Program Files\ADBC\Drivers", + /// @"C:\MyApp\Drivers" + /// }); + /// DriverManagerSecurity.Allowlist = allowlist; + /// + /// + /// + public sealed class DirectoryAllowlist : IDriverAllowlist + { + private readonly List _allowedDirectories; + private readonly StringComparison _comparison; + + /// + /// Initializes a new instance of . + /// + /// + /// The directories from which drivers may be loaded. Paths are + /// canonicalized during construction. + /// + public DirectoryAllowlist(IEnumerable allowedDirectories) + { + if (allowedDirectories == null) + { + throw new ArgumentNullException(nameof(allowedDirectories)); + } + + _allowedDirectories = new List(); + foreach (string dir in allowedDirectories) + { + if (!string.IsNullOrWhiteSpace(dir)) + { + // Canonicalize and ensure trailing separator for proper prefix matching + string canonical = Path.GetFullPath(dir); + if (!canonical.EndsWith(Path.DirectorySeparatorChar.ToString())) + { + canonical += Path.DirectorySeparatorChar; + } + _allowedDirectories.Add(canonical); + } + } + + // Use case-insensitive comparison on Windows + _comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + + /// + public bool IsDriverAllowed(string driverPath, string? typeName) + { + if (string.IsNullOrEmpty(driverPath)) + { + return false; + } + + string canonicalPath = Path.GetFullPath(driverPath); + + foreach (string allowedDir in _allowedDirectories) + { + if (canonicalPath.StartsWith(allowedDir, _comparison)) + { + return true; + } + } + + return false; + } + } + + /// + /// A type-based driver allowlist that permits only specific driver types + /// for managed drivers. + /// + /// + /// + /// This allowlist implementation checks whether the driver type name + /// matches one of the explicitly allowed type names. For native drivers, + /// it delegates to an optional inner allowlist or permits all native drivers. + /// + /// + /// Example usage: + /// + /// var allowlist = new TypeAllowlist(new[] + /// { + /// "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver", + /// "Apache.Arrow.Adbc.Drivers.Snowflake.SnowflakeDriver" + /// }); + /// DriverManagerSecurity.Allowlist = allowlist; + /// + /// + /// + public sealed class TypeAllowlist : IDriverAllowlist + { + private readonly HashSet _allowedTypes; + private readonly IDriverAllowlist? _nativeDriverAllowlist; + + /// + /// Initializes a new instance of . + /// + /// + /// The fully-qualified type names that are allowed for managed drivers. + /// + /// + /// Optional allowlist for native drivers. If null, all native drivers are permitted. + /// + public TypeAllowlist( + IEnumerable allowedTypeNames, + IDriverAllowlist? nativeDriverAllowlist = null) + { + if (allowedTypeNames == null) + { + throw new ArgumentNullException(nameof(allowedTypeNames)); + } + + _allowedTypes = new HashSet(allowedTypeNames, StringComparer.Ordinal); + _nativeDriverAllowlist = nativeDriverAllowlist; + } + + /// + public bool IsDriverAllowed(string driverPath, string? typeName) + { + if (typeName != null) + { + // Managed driver - check type allowlist + return _allowedTypes.Contains(typeName); + } + else + { + // Native driver - delegate to inner allowlist or permit + return _nativeDriverAllowlist?.IsDriverAllowed(driverPath, null) ?? true; + } + } + } + + /// + /// Combines multiple allowlists, requiring all of them to permit a driver. + /// + public sealed class CompositeAllowlist : IDriverAllowlist + { + private readonly IDriverAllowlist[] _allowlists; + + /// + /// Initializes a new instance of . + /// + /// + /// The allowlists to combine. All must return true for a driver to be permitted. + /// + public CompositeAllowlist(params IDriverAllowlist[] allowlists) + { + _allowlists = allowlists ?? throw new ArgumentNullException(nameof(allowlists)); + } + + /// + public bool IsDriverAllowed(string driverPath, string? typeName) + { + foreach (IDriverAllowlist allowlist in _allowlists) + { + if (!allowlist.IsDriverAllowed(driverPath, typeName)) + { + return false; + } + } + return true; + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs index 0e92dd290c..8e2ea02155 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs @@ -66,30 +66,41 @@ public FilesystemProfileProvider() { } /// public IConnectionProfile? GetProfile(string profileName, string? additionalSearchPathList = null) { - if (profileName == null) throw new ArgumentNullException(nameof(profileName)); + if (profileName == null) + { + throw new ArgumentNullException(nameof(profileName)); + } // If already an absolute path, load directly. if (Path.IsPathRooted(profileName)) { string candidate = EnsureTomlExtension(profileName); if (File.Exists(candidate)) + { return TomlConnectionProfile.FromFile(candidate); + } return null; } // Build the ordered search directories. - var dirs = new List(); + List dirs = new List(); if (!string.IsNullOrEmpty(additionalSearchPathList)) + { dirs.AddRange(SplitPathList(additionalSearchPathList!)); + } string? profilePathEnv = Environment.GetEnvironmentVariable(ProfilePathEnvVar); if (!string.IsNullOrEmpty(profilePathEnv)) + { dirs.AddRange(SplitPathList(profilePathEnv!)); + } string userDir = GetUserProfileDirectory(); if (!string.IsNullOrEmpty(userDir)) + { dirs.Add(userDir); + } string fileName = EnsureTomlExtension(profileName); @@ -97,7 +108,9 @@ public FilesystemProfileProvider() { } { string candidate = Path.Combine(dir, fileName); if (File.Exists(candidate)) + { return TomlConnectionProfile.FromFile(candidate); + } } return null; diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs index 453f9c0cde..f1888507fb 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs @@ -92,29 +92,36 @@ private TomlConnectionProfile( /// public static TomlConnectionProfile FromContent(string tomlContent) { - if (tomlContent == null) throw new ArgumentNullException(nameof(tomlContent)); + if (tomlContent == null) + { + throw new ArgumentNullException(nameof(tomlContent)); + } - var sections = TomlParser.Parse(tomlContent); + Dictionary> sections = TomlParser.Parse(tomlContent); - var root = sections.TryGetValue("", out var r) ? r : new Dictionary(); + Dictionary root = sections.TryGetValue("", out Dictionary? r) ? r : new Dictionary(); ValidateVersion(root); string? driverName = null; if (root.TryGetValue("driver", out object? driverObj) && driverObj is string driverStr) + { driverName = driverStr; + } string? driverTypeName = null; if (root.TryGetValue("driver_type", out object? driverTypeObj) && driverTypeObj is string driverTypeStr) + { driverTypeName = driverTypeStr; + } - var stringOpts = new Dictionary(StringComparer.Ordinal); - var intOpts = new Dictionary(StringComparer.Ordinal); - var doubleOpts = new Dictionary(StringComparer.Ordinal); + Dictionary stringOpts = new Dictionary(StringComparer.Ordinal); + Dictionary intOpts = new Dictionary(StringComparer.Ordinal); + Dictionary doubleOpts = new Dictionary(StringComparer.Ordinal); - if (sections.TryGetValue(OptionsSection, out var optSection)) + if (sections.TryGetValue(OptionsSection, out Dictionary? optSection)) { - foreach (var kv in optSection) + foreach (KeyValuePair kv in optSection) { string key = kv.Key; object val = kv.Value; @@ -147,7 +154,10 @@ public static TomlConnectionProfile FromContent(string tomlContent) /// A new . public static TomlConnectionProfile FromFile(string filePath) { - if (filePath == null) throw new ArgumentNullException(nameof(filePath)); + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } string content = System.IO.File.ReadAllText(filePath, System.Text.Encoding.UTF8); return FromContent(content); } @@ -162,8 +172,8 @@ public static TomlConnectionProfile FromFile(string filePath) /// public TomlConnectionProfile ResolveEnvVars() { - var resolved = new Dictionary(StringComparer.Ordinal); - foreach (var kv in _stringOptions) + Dictionary resolved = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair kv in _stringOptions) { string value = kv.Value; if (value.StartsWith(EnvVarPrefix, StringComparison.Ordinal) && @@ -172,9 +182,11 @@ public TomlConnectionProfile ResolveEnvVars() string varName = value.Substring(EnvVarPrefix.Length, value.Length - EnvVarPrefix.Length - 1); string? envValue = Environment.GetEnvironmentVariable(varName); if (envValue == null) + { throw new AdbcException( $"Environment variable '{varName}' required by profile option '{kv.Key}' is not set.", AdbcStatusCode.InvalidState); + } resolved[kv.Key] = envValue; } else @@ -193,9 +205,11 @@ public void Dispose() private static void ValidateVersion(Dictionary root) { if (!root.TryGetValue("version", out object? versionObj)) + { throw new AdbcException( "TOML profile is missing the required 'version' field.", AdbcStatusCode.InvalidArgument); + } long version; if (versionObj is long lv) @@ -217,9 +231,11 @@ private static void ValidateVersion(Dictionary root) } if (version != 1) + { throw new AdbcException( $"Unsupported profile version '{version}'. Only version 1 is supported.", AdbcStatusCode.NotImplemented); + } } } } diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs index a78c350709..1619fc2674 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlParser.cs @@ -48,9 +48,12 @@ internal static class TomlParser /// internal static Dictionary> Parse(string content) { - if (content == null) throw new ArgumentNullException(nameof(content)); + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } - var result = new Dictionary>(StringComparer.OrdinalIgnoreCase) + Dictionary> result = new Dictionary>(StringComparer.OrdinalIgnoreCase) { [RootSection] = new Dictionary(StringComparer.OrdinalIgnoreCase), }; @@ -62,19 +65,25 @@ internal static Dictionary> Parse(string cont string line = StripComment(rawLine).Trim(); if (line.Length == 0) + { continue; + } if (line.StartsWith("[", StringComparison.Ordinal) && line.EndsWith("]", StringComparison.Ordinal)) { currentSection = line.Substring(1, line.Length - 2).Trim(); if (!result.ContainsKey(currentSection)) + { result[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase); + } continue; } int eqIndex = line.IndexOf('='); if (eqIndex <= 0) + { continue; + } string key = line.Substring(0, eqIndex).Trim(); string valueRaw = line.Substring(eqIndex + 1).Trim(); @@ -97,17 +106,25 @@ private static object ParseValue(string raw) // Boolean if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + { return true; + } if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase)) + { return false; + } // Integer (try before float, since integers are a subset) if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long intValue)) + { return intValue; + } // Float if (double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out double dblValue)) + { return dblValue; + } // Fallback: treat as unquoted string return raw; @@ -115,7 +132,7 @@ private static object ParseValue(string raw) private static string UnescapeString(string s) { - var sb = new System.Text.StringBuilder(s.Length); + System.Text.StringBuilder sb = new System.Text.StringBuilder(s.Length); for (int i = 0; i < s.Length; i++) { if (s[i] == '\\' && i + 1 < s.Length) @@ -147,9 +164,13 @@ private static string StripComment(string line) { char c = line[i]; if (c == '"' && (i == 0 || line[i - 1] != '\\')) + { inString = !inString; + } if (c == '#' && !inString) + { return line.Substring(0, i); + } } return line; } diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md index 324fbf0bad..f82e3c792b 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md @@ -67,3 +67,77 @@ adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" - Values of the form `env_var(ENV_VAR_NAME)` are expanded from the named environment variable at connection time. - For unmanaged drivers, use `driver` for the library path and `entrypoint` for the initialization function. - For managed drivers, use `driver` for the assembly path and `driver_type` for the fully-qualified type name. + +## Security Features + +The Driver Manager includes security features to protect against common attacks when loading drivers dynamically. + +### Path Traversal Protection + +The driver manager validates all paths from manifest files to prevent path traversal attacks: + +- Paths containing `..` sequences are rejected +- Paths containing null bytes are rejected +- Relative paths in manifests are validated to ensure they don't escape the manifest directory + +```csharp +// Manual path validation +DriverManagerSecurity.ValidatePathSecurity(userProvidedPath, "driverPath"); + +// Validate and resolve a relative path against a base directory +string resolvedPath = DriverManagerSecurity.ValidateAndResolveManifestPath( + manifestDirectory, relativePath); +``` + +### Driver Allowlist + +Restrict which drivers can be loaded by configuring an allowlist: + +```csharp +// Only allow drivers from specific directories +DriverManagerSecurity.Allowlist = new DirectoryAllowlist(new[] +{ + @"C:\Program Files\ADBC\Drivers", + @"C:\MyApp\TrustedDrivers" +}); + +// Only allow specific managed driver types +DriverManagerSecurity.Allowlist = new TypeAllowlist(new[] +{ + "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver", + "Apache.Arrow.Adbc.Drivers.Snowflake.SnowflakeDriver" +}); + +// Combine multiple restrictions (all must pass) +DriverManagerSecurity.Allowlist = new CompositeAllowlist( + new DirectoryAllowlist(trustedDirectories), + new TypeAllowlist(trustedTypes) +); +``` + +### Audit Logging + +Log all driver load attempts for security monitoring: + +```csharp +public class MyAuditLogger : IDriverLoadAuditLogger +{ + public void LogDriverLoadAttempt(DriverLoadAttempt attempt) + { + Console.WriteLine($"[{attempt.TimestampUtc:O}] {attempt.LoadMethod}: " + + $"{attempt.DriverPath} - {(attempt.Success ? "SUCCESS" : "FAILED: " + attempt.ErrorMessage)}"); + } +} + +// Enable audit logging +DriverManagerSecurity.AuditLogger = new MyAuditLogger(); +``` + +The `DriverLoadAttempt` class captures: +- `TimestampUtc` - When the load was attempted +- `DriverPath` - Path to the driver +- `TypeName` - Type name for managed drivers (null for native) +- `ManifestPath` - Path to manifest if used +- `Success` - Whether the load succeeded +- `ErrorMessage` - Error details if failed +- `LoadMethod` - Which method was used (LoadDriver, LoadManagedDriver, etc.) diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs index 708f2b3bd7..e9427b4732 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs @@ -31,7 +31,14 @@ public class ColocatedManifestTests : IDisposable private readonly List _tempFiles = new List(); private readonly List _tempDirs = new List(); - private (string dllPath, string tomlPath) CreateTestFilePair(string baseName, string tomlContent) + /// + /// Creates a test directory with a placeholder DLL, a co-located TOML manifest, + /// and optionally copies the actual driver assembly. + /// + private (string dllPath, string tomlPath) CreateTestFilePair( + string baseName, + string tomlContent, + bool copyRealAssembly = false) { string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); @@ -40,7 +47,17 @@ public class ColocatedManifestTests : IDisposable string dllPath = Path.Combine(tempDir, baseName + ".dll"); string tomlPath = Path.Combine(tempDir, baseName + ".toml"); - File.WriteAllText(dllPath, "fake dll content"); + if (copyRealAssembly) + { + // Copy the real FakeAdbcDriver assembly to the temp directory + string realAssemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + File.Copy(realAssemblyPath, dllPath, overwrite: true); + } + else + { + File.WriteAllText(dllPath, "fake dll content"); + } + File.WriteAllText(tomlPath, tomlContent); _tempFiles.Add(dllPath); @@ -49,6 +66,43 @@ public class ColocatedManifestTests : IDisposable return (dllPath, tomlPath); } + /// + /// Creates test files where the manifest uses a relative path to a real assembly. + /// + private (string placeholderDllPath, string tomlPath, string realAssemblyPath) CreateTestFilesWithRelativeDriver( + string baseName, + string typeName) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + // Copy the real assembly to the temp directory + string realAssemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string realAssemblyName = Path.GetFileName(realAssemblyPath); + string copiedAssemblyPath = Path.Combine(tempDir, realAssemblyName); + File.Copy(realAssemblyPath, copiedAssemblyPath, overwrite: true); + _tempFiles.Add(copiedAssemblyPath); + + // Create a placeholder DLL with a different name + string placeholderDllPath = Path.Combine(tempDir, baseName + ".dll"); + File.WriteAllText(placeholderDllPath, "placeholder"); + _tempFiles.Add(placeholderDllPath); + + // Create manifest that uses relative path to the real assembly + string toml = "version = 1\n" + + "driver = \"" + realAssemblyName + "\"\n" + + "driver_type = \"" + typeName + "\"\n" + + "\n[options]\n" + + "from_manifest = \"true\"\n"; + + string tomlPath = Path.Combine(tempDir, baseName + ".toml"); + File.WriteAllText(tomlPath, toml); + _tempFiles.Add(tomlPath); + + return (placeholderDllPath, tomlPath, copiedAssemblyPath); + } + public void Dispose() { foreach (string f in _tempFiles) @@ -64,33 +118,23 @@ public void Dispose() [Fact] public void LoadDriver_WithColocatedManifest_LoadsFromManifest() { - string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" - + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" - + "from_manifest = \"true\"\n" - + "manifest_version = \"1.0\"\n"; - - var (dllPath, tomlPath) = CreateTestFilePair("test_driver", toml); + (string dllPath, string tomlPath, string realAssemblyPath) = + CreateTestFilesWithRelativeDriver("test_driver", typeName); // LoadDriver should auto-detect the co-located manifest and use it to determine: - // - The actual driver location (from the 'driver' field) + // - The actual driver location (from the 'driver' field - relative path) // - Whether it's a managed driver (from 'driver_type') - // - The entrypoint (if specified) AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); Assert.NotNull(driver); - Assert.IsType(driver); + // Check type name instead of IsType to avoid assembly identity issues + Assert.Equal(typeName, driver.GetType().FullName); // NOTE: Manifest options are stored in the profile but NOT automatically applied here. - // To use manifest options, explicitly load the profile and use OpenDatabaseFromProfile, - // or pass options when opening the database. - var db = driver.Open(new Dictionary { { "test_key", "test_value" } }); - FakeAdbcDatabase fakeDb = Assert.IsType(db); - Assert.Equal("test_value", fakeDb.Parameters["test_key"]); + AdbcDatabase db = driver.Open(new Dictionary { { "test_key", "test_value" } }); + // FakeAdbcDatabase full name + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); } [Fact] @@ -113,73 +157,47 @@ public void LoadDriver_WithoutColocatedManifest_FailsAsExpected() [Fact] public void FindLoadDriver_WithColocatedManifest_UsesManifest() { - string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" - + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" // Important: Must specify driver_type for managed drivers - + "\n[options]\n" - + "auto_discovered = \"yes\"\n"; - - var (dllPath, tomlPath) = CreateTestFilePair("my_driver", toml); + (string dllPath, string tomlPath, string realAssemblyPath) = + CreateTestFilesWithRelativeDriver("my_driver", typeName); // FindLoadDriver should auto-detect co-located manifest and use it to load the driver - // NOTE: Options from the manifest are NOT automatically applied - they're only available - // when using OpenDatabaseFromProfile. The manifest is primarily for specifying HOW to - // load the driver (driver path, driver_type, entrypoint), not database configuration. AdbcDriver driver = AdbcDriverManager.FindLoadDriver(dllPath); Assert.NotNull(driver); - Assert.IsType(driver); - - // To actually use the manifest options, you would need to: - // 1. Load the profile separately with TomlConnectionProfile.FromFile(tomlPath) - // 2. Use AdbcDriverManager.OpenDatabaseFromProfile(profile) - // Or just pass options when opening the database - var db = driver.Open(new Dictionary { { "manual_option", "value" } }); - FakeAdbcDatabase fakeDb = Assert.IsType(db); - Assert.Equal("value", fakeDb.Parameters["manual_option"]); + Assert.Equal(typeName, driver.GetType().FullName); + + AdbcDatabase db = driver.Open(new Dictionary { { "manual_option", "value" } }); + Assert.NotNull(db); } [Fact] public void LoadDriver_ManifestCanOverrideDriverPath() { - string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - // Manifest points to the actual driver assembly - string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" - + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n"; - // DLL file is just a placeholder - the manifest redirects to the real driver - var (dllPath, tomlPath) = CreateTestFilePair("placeholder", toml); + (string dllPath, string tomlPath, string realAssemblyPath) = + CreateTestFilesWithRelativeDriver("placeholder", typeName); AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); Assert.NotNull(driver); - Assert.IsType(driver); + Assert.Equal(typeName, driver.GetType().FullName); } [Fact] public void LoadDriver_ExplicitEntrypointStillWorks() { - string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" - + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n"; - - var (dllPath, tomlPath) = CreateTestFilePair("entrypoint_test", toml); + (string dllPath, string tomlPath, string realAssemblyPath) = + CreateTestFilesWithRelativeDriver("entrypoint_test", typeName); // Even with a manifest, explicit entrypoint parameter should work // (though for managed drivers, entrypoint doesn't apply - it's ignored) AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath, "CustomEntrypoint"); Assert.NotNull(driver); - Assert.IsType(driver); + Assert.Equal(typeName, driver.GetType().FullName); } [Fact] @@ -214,31 +232,38 @@ public void LoadDriver_RelativePathInManifest_ResolvedCorrectly() AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); Assert.NotNull(driver); - Assert.IsType(driver); + Assert.Equal(typeName, driver.GetType().FullName); } [Fact] public void LoadDriver_DifferentExtensions_AllDetectManifest() { - string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" - + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n"; - - // Test with .dll extension - var (dll1, _) = CreateTestFilePair("test.driver", toml); + // Test with .dll extension - use relative path helper + (string dll1, string toml1, string real1) = CreateTestFilesWithRelativeDriver("test.driver", typeName); AdbcDriver driver1 = AdbcDriverManager.LoadDriver(dll1); Assert.NotNull(driver1); - Assert.IsType(driver1); + Assert.Equal(typeName, driver1.GetType().FullName); + + // Test with .so extension - also with relative path + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string assemblyFileName = Path.GetFileName(assemblyPath); - // Test with .so extension string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); _tempDirs.Add(tempDir); + // Copy the real assembly + string copiedAssemblyPath = Path.Combine(tempDir, assemblyFileName); + File.Copy(assemblyPath, copiedAssemblyPath, overwrite: true); + _tempFiles.Add(copiedAssemblyPath); + + // Create manifest with relative path + string toml = "version = 1\n" + + "driver = \"" + assemblyFileName + "\"\n" + + "driver_type = \"" + typeName + "\"\n"; + string soPath = Path.Combine(tempDir, "test.driver.so"); string soToml = Path.Combine(tempDir, "test.driver.toml"); File.WriteAllText(soPath, "fake"); @@ -249,7 +274,37 @@ public void LoadDriver_DifferentExtensions_AllDetectManifest() // Should auto-detect manifest even with .so extension AdbcDriver driver2 = AdbcDriverManager.LoadDriver(soPath); Assert.NotNull(driver2); - Assert.IsType(driver2); + Assert.Equal(typeName, driver2.GetType().FullName); + } + + [Fact] + public void LoadManagedDriver_LoadsDirectly() + { + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + // LoadManagedDriver loads directly from the provided assembly path and type name + // Note: This bypasses manifest entirely and loads directly from absolute path + AdbcDriver driver = AdbcDriverManager.LoadManagedDriver(assemblyPath, typeName); + Assert.NotNull(driver); + // Use type name comparison to avoid assembly identity issues when loaded from different path + Assert.Equal(typeName, driver.GetType().FullName); + } + + [Fact] + public void LoadManagedDriver_WithColocatedManifest_LoadsDirectly() + { + // Note: LoadManagedDriver does not currently detect co-located manifests. + // It loads directly from the specified assembly path. + // To use manifest redirection, use LoadDriver with a co-located manifest + // that specifies driver_type. + string typeName = typeof(FakeAdbcDriver).FullName!; + string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + + // LoadManagedDriver loads directly from the provided path + AdbcDriver driver = AdbcDriverManager.LoadManagedDriver(assemblyPath, typeName); + Assert.NotNull(driver); + Assert.Equal(typeName, driver.GetType().FullName); } } } diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs new file mode 100644 index 0000000000..d641e16d39 --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs @@ -0,0 +1,575 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using Apache.Arrow.Adbc.DriverManager; +using Xunit; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Tests for functionality. + /// + public class DriverManagerSecurityTests : IDisposable + { + private readonly List _tempDirs = new List(); + + public void Dispose() + { + // Reset global state after each test + DriverManagerSecurity.AuditLogger = null; + DriverManagerSecurity.Allowlist = null; + + foreach (string dir in _tempDirs) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, true); + } + } + catch { } + } + } + + private string CreateTempDirectory() + { + string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + // ----------------------------------------------------------------------- + // Path Traversal Protection Tests + // ----------------------------------------------------------------------- + + [Theory] + [InlineData("..\\malicious.dll")] + [InlineData("../malicious.dll")] + [InlineData("subdir\\..\\..\\malicious.dll")] + [InlineData("subdir/../../../malicious.dll")] + [InlineData("foo\\..\\bar\\..\\..\\evil.dll")] + public void ValidatePathSecurity_PathTraversal_ThrowsException(string maliciousPath) + { + AdbcException ex = Assert.Throws( + () => DriverManagerSecurity.ValidatePathSecurity(maliciousPath, "testPath")); + + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + Assert.Contains("..", ex.Message); + } + + [Theory] + [InlineData("driver.dll")] + [InlineData("subdir\\driver.dll")] + [InlineData("subdir/driver.dll")] + [InlineData("C:\\Program Files\\Driver\\driver.dll")] + [InlineData("/usr/lib/driver.so")] + public void ValidatePathSecurity_ValidPath_DoesNotThrow(string validPath) + { + // Should not throw + DriverManagerSecurity.ValidatePathSecurity(validPath, "testPath"); + } + + [Fact] + public void ValidatePathSecurity_NullByte_ThrowsException() + { + string pathWithNull = "driver\0.dll"; + + AdbcException ex = Assert.Throws( + () => DriverManagerSecurity.ValidatePathSecurity(pathWithNull, "testPath")); + + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + Assert.Contains("null", ex.Message.ToLower()); + } + + [Fact] + public void ValidatePathSecurity_NullOrEmpty_DoesNotThrow() + { + // Null and empty should be allowed (validation happens elsewhere) + DriverManagerSecurity.ValidatePathSecurity(null!, "testPath"); + DriverManagerSecurity.ValidatePathSecurity(string.Empty, "testPath"); + } + + // ----------------------------------------------------------------------- + // Manifest Path Resolution Tests + // ----------------------------------------------------------------------- + + [Fact] + public void ValidateAndResolveManifestPath_ValidRelativePath_ResolvesCorrectly() + { + string manifestDir = CreateTempDirectory(); + string subDir = Path.Combine(manifestDir, "drivers"); + Directory.CreateDirectory(subDir); + + string resolved = DriverManagerSecurity.ValidateAndResolveManifestPath( + manifestDir, "drivers\\mydriver.dll"); + + Assert.Equal(Path.Combine(manifestDir, "drivers", "mydriver.dll"), resolved); + } + + [Fact] + public void ValidateAndResolveManifestPath_PathTraversal_ThrowsException() + { + string manifestDir = CreateTempDirectory(); + + AdbcException ex = Assert.Throws( + () => DriverManagerSecurity.ValidateAndResolveManifestPath( + manifestDir, "..\\..\\malicious.dll")); + + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + [Fact] + public void ValidateAndResolveManifestPath_EscapesDirectory_ThrowsException() + { + string manifestDir = CreateTempDirectory(); + + // Even without .. in the literal string, symlinks or other means + // could cause path escape - the method should catch this + AdbcException ex = Assert.Throws( + () => DriverManagerSecurity.ValidateAndResolveManifestPath( + manifestDir, "subdir\\..\\..\\escape.dll")); + + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Directory Allowlist Tests + // ----------------------------------------------------------------------- + + [Fact] + public void DirectoryAllowlist_DriverInAllowedDirectory_ReturnsTrue() + { + string allowedDir = CreateTempDirectory(); + DirectoryAllowlist allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + string driverPath = Path.Combine(allowedDir, "mydriver.dll"); + + Assert.True(allowlist.IsDriverAllowed(driverPath, null)); + Assert.True(allowlist.IsDriverAllowed(driverPath, "Some.Type.Name")); + } + + [Fact] + public void DirectoryAllowlist_DriverInSubdirectory_ReturnsTrue() + { + string allowedDir = CreateTempDirectory(); + string subDir = Path.Combine(allowedDir, "subdir"); + Directory.CreateDirectory(subDir); + + DirectoryAllowlist allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + string driverPath = Path.Combine(subDir, "mydriver.dll"); + + Assert.True(allowlist.IsDriverAllowed(driverPath, null)); + } + + [Fact] + public void DirectoryAllowlist_DriverNotInAllowedDirectory_ReturnsFalse() + { + string allowedDir = CreateTempDirectory(); + string otherDir = CreateTempDirectory(); + + DirectoryAllowlist allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + string driverPath = Path.Combine(otherDir, "mydriver.dll"); + + Assert.False(allowlist.IsDriverAllowed(driverPath, null)); + } + + [Fact] + public void DirectoryAllowlist_MultipleDirectories_WorksCorrectly() + { + string dir1 = CreateTempDirectory(); + string dir2 = CreateTempDirectory(); + string dir3 = CreateTempDirectory(); + + DirectoryAllowlist allowlist = new DirectoryAllowlist(new[] { dir1, dir2 }); + + Assert.True(allowlist.IsDriverAllowed(Path.Combine(dir1, "a.dll"), null)); + Assert.True(allowlist.IsDriverAllowed(Path.Combine(dir2, "b.dll"), null)); + Assert.False(allowlist.IsDriverAllowed(Path.Combine(dir3, "c.dll"), null)); + } + + [Fact] + public void DirectoryAllowlist_EmptyPath_ReturnsFalse() + { + string allowedDir = CreateTempDirectory(); + DirectoryAllowlist allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + Assert.False(allowlist.IsDriverAllowed(string.Empty, null)); + Assert.False(allowlist.IsDriverAllowed(null!, null)); + } + + // ----------------------------------------------------------------------- + // Type Allowlist Tests + // ----------------------------------------------------------------------- + + [Fact] + public void TypeAllowlist_AllowedType_ReturnsTrue() + { + TypeAllowlist allowlist = new TypeAllowlist(new[] + { + "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver", + "Apache.Arrow.Adbc.Drivers.Snowflake.SnowflakeDriver" + }); + + Assert.True(allowlist.IsDriverAllowed( + "any_path.dll", + "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver")); + } + + [Fact] + public void TypeAllowlist_NotAllowedType_ReturnsFalse() + { + TypeAllowlist allowlist = new TypeAllowlist(new[] + { + "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" + }); + + Assert.False(allowlist.IsDriverAllowed( + "any_path.dll", + "Evil.Malware.Driver")); + } + + [Fact] + public void TypeAllowlist_NativeDriver_PermittedByDefault() + { + TypeAllowlist allowlist = new TypeAllowlist(new[] + { + "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" + }); + + // Native drivers (typeName = null) are permitted by default when no inner allowlist + Assert.True(allowlist.IsDriverAllowed("native_driver.dll", null)); + } + + [Fact] + public void TypeAllowlist_NativeDriverWithInnerAllowlist_DelegatesToInner() + { + string allowedDir = CreateTempDirectory(); + string otherDir = CreateTempDirectory(); + + DirectoryAllowlist dirAllowlist = new DirectoryAllowlist(new[] { allowedDir }); + TypeAllowlist allowlist = new TypeAllowlist( + new[] { "Some.Type" }, + nativeDriverAllowlist: dirAllowlist); + + // Native driver in allowed directory + Assert.True(allowlist.IsDriverAllowed( + Path.Combine(allowedDir, "native.dll"), null)); + + // Native driver in disallowed directory + Assert.False(allowlist.IsDriverAllowed( + Path.Combine(otherDir, "native.dll"), null)); + } + + // ----------------------------------------------------------------------- + // Composite Allowlist Tests + // ----------------------------------------------------------------------- + + [Fact] + public void CompositeAllowlist_AllAllowlistsPermit_ReturnsTrue() + { + string allowedDir = CreateTempDirectory(); + + DirectoryAllowlist dirAllowlist = new DirectoryAllowlist(new[] { allowedDir }); + TypeAllowlist typeAllowlist = new TypeAllowlist(new[] { "Good.Driver" }); + + CompositeAllowlist composite = new CompositeAllowlist(dirAllowlist, typeAllowlist); + + Assert.True(composite.IsDriverAllowed( + Path.Combine(allowedDir, "driver.dll"), + "Good.Driver")); + } + + [Fact] + public void CompositeAllowlist_OneAllowlistDenies_ReturnsFalse() + { + string allowedDir = CreateTempDirectory(); + string otherDir = CreateTempDirectory(); + + DirectoryAllowlist dirAllowlist = new DirectoryAllowlist(new[] { allowedDir }); + TypeAllowlist typeAllowlist = new TypeAllowlist(new[] { "Good.Driver" }); + + CompositeAllowlist composite = new CompositeAllowlist(dirAllowlist, typeAllowlist); + + // Wrong directory + Assert.False(composite.IsDriverAllowed( + Path.Combine(otherDir, "driver.dll"), + "Good.Driver")); + + // Wrong type + Assert.False(composite.IsDriverAllowed( + Path.Combine(allowedDir, "driver.dll"), + "Bad.Driver")); + } + + // ----------------------------------------------------------------------- + // Audit Logger Tests + // ----------------------------------------------------------------------- + + [Fact] + public void AuditLogger_CanBeSetAndRetrieved() + { + TestAuditLogger logger = new TestAuditLogger(); + + DriverManagerSecurity.AuditLogger = logger; + + Assert.Same(logger, DriverManagerSecurity.AuditLogger); + } + + [Fact] + public void AuditLogger_SetToNull_DisablesLogging() + { + TestAuditLogger logger = new TestAuditLogger(); + DriverManagerSecurity.AuditLogger = logger; + + DriverManagerSecurity.AuditLogger = null; + + Assert.Null(DriverManagerSecurity.AuditLogger); + } + + [Fact] + public void LogDriverLoadAttempt_WithLogger_LogsAttempt() + { + TestAuditLogger logger = new TestAuditLogger(); + DriverManagerSecurity.AuditLogger = logger; + + DriverLoadAttempt attempt = new DriverLoadAttempt( + driverPath: "C:\\test\\driver.dll", + typeName: "Test.Driver", + manifestPath: "C:\\test\\driver.toml", + success: true, + errorMessage: null, + loadMethod: "LoadDriver"); + + DriverManagerSecurity.LogDriverLoadAttempt(attempt); + + Assert.Single(logger.Attempts); + Assert.Equal("C:\\test\\driver.dll", logger.Attempts[0].DriverPath); + Assert.Equal("Test.Driver", logger.Attempts[0].TypeName); + Assert.True(logger.Attempts[0].Success); + } + + [Fact] + public void LogDriverLoadAttempt_WithoutLogger_DoesNotThrow() + { + DriverManagerSecurity.AuditLogger = null; + + DriverLoadAttempt attempt = new DriverLoadAttempt( + driverPath: "C:\\test\\driver.dll", + typeName: null, + manifestPath: null, + success: false, + errorMessage: "Test error", + loadMethod: "LoadDriver"); + + // Should not throw + DriverManagerSecurity.LogDriverLoadAttempt(attempt); + } + + [Fact] + public void LogDriverLoadAttempt_LoggerThrows_DoesNotPropagate() + { + ThrowingAuditLogger logger = new ThrowingAuditLogger(); + DriverManagerSecurity.AuditLogger = logger; + + DriverLoadAttempt attempt = new DriverLoadAttempt( + driverPath: "test.dll", + typeName: null, + manifestPath: null, + success: true, + errorMessage: null, + loadMethod: "Test"); + + // Should not throw even though logger throws + DriverManagerSecurity.LogDriverLoadAttempt(attempt); + } + + // ----------------------------------------------------------------------- + // Allowlist Validation Tests + // ----------------------------------------------------------------------- + + [Fact] + public void ValidateAllowlist_NoAllowlistConfigured_DoesNotThrow() + { + DriverManagerSecurity.Allowlist = null; + + // Should not throw when no allowlist is configured + DriverManagerSecurity.ValidateAllowlist("any_path.dll", null); + DriverManagerSecurity.ValidateAllowlist("any_path.dll", "Any.Type"); + } + + [Fact] + public void ValidateAllowlist_DriverAllowed_DoesNotThrow() + { + string allowedDir = CreateTempDirectory(); + DriverManagerSecurity.Allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + string driverPath = Path.Combine(allowedDir, "driver.dll"); + + // Should not throw + DriverManagerSecurity.ValidateAllowlist(driverPath, null); + } + + [Fact] + public void ValidateAllowlist_DriverNotAllowed_ThrowsUnauthorized() + { + string allowedDir = CreateTempDirectory(); + string otherDir = CreateTempDirectory(); + DriverManagerSecurity.Allowlist = new DirectoryAllowlist(new[] { allowedDir }); + + string driverPath = Path.Combine(otherDir, "driver.dll"); + + AdbcException ex = Assert.Throws( + () => DriverManagerSecurity.ValidateAllowlist(driverPath, null)); + + Assert.Equal(AdbcStatusCode.Unauthorized, ex.Status); + Assert.Contains("allowlist", ex.Message.ToLower()); + } + + // ----------------------------------------------------------------------- + // DriverLoadAttempt Tests + // ----------------------------------------------------------------------- + + [Fact] + public void DriverLoadAttempt_Constructor_SetsAllProperties() + { + DateTime before = DateTime.UtcNow; + + DriverLoadAttempt attempt = new DriverLoadAttempt( + driverPath: "C:\\drivers\\test.dll", + typeName: "Test.Driver.Type", + manifestPath: "C:\\drivers\\test.toml", + success: false, + errorMessage: "Test error message", + loadMethod: "LoadManagedDriver"); + + DateTime after = DateTime.UtcNow; + + Assert.Equal("C:\\drivers\\test.dll", attempt.DriverPath); + Assert.Equal("Test.Driver.Type", attempt.TypeName); + Assert.Equal("C:\\drivers\\test.toml", attempt.ManifestPath); + Assert.False(attempt.Success); + Assert.Equal("Test error message", attempt.ErrorMessage); + Assert.Equal("LoadManagedDriver", attempt.LoadMethod); + Assert.True(attempt.TimestampUtc >= before && attempt.TimestampUtc <= after); + } + + [Fact] + public void DriverLoadAttempt_NullDriverPath_ThrowsArgumentNullException() + { + Assert.Throws(() => new DriverLoadAttempt( + driverPath: null!, + typeName: null, + manifestPath: null, + success: true, + errorMessage: null, + loadMethod: "Test")); + } + + [Fact] + public void DriverLoadAttempt_NullLoadMethod_ThrowsArgumentNullException() + { + Assert.Throws(() => new DriverLoadAttempt( + driverPath: "test.dll", + typeName: null, + manifestPath: null, + success: true, + errorMessage: null, + loadMethod: null!)); + } + + // ----------------------------------------------------------------------- + // Thread Safety Tests + // ----------------------------------------------------------------------- + + [Fact] + public void AuditLogger_ConcurrentAccess_IsThreadSafe() + { + TestAuditLogger logger1 = new TestAuditLogger(); + TestAuditLogger logger2 = new TestAuditLogger(); + + System.Threading.Tasks.Parallel.For(0, 100, i => + { + if (i % 2 == 0) + { + DriverManagerSecurity.AuditLogger = logger1; + } + else + { + DriverManagerSecurity.AuditLogger = logger2; + } + + IDriverLoadAuditLogger? current = DriverManagerSecurity.AuditLogger; + // Just verify we can read without exception + Assert.True(current == null || current == logger1 || current == logger2); + }); + } + + [Fact] + public void Allowlist_ConcurrentAccess_IsThreadSafe() + { + string dir1 = CreateTempDirectory(); + string dir2 = CreateTempDirectory(); + + DirectoryAllowlist allowlist1 = new DirectoryAllowlist(new[] { dir1 }); + DirectoryAllowlist allowlist2 = new DirectoryAllowlist(new[] { dir2 }); + + System.Threading.Tasks.Parallel.For(0, 100, i => + { + if (i % 2 == 0) + { + DriverManagerSecurity.Allowlist = allowlist1; + } + else + { + DriverManagerSecurity.Allowlist = allowlist2; + } + + IDriverAllowlist? current = DriverManagerSecurity.Allowlist; + Assert.True(current == null || current == allowlist1 || current == allowlist2); + }); + } + + // ----------------------------------------------------------------------- + // Helper Classes + // ----------------------------------------------------------------------- + + private class TestAuditLogger : IDriverLoadAuditLogger + { + public List Attempts { get; } = new List(); + + public void LogDriverLoadAttempt(DriverLoadAttempt attempt) + { + Attempts.Add(attempt); + } + } + + private class ThrowingAuditLogger : IDriverLoadAuditLogger + { + public void LogDriverLoadAttempt(DriverLoadAttempt attempt) + { + throw new InvalidOperationException("Intentional test exception"); + } + } + } +} diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs index a0e4700a46..2116591d7e 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs @@ -447,7 +447,7 @@ public void LoadDriverFromProfile_ProfileWithNoDriver_ThrowsAdbcException() [Fact] public void FilesystemProfileProvider_UnknownProfile_ReturnsNull() { - var provider = new FilesystemProfileProvider(); + FilesystemProfileProvider provider = new FilesystemProfileProvider(); IConnectionProfile? result = provider.GetProfile( "definitely_not_a_real_profile_xyz", additionalSearchPathList: Path.GetTempPath()); @@ -457,7 +457,7 @@ public void FilesystemProfileProvider_UnknownProfile_ReturnsNull() [Fact] public void FilesystemProfileProvider_NullName_ThrowsArgumentNullException() { - var provider = new FilesystemProfileProvider(); + FilesystemProfileProvider provider = new FilesystemProfileProvider(); Assert.Throws(() => provider.GetProfile(null!)); } @@ -481,7 +481,7 @@ public void FilesystemProfileProvider_ProfileExistsInAdditionalPath_ReturnsProfi File.WriteAllText(filePath, toml, System.Text.Encoding.UTF8); try { - var provider = new FilesystemProfileProvider(); + FilesystemProfileProvider provider = new FilesystemProfileProvider(); IConnectionProfile? profile = provider.GetProfile("myprofile", additionalSearchPathList: dir); Assert.NotNull(profile); @@ -503,7 +503,7 @@ public void FilesystemProfileProvider_AbsoluteTomlPath_LoadsDirectly() driver = ""abs_driver"" "; string path = WriteTempToml(toml); - var provider = new FilesystemProfileProvider(); + FilesystemProfileProvider provider = new FilesystemProfileProvider(); IConnectionProfile? profile = provider.GetProfile(path); Assert.NotNull(profile); @@ -846,7 +846,7 @@ public void BuildStringOptions_WithExplicitOptions_MergesCorrectly() "; TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); - var explicitOptions = new Dictionary + Dictionary explicitOptions = new Dictionary { { "explicit_key", "from_explicit" }, { "shared_key", "explicit_value" } @@ -892,7 +892,7 @@ public void BuildStringOptions_EmptyExplicitOptions_UsesOnlyProfileOptions() key = ""value"" "; TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); - var empty = new Dictionary(); + Dictionary empty = new Dictionary(); IReadOnlyDictionary opts = AdbcDriverManager.BuildStringOptions(profile, empty); Assert.Equal("value", opts["key"]); @@ -913,7 +913,7 @@ public void BuildStringOptions_ExplicitOptionsOverrideAllProfileTypes() "; TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); - var explicitOptions = new Dictionary + Dictionary explicitOptions = new Dictionary { { "str_key", "explicit_string" }, { "int_key", "999" }, @@ -950,7 +950,7 @@ public void OpenDatabaseFromProfile_WithExplicitOptions_MergesCorrectly() TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); - var explicitOptions = new Dictionary + Dictionary explicitOptions = new Dictionary { { "explicit_option", "from_explicit" }, { "shared_option", "explicit_value" } From 14063227979877f8ffcf0e792be65e3269392217 Mon Sep 17 00:00:00 2001 From: David Coe <> Date: Thu, 12 Mar 2026 23:27:04 -0400 Subject: [PATCH 4/5] fix tests for GitHub --- .../DriverManagerSecurityTests.cs | 12 +++-- .../TomlConnectionProfileTests.cs | 52 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs index d641e16d39..2c836dede4 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManagerSecurityTests.cs @@ -119,8 +119,10 @@ public void ValidateAndResolveManifestPath_ValidRelativePath_ResolvesCorrectly() string subDir = Path.Combine(manifestDir, "drivers"); Directory.CreateDirectory(subDir); + // Use Path.Combine for the relative path to be cross-platform + string relativePath = Path.Combine("drivers", "mydriver.dll"); string resolved = DriverManagerSecurity.ValidateAndResolveManifestPath( - manifestDir, "drivers\\mydriver.dll"); + manifestDir, relativePath); Assert.Equal(Path.Combine(manifestDir, "drivers", "mydriver.dll"), resolved); } @@ -130,9 +132,11 @@ public void ValidateAndResolveManifestPath_PathTraversal_ThrowsException() { string manifestDir = CreateTempDirectory(); + // Use ".." with platform-specific separator + string maliciousPath = ".." + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar + "malicious.dll"; AdbcException ex = Assert.Throws( () => DriverManagerSecurity.ValidateAndResolveManifestPath( - manifestDir, "..\\..\\malicious.dll")); + manifestDir, maliciousPath)); Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); } @@ -144,9 +148,11 @@ public void ValidateAndResolveManifestPath_EscapesDirectory_ThrowsException() // Even without .. in the literal string, symlinks or other means // could cause path escape - the method should catch this + string escapePath = "subdir" + Path.DirectorySeparatorChar + ".." + + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar + "escape.dll"; AdbcException ex = Assert.Throws( () => DriverManagerSecurity.ValidateAndResolveManifestPath( - manifestDir, "subdir\\..\\..\\escape.dll")); + manifestDir, escapePath)); Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); } diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs index 2116591d7e..1fbae9a5be 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs @@ -551,7 +551,8 @@ public void LoadManagedDriver_ValidType_ReturnsDriverInstance() AdbcDriver driver = AdbcDriverManager.LoadManagedDriver(assemblyPath, typeName); Assert.NotNull(driver); - Assert.IsType(driver); + // Use type name comparison to avoid assembly identity issues when loaded via Assembly.LoadFrom + Assert.Equal(typeName, driver.GetType().FullName); } // ----------------------------------------------------------------------- @@ -615,9 +616,15 @@ public void OpenDatabaseFromProfile_ManagedDriver_OpensDatabase() TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); - FakeAdbcDatabase fakeDb = Assert.IsType(db); - Assert.Equal("my-project", fakeDb.Parameters["project_id"]); - Assert.Equal("us-east1", fakeDb.Parameters["region"]); + // Use type name comparison to avoid assembly identity issues when loaded via Assembly.LoadFrom + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); + + // Access parameters via reflection since the type identity differs + System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); + Assert.NotNull(paramsProp); + IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; + Assert.Equal("my-project", parameters["project_id"]); + Assert.Equal("us-east1", parameters["region"]); } // ----------------------------------------------------------------------- @@ -822,10 +829,17 @@ public void OpenDatabaseFromProfile_UnknownOptionsPassThroughToDriver() TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); - FakeAdbcDatabase fakeDb = Assert.IsType(db); + // Use type name comparison to avoid assembly identity issues + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); + + // Access parameters via reflection since the type identity differs + System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); + Assert.NotNull(paramsProp); + IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; + // Both keys arrive at the driver; the driver decides what to do with them. - Assert.Equal("hello", fakeDb.Parameters["known_key"]); - Assert.Equal("ignored_by_driver", fakeDb.Parameters["unknown_widget"]); + Assert.Equal("hello", parameters["known_key"]); + Assert.Equal("ignored_by_driver", parameters["unknown_widget"]); } // ----------------------------------------------------------------------- @@ -958,16 +972,22 @@ public void OpenDatabaseFromProfile_WithExplicitOptions_MergesCorrectly() AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, explicitOptions); - FakeAdbcDatabase fakeDb = Assert.IsType(db); + // Use type name comparison to avoid assembly identity issues + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); + + // Access parameters via reflection since the type identity differs + System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); + Assert.NotNull(paramsProp); + IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; // Profile-only option should be present - Assert.Equal("from_profile", fakeDb.Parameters["profile_option"]); + Assert.Equal("from_profile", parameters["profile_option"]); // Explicit-only option should be present - Assert.Equal("from_explicit", fakeDb.Parameters["explicit_option"]); + Assert.Equal("from_explicit", parameters["explicit_option"]); // Shared option: explicit should override profile - Assert.Equal("explicit_value", fakeDb.Parameters["shared_option"]); + Assert.Equal("explicit_value", parameters["shared_option"]); } [Fact] @@ -986,8 +1006,14 @@ public void OpenDatabaseFromProfile_NullExplicitOptions_UsesOnlyProfile() TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, null); - FakeAdbcDatabase fakeDb = Assert.IsType(db); - Assert.Equal("value", fakeDb.Parameters["key"]); + // Use type name comparison to avoid assembly identity issues + Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); + + // Access parameters via reflection since the type identity differs + System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); + Assert.NotNull(paramsProp); + IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; + Assert.Equal("value", parameters["key"]); } } } From 7e1391209639a3d40364b3282e6c8f36d05cf40a Mon Sep 17 00:00:00 2001 From: David Coe <> Date: Fri, 13 Mar 2026 13:03:10 -0400 Subject: [PATCH 5/5] update profile parsing --- .../DriverManager/AdbcDriverManager.cs | 24 ++++- .../DriverManager/ManagedDriverLoader.cs | 95 +++++++++++++++++++ .../DriverManager/TomlConnectionProfile.cs | 48 ++++++++-- .../Apache.Arrow.Adbc/DriverManager/readme.md | 50 ++++++++-- .../TomlConnectionProfileTests.cs | 82 +++++++++++++++- 5 files changed, 276 insertions(+), 23 deletions(-) create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/ManagedDriverLoader.cs diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs index 23fcd97544..70f4dace21 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs @@ -235,27 +235,45 @@ public static AdbcDriver LoadDriverFromProfile( public static AdbcDriver LoadManagedDriver(string assemblyPath, string typeName) { if (string.IsNullOrEmpty(assemblyPath)) + { throw new ArgumentException("Assembly path must not be null or empty.", nameof(assemblyPath)); + } if (string.IsNullOrEmpty(typeName)) + { throw new ArgumentException("Type name must not be null or empty.", nameof(typeName)); + } Assembly assembly; try { - assembly = Assembly.LoadFrom(assemblyPath); + assembly = ManagedDriverLoader.LoadAssembly(assemblyPath); + } + catch (FileNotFoundException ex) + { + throw new AdbcException( + $"Driver assembly not found: {ex.FileName}", + AdbcStatusCode.NotFound); + } + catch (BadImageFormatException ex) + { + throw new AdbcException( + $"Driver assembly has an invalid format (possibly wrong platform or not a .NET assembly): {ex.Message}", + AdbcStatusCode.InvalidArgument); } catch (Exception ex) { throw new AdbcException( - $"Failed to load managed driver assembly '{assemblyPath}': {ex.Message}", + $"Failed to load managed driver assembly: {ex.Message}", AdbcStatusCode.IOError); } Type? driverType = assembly.GetType(typeName, throwOnError: false); if (driverType == null) + { throw new AdbcException( - $"Type '{typeName}' was not found in assembly '{assemblyPath}'.", + $"Type '{typeName}' was not found in the assembly.", AdbcStatusCode.NotFound); + } if (!typeof(AdbcDriver).IsAssignableFrom(driverType)) throw new AdbcException( diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/ManagedDriverLoader.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/ManagedDriverLoader.cs new file mode 100644 index 0000000000..27815a0a97 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/ManagedDriverLoader.cs @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.IO; +using System.Reflection; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// Provides cross-platform assembly loading functionality for managed ADBC drivers. + /// + /// + /// + /// This class abstracts the differences between .NET Framework and .NET Core/.NET 8 + /// assembly loading, providing a consistent interface for loading driver assemblies. + /// + /// + /// Important Notes for .NET Core/.NET 8: + /// + /// + /// + /// + /// Driver assemblies should include a .deps.json file for proper dependency + /// resolution. This file is automatically generated when building with the .NET SDK. + /// + /// + /// + /// + /// Driver dependencies should be located in the same directory as the driver assembly + /// or in a subdirectory following standard .NET runtime conventions. + /// + /// + /// + /// + /// For self-contained drivers with native dependencies, ensure all native libraries + /// are in the appropriate runtime-specific subdirectories. + /// + /// + /// + /// + internal static class ManagedDriverLoader + { + /// + /// Loads an assembly from the specified path. + /// + /// The path to the assembly to load. + /// The loaded assembly. + /// The assembly file was not found. + /// The file is not a valid assembly. + /// + /// + /// On .NET Core/.NET 8, this method uses which loads + /// the assembly into the default AssemblyLoadContext. Dependencies are resolved + /// using the standard .NET Core dependency resolution mechanism, which includes: + /// + /// + /// The .deps.json file if present + /// The assembly's directory + /// The application's probing paths + /// + /// + /// For advanced scenarios requiring isolated loading or explicit dependency resolution, + /// consider using AssemblyLoadContext directly in .NET Core/.NET 8 applications. + /// + /// + internal static Assembly LoadAssembly(string assemblyPath) + { + string fullPath = Path.GetFullPath(assemblyPath); + + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"Assembly not found: {fullPath}", fullPath); + } + + // Use LoadFrom which works on both .NET Framework and .NET Core + // On .NET Core, this loads into AssemblyLoadContext.Default + return Assembly.LoadFrom(fullPath); + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs index f1888507fb..6f993dccdd 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/TomlConnectionProfile.cs @@ -25,27 +25,41 @@ namespace Apache.Arrow.Adbc.DriverManager /// An loaded from a TOML file. /// /// - /// The expected file format is: + /// + /// The expected file format for profiles is: + /// /// - /// version = 1 + /// profile_version = 1 /// driver = "driver_name" /// - /// [options] + /// [Options] /// option1 = "value1" /// option2 = 42 /// option3 = 3.14 /// + /// + /// For backward compatibility, the following legacy field names are also supported: + /// + /// + /// version (use profile_version instead) + /// [options] section (use [Options] instead) + /// + /// /// Boolean option values are converted to the string equivalents "true" or /// "false" and placed in . /// Integer values are placed in and double values in - /// (integer values are also reflected in - /// ). + /// . /// Values of the form env_var(ENV_VAR_NAME) are expanded from the named /// environment variable when is called. + /// /// public sealed class TomlConnectionProfile : IConnectionProfile { - private const string OptionsSection = "options"; + // New spec names + private const string OptionsSectionNew = "Options"; + // Legacy names (for backward compatibility) + private const string OptionsSectionLegacy = "options"; + private const string EnvVarPrefix = "env_var("; private readonly Dictionary _stringOptions; @@ -119,7 +133,14 @@ public static TomlConnectionProfile FromContent(string tomlContent) Dictionary intOpts = new Dictionary(StringComparer.Ordinal); Dictionary doubleOpts = new Dictionary(StringComparer.Ordinal); - if (sections.TryGetValue(OptionsSection, out Dictionary? optSection)) + // Try new section name first, fall back to legacy + Dictionary? optSection = null; + if (!sections.TryGetValue(OptionsSectionNew, out optSection)) + { + sections.TryGetValue(OptionsSectionLegacy, out optSection); + } + + if (optSection != null) { foreach (KeyValuePair kv in optSection) { @@ -204,10 +225,17 @@ public void Dispose() private static void ValidateVersion(Dictionary root) { - if (!root.TryGetValue("version", out object? versionObj)) + // Try new field name first, fall back to legacy + object? versionObj = null; + if (!root.TryGetValue("profile_version", out versionObj)) + { + root.TryGetValue("version", out versionObj); + } + + if (versionObj == null) { throw new AdbcException( - "TOML profile is missing the required 'version' field.", + "TOML profile is missing the required 'profile_version' field.", AdbcStatusCode.InvalidArgument); } @@ -225,7 +253,7 @@ private static void ValidateVersion(Dictionary root) catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is OverflowException) { throw new AdbcException( - $"The 'version' field has an invalid value '{versionObj}'. It must be an integer.", + $"The 'profile_version' field has an invalid value '{versionObj}'. It must be an integer.", AdbcStatusCode.InvalidArgument); } } diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md index f82e3c792b..ea74f20e82 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/readme.md @@ -29,16 +29,16 @@ A .NET implementation of the ADBC Driver Manager, based on the C interface defin ## TOML Manifest / Profile Format -### Unmanaged Driver Example (Snowflake) +### Connection Profile Example (Snowflake) For unmanaged drivers loaded from native shared libraries: ```toml -version = 1 +profile_version = 1 driver = "libadbc_driver_snowflake" entrypoint = "AdbcDriverSnowflakeInit" -[options] +[Options] adbc.snowflake.sql.account = "myaccount" adbc.snowflake.sql.warehouse = "mywarehouse" adbc.snowflake.sql.auth_type = "auth_snowflake" @@ -46,16 +46,16 @@ username = "myuser" password = "env_var(SNOWFLAKE_PASSWORD)" ``` -### Managed Driver Example (BigQuery) +### Managed Driver Profile Example (BigQuery) For managed .NET drivers: ```toml -version = 1 +profile_version = 1 driver = "C:\\path\\to\\Apache.Arrow.Adbc.Drivers.BigQuery.dll" driver_type = "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" -[options] +[Options] adbc.bigquery.project_id = "my-project" adbc.bigquery.auth_type = "service" adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" @@ -63,11 +63,49 @@ adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" ### Format Notes +- Use `profile_version = 1` for the version field (legacy `version` is also supported for backward compatibility) +- Use `[Options]` for the options section (legacy `[options]` is also supported for backward compatibility) - Boolean option values are converted to the string equivalents `"true"` or `"false"`. - Values of the form `env_var(ENV_VAR_NAME)` are expanded from the named environment variable at connection time. - For unmanaged drivers, use `driver` for the library path and `entrypoint` for the initialization function. - For managed drivers, use `driver` for the assembly path and `driver_type` for the fully-qualified type name. +## Managed Driver Loading (.NET Core / .NET 8) + +When loading managed .NET drivers using `LoadManagedDriver`, the driver manager uses `Assembly.LoadFrom()` which has different behavior on .NET Core/.NET 8 compared to .NET Framework: + +### Dependency Resolution + +On .NET Core/.NET 8, dependencies are resolved in the following order: + +1. **The `.deps.json` file** - If present alongside the driver assembly, this file describes all dependencies and their locations. This is automatically generated when building with the .NET SDK. + +2. **The assembly's directory** - Dependencies in the same directory as the driver are discovered automatically. + +3. **The application's probing paths** - Standard .NET Core probing paths are searched. + +### Best Practices for Driver Authors + +1. **Include a `.deps.json` file** - Build your driver with the .NET SDK to automatically generate this file. It ensures all dependencies are properly resolved. + +2. **Deploy dependencies alongside the driver** - Place all required DLLs in the same directory as your driver assembly. + +3. **Consider self-contained deployment** - For maximum compatibility, publish your driver as a self-contained deployment with all dependencies included. + +4. **Test on both .NET Framework and .NET 8** - Assembly loading behavior differs, so test on both platforms if you support both. + +### Advanced Scenarios + +For applications requiring isolated loading or explicit dependency resolution, consider using `AssemblyLoadContext` directly: + +```csharp +// Example of isolated loading (requires .NET Core 3.0+) +var loadContext = new AssemblyLoadContext("MyDriverContext", isCollectible: true); +var assembly = loadContext.LoadFromAssemblyPath(driverPath); +// ... use the driver ... +loadContext.Unload(); // Unload when done (if collectible) +``` + ## Security Features The Driver Manager includes security features to protect against common attacks when loading drivers dynamically. diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs index 1fbae9a5be..745ad8e3dd 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs @@ -80,6 +80,79 @@ public void ParseProfile_WithAllOptionTypes_SetsCorrectValues() Assert.Equal("true", profile.StringOptions["use_ssl"]); } + // ----------------------------------------------------------------------- + // New spec format: profile_version and [Options] + // ----------------------------------------------------------------------- + + [Fact] + public void ParseProfile_NewFormat_WithProfileVersionAndOptions_SetsCorrectValues() + { + const string toml = @" +profile_version = 1 +driver = ""libadbc_driver_postgresql"" + +[Options] +uri = ""postgresql://localhost/mydb"" +port = 5432 +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + Assert.Equal("libadbc_driver_postgresql", profile.DriverName); + Assert.Equal("postgresql://localhost/mydb", profile.StringOptions["uri"]); + Assert.Equal(5432L, profile.IntOptions["port"]); + } + + [Fact] + public void ParseProfile_LegacyFormat_WithVersionAndLowercaseOptions_SetsCorrectValues() + { + // Legacy format should still work for backward compatibility + const string toml = @" +version = 1 +driver = ""mydriver"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + Assert.Equal("mydriver", profile.DriverName); + Assert.Equal("value", profile.StringOptions["key"]); + } + + [Fact] + public void ParseProfile_MixedFormat_ProfileVersionWithLowercaseOptions_Works() + { + // Mix of new and old should work + const string toml = @" +profile_version = 1 +driver = ""mydriver"" + +[options] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + Assert.Equal("mydriver", profile.DriverName); + Assert.Equal("value", profile.StringOptions["key"]); + } + + [Fact] + public void ParseProfile_OptionsSectionIsCaseInsensitive() + { + // The TOML parser treats section names case-insensitively + // so [Options], [options], [OPTIONS] all work the same + const string toml = @" +profile_version = 1 +driver = ""mydriver"" + +[OPTIONS] +key = ""value"" +"; + TomlConnectionProfile profile = TomlConnectionProfile.FromContent(toml); + + Assert.Equal("value", profile.StringOptions["key"]); + } + [Fact] public void ParseProfile_WithFalseBoolean_SetsStringFalse() { @@ -278,6 +351,7 @@ public void AdbcLoadFlags_NoneHasNoFlags() Assert.False(flags.HasFlag(AdbcLoadFlags.AllowRelativePaths)); } + // ----------------------------------------------------------------------- // Negative: missing version field // ----------------------------------------------------------------------- @@ -293,7 +367,7 @@ public void ParseProfile_MissingVersion_ThrowsAdbcException() "; AdbcException ex = Assert.Throws(() => TomlConnectionProfile.FromContent(toml)); Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); - Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("profile_version", ex.Message, StringComparison.OrdinalIgnoreCase); } // ----------------------------------------------------------------------- @@ -304,7 +378,7 @@ public void ParseProfile_MissingVersion_ThrowsAdbcException() public void ParseProfile_UnsupportedVersion_ThrowsAdbcException() { const string toml = @" -version = 99 +profile_version = 99 driver = ""mydriver"" "; AdbcException ex = Assert.Throws(() => TomlConnectionProfile.FromContent(toml)); @@ -711,12 +785,12 @@ public void BuildStringOptions_NullProfile_ThrowsArgumentNullException() // ----------------------------------------------------------------------- [Fact] - public void LoadManagedDriver_NonExistentAssemblyFile_ThrowsAdbcExceptionWithIOErrorStatus() + public void LoadManagedDriver_NonExistentAssemblyFile_ThrowsAdbcExceptionWithNotFoundStatus() { string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".dll"); AdbcException ex = Assert.Throws( () => AdbcDriverManager.LoadManagedDriver(missingPath, "Some.Type")); - Assert.Equal(AdbcStatusCode.IOError, ex.Status); + Assert.Equal(AdbcStatusCode.NotFound, ex.Status); } // -----------------------------------------------------------------------