diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs
index 5814623..c39f1e4 100644
--- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs
+++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs
@@ -1,5 +1,6 @@
-using System.IO.Compression;
+using System.IO.Compression;
using System.Reflection;
+using System.Security.Cryptography;
using WebExpress.WebCore.Test.Fixture;
using WebExpress.WebCore.WebComponent;
using WebExpress.WebCore.WebPackage;
@@ -246,5 +247,198 @@ public void LoadPackageReadsSpec()
Directory.Delete(packagePath, true);
}
}
+
+ ///
+ /// Tests package validation with extension/type checks.
+ ///
+ [Fact]
+ public void ValidatePackageRejectsInvalidExtension()
+ {
+ // arrange
+ var httpServerContext = UnitTestFixture.CreateHttpServerContextMock();
+ var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext);
+ var packageManager = componentHub.PackageManager as PackageManager;
+ var packagePath = httpServerContext.PackagePath;
+ var dummyFile = Path.Combine(packagePath, "dummy.zip");
+
+ try
+ {
+ Directory.CreateDirectory(packagePath);
+ File.WriteAllText(dummyFile, "not-a-package");
+
+ // act
+ var validation = packageManager.ValidatePackage(dummyFile);
+
+ // validation
+ Assert.False(validation.IsValid);
+ Assert.Contains(validation.Messages, x => x.Contains("extension", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ if (File.Exists(dummyFile))
+ {
+ File.Delete(dummyFile);
+ }
+
+ if (Directory.Exists(packagePath))
+ {
+ Directory.Delete(packagePath, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests a complete package lifecycle using explicit package manager operations.
+ ///
+ [Fact]
+ public void PackageLifecycleInstallDeactivateActivateUninstall()
+ {
+ // arrange
+ var httpServerContext = UnitTestFixture.CreateHttpServerContextMock();
+ var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext);
+ var packageManager = componentHub.PackageManager as PackageManager;
+ var packagePath = httpServerContext.PackagePath;
+ var packageFile = Path.Combine(packagePath, "lifecycle.1.0.0.wxp");
+
+ try
+ {
+ Directory.CreateDirectory(packagePath);
+ CreatePackageArchive(packageFile, "lifecycle", "1.0.0");
+
+ // act + validation (install active)
+ var install = packageManager.InstallPackage(packageFile, true);
+ Assert.True(install.Success);
+ Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State);
+
+ // act + validation (deactivate)
+ var deactivate = packageManager.DeactivatePackage("lifecycle");
+ Assert.True(deactivate.Success);
+ Assert.Equal(PackageCatalogeItemState.Disable, packageManager.GetPackage("lifecycle")?.State);
+
+ // act + validation (activate)
+ var activate = packageManager.ActivatePackage("lifecycle");
+ Assert.True(activate.Success);
+ Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State);
+
+ // act + validation (uninstall)
+ var uninstall = packageManager.UninstallPackage("lifecycle");
+ Assert.True(uninstall.Success);
+ Assert.Null(packageManager.GetPackage("lifecycle"));
+ }
+ finally
+ {
+ if (Directory.Exists(packagePath))
+ {
+ Directory.Delete(packagePath, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests that update fails when uploaded package id does not match the requested package id.
+ ///
+ [Fact]
+ public void UpdatePackageRejectsMismatchedId()
+ {
+ // arrange
+ var httpServerContext = UnitTestFixture.CreateHttpServerContextMock();
+ var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext);
+ var packageManager = componentHub.PackageManager as PackageManager;
+ var packagePath = httpServerContext.PackagePath;
+ var packageFile = Path.Combine(packagePath, "other.1.0.0.wxp");
+
+ try
+ {
+ Directory.CreateDirectory(packagePath);
+ CreatePackageArchive(packageFile, "other", "1.0.0");
+
+ // act
+ var result = packageManager.UpdatePackage("expected", packageFile);
+
+ // validation
+ Assert.False(result.Success);
+ Assert.Contains("does not match", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ if (Directory.Exists(packagePath))
+ {
+ Directory.Delete(packagePath, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests package SHA-256 verification support.
+ ///
+ [Fact]
+ public void ValidatePackageWithSha256()
+ {
+ // arrange
+ var httpServerContext = UnitTestFixture.CreateHttpServerContextMock();
+ var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext);
+ var packageManager = componentHub.PackageManager as PackageManager;
+ var packagePath = httpServerContext.PackagePath;
+ var packageFile = Path.Combine(packagePath, "signed.1.0.0.wxp");
+
+ try
+ {
+ Directory.CreateDirectory(packagePath);
+ CreatePackageArchive(packageFile, "signed", "1.0.0");
+ var expectedHash = ComputeSha256(packageFile);
+
+ // act
+ var valid = packageManager.ValidatePackage(packageFile, expectedSha256: expectedHash);
+ var invalid = packageManager.ValidatePackage(packageFile, expectedSha256: "deadbeef");
+
+ // validation
+ Assert.True(valid.IsValid);
+ Assert.False(invalid.IsValid);
+ }
+ finally
+ {
+ if (Directory.Exists(packagePath))
+ {
+ Directory.Delete(packagePath, true);
+ }
+ }
+ }
+
+ ///
+ /// Creates a simple package archive for tests.
+ ///
+ /// The package file path.
+ /// The package id.
+ /// The package version.
+ private static void CreatePackageArchive(string file, string id, string version)
+ {
+ using var zip = ZipFile.Open(file, ZipArchiveMode.Create);
+ var specEntry = zip.CreateEntry($"{id}.spec");
+ using (var writer = new StreamWriter(specEntry.Open()))
+ {
+ writer.Write($@"
+
+ {id}
+ {version}
+ {id}-title
+ UnitTest
+ ");
+ }
+
+ // add minimal lib folder marker to resemble package layout
+ zip.CreateEntry("lib/");
+ }
+
+ ///
+ /// Computes SHA-256 for a test file.
+ ///
+ /// The file path.
+ /// The sha-256 hash as lowercase hex.
+ private static string ComputeSha256(string file)
+ {
+ using var stream = File.OpenRead(file);
+ var hash = SHA256.HashData(stream);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs
index 815f347..16b6a81 100644
--- a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs
+++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs
@@ -1,6 +1,7 @@
-using WebExpress.WebCore.Test.Fixture;
+using WebExpress.WebCore.Test.Fixture;
using WebExpress.WebCore.WebComponent;
using WebExpress.WebCore.WebPlugin;
+using WebExpress.WebCore.WebPlugin.Model;
namespace WebExpress.WebCore.Test.Manager
{
@@ -253,5 +254,24 @@ public void IsIContext()
Assert.True(typeof(IContext).IsAssignableFrom(plugin.GetType()), $"Plugin context {plugin.GetType().Name} does not implement IContext.");
}
}
+
+ ///
+ /// Tests runtime plugin metadata retrieval.
+ ///
+ [Fact]
+ public void GetPluginRuntimeInfos()
+ {
+ // arrange
+ var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock();
+ var pluginManager = componentHub.PluginManager;
+
+ // act
+ var infos = pluginManager.GetPluginRuntimeInfos().ToList();
+
+ // validation
+ Assert.NotEmpty(infos);
+ Assert.Contains(infos, x => x.PluginContext.PluginId.ToString() == "webexpress.webcore.test");
+ Assert.All(infos, x => Assert.Equal(PluginRuntimeState.Active, x.State));
+ }
}
}
diff --git a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs
index 40c14c3..0f05615 100644
--- a/src/WebExpress.WebCore/WebPackage/IPackageManager.cs
+++ b/src/WebExpress.WebCore/WebPackage/IPackageManager.cs
@@ -1,4 +1,6 @@
-using System;
+using System;
+using System.IO;
+using System.Collections.Generic;
using WebExpress.WebCore.WebComponent;
using WebExpress.WebCore.WebPackage.Model;
@@ -24,5 +26,80 @@ public interface IPackageManager : IComponentManager
/// Gets the catalog of installed packages.
///
PackageCatalog Catalog { get; }
+
+ ///
+ /// Returns all package entries from the package catalog.
+ ///
+ /// An enumerable collection with all package entries.
+ IEnumerable GetPackages();
+
+ ///
+ /// Returns a package by id.
+ ///
+ /// The package id.
+ /// The package or null.
+ PackageCatalogItem GetPackage(string packageId);
+
+ ///
+ /// Validates a package file.
+ ///
+ /// The package file path.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The validation result.
+ PackageValidationResult ValidatePackage(string packageFile, long maxPackageBytes = 0, string expectedSha256 = null);
+
+ ///
+ /// Uploads and installs a package from a stream.
+ ///
+ /// The package stream.
+ /// The package file name.
+ /// True to activate directly after install; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ PackageOperationResult UploadPackage(Stream packageStream, string fileName, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null);
+
+ ///
+ /// Installs a package from a file.
+ ///
+ /// The package file path.
+ /// True to activate directly after install; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ PackageOperationResult InstallPackage(string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null);
+
+ ///
+ /// Activates a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ PackageOperationResult ActivatePackage(string packageId);
+
+ ///
+ /// Deactivates a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ PackageOperationResult DeactivatePackage(string packageId);
+
+ ///
+ /// Updates a package from a file path.
+ ///
+ /// The package id.
+ /// The package file path.
+ /// True to activate directly after update; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ PackageOperationResult UpdatePackage(string packageId, string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null);
+
+ ///
+ /// Uninstalls and removes a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ PackageOperationResult UninstallPackage(string packageId);
}
}
diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs
index f74228f..3fee70c 100644
--- a/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs
+++ b/src/WebExpress.WebCore/WebPackage/Model/PackageItem.cs
@@ -62,6 +62,11 @@ public class PackageItem
///
public IEnumerable PluginSources { get; set; }
+ ///
+ /// Gets or sets the package dependencies.
+ ///
+ public IEnumerable Dependencies { get; set; }
+
///
/// Initializes a new instance of the class.
///
diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs
index 5486934..5b3dffb 100644
--- a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs
+++ b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs
@@ -80,6 +80,12 @@ public class PackageItemSpec
[XmlElement("plugin", IsNullable = true)]
public string[] Plugins { get; set; }
+ ///
+ /// Gets or sets the package dependencies.
+ ///
+ [XmlElement("dependency", IsNullable = true)]
+ public string[] Dependencies { get; set; }
+
///
/// Gets or sets the artifacts.
///
diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs
new file mode 100644
index 0000000..67c18b4
--- /dev/null
+++ b/src/WebExpress.WebCore/WebPackage/Model/PackageOperationResult.cs
@@ -0,0 +1,55 @@
+namespace WebExpress.WebCore.WebPackage.Model
+{
+ ///
+ /// Represents the result of a package lifecycle operation.
+ ///
+ public sealed class PackageOperationResult
+ {
+ ///
+ /// Gets or sets whether the operation was successful.
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Gets or sets an optional operation message.
+ ///
+ public string Message { get; set; }
+
+ ///
+ /// Gets or sets the affected package, if available.
+ ///
+ public PackageCatalogItem Package { get; set; }
+
+ ///
+ /// Creates a successful operation result.
+ ///
+ /// The result message.
+ /// The affected package.
+ /// The created operation result.
+ public static PackageOperationResult Ok(string message, PackageCatalogItem package = null)
+ {
+ return new PackageOperationResult()
+ {
+ Success = true,
+ Message = message,
+ Package = package
+ };
+ }
+
+ ///
+ /// Creates a failed operation result.
+ ///
+ /// The result message.
+ /// The affected package.
+ /// The created operation result.
+ public static PackageOperationResult Failed(string message, PackageCatalogItem package = null)
+ {
+ return new PackageOperationResult()
+ {
+ Success = false,
+ Message = message,
+ Package = package
+ };
+ }
+ }
+}
diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs
new file mode 100644
index 0000000..502d527
--- /dev/null
+++ b/src/WebExpress.WebCore/WebPackage/Model/PackageValidationResult.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+
+namespace WebExpress.WebCore.WebPackage.Model
+{
+ ///
+ /// Represents the result of package file validation.
+ ///
+ public sealed class PackageValidationResult
+ {
+ ///
+ /// Gets or sets whether the package is valid.
+ ///
+ public bool IsValid { get; set; }
+
+ ///
+ /// Gets validation messages.
+ ///
+ public List Messages { get; } = [];
+
+ ///
+ /// Gets or sets metadata resolved from the package file.
+ ///
+ public PackageCatalogItem Package { get; set; }
+ }
+}
diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs
index 72b4d4b..0c9e7a5 100644
--- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs
+++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs
@@ -214,6 +214,7 @@ private static void SpecToZip(ZipArchive archive, PackageItemSpec package)
Description = package?.Description,
Tags = package?.Tags,
Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(),
+ Dependencies = package?.Dependencies,
};
serializer.Serialize(zipStream, newPackage);
@@ -442,4 +443,4 @@ private static string SanitizeEntryPath(string entryPath)
return string.Join("/", safeSegments);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs
index 992b0b5..597e574 100644
--- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs
+++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs
@@ -6,6 +6,7 @@
using System.Linq;
using System.Reflection;
using System.Runtime.Versioning;
+using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -291,6 +292,398 @@ public void Scan()
}
}
+ ///
+ /// Returns all package entries from the package catalog.
+ ///
+ /// An enumerable collection with all package entries.
+ public IEnumerable GetPackages()
+ {
+ lock (_scanLock)
+ {
+ return [.. Catalog.Packages.Where(x => x is not null)];
+ }
+ }
+
+ ///
+ /// Returns a package by id.
+ ///
+ /// The package id.
+ /// The package or null.
+ public PackageCatalogItem GetPackage(string packageId)
+ {
+ if (string.IsNullOrWhiteSpace(packageId))
+ {
+ return null;
+ }
+
+ lock (_scanLock)
+ {
+ return Catalog.Packages
+ .FirstOrDefault(x => x is not null && x.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ ///
+ /// Validates a package file.
+ ///
+ /// The package file path.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The validation result.
+ public PackageValidationResult ValidatePackage(string packageFile, long maxPackageBytes = 0, string expectedSha256 = null)
+ {
+ var result = new PackageValidationResult();
+
+ if (string.IsNullOrWhiteSpace(packageFile))
+ {
+ result.Messages.Add("The package path is empty.");
+ return result;
+ }
+
+ if (!File.Exists(packageFile))
+ {
+ result.Messages.Add("The package file does not exist.");
+ return result;
+ }
+
+ if (!Path.GetExtension(packageFile).Equals(".wxp", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Messages.Add("The package file extension must be '.wxp'.");
+ return result;
+ }
+
+ if (maxPackageBytes > 0)
+ {
+ var fileInfo = new FileInfo(packageFile);
+ if (fileInfo.Length > maxPackageBytes)
+ {
+ result.Messages.Add($"The package size exceeds the allowed limit ({maxPackageBytes} bytes).");
+ return result;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(expectedSha256))
+ {
+ var hash = ComputeSha256(packageFile);
+ if (!hash.Equals(expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase))
+ {
+ result.Messages.Add("The package signature/hash verification failed.");
+ return result;
+ }
+ }
+
+ try
+ {
+ using var zip = ZipFile.OpenRead(packageFile);
+ var specEntry = zip.Entries.FirstOrDefault(x => Path.GetExtension(x.FullName).Equals(".spec", StringComparison.OrdinalIgnoreCase));
+ if (specEntry is null)
+ {
+ result.Messages.Add("The package does not contain a .spec file.");
+ return result;
+ }
+
+ var spec = ReadSpec(specEntry);
+ if (spec is null)
+ {
+ result.Messages.Add("The package specification could not be read.");
+ return result;
+ }
+
+ if (string.IsNullOrWhiteSpace(spec.Id))
+ {
+ result.Messages.Add("The package id is missing in the .spec file.");
+ }
+
+ if (string.IsNullOrWhiteSpace(spec.Version))
+ {
+ result.Messages.Add("The package version is missing in the .spec file.");
+ }
+
+ foreach (var plugin in spec.Plugins ?? [])
+ {
+ if (string.IsNullOrWhiteSpace(plugin))
+ {
+ result.Messages.Add("A plugin entry in the .spec file is empty.");
+ continue;
+ }
+
+ if (Path.IsPathRooted(plugin))
+ {
+ result.Messages.Add($"The plugin entry '{plugin}' must be a relative path.");
+ }
+
+ var normalized = plugin.Replace('\\', '/');
+ if (normalized.Contains("..", StringComparison.Ordinal))
+ {
+ result.Messages.Add($"The plugin entry '{plugin}' contains invalid traversal segments.");
+ }
+ }
+
+ result.Package = CreateCatalogItem(packageFile, spec);
+ }
+ catch (Exception ex)
+ {
+ _httpServerContext.Log.Exception(ex);
+ result.Messages.Add("The package archive is invalid or corrupted.");
+ }
+
+ result.IsValid = result.Messages.Count == 0;
+ return result;
+ }
+
+ ///
+ /// Uploads and installs a package from a stream.
+ ///
+ /// The package stream.
+ /// The package file name.
+ /// True to activate directly after install; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ public PackageOperationResult UploadPackage(Stream packageStream, string fileName, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null)
+ {
+ if (packageStream is null)
+ {
+ return PackageOperationResult.Failed("The upload stream is null.");
+ }
+
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return PackageOperationResult.Failed("The upload file name is empty.");
+ }
+
+ var safeFileName = Path.GetFileName(fileName);
+ if (!safeFileName.EndsWith(".wxp", StringComparison.OrdinalIgnoreCase))
+ {
+ return PackageOperationResult.Failed("The upload file extension must be '.wxp'.");
+ }
+
+ var tmpFile = Path.Combine(_httpServerContext.PackagePath, $"{Guid.NewGuid()}.{safeFileName}");
+ Directory.CreateDirectory(_httpServerContext.PackagePath);
+
+ try
+ {
+ using (var fileStream = new FileStream(tmpFile, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ CopyStream(packageStream, fileStream, maxPackageBytes);
+ }
+
+ var targetFile = Path.Combine(_httpServerContext.PackagePath, safeFileName);
+ File.Copy(tmpFile, targetFile, true);
+
+ return InstallPackage(targetFile, activate, maxPackageBytes, expectedSha256);
+ }
+ catch (Exception ex)
+ {
+ _httpServerContext.Log.Exception(ex);
+ return PackageOperationResult.Failed("The package upload failed.");
+ }
+ finally
+ {
+ if (File.Exists(tmpFile))
+ {
+ File.Delete(tmpFile);
+ }
+ }
+ }
+
+ ///
+ /// Installs a package from a file.
+ ///
+ /// The package file path.
+ /// True to activate directly after install; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ public PackageOperationResult InstallPackage(string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null)
+ {
+ lock (_scanLock)
+ {
+ var validation = ValidatePackage(packageFile, maxPackageBytes, expectedSha256);
+ if (!validation.IsValid)
+ {
+ return PackageOperationResult.Failed(string.Join(" ", validation.Messages));
+ }
+
+ var package = validation.Package;
+ var safeFile = Path.GetFileName(packageFile);
+ var targetFile = Path.Combine(_httpServerContext.PackagePath, safeFile);
+ Directory.CreateDirectory(_httpServerContext.PackagePath);
+
+ if (!Path.GetFullPath(packageFile).Equals(Path.GetFullPath(targetFile), StringComparison.OrdinalIgnoreCase))
+ {
+ File.Copy(packageFile, targetFile, true);
+ }
+
+ package.File = safeFile;
+
+ var existing = Catalog.Packages
+ .FirstOrDefault(x => x is not null && x.Id.Equals(package.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (existing is null)
+ {
+ Catalog.Packages.Add(package);
+ existing = package;
+ OnAddPackage(existing);
+ }
+ else
+ {
+ var oldPackageFile = Path.Combine(_httpServerContext.PackagePath, existing.File);
+ DeactivateAndUnregisterPackage(existing);
+ RemoveExtractedDirectory(existing);
+
+ existing.File = package.File;
+ existing.Metadata = package.Metadata;
+
+ if (!oldPackageFile.Equals(targetFile, StringComparison.OrdinalIgnoreCase) && File.Exists(oldPackageFile))
+ {
+ File.Delete(oldPackageFile);
+ }
+ }
+
+ if (!activate)
+ {
+ existing.State = PackageCatalogeItemState.Disable;
+ SaveCatalog();
+ _componentHub.SitemapManager.Refresh();
+ return PackageOperationResult.Ok($"Package '{existing.Id}' installed (disabled).", existing);
+ }
+
+ var activateResult = ActivatePackage(existing.Id);
+ return activateResult.Success
+ ? PackageOperationResult.Ok($"Package '{existing.Id}' installed and activated.", existing)
+ : activateResult;
+ }
+ }
+
+ ///
+ /// Activates a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ public PackageOperationResult ActivatePackage(string packageId)
+ {
+ lock (_scanLock)
+ {
+ var package = GetPackage(packageId);
+ if (package is null)
+ {
+ return PackageOperationResult.Failed($"Package '{packageId}' was not found.");
+ }
+
+ if (package.State == PackageCatalogeItemState.Active)
+ {
+ return PackageOperationResult.Ok($"Package '{packageId}' is already active.", package);
+ }
+
+ var missingDependencies = GetUnfulfilledPackageDependencies(package).ToList();
+ if (missingDependencies.Count > 0)
+ {
+ package.State = PackageCatalogeItemState.Disable;
+ SaveCatalog();
+ return PackageOperationResult.Failed($"Package '{packageId}' has missing dependencies: {string.Join(", ", missingDependencies)}", package);
+ }
+
+ DeactivateAndUnregisterPackage(package);
+ RemoveExtractedDirectory(package);
+
+ ExtractPackage(package);
+ RegisterPackage(package);
+ BootPackage(package);
+ package.State = PackageCatalogeItemState.Active;
+
+ SaveCatalog();
+ _componentHub.SitemapManager.Refresh();
+
+ return PackageOperationResult.Ok($"Package '{packageId}' activated.", package);
+ }
+ }
+
+ ///
+ /// Deactivates a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ public PackageOperationResult DeactivatePackage(string packageId)
+ {
+ lock (_scanLock)
+ {
+ var package = GetPackage(packageId);
+ if (package is null)
+ {
+ return PackageOperationResult.Failed($"Package '{packageId}' was not found.");
+ }
+
+ DeactivateAndUnregisterPackage(package);
+ RemoveExtractedDirectory(package);
+ package.State = PackageCatalogeItemState.Disable;
+
+ SaveCatalog();
+ _componentHub.SitemapManager.Refresh();
+
+ return PackageOperationResult.Ok($"Package '{packageId}' deactivated.", package);
+ }
+ }
+
+ ///
+ /// Updates a package from a file path.
+ ///
+ /// The package id.
+ /// The package file path.
+ /// True to activate directly after update; false to keep it disabled.
+ /// Optional max allowed package size in bytes. 0 disables the limit check.
+ /// Optional expected SHA-256 hash in hex format.
+ /// The operation result.
+ public PackageOperationResult UpdatePackage(string packageId, string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null)
+ {
+ var validation = ValidatePackage(packageFile, maxPackageBytes, expectedSha256);
+ if (!validation.IsValid)
+ {
+ return PackageOperationResult.Failed(string.Join(" ", validation.Messages));
+ }
+
+ if (validation.Package is null || !validation.Package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))
+ {
+ return PackageOperationResult.Failed($"The uploaded package id does not match '{packageId}'.");
+ }
+
+ return InstallPackage(packageFile, activate, maxPackageBytes, expectedSha256);
+ }
+
+ ///
+ /// Uninstalls and removes a package.
+ ///
+ /// The package id.
+ /// The operation result.
+ public PackageOperationResult UninstallPackage(string packageId)
+ {
+ lock (_scanLock)
+ {
+ var package = GetPackage(packageId);
+ if (package is null)
+ {
+ return PackageOperationResult.Failed($"Package '{packageId}' was not found.");
+ }
+
+ DeactivateAndUnregisterPackage(package);
+ RemoveExtractedDirectory(package);
+
+ var packageFile = Path.Combine(_httpServerContext.PackagePath, package.File);
+ if (File.Exists(packageFile))
+ {
+ File.Delete(packageFile);
+ }
+
+ Catalog.Packages.Remove(package);
+ OnRemovePackage(package);
+
+ SaveCatalog();
+ _componentHub.SitemapManager.Refresh();
+
+ return PackageOperationResult.Ok($"Package '{packageId}' uninstalled.", package);
+ }
+ }
+
///
/// Opens a package and finds the meta information.
///
@@ -304,40 +697,16 @@ private PackageCatalogItem LoadPackage(string file)
{
using var zip = ZipFile.Open(file, ZipArchiveMode.Read);
- var specEntry = zip.Entries.Where(x => Path.GetExtension(x.FullName) == ".spec").FirstOrDefault();
+ var specEntry = zip.Entries
+ .FirstOrDefault(x => Path.GetExtension(x.FullName).Equals(".spec", StringComparison.OrdinalIgnoreCase));
if (specEntry is null)
{
_httpServerContext.Log.Warning($"package spec was not found in '{file}'");
return null;
}
- var serializer = new XmlSerializer(typeof(PackageItemSpec));
- PackageItemSpec spec;
- using (var stream = specEntry.Open())
- {
- spec = (PackageItemSpec)serializer.Deserialize(stream);
- }
-
- return new PackageCatalogItem()
- {
- Id = spec.Id,
- File = Path.GetFileName(file),
- State = PackageCatalogeItemState.Available,
- Metadata = new PackageItem()
- {
- FileName = Path.GetFileName(file),
- Id = spec.Id,
- Version = spec.Version,
- Title = spec.Title,
- Authors = spec.Authors,
- License = spec.License,
- Icon = spec.Icon,
- Readme = spec.Readme,
- Description = spec.Description,
- Tags = spec.Tags,
- PluginSources = spec.Plugins
- }
- };
+ var spec = ReadSpec(specEntry);
+ return CreateCatalogItem(file, spec);
}
}
catch (Exception ex)
@@ -416,18 +785,41 @@ private void ExtractPackage(PackageCatalogItem package)
using var zip = ZipFile.Open(packageFile, ZipArchiveMode.Read);
var extractedPath = Path.Combine(_httpServerContext.PackagePath, Path.GetFileNameWithoutExtension(package?.File));
+ var extractedPathFull = Path.GetFullPath(extractedPath);
if (!Directory.Exists(extractedPath))
{
Directory.CreateDirectory(extractedPath);
}
- foreach (var entry in zip.Entries.Where(x => Path.GetDirectoryName(x.FullName).StartsWith("lib", StringComparison.OrdinalIgnoreCase)))
+ foreach (var entry in zip.Entries)
{
+ var normalized = (entry.FullName ?? string.Empty).Replace('\\', '/').TrimStart('/');
+
+ if (!normalized.StartsWith("lib/", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (normalized.Contains("../", StringComparison.Ordinal) || normalized.StartsWith("..", StringComparison.Ordinal))
+ {
+ _httpServerContext.Log.Warning($"Unsafe package entry '{entry.FullName}' ignored.");
+ continue;
+ }
+
+ var targetFilePath = Path.GetFullPath(Path.Combine(extractedPath, normalized));
+ var isInExtractedPath = targetFilePath.StartsWith(extractedPathFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
+ || targetFilePath.Equals(extractedPathFull, StringComparison.OrdinalIgnoreCase);
+ if (!isInExtractedPath)
+ {
+ _httpServerContext.Log.Warning($"Unsafe package entry '{entry.FullName}' ignored.");
+ continue;
+ }
+
// directory entries in the zip have an empty Name
if (string.IsNullOrEmpty(entry.Name))
{
- var dirPath = Path.Combine(extractedPath, entry.FullName);
+ var dirPath = targetFilePath;
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
@@ -436,7 +828,6 @@ private void ExtractPackage(PackageCatalogItem package)
continue;
}
- var targetFilePath = Path.Combine(extractedPath, entry.FullName);
var targetDir = Path.GetDirectoryName(targetFilePath);
if (!Directory.Exists(targetDir))
@@ -444,10 +835,7 @@ private void ExtractPackage(PackageCatalogItem package)
Directory.CreateDirectory(targetDir);
}
- if (!File.Exists(targetFilePath))
- {
- entry.ExtractToFile(targetFilePath);
- }
+ entry.ExtractToFile(targetFilePath, true);
}
}
}
@@ -592,6 +980,22 @@ private static bool HasPackageChanged(PackageCatalogItem existing, PackageCatalo
}
}
+ var dependenciesA = (existing.Metadata.Dependencies ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
+ var dependenciesB = (fromFile.Metadata.Dependencies ?? []).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
+
+ if (dependenciesA.Length != dependenciesB.Length)
+ {
+ return true;
+ }
+
+ for (int i = 0; i < dependenciesA.Length; i++)
+ {
+ if (!string.Equals(dependenciesA[i], dependenciesB[i], StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
return false;
}
@@ -639,6 +1043,235 @@ private void RemoveExtractedDirectory(PackageCatalogItem package)
}
}
+ ///
+ /// Reads and deserializes the package specification from an archive entry.
+ ///
+ /// The spec archive entry.
+ /// The deserialized package spec.
+ private static PackageItemSpec ReadSpec(ZipArchiveEntry specEntry)
+ {
+ var serializer = new XmlSerializer(typeof(PackageItemSpec));
+ using var stream = specEntry.Open();
+ using var xmlReader = XmlReader.Create(stream, new XmlReaderSettings()
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null
+ });
+
+ return (PackageItemSpec)serializer.Deserialize(xmlReader);
+ }
+
+ ///
+ /// Creates a package catalog item from a package specification.
+ ///
+ /// The package file path.
+ /// The package specification.
+ /// The package catalog item.
+ private static PackageCatalogItem CreateCatalogItem(string file, PackageItemSpec spec)
+ {
+ if (spec is null)
+ {
+ return null;
+ }
+
+ return new PackageCatalogItem()
+ {
+ Id = spec.Id,
+ File = Path.GetFileName(file),
+ State = PackageCatalogeItemState.Available,
+ Metadata = new PackageItem()
+ {
+ FileName = Path.GetFileName(file),
+ Id = spec.Id,
+ Version = spec.Version,
+ Title = spec.Title,
+ Authors = spec.Authors,
+ License = spec.License,
+ Icon = spec.Icon,
+ Readme = spec.Readme,
+ Description = spec.Description,
+ Tags = spec.Tags,
+ PluginSources = spec.Plugins ?? [],
+ Dependencies = spec.Dependencies ?? []
+ }
+ };
+ }
+
+ ///
+ /// Copies an input stream to an output stream while optionally enforcing a maximum number of bytes.
+ ///
+ /// The source stream.
+ /// The target stream.
+ /// The maximum allowed bytes. 0 disables the size check.
+ private static void CopyStream(Stream source, Stream target, long maxBytes)
+ {
+ long totalBytes = 0;
+ var buffer = new byte[81920];
+ int read;
+
+ while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ totalBytes += read;
+ if (maxBytes > 0 && totalBytes > maxBytes)
+ {
+ throw new InvalidOperationException($"The uploaded package exceeds the allowed size ({maxBytes} bytes).");
+ }
+
+ target.Write(buffer, 0, read);
+ }
+ }
+
+ ///
+ /// Computes the SHA-256 hash for a file.
+ ///
+ /// The file path.
+ /// The SHA-256 hash as lowercase hex string.
+ private static string ComputeSha256(string file)
+ {
+ using var stream = File.OpenRead(file);
+ var hash = SHA256.HashData(stream);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ ///
+ /// Returns unfulfilled dependency expressions for a package.
+ ///
+ /// The package to evaluate.
+ /// The unfulfilled dependency list.
+ private IEnumerable GetUnfulfilledPackageDependencies(PackageCatalogItem package)
+ {
+ var dependencies = package?.Metadata?.Dependencies ?? [];
+ var missing = new List();
+
+ foreach (var dependency in dependencies.Where(x => !string.IsNullOrWhiteSpace(x)))
+ {
+ if (!TryParseDependency(dependency, out string id, out string op, out string requiredVersion))
+ {
+ missing.Add(dependency);
+ continue;
+ }
+
+ var dependencyPackage = Catalog.Packages
+ .FirstOrDefault(x => x is not null && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
+
+ if (dependencyPackage is null || dependencyPackage.State == PackageCatalogeItemState.Disable)
+ {
+ missing.Add(dependency);
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(op))
+ {
+ var currentVersion = dependencyPackage.Metadata?.Version;
+ if (!IsVersionConstraintSatisfied(currentVersion, op, requiredVersion))
+ {
+ missing.Add(dependency);
+ }
+ }
+ }
+
+ return missing;
+ }
+
+ ///
+ /// Parses a dependency expression.
+ ///
+ /// The dependency expression (e.g. "pkg>=1.0.0").
+ /// The dependency id.
+ /// The comparison operator.
+ /// The version constraint.
+ /// True if parsing was successful; otherwise false.
+ private static bool TryParseDependency(string expression, out string id, out string op, out string version)
+ {
+ id = null;
+ op = null;
+ version = null;
+
+ if (string.IsNullOrWhiteSpace(expression))
+ {
+ return false;
+ }
+
+ var value = expression.Trim();
+ var operators = new[] { ">=", "<=", "==", "=", ">", "<" };
+ var index = -1;
+ var selectedOperator = string.Empty;
+
+ foreach (var candidate in operators)
+ {
+ index = value.IndexOf(candidate, StringComparison.Ordinal);
+ if (index > 0)
+ {
+ selectedOperator = candidate;
+ break;
+ }
+ }
+
+ if (index < 0)
+ {
+ id = value;
+ return !string.IsNullOrWhiteSpace(id);
+ }
+
+ id = value[..index].Trim();
+ op = selectedOperator;
+ version = value[(index + selectedOperator.Length)..].Trim();
+
+ return !string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(op) && !string.IsNullOrWhiteSpace(version);
+ }
+
+ ///
+ /// Evaluates whether a version satisfies a version constraint.
+ ///
+ /// The current version.
+ /// The operator.
+ /// The required version.
+ /// True if the constraint is satisfied; otherwise false.
+ private static bool IsVersionConstraintSatisfied(string currentVersion, string op, string requiredVersion)
+ {
+ if (!TryParseVersion(currentVersion, out var current) || !TryParseVersion(requiredVersion, out var required))
+ {
+ return false;
+ }
+
+ var compare = current.CompareTo(required);
+
+ return op switch
+ {
+ ">" => compare > 0,
+ ">=" => compare >= 0,
+ "<" => compare < 0,
+ "<=" => compare <= 0,
+ "=" => compare == 0,
+ "==" => compare == 0,
+ _ => false
+ };
+ }
+
+ ///
+ /// Parses a version string with support for prerelease/build suffixes.
+ ///
+ /// The version string.
+ /// The parsed version.
+ /// True if parsing succeeded; otherwise false.
+ private static bool TryParseVersion(string value, out Version version)
+ {
+ version = null;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var normalized = value.Trim();
+ var suffixIndex = normalized.IndexOfAny(['-', '+']);
+ if (suffixIndex >= 0)
+ {
+ normalized = normalized[..suffixIndex];
+ }
+
+ return Version.TryParse(normalized, out version);
+ }
+
///
/// Release of unmanaged resources reserved during use.
///
@@ -646,4 +1279,4 @@ public void Dispose()
{
}
}
-}
\ No newline at end of file
+}
diff --git a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs
index db05863..37ead1e 100644
--- a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs
+++ b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs
@@ -1,7 +1,8 @@
-using System;
+using System;
using System.Collections.Generic;
using WebExpress.WebCore.WebApplication;
using WebExpress.WebCore.WebComponent;
+using WebExpress.WebCore.WebPlugin.Model;
namespace WebExpress.WebCore.WebPlugin
{
@@ -52,5 +53,11 @@ public interface IPluginManager : IComponentManager
/// The context of the plugin.
/// A collection of ApplicationContext instances.
IEnumerable GetAssociatedApplications(IPluginContext pluginContext);
+
+ ///
+ /// Gets runtime metadata for all known plugins, including dependency and status information.
+ ///
+ /// A list of plugin runtime metadata entries.
+ IEnumerable GetPluginRuntimeInfos();
}
}
diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs
new file mode 100644
index 0000000..c1a3bb2
--- /dev/null
+++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeInfo.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+
+namespace WebExpress.WebCore.WebPlugin.Model
+{
+ ///
+ /// Represents runtime metadata for a plugin.
+ ///
+ public sealed class PluginRuntimeInfo
+ {
+ ///
+ /// Gets or sets the plugin context.
+ ///
+ public IPluginContext PluginContext { get; set; }
+
+ ///
+ /// Gets or sets the plugin dependency ids.
+ ///
+ public IEnumerable Dependencies { get; set; } = [];
+
+ ///
+ /// Gets or sets the plugin runtime state.
+ ///
+ public PluginRuntimeState State { get; set; }
+ }
+}
diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs
new file mode 100644
index 0000000..9cd6592
--- /dev/null
+++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs
@@ -0,0 +1,18 @@
+namespace WebExpress.WebCore.WebPlugin.Model
+{
+ ///
+ /// Represents the runtime state of a plugin.
+ ///
+ public enum PluginRuntimeState
+ {
+ ///
+ /// The plugin is loaded and active.
+ ///
+ Active,
+
+ ///
+ /// The plugin is known but waiting for one or more dependencies.
+ ///
+ WaitingForDependencies
+ }
+}
diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs
index 3b7fe20..a8dc2e4 100644
--- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs
+++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs
@@ -521,6 +521,33 @@ public IEnumerable GetAssociatedApplications(IPluginContext
.Where(x => x is not null) ?? [];
}
+ ///
+ /// Gets runtime metadata for all known plugins, including dependency and status information.
+ ///
+ /// A list of plugin runtime metadata entries.
+ public IEnumerable GetPluginRuntimeInfos()
+ {
+ var active = _dictionary.Values
+ .Where(x => x?.PluginContext is not null)
+ .Select(x => new PluginRuntimeInfo()
+ {
+ PluginContext = x.PluginContext,
+ Dependencies = x.Dependencies ?? [],
+ State = PluginRuntimeState.Active
+ });
+
+ var waiting = _unfulfilledDependencies.Values
+ .Where(x => x?.PluginContext is not null)
+ .Select(x => new PluginRuntimeInfo()
+ {
+ PluginContext = x.PluginContext,
+ Dependencies = x.Dependencies ?? [],
+ State = PluginRuntimeState.WaitingForDependencies
+ });
+
+ return [.. active.Concat(waiting)];
+ }
+
///
/// Returns a plugin item based on the context.