Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion Modules/PluginManagement/PluginLoader.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO.Compression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using sharwapi.Contracts.Core;
Expand Down Expand Up @@ -50,7 +51,7 @@ public List<IApiPlugin> LoadPlugins()
return loadedPlugins;
}

// 遍历 plugins 目录下的所有 DLL 文件
// 遍历 plugins 目录下的所有 DLL 文件(单文件插件)
foreach (var dllPath in Directory.GetFiles(pluginsPath, "*.dll"))
{
try
Expand All @@ -68,9 +69,149 @@ public List<IApiPlugin> LoadPlugins()
}
}

// 遍历 .sharw 插件包,解压到 .cache 后加载
string cacheRoot = Path.Combine(pluginsPath, ".cache");
foreach (var sharwPath in Directory.GetFiles(pluginsPath, "*.sharw"))
{
try
{
var cacheDir = ExtractSharwToCache(sharwPath, cacheRoot);
if (cacheDir != null)
{
var plugins = LoadPluginFromDirectory(cacheDir);
loadedPlugins.AddRange(plugins);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading .sharw plugin from {SharwPath}", sharwPath);
}
}

// 遍历 plugins 目录下的所有子目录(多文件插件,深度为 1),跳过 .cache 缓存目录
foreach (var subDir in Directory.GetDirectories(pluginsPath)
.Where(d => !string.Equals(Path.GetFileName(d), ".cache",
StringComparison.OrdinalIgnoreCase)))
{
try
{
var plugins = LoadPluginFromDirectory(subDir);
loadedPlugins.AddRange(plugins);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading plugin from directory {SubDir}", subDir);
}
}

return loadedPlugins;
}

/// <summary>
/// 从子目录加载多文件插件,返回目录中所有 IApiPlugin 实现。
/// 优先查找与目录同名的 DLL 作为主程序集;若不存在,则逐一扫描目录内所有 DLL 文件。
/// AssemblyDependencyResolver 会依据主程序集的 .deps.json 自动解析同目录内的依赖。
/// </summary>
/// <param name="dirPath">插件子目录路径</param>
/// <returns>加载到的所有插件实例列表</returns>
private List<IApiPlugin> LoadPluginFromDirectory(string dirPath)
{
var result = new List<IApiPlugin>();
var dirName = Path.GetFileName(dirPath);

// 优先约定:主 DLL 与目录名相同(例如 plugins/MyPlugin/MyPlugin.dll)
var conventionDllPath = Path.Combine(dirPath, dirName + ".dll");
if (File.Exists(conventionDllPath))
{
_logger.LogDebug("Loading plugin from directory {DirPath} using convention DLL {DllName}.dll", dirPath, dirName);
var plugin = LoadPluginFromPath(conventionDllPath);
if (plugin != null) result.Add(plugin);
return result;
}

// 回退:遍历目录内所有 DLL,收集所有包含 IApiPlugin 实现的插件
_logger.LogDebug("Convention DLL not found in {DirPath}, scanning all DLLs", dirPath);
foreach (var dllPath in Directory.GetFiles(dirPath, "*.dll"))
{
try
{
var plugin = LoadPluginFromPath(dllPath);
if (plugin != null)
{
result.Add(plugin);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "DLL {DllPath} in directory does not contain a valid plugin", dllPath);
}
}

if (result.Count == 0)
{
_logger.LogWarning("No valid IApiPlugin implementation found in directory {DirPath}", dirPath);
}

return result;
}

/// <summary>
/// 将 .sharw 插件包解压到缓存目录,若缓存已是最新则跳过解压。
/// 解压时执行 Zip Slip 路径合法性检查,防止恶意路径遍历攻击。
/// </summary>
/// <param name="sharwPath">.sharw 文件路径</param>
/// <param name="cacheRoot">缓存根目录路径(plugins/.cache)</param>
/// <returns>解压后的插件缓存目录路径</returns>
private string? ExtractSharwToCache(string sharwPath, string cacheRoot)
{
var pluginName = Path.GetFileNameWithoutExtension(sharwPath);
var cacheDir = Path.Combine(cacheRoot, pluginName);
var sharwModified = File.GetLastWriteTimeUtc(sharwPath);

// 若缓存目录已存在且 .sharw 未更新,跳过解压
if (Directory.Exists(cacheDir))
{
var cacheModified = Directory.GetLastWriteTimeUtc(cacheDir);
if (sharwModified <= cacheModified)
{
_logger.LogDebug("Cache for {PluginName} is up-to-date, skipping extraction", pluginName);
return cacheDir;
}
_logger.LogInformation("Updating cache for {PluginName}", pluginName);
Directory.Delete(cacheDir, recursive: true);
}

Directory.CreateDirectory(cacheDir);
var resolvedCache = Path.GetFullPath(cacheDir);

using var archive = ZipFile.OpenRead(sharwPath);
foreach (var entry in archive.Entries)
{
// Zip Slip 防护:确保解压路径不超出缓存目录
var destPath = Path.GetFullPath(Path.Combine(resolvedCache, entry.FullName));
if (!destPath.StartsWith(resolvedCache + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Zip Slip attempt detected in {SharwPath}: {Entry}", sharwPath, entry.FullName);
throw new InvalidOperationException($"Zip Slip detected: {entry.FullName}");
}

if (string.IsNullOrEmpty(entry.Name)) // 目录条目
{
Directory.CreateDirectory(destPath);
continue;
}

Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
entry.ExtractToFile(destPath, overwrite: true);
}

// 将缓存目录修改时间与 .sharw 同步,用于下次更新校验
Directory.SetLastWriteTimeUtc(cacheDir, sharwModified);
_logger.LogInformation("Extracted .sharw plugin {PluginName} to {CacheDir}", pluginName, cacheDir);
return cacheDir;
}

/// <summary>
/// 从指定路径加载单个插件
/// </summary>
Expand Down