/*
* Copyright (c) 2022 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Extensions.Loading
* File: LoadingExtensions.cs
*
* LoadingExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 2 of the License,
* or (at your option) any later version.
*
* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/.
*/
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Runtime.CompilerServices;
using VNLib.Utils;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
using VNLib.Plugins.Essentials.Accounts;
namespace VNLib.Plugins.Extensions.Loading
{
///
/// Provides common loading (and unloading when required) extensions for plugins
///
public static class LoadingExtensions
{
public const string DEBUG_CONFIG_KEY = "debug";
public const string SECRETS_CONFIG_KEY = "secrets";
public const string PASSWORD_HASHING_KEY = "passwords";
/*
* Plugin local cache used for storing singletons for a plugin instance
*/
private static readonly ConditionalWeakTable _localCache = new();
///
/// Gets a previously cached service singleton for the desired plugin
///
/// The service instance type
/// The plugin to obtain or build the singleton for
/// The method to produce the singleton
/// The cached or newly created singleton
public static object GetOrCreateSingleton(PluginBase plugin, Type serviceType, Func serviceFactory)
{
Lazy? service;
//Get local cache
PluginLocalCache pc = _localCache.GetValue(plugin, PluginLocalCache.Create);
//Hold lock while get/set the singleton
lock (pc.SyncRoot)
{
//Check if service already exists
service = pc.GetService(serviceType);
//publish the service if it isnt loaded yet
service ??= pc.AddService(serviceType, serviceFactory);
}
//Deferred load of the service
return service.Value;
}
///
/// Gets a previously cached service singleton for the desired plugin
/// or creates a new singleton instance for the plugin
///
///
/// The plugin to obtain or build the singleton for
/// The method to produce the singleton
/// The cached or newly created singleton
public static T GetOrCreateSingleton(PluginBase plugin, Func serviceFactory)
=> (T)GetOrCreateSingleton(plugin, typeof(T), p => serviceFactory(p)!);
///
/// Gets the plugins ambient if loaded, or loads it if required. This class will
/// be unloaded when the plugin us unloaded.
///
///
/// The ambient
///
///
///
public static PasswordHashing GetPasswords(this PluginBase plugin)
{
plugin.ThrowIfUnloaded();
//Get/load the passwords one time only
return GetOrCreateSingleton(plugin, LoadPasswords);
}
private static PasswordHashing LoadPasswords(PluginBase plugin)
{
PasswordHashing Passwords;
//Get the global password system secret (pepper)
byte[] pepper = plugin.TryGetSecretAsync(PASSWORD_HASHING_KEY)
.ToBase64Bytes().Result ?? throw new KeyNotFoundException($"Missing required key '{PASSWORD_HASHING_KEY}' in secrets");
ERRNO cb(Span buffer)
{
//No longer valid peper if plugin is unloaded as its set to zero, so we need to protect it
plugin.ThrowIfUnloaded();
pepper.CopyTo(buffer);
return pepper.Length;
}
//See hashing params are defined
IReadOnlyDictionary? hashingArgs = plugin.TryGetConfig(PASSWORD_HASHING_KEY);
if (hashingArgs != null)
{
//Get hashing arguments
uint saltLen = hashingArgs["salt_len"].GetUInt32();
uint hashLen = hashingArgs["hash_len"].GetUInt32();
uint timeCost = hashingArgs["time_cost"].GetUInt32();
uint memoryCost = hashingArgs["memory_cost"].GetUInt32();
uint parallelism = hashingArgs["parallelism"].GetUInt32();
//Load passwords
Passwords = new(cb, pepper.Length, (int)saltLen, timeCost, memoryCost, parallelism, hashLen);
}
else
{
//Init default password hashing
Passwords = new(cb, pepper.Length);
}
//Register event to cleanup the password class
_ = plugin.RegisterForUnload(() =>
{
//Zero the pepper
CryptographicOperations.ZeroMemory(pepper);
});
//return
return Passwords;
}
///
/// Loads an assembly into the current plugins AppDomain and will unload when disposed
/// or the plugin is unloaded from the host application.
///
/// The desired exported type to load from the assembly
///
/// The name of the assembly (ex: 'file.dll') to search for
/// Directory/file search option
/// The managing the loaded assmbly in the current AppDomain
///
///
///
public static AssemblyLoader LoadAssembly(this PluginBase plugin, string assemblyName, SearchOption dirSearchOption = SearchOption.AllDirectories)
{
plugin.ThrowIfUnloaded();
_ = assemblyName ?? throw new ArgumentNullException(nameof(assemblyName));
//get plugin directory from config
IReadOnlyDictionary config = plugin.GetConfig("plugins");
string? pluginsBaseDir = config["path"].GetString();
/*
* This should never happen since this method can only be called from a
* plugin context, which means this path was used to load the current plugin
*/
_ = pluginsBaseDir ?? throw new ArgumentNullException("path", "No plugin path is defined for the current host configuration, this is likely a bug");
//Get the first file that matches the search file
string? asmFile = Directory.EnumerateFiles(pluginsBaseDir, assemblyName, dirSearchOption).FirstOrDefault();
_ = asmFile ?? throw new FileNotFoundException($"Failed to load custom assembly {assemblyName} from plugin directory");
//Load the assembly
return AssemblyLoader.Load(asmFile, plugin.UnloadToken);
}
///
/// Determintes if the current plugin config has a debug propety set
///
///
/// True if debug mode is enabled, false otherwise
///
public static bool IsDebug(this PluginBase plugin)
{
plugin.ThrowIfUnloaded();
//Check for debug element
return plugin.PluginConfig.TryGetProperty(DEBUG_CONFIG_KEY, out JsonElement dbgEl) && dbgEl.GetBoolean();
}
///
/// Internal exception helper to raise if the plugin has been unlaoded
///
///
///
public static void ThrowIfUnloaded(this PluginBase plugin)
{
//See if the plugin was unlaoded
if (plugin.UnloadToken.IsCancellationRequested)
{
throw new ObjectDisposedException("The plugin has been unloaded");
}
}
///
/// Schedules an asynchronous callback function to run and its results will be observed
/// when the operation completes, or when the plugin is unloading
///
///
/// The asynchronous operation to observe
/// An optional startup delay for the operation
/// A task that completes when the deferred task completes
///
public static async Task DeferTask(this PluginBase plugin, Func asyncTask, int delayMs = 0)
{
/*
* Motivation:
* Sometimes during plugin loading, a plugin may want to asynchronously load
* data, where the results are not required to be observed during loading, but
* should not be pending after the plugin is unloaded, as the assembly may be
* unloaded and referrences collected by the GC.
*
* So we can use the plugin's unload cancellation token to observe the results
* of a pending async operation
*/
//Test status
plugin.ThrowIfUnloaded();
//Optional delay
await Task.Delay(delayMs);
//Run on ts
Task deferred = Task.Run(asyncTask);
//Add task to deferred list
plugin.ObserveTask(deferred);
try
{
//Await the task results
await deferred.ConfigureAwait(false);
}
catch(Exception ex)
{
//Log errors
plugin.Log.Error(ex, "Error occured while observing deferred task");
}
finally
{
//Remove task when complete
plugin.RemoveObservedTask(deferred);
}
}
///
/// Registers an event to occur when the plugin is unloaded on a background thread
/// and will cause the Plugin.Unload() method to block until the event completes
///
///
/// The method to call when the plugin is unloaded
/// A task that represents the registered work
///
///
public static Task RegisterForUnload(this PluginBase pbase, Action callback)
{
//Test status
pbase.ThrowIfUnloaded();
_ = callback ?? throw new ArgumentNullException(nameof(callback));
//Wait method
static async Task WaitForUnload(PluginBase pb, Action callback)
{
//Wait for unload as a task on the threadpool to avoid deadlocks
await pb.UnloadToken.WaitHandle.WaitAsync()
.ConfigureAwait(false);
callback();
}
//Registaer the task to cause the plugin to wait
return pbase.DeferTask(() => WaitForUnload(pbase, callback));
}
private sealed class PluginLocalCache
{
private readonly PluginBase _plugin;
private readonly Dictionary> _store;
public object SyncRoot { get; } = new();
private PluginLocalCache(PluginBase plugin)
{
_plugin = plugin;
_store = new();
//Register cleanup on unload
_ = _plugin.RegisterForUnload(() => _store.Clear());
}
public static PluginLocalCache Create(PluginBase plugin) => new(plugin);
public Lazy? GetService(Type serviceType)
{
Lazy? t = _store.Where(t => t.Key.IsAssignableTo(serviceType))
.Select(static tk => tk.Value)
.FirstOrDefault();
return t;
}
public Lazy AddService(Type serviceType, Func factory)
{
//Get lazy loader to invoke factory outside of cache lock
Lazy lazyFactory = new(() => factory(_plugin), true);
//Store lazy factory
_store.Add(serviceType, lazyFactory);
//Pass the lazy factory back
return lazyFactory;
}
}
}
}