From 891b9f7a997ce5e5892c1b0f166a921ff07abf68 Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Thu, 30 Mar 2023 08:59:21 -0600 Subject: [PATCH 1/7] Add DLL whitelist support for plugins --- .../Library/PathExtensions.cs | 99 ++++++++++--- .../Plugins/PluginManager.cs | 83 ++++++++++- MediaBrowser.Common/Plugins/PluginManifest.cs | 9 ++ MediaBrowser.Model/Updates/VersionInfo.cs | 8 ++ .../Library/PathExtensionsTests.cs | 43 ++++++ .../Plugins/PluginManagerTests.cs | 135 ++++++++++++++++++ .../Test Data/Updates/manifest-stable.json | 10 +- .../Updates/InstallationManagerTests.cs | 14 ++ 8 files changed, 375 insertions(+), 26 deletions(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 64e7d54466..7e0d0b78d5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,6 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using MediaBrowser.Common.Providers; +using Nikse.SubtitleEdit.Core.Common; namespace Emby.Server.Implementations.Library { @@ -86,24 +89,8 @@ namespace Emby.Server.Implementations.Library return false; } - char oldDirectorySeparatorChar; - char newDirectorySeparatorChar; - // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 - // The reasoning behind this is that a forward slash likely means it's a Linux path and - // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). - if (newSubPath.Contains('/', StringComparison.Ordinal)) - { - oldDirectorySeparatorChar = '\\'; - newDirectorySeparatorChar = '/'; - } - else - { - oldDirectorySeparatorChar = '/'; - newDirectorySeparatorChar = '\\'; - } - - path = path.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); - subPath = subPath.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); + subPath = subPath.NormalizePath(out var newDirectorySeparatorChar)!; + path = path.NormalizePath(newDirectorySeparatorChar)!; // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results // when the sub path matches a similar but in-complete subpath @@ -120,12 +107,86 @@ namespace Emby.Server.Implementations.Library return false; } - var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd(newDirectorySeparatorChar); + var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd((char)newDirectorySeparatorChar!); // Ensure that the path with the old subpath removed starts with a leading dir separator int idx = oldSubPathEndsWithSeparator ? subPath.Length - 1 : subPath.Length; newPath = string.Concat(newSubPathTrimmed, path.AsSpan(idx)); return true; } + + /// + /// Retrieves the full resolved path and normalizes path separators to the . + /// + /// The path to canonicalize. + /// The fully expanded, normalized path. + public static string Canonicalize(this string path) + { + return Path.GetFullPath(path).NormalizePath()!; + } + + /// + /// Normalizes the path's directory separator character to the currently defined . + /// + /// The path to normalize. + /// The normalized path string or if the input path is null or empty. + public static string? NormalizePath(this string? path) + { + return path.NormalizePath(Path.DirectorySeparatorChar); + } + + /// + /// Normalizes the path's directory separator character. + /// + /// The path to normalize. + /// The separator character the path now uses or . + /// The normalized path string or if the input path is null or empty. + public static string? NormalizePath(this string? path, out char separator) + { + if (string.IsNullOrEmpty(path)) + { + separator = default; + return path; + } + + var newSeparator = '\\'; + + // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 + // The reasoning behind this is that a forward slash likely means it's a Linux path and + // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). + if (path.Contains('/', StringComparison.Ordinal)) + { + newSeparator = '/'; + } + + separator = newSeparator; + + return path?.NormalizePath(newSeparator); + } + + /// + /// Normalizes the path's directory separator character to the specified character. + /// + /// The path to normalize. + /// The replacement directory separator character. Must be a valid directory separator. + /// The normalized path. + /// Thrown if the new separator character is not a directory separator. + public static string? NormalizePath(this string? path, char newSeparator) + { + const char Bs = '\\'; + const char Fs = '/'; + + if (!(newSeparator == Bs || newSeparator == Fs)) + { + throw new ArgumentException("The character must be a directory separator."); + } + + if (string.IsNullOrEmpty(path)) + { + return path; + } + + return newSeparator == Bs ? path?.Replace(Fs, newSeparator) : path?.Replace(Bs, newSeparator); + } } } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 7c23254a12..a5c55c8a09 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -9,6 +10,8 @@ using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Emby.Server.Implementations.Library; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; @@ -19,8 +22,11 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; +using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.Common; +using SQLitePCL.pretty; namespace Emby.Server.Implementations.Plugins { @@ -44,7 +50,7 @@ namespace Emby.Server.Implementations.Plugins /// /// Initializes a new instance of the class. /// - /// The . + /// The . /// The . /// The . /// The plugin path. @@ -424,7 +430,8 @@ namespace Emby.Server.Implementations.Plugins Version = versionInfo.Version, Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. AutoUpdate = true, - ImagePath = imagePath + ImagePath = imagePath, + Assemblies = versionInfo.Assemblies }; return SaveManifest(manifest, path); @@ -688,7 +695,15 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { - entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); + if (!TryGetPluginDlls(entry, out var allowedDlls)) + { + _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); + ChangePluginState(entry, PluginStatus.Malfunctioned); + continue; + } + + entry.DllFiles = allowedDlls; + if (entry.IsEnabledAndSupported) { lastName = entry.Name; @@ -734,6 +749,68 @@ namespace Emby.Server.Implementations.Plugins return versions.Where(p => p.DllFiles.Count != 0); } + /// + /// Attempts to retrieve valid DLLs from the plugin path. This method will consider the assembly whitelist + /// from the manifest. + /// + /// + /// Loading DLLs from externally supplied paths introduces a path traversal risk. This method + /// uses a safelisting tactic of considering DLLs from the plugin directory and only using + /// the plugin's canonicalized assembly whitelist for comparison. See + /// for more details. + /// + /// The plugin. + /// The whitelisted DLLs. If the method returns , this will be empty. + /// + /// if all assemblies listed in the manifest were available in the plugin directory. + /// if any assemblies were invalid or missing from the plugin directory. + /// + /// If the is null. + private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList whitelistedDlls) + { + _ = plugin ?? throw new ArgumentNullException(nameof(plugin)); + + IReadOnlyList pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); + + whitelistedDlls = Array.Empty(); + if (pluginDlls.Count > 0 && plugin.Manifest.Assemblies.Count > 0) + { + _logger.LogInformation("Registering whitelisted assemblies for plugin \"{Plugin}\"...", plugin.Name); + + var canonicalizedPaths = new List(); + foreach (var path in plugin.Manifest.Assemblies) + { + var canonicalized = Path.Combine(plugin.Path, path).Canonicalize(); + + // Ensure we stay in the plugin directory. + if (!canonicalized.StartsWith(plugin.Path.NormalizePath()!, StringComparison.Ordinal)) + { + _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path); + return false; + } + + canonicalizedPaths.Add(canonicalized); + } + + var intersected = pluginDlls.Intersect(canonicalizedPaths).ToList(); + + if (intersected.Count != canonicalizedPaths.Count) + { + _logger.LogError("Plugin {Plugin} contained assembly paths that were not found in the directory.", plugin.Name); + return false; + } + + whitelistedDlls = intersected; + } + else + { + // No whitelist, default to loading all DLLs in plugin directory. + whitelistedDlls = pluginDlls; + } + + return true; + } + /// /// Changes the status of the other versions of the plugin to "Superceded". /// diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index 2910dbe144..2bad3454d1 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using MediaBrowser.Model.Plugins; @@ -23,6 +24,7 @@ namespace MediaBrowser.Common.Plugins Overview = string.Empty; TargetAbi = string.Empty; Version = string.Empty; + Assemblies = new List(); } /// @@ -104,5 +106,12 @@ namespace MediaBrowser.Common.Plugins /// [JsonPropertyName("imagePath")] public string? ImagePath { get; set; } + + /// + /// Gets or sets the collection of assemblies that should be loaded. + /// Paths are considered relative to the plugin folder. + /// + [JsonPropertyName("assemblies")] + public IList Assemblies { get; set; } } } diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index 320199f984..1e24bde84e 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/MediaBrowser.Model/Updates/VersionInfo.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using SysVersion = System.Version; @@ -73,5 +75,11 @@ namespace MediaBrowser.Model.Updates /// [JsonPropertyName("repositoryUrl")] public string RepositoryUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the assemblies whitelist for this version. + /// + [JsonPropertyName("assemblies")] + public IList Assemblies { get; set; } = Array.Empty(); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index be2dfe0a86..c33a957e69 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Emby.Server.Implementations.Library; using Xunit; @@ -73,5 +74,47 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.False(PathExtensions.TryReplaceSubPath(path, subPath, newSubPath, out var result)); Assert.Null(result); } + + [Theory] + [InlineData(null, '/', null)] + [InlineData(null, '\\', null)] + [InlineData("/home/jeff/myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] + [InlineData("C:\\Users\\Jeff\\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv", '/', "/home/jeff/myfile.mkv")] + [InlineData("", '/', "")] + public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath) + { + Assert.Equal(expectedPath, path.NormalizePath(separator)); + } + + [Theory] + [InlineData("/home/jeff/myfile.mkv")] + [InlineData("C:\\Users\\Jeff\\myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv")] + public void NormalizePath_NoArgs_UsesDirectorySeparatorChar(string path) + { + var separator = Path.DirectorySeparatorChar; + + Assert.Equal(path.Replace('\\', separator).Replace('/', separator), path.NormalizePath()); + } + + [Theory] + [InlineData("/home/jeff/myfile.mkv", '/')] + [InlineData("C:\\Users\\Jeff\\myfile.mkv", '\\')] + [InlineData("\\home/jeff\\myfile.mkv", '/')] + public void NormalizePath_OutVar_Correct(string path, char expectedSeparator) + { + var result = path.NormalizePath(out var separator); + + Assert.Equal(expectedSeparator, separator); + Assert.Equal(path.Replace('\\', separator).Replace('/', separator), result); + } + + [Fact] + public void NormalizePath_SpecifyInvalidSeparator_ThrowsException() + { + Assert.Throws(() => string.Empty.NormalizePath('a')); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index bc6a447410..d9fdc96f55 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -1,7 +1,12 @@ using System; using System.IO; +using System.Text.Json; +using Emby.Server.Implementations.Library; using Emby.Server.Implementations.Plugins; +using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -40,6 +45,136 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assert.Equal(manifest.Status, res.Manifest.Status); Assert.Equal(manifest.AutoUpdate, res.Manifest.AutoUpdate); Assert.Equal(manifest.ImagePath, res.Manifest.ImagePath); + Assert.Equal(manifest.Assemblies, res.Manifest.Assemblies); + } + + /// + /// Tests safe traversal within the plugin directory. + /// + /// The safe path to evaluate. + [Theory] + [InlineData("./some.dll")] + [InlineData("some.dll")] + [InlineData("sub/path/some.dll")] + public void Constructor_DiscoversSafePluginAssembly_Status_Active(string dllFile) + { + var manifest = new PluginManifest + { + Id = Guid.NewGuid(), + Name = "Safe Assembly", + Assemblies = new string[] { dllFile } + }; + + var filename = Path.GetFileName(dllFile)!; + var (tempPath, pluginPath) = GetTestPaths("safe"); + + Directory.CreateDirectory(Path.Combine(pluginPath, dllFile.Replace(filename, string.Empty, StringComparison.OrdinalIgnoreCase))); + File.Create(Path.Combine(pluginPath, dllFile)); + + var options = GetTestSerializerOptions(); + var data = JsonSerializer.Serialize(manifest, options); + var metafilePath = Path.Combine(tempPath, "safe", "meta.json"); + + File.WriteAllText(metafilePath, data); + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, tempPath, new Version(1, 0)); + + var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), options); + + var expectedFullPath = Path.Combine(pluginPath, dllFile).Canonicalize(); + + Assert.NotNull(res); + Assert.NotEmpty(pluginManager.Plugins); + Assert.Equal(PluginStatus.Active, res!.Status); + Assert.Equal(expectedFullPath, pluginManager.Plugins[0].DllFiles[0]); + Assert.StartsWith(Path.Combine(tempPath, "safe"), expectedFullPath, StringComparison.InvariantCulture); + } + + /// + /// Tests unsafe attempts to traverse to higher directories. + /// + /// + /// Attempts to load directories outside of the plugin should be + /// constrained. Path traversal, shell expansion, and double encoding + /// can be used to load unintended files. + /// See for more. + /// + /// The unsafe path to evaluate. + [Theory] + [InlineData("/some.dll")] // Root path. + [InlineData("../some.dll")] // Simple traversal. + [InlineData("C:\\some.dll")] // Windows root path. + [InlineData("test.txt")] // Not a DLL + [InlineData(".././.././../some.dll")] // Traversal with current and parent + [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent + [InlineData("\\\\network\\resource.dll")] // UNC Path + [InlineData("https://jellyfin.org/some.dll")] // URL + [InlineData("....//....//some.dll")] // Path replacement risk if a single "../" replacement occurs. + [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character. + public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath) + { + var manifest = new PluginManifest + { + Id = Guid.NewGuid(), + Name = "Unsafe Assembly", + Assemblies = new string[] { unsafePath } + }; + + var (tempPath, pluginPath) = GetTestPaths("unsafe"); + + Directory.CreateDirectory(pluginPath); + + var files = new string[] + { + "../other.dll", + "some.dll" + }; + + foreach (var file in files) + { + File.Create(Path.Combine(pluginPath, file)); + } + + var options = GetTestSerializerOptions(); + var data = JsonSerializer.Serialize(manifest, options); + var metafilePath = Path.Combine(tempPath, "unsafe", "meta.json"); + + File.WriteAllText(metafilePath, data); + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, tempPath, new Version(1, 0)); + + var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), options); + + Assert.NotNull(res); + Assert.Empty(pluginManager.Plugins); + Assert.Equal(PluginStatus.Malfunctioned, res!.Status); + } + + private JsonSerializerOptions GetTestSerializerOptions() + { + var options = new JsonSerializerOptions(JsonDefaults.Options) + { + WriteIndented = true + }; + + for (var i = 0; i < options.Converters.Count; i++) + { + // Remove the Guid converter for parity with plugin manager. + if (options.Converters[i] is JsonGuidConverter converter) + { + options.Converters.Remove(converter); + } + } + + return options; + } + + private (string TempPath, string PluginPath) GetTestPaths(string pluginFolderName) + { + var tempPath = Path.Combine(_testPathRoot, "plugins-" + Path.GetRandomFileName()); + var pluginPath = Path.Combine(tempPath, pluginFolderName); + + return (tempPath, pluginPath); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json index fa8fbd8d2c..d69a52d6d0 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -13,7 +13,8 @@ "targetAbi": "10.6.0.0", "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_10.0.0.0.zip", "checksum": "93e969adeba1050423fc8817ed3c36f8", - "timestamp": "2020-08-17T01:41:13Z" + "timestamp": "2020-08-17T01:41:13Z", + "assemblies": [ "Jellyfin.Plugin.Anime.dll" ] }, { "version": "9.0.0.0", @@ -21,9 +22,10 @@ "targetAbi": "10.6.0.0", "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_9.0.0.0.zip", "checksum": "9b1cebff835813e15f414f44b40c41c8", - "timestamp": "2020-07-20T01:30:16Z" + "timestamp": "2020-07-20T01:30:16Z", + "assemblies": [ "Jellyfin.Plugin.Anime.dll" ] } - ] + ] }, { "guid": "70b7b43b-471b-4159-b4be-56750c795499", @@ -681,4 +683,4 @@ } ] } -] \ No newline at end of file +] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 7abd2e685f..70d03f8c4a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -106,5 +106,19 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); Assert.Null(ex); } + + [Fact] + public async Task InstallPackage_WithAssemblies_Success() + { + PackageInfo[] packages = await _installationManager.GetPackages( + "Jellyfin Stable", + "https://repo.jellyfin.org/releases/plugin/manifest-stable.json", + false); + + packages = _installationManager.FilterPackages(packages, "Anime").ToArray(); + Assert.Single(packages); + Assert.NotEmpty(packages[0].Versions[0].Assemblies); + Assert.Equal("Jellyfin.Plugin.Anime.dll", packages[0].Versions[0].Assemblies[0]); + } } } From 677b1f8e34799d26ffa9ef792aabcc79ad9073ca Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Thu, 30 Mar 2023 12:56:57 -0600 Subject: [PATCH 2/7] Remove unnecessary using statements in PluginManager --- Emby.Server.Implementations/Library/PathExtensions.cs | 2 -- Emby.Server.Implementations/Plugins/PluginManager.cs | 3 --- 2 files changed, 5 deletions(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 7e0d0b78d5..a3d748a138 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,9 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using MediaBrowser.Common.Providers; -using Nikse.SubtitleEdit.Core.Common; namespace Emby.Server.Implementations.Library { diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index a5c55c8a09..d253a0ab99 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -22,11 +22,8 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Nikse.SubtitleEdit.Core.Common; -using SQLitePCL.pretty; namespace Emby.Server.Implementations.Plugins { From a944352aa8b961ab723fed2d66947c19129a11cc Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Sat, 1 Apr 2023 04:59:07 -0600 Subject: [PATCH 3/7] Correct style inconsistencies --- Emby.Server.Implementations/Library/PathExtensions.cs | 9 ++++++--- Emby.Server.Implementations/Plugins/PluginManager.cs | 2 +- MediaBrowser.Common/Plugins/PluginManifest.cs | 4 ++-- MediaBrowser.Model/Updates/VersionInfo.cs | 2 +- .../Test Data/Updates/manifest-stable.json | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index a3d748a138..62a9e6419e 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -87,8 +87,8 @@ namespace Emby.Server.Implementations.Library return false; } - subPath = subPath.NormalizePath(out var newDirectorySeparatorChar)!; - path = path.NormalizePath(newDirectorySeparatorChar)!; + subPath = subPath.NormalizePath(out var newDirectorySeparatorChar); + path = path.NormalizePath(newDirectorySeparatorChar); // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results // when the sub path matches a similar but in-complete subpath @@ -128,6 +128,7 @@ namespace Emby.Server.Implementations.Library /// /// The path to normalize. /// The normalized path string or if the input path is null or empty. + [return: NotNullIfNotNull(nameof(path))] public static string? NormalizePath(this string? path) { return path.NormalizePath(Path.DirectorySeparatorChar); @@ -139,6 +140,7 @@ namespace Emby.Server.Implementations.Library /// The path to normalize. /// The separator character the path now uses or . /// The normalized path string or if the input path is null or empty. + [return: NotNullIfNotNull(nameof(path))] public static string? NormalizePath(this string? path, out char separator) { if (string.IsNullOrEmpty(path)) @@ -169,6 +171,7 @@ namespace Emby.Server.Implementations.Library /// The replacement directory separator character. Must be a valid directory separator. /// The normalized path. /// Thrown if the new separator character is not a directory separator. + [return: NotNullIfNotNull(nameof(path))] public static string? NormalizePath(this string? path, char newSeparator) { const char Bs = '\\'; @@ -184,7 +187,7 @@ namespace Emby.Server.Implementations.Library return path; } - return newSeparator == Bs ? path?.Replace(Fs, newSeparator) : path?.Replace(Bs, newSeparator); + return newSeparator == Bs ? path.Replace(Fs, newSeparator) : path.Replace(Bs, newSeparator); } } } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index d253a0ab99..0a7c144ed7 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -765,7 +765,7 @@ namespace Emby.Server.Implementations.Plugins /// If the is null. private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList whitelistedDlls) { - _ = plugin ?? throw new ArgumentNullException(nameof(plugin)); + ArgumentNullException.ThrowIfNull(nameof(plugin)); IReadOnlyList pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index 2bad3454d1..e0847ccea4 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.Common.Plugins Overview = string.Empty; TargetAbi = string.Empty; Version = string.Empty; - Assemblies = new List(); + Assemblies = Array.Empty(); } /// @@ -112,6 +112,6 @@ namespace MediaBrowser.Common.Plugins /// Paths are considered relative to the plugin folder. /// [JsonPropertyName("assemblies")] - public IList Assemblies { get; set; } + public IReadOnlyList Assemblies { get; set; } } } diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index 1e24bde84e..8f76806450 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/MediaBrowser.Model/Updates/VersionInfo.cs @@ -80,6 +80,6 @@ namespace MediaBrowser.Model.Updates /// Gets or sets the assemblies whitelist for this version. /// [JsonPropertyName("assemblies")] - public IList Assemblies { get; set; } = Array.Empty(); + public IReadOnlyList Assemblies { get; set; } = Array.Empty(); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json index d69a52d6d0..3aec299587 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -25,7 +25,7 @@ "timestamp": "2020-07-20T01:30:16Z", "assemblies": [ "Jellyfin.Plugin.Anime.dll" ] } - ] + ] }, { "guid": "70b7b43b-471b-4159-b4be-56750c795499", From 3a731051adf6d636517e5a9babbbe9f9da7d520b Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Sat, 1 Apr 2023 05:03:55 -0600 Subject: [PATCH 4/7] Correct styling inconsistencies --- Emby.Server.Implementations/Library/PathExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 62a9e6419e..c4b6b37561 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -105,7 +105,7 @@ namespace Emby.Server.Implementations.Library return false; } - var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd((char)newDirectorySeparatorChar!); + var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd(newDirectorySeparatorChar); // Ensure that the path with the old subpath removed starts with a leading dir separator int idx = oldSubPathEndsWithSeparator ? subPath.Length - 1 : subPath.Length; newPath = string.Concat(newSubPathTrimmed, path.AsSpan(idx)); @@ -120,7 +120,7 @@ namespace Emby.Server.Implementations.Library /// The fully expanded, normalized path. public static string Canonicalize(this string path) { - return Path.GetFullPath(path).NormalizePath()!; + return Path.GetFullPath(path).NormalizePath(); } /// @@ -161,7 +161,7 @@ namespace Emby.Server.Implementations.Library separator = newSeparator; - return path?.NormalizePath(newSeparator); + return path.NormalizePath(newSeparator); } /// From 7dd4201971f1bb19ea380f2ca83aed11206cfe97 Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Sun, 9 Apr 2023 10:53:09 -0600 Subject: [PATCH 5/7] Reconcile pre-packaged meta.json against manifest on install --- .../Plugins/PluginManager.cs | 74 ++++++- .../Updates/InstallationManager.cs | 7 +- MediaBrowser.Common/Plugins/IPluginManager.cs | 2 +- MediaBrowser.Model/Updates/VersionInfo.cs | 6 - src/Jellyfin.Extensions/TypeExtensions.cs | 45 +++++ .../TypeExtensionsTests.cs | 68 +++++++ .../Plugins/PluginManagerTests.cs | 181 +++++++++++++++--- .../Test Data/Updates/manifest-stable.json | 6 +- .../Updates/InstallationManagerTests.cs | 14 -- 9 files changed, 341 insertions(+), 62 deletions(-) create mode 100644 src/Jellyfin.Extensions/TypeExtensions.cs create mode 100644 tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 0a7c144ed7..c6a7f45464 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -32,6 +32,8 @@ namespace Emby.Server.Implementations.Plugins /// public class PluginManager : IPluginManager { + private const string MetafileName = "meta.json"; + private readonly string _pluginsPath; private readonly Version _appVersion; private readonly List _assemblyLoadContexts; @@ -374,7 +376,7 @@ namespace Emby.Server.Implementations.Plugins try { var data = JsonSerializer.Serialize(manifest, _jsonOptions); - File.WriteAllText(Path.Combine(path, "meta.json"), data); + File.WriteAllText(Path.Combine(path, MetafileName), data); return true; } catch (ArgumentException e) @@ -385,7 +387,7 @@ namespace Emby.Server.Implementations.Plugins } /// - public async Task GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) + public async Task PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) { var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); var imagePath = string.Empty; @@ -427,13 +429,75 @@ namespace Emby.Server.Implementations.Plugins Version = versionInfo.Version, Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. AutoUpdate = true, - ImagePath = imagePath, - Assemblies = versionInfo.Assemblies + ImagePath = imagePath }; + var metafile = Path.Combine(Path.Combine(path, MetafileName)); + if (File.Exists(metafile)) + { + var data = File.ReadAllBytes(metafile); + var localManifest = JsonSerializer.Deserialize(data, _jsonOptions) ?? new PluginManifest(); + + // Plugin installation is the typical cause for populating a manifest. Activate. + localManifest.Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active; + + if (!Equals(localManifest.Id, manifest.Id)) + { + _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id); + localManifest.Status = PluginStatus.Malfunctioned; + } + + if (localManifest.Version != manifest.Version) + { + _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version); + + // Correct the local version. + localManifest.Version = manifest.Version; + } + + // Reconcile missing data against repository manifest. + ReconcileManifest(localManifest, manifest); + + manifest = localManifest; + } + else + { + _logger.LogInformation("No local manifest exists for plugin {Plugin}. Populating from repository manifest.", manifest.Name); + } + return SaveManifest(manifest, path); } + /// + /// Resolve the target plugin manifest against the source. Values are mapped onto the + /// target only if they are default values or empty strings. ID and status fields are ignored. + /// + /// The base to be reconciled. + /// The to reconcile against. + private void ReconcileManifest(PluginManifest baseManifest, PluginManifest projector) + { + var ignoredFields = new string[] + { + nameof(baseManifest.Id), + nameof(baseManifest.Status) + }; + + foreach (var property in baseManifest.GetType().GetProperties()) + { + var localValue = property.GetValue(baseManifest); + + if (property.PropertyType == typeof(bool) || ignoredFields.Any(s => Equals(s, property.Name))) + { + continue; + } + + if (property.PropertyType.IsNullOrDefault(localValue) || (property.PropertyType == typeof(string) && (string)localValue! == string.Empty)) + { + property.SetValue(baseManifest, property.GetValue(projector)); + } + } + } + /// /// Changes a plugin's load status. /// @@ -598,7 +662,7 @@ namespace Emby.Server.Implementations.Plugins { Version? version; PluginManifest? manifest = null; - var metafile = Path.Combine(dir, "meta.json"); + var metafile = Path.Combine(dir, MetafileName); if (File.Exists(metafile)) { // Only path where this stays null is when File.ReadAllBytes throws an IOException diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5e897833e0..6c198b6f99 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -183,7 +183,7 @@ namespace Emby.Server.Implementations.Updates var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber); if (plugin is not null) { - await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); + await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); } // Remove versions with a target ABI greater then the current application version. @@ -555,7 +555,10 @@ namespace Emby.Server.Implementations.Updates stream.Position = 0; using var reader = new ZipArchive(stream); reader.ExtractToDirectory(targetDir, true); - await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + + // Ensure we create one or populate existing ones with missing data. + await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status); + _pluginManager.ImportPluginFrom(targetDir); } diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index fa92d383a2..1d73de3c95 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -57,7 +57,7 @@ namespace MediaBrowser.Common.Plugins /// The path where to save the manifest. /// Initial status of the plugin. /// True if successful. - Task GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status); + Task PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status); /// /// Imports plugin details from a folder. diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index 8f76806450..53e1d29b01 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/MediaBrowser.Model/Updates/VersionInfo.cs @@ -75,11 +75,5 @@ namespace MediaBrowser.Model.Updates /// [JsonPropertyName("repositoryUrl")] public string RepositoryUrl { get; set; } = string.Empty; - - /// - /// Gets or sets the assemblies whitelist for this version. - /// - [JsonPropertyName("assemblies")] - public IReadOnlyList Assemblies { get; set; } = Array.Empty(); } } diff --git a/src/Jellyfin.Extensions/TypeExtensions.cs b/src/Jellyfin.Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..5b1111d594 --- /dev/null +++ b/src/Jellyfin.Extensions/TypeExtensions.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; + +namespace Jellyfin.Extensions; + +/// +/// Provides extensions methods for . +/// +public static class TypeExtensions +{ + /// + /// Checks if the supplied value is the default or null value for that type. + /// + /// The type of the value to compare. + /// The type. + /// The value to check. + /// if the value is the default for the type. Otherwise, . + public static bool IsNullOrDefault(this Type type, T value) + { + if (value is null) + { + return true; + } + + object? tmp = value; + object? defaultValue = type.IsValueType ? Activator.CreateInstance(type) : null; + if (type.IsAssignableTo(typeof(IConvertible))) + { + tmp = Convert.ChangeType(value, type, CultureInfo.InvariantCulture); + } + + return Equals(tmp, defaultValue); + } + + /// + /// Checks if the object is currently a default or null value. Boxed types will be unboxed prior to comparison. + /// + /// The object to check. + /// if the value is the default for the type. Otherwise, . + public static bool IsNullOrDefault(this object? obj) + { + // Unbox the type and check. + return obj?.GetType().IsNullOrDefault(obj) ?? true; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs new file mode 100644 index 0000000000..747913fa1a --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs @@ -0,0 +1,68 @@ +using System; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public class TypeExtensionsTests + { + [Theory] + [InlineData(typeof(byte), byte.MaxValue, false)] + [InlineData(typeof(short), short.MinValue, false)] + [InlineData(typeof(ushort), ushort.MaxValue, false)] + [InlineData(typeof(int), int.MinValue, false)] + [InlineData(typeof(uint), uint.MaxValue, false)] + [InlineData(typeof(long), long.MinValue, false)] + [InlineData(typeof(ulong), ulong.MaxValue, false)] + [InlineData(typeof(decimal), -1.0, false)] + [InlineData(typeof(bool), true, false)] + [InlineData(typeof(char), 'a', false)] + [InlineData(typeof(string), "", false)] + [InlineData(typeof(object), 1, false)] + [InlineData(typeof(byte), 0, true)] + [InlineData(typeof(short), 0, true)] + [InlineData(typeof(ushort), 0, true)] + [InlineData(typeof(int), 0, true)] + [InlineData(typeof(uint), 0, true)] + [InlineData(typeof(long), 0, true)] + [InlineData(typeof(ulong), 0, true)] + [InlineData(typeof(decimal), 0, true)] + [InlineData(typeof(bool), false, true)] + [InlineData(typeof(char), '\x0000', true)] + [InlineData(typeof(string), null, true)] + [InlineData(typeof(object), null, true)] + [InlineData(typeof(PhonyClass), null, true)] + [InlineData(typeof(DateTime), null, true)] // Special case handled within the test. + [InlineData(typeof(DateTime), null, false)] // Special case handled within the test. + [InlineData(typeof(byte?), null, true)] + [InlineData(typeof(short?), null, true)] + [InlineData(typeof(ushort?), null, true)] + [InlineData(typeof(int?), null, true)] + [InlineData(typeof(uint?), null, true)] + [InlineData(typeof(long?), null, true)] + [InlineData(typeof(ulong?), null, true)] + [InlineData(typeof(decimal?), null, true)] + [InlineData(typeof(bool?), null, true)] + [InlineData(typeof(char?), null, true)] + public void IsNullOrDefault_Matches_Expected(Type type, object? value, bool expectedResult) + { + if (type == typeof(DateTime)) + { + if (expectedResult) + { + value = default(DateTime); + } + else + { + value = DateTime.Now; + } + } + + Assert.Equal(expectedResult, type.IsNullOrDefault(value)); + Assert.Equal(expectedResult, value.IsNullOrDefault()); + } + + private class PhonyClass + { + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index d9fdc96f55..204d144214 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -1,12 +1,16 @@ using System; +using System.Globalization; using System.IO; using System.Text.Json; +using System.Threading.Tasks; +using AutoFixture; using Emby.Server.Implementations.Library; using Emby.Server.Implementations.Plugins; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -16,6 +20,21 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins { private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); + private string _tempPath = string.Empty; + + private string _pluginPath = string.Empty; + + private JsonSerializerOptions _options; + + public PluginManagerTests() + { + (_tempPath, _pluginPath) = GetTestPaths("plugin-" + Path.GetRandomFileName()); + + Directory.CreateDirectory(_pluginPath); + + _options = GetTestSerializerOptions(); + } + [Fact] public void SaveManifest_RoundTrip_Success() { @@ -25,12 +44,9 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Version = "1.0" }; - var tempPath = Path.Combine(_testPathRoot, "manifest-" + Path.GetRandomFileName()); - Directory.CreateDirectory(tempPath); + Assert.True(pluginManager.SaveManifest(manifest, _pluginPath)); - Assert.True(pluginManager.SaveManifest(manifest, tempPath)); - - var res = pluginManager.LoadManifest(tempPath); + var res = pluginManager.LoadManifest(_pluginPath); Assert.Equal(manifest.Category, res.Manifest.Category); Assert.Equal(manifest.Changelog, res.Manifest.Changelog); @@ -66,28 +82,25 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var filename = Path.GetFileName(dllFile)!; - var (tempPath, pluginPath) = GetTestPaths("safe"); + var dllPath = Path.GetDirectoryName(Path.Combine(_pluginPath, dllFile))!; - Directory.CreateDirectory(Path.Combine(pluginPath, dllFile.Replace(filename, string.Empty, StringComparison.OrdinalIgnoreCase))); - File.Create(Path.Combine(pluginPath, dllFile)); + Directory.CreateDirectory(dllPath); + File.Create(Path.Combine(dllPath, filename)); + var metafilePath = Path.Combine(_pluginPath, "meta.json"); - var options = GetTestSerializerOptions(); - var data = JsonSerializer.Serialize(manifest, options); - var metafilePath = Path.Combine(tempPath, "safe", "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options)); - File.WriteAllText(metafilePath, data); + var pluginManager = new PluginManager(new NullLogger(), null!, null!, _tempPath, new Version(1, 0)); - var pluginManager = new PluginManager(new NullLogger(), null!, null!, tempPath, new Version(1, 0)); + var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), _options); - var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), options); - - var expectedFullPath = Path.Combine(pluginPath, dllFile).Canonicalize(); + var expectedFullPath = Path.Combine(_pluginPath, dllFile).Canonicalize(); Assert.NotNull(res); Assert.NotEmpty(pluginManager.Plugins); Assert.Equal(PluginStatus.Active, res!.Status); Assert.Equal(expectedFullPath, pluginManager.Plugins[0].DllFiles[0]); - Assert.StartsWith(Path.Combine(tempPath, "safe"), expectedFullPath, StringComparison.InvariantCulture); + Assert.StartsWith(_pluginPath, expectedFullPath, StringComparison.InvariantCulture); } /// @@ -109,7 +122,6 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent [InlineData("\\\\network\\resource.dll")] // UNC Path [InlineData("https://jellyfin.org/some.dll")] // URL - [InlineData("....//....//some.dll")] // Path replacement risk if a single "../" replacement occurs. [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character. public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath) { @@ -120,10 +132,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assemblies = new string[] { unsafePath } }; - var (tempPath, pluginPath) = GetTestPaths("unsafe"); - - Directory.CreateDirectory(pluginPath); - + // Only create very specific files. Otherwise the test will be exploiting path traversal. var files = new string[] { "../other.dll", @@ -132,24 +141,136 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins foreach (var file in files) { - File.Create(Path.Combine(pluginPath, file)); + File.Create(Path.Combine(_pluginPath, file)); } - var options = GetTestSerializerOptions(); - var data = JsonSerializer.Serialize(manifest, options); - var metafilePath = Path.Combine(tempPath, "unsafe", "meta.json"); + var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, data); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options)); - var pluginManager = new PluginManager(new NullLogger(), null!, null!, tempPath, new Version(1, 0)); + var pluginManager = new PluginManager(new NullLogger(), null!, null!, _tempPath, new Version(1, 0)); - var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), options); + var res = JsonSerializer.Deserialize(File.ReadAllText(metafilePath), _options); Assert.NotNull(res); Assert.Empty(pluginManager.Plugins); Assert.Equal(PluginStatus.Malfunctioned, res!.Status); } + [Fact] + public async Task PopulateManifest_ExistingMetafilePlugin_PopulatesMissingFields() + { + var packageInfo = GenerateTestPackage(); + + // Partial plugin without a name, but matching version and package ID + var partial = new PluginManifest + { + Id = packageInfo.Id, + AutoUpdate = false, // Turn off AutoUpdate + Status = PluginStatus.Restart, + Version = new Version(1, 0, 0).ToString(), + Assemblies = new[] { "Jellyfin.Test.dll" } + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equal(packageInfo.Name, result.Name); + Assert.Equal(packageInfo.Owner, result.Owner); + Assert.Equal(PluginStatus.Active, result.Status); + Assert.NotEmpty(result.Assemblies); + Assert.False(result.AutoUpdate); + + // Preserved + Assert.Equal(packageInfo.Category, result.Category); + Assert.Equal(packageInfo.Description, result.Description); + Assert.Equal(packageInfo.Id, result.Id); + Assert.Equal(packageInfo.Overview, result.Overview); + Assert.Equal(partial.Assemblies[0], result.Assemblies[0]); + Assert.Equal(packageInfo.Versions[0].TargetAbi, result.TargetAbi); + Assert.Equal(DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), result.Timestamp); + Assert.Equal(packageInfo.Versions[0].Changelog, result.Changelog); + Assert.Equal(packageInfo.Versions[0].Version, result.Version); + } + + [Fact] + public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned() + { + var packageInfo = GenerateTestPackage(); + + // Partial plugin without a name, but matching version and package ID + var partial = new PluginManifest + { + Id = Guid.NewGuid(), + Version = new Version(1, 0, 0).ToString() + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equal(packageInfo.Name, result.Name); + Assert.Equal(PluginStatus.Malfunctioned, result.Status); + } + + [Fact] + public async Task PopulateManifest_ExistingMetafileMismatchedVersions_Updates_Version() + { + var packageInfo = GenerateTestPackage(); + + var partial = new PluginManifest + { + Id = packageInfo.Id, + Version = new Version(2, 0, 0).ToString() + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equal(packageInfo.Name, result.Name); + Assert.Equal(PluginStatus.Active, result.Status); + Assert.Equal(packageInfo.Versions[0].Version, result.Version); + } + + private PackageInfo GenerateTestPackage() + { + var fixture = new Fixture(); + fixture.Customize(c => c.Without(x => x.Versions).Without(x => x.ImageUrl)); + fixture.Customize(c => c.Without(x => x.Version).Without(x => x.Timestamp)); + + var versionInfo = fixture.Create(); + versionInfo.Version = new Version(1, 0).ToString(); + versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture); + + var packageInfo = fixture.Create(); + packageInfo.Versions = new[] { versionInfo }; + + return packageInfo; + } + private JsonSerializerOptions GetTestSerializerOptions() { var options = new JsonSerializerOptions(JsonDefaults.Options) @@ -171,7 +292,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins private (string TempPath, string PluginPath) GetTestPaths(string pluginFolderName) { - var tempPath = Path.Combine(_testPathRoot, "plugins-" + Path.GetRandomFileName()); + var tempPath = Path.Combine(_testPathRoot, "plugin-manager" + Path.GetRandomFileName()); var pluginPath = Path.Combine(tempPath, pluginFolderName); return (tempPath, pluginPath); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json index 3aec299587..57367ce88c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -13,8 +13,7 @@ "targetAbi": "10.6.0.0", "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_10.0.0.0.zip", "checksum": "93e969adeba1050423fc8817ed3c36f8", - "timestamp": "2020-08-17T01:41:13Z", - "assemblies": [ "Jellyfin.Plugin.Anime.dll" ] + "timestamp": "2020-08-17T01:41:13Z" }, { "version": "9.0.0.0", @@ -22,8 +21,7 @@ "targetAbi": "10.6.0.0", "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_9.0.0.0.zip", "checksum": "9b1cebff835813e15f414f44b40c41c8", - "timestamp": "2020-07-20T01:30:16Z", - "assemblies": [ "Jellyfin.Plugin.Anime.dll" ] + "timestamp": "2020-07-20T01:30:16Z" } ] }, diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 70d03f8c4a..7abd2e685f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -106,19 +106,5 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); Assert.Null(ex); } - - [Fact] - public async Task InstallPackage_WithAssemblies_Success() - { - PackageInfo[] packages = await _installationManager.GetPackages( - "Jellyfin Stable", - "https://repo.jellyfin.org/releases/plugin/manifest-stable.json", - false); - - packages = _installationManager.FilterPackages(packages, "Anime").ToArray(); - Assert.Single(packages); - Assert.NotEmpty(packages[0].Versions[0].Assemblies); - Assert.Equal("Jellyfin.Plugin.Anime.dll", packages[0].Versions[0].Assemblies[0]); - } } } From 92f50054b28c85afbee0dfa99016c4b71548de6f Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Sun, 16 Apr 2023 07:46:12 -0600 Subject: [PATCH 6/7] Add explicit mapping instead of reflection to manifest reconciliation. --- .../Plugins/PluginManager.cs | 94 +++++++++---------- MediaBrowser.Model/Updates/VersionInfo.cs | 2 - .../Plugins/PluginManagerTests.cs | 67 ++++++++++--- 3 files changed, 98 insertions(+), 65 deletions(-) diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 2d2ad26d29..10d5ea906e 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -432,69 +432,67 @@ namespace Emby.Server.Implementations.Plugins ImagePath = imagePath }; - var metafile = Path.Combine(Path.Combine(path, MetafileName)); - if (File.Exists(metafile)) + if (!ReconcileManifest(manifest, path)) { - var data = File.ReadAllBytes(metafile); - var localManifest = JsonSerializer.Deserialize(data, _jsonOptions) ?? new PluginManifest(); - - // Plugin installation is the typical cause for populating a manifest. Activate. - localManifest.Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active; - - if (!Equals(localManifest.Id, manifest.Id)) - { - _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id); - localManifest.Status = PluginStatus.Malfunctioned; - } - - if (localManifest.Version != manifest.Version) - { - _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version); - - // Correct the local version. - localManifest.Version = manifest.Version; - } - - // Reconcile missing data against repository manifest. - ReconcileManifest(localManifest, manifest); - - manifest = localManifest; - } - else - { - _logger.LogInformation("No local manifest exists for plugin {Plugin}. Populating from repository manifest.", manifest.Name); + // An error occurred during reconciliation and saving could be undesirable. + return false; } return SaveManifest(manifest, path); } /// - /// Resolve the target plugin manifest against the source. Values are mapped onto the - /// target only if they are default values or empty strings. ID and status fields are ignored. + /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. + /// If no file is found, no reconciliation occurs. /// - /// The base to be reconciled. - /// The to reconcile against. - private void ReconcileManifest(PluginManifest baseManifest, PluginManifest projector) + /// The to reconcile against. + /// The plugin path. + /// The reconciled . + private bool ReconcileManifest(PluginManifest manifest, string path) { - var ignoredFields = new string[] + try { - nameof(baseManifest.Id), - nameof(baseManifest.Status) - }; - - foreach (var property in baseManifest.GetType().GetProperties()) - { - var localValue = property.GetValue(baseManifest); - - if (property.PropertyType == typeof(bool) || ignoredFields.Any(s => Equals(s, property.Name))) + var metafile = Path.Combine(path, MetafileName); + if (!File.Exists(metafile)) { - continue; + _logger.LogInformation("No local manifest exists for plugin {Plugin}. Skipping manifest reconciliation.", manifest.Name); + return true; } - if (property.PropertyType.IsNullOrDefault(localValue) || (property.PropertyType == typeof(string) && (string)localValue! == string.Empty)) + var data = File.ReadAllBytes(metafile); + var localManifest = JsonSerializer.Deserialize(data, _jsonOptions) ?? new PluginManifest(); + + if (!Equals(localManifest.Id, manifest.Id)) { - property.SetValue(baseManifest, property.GetValue(projector)); + _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id); + manifest.Status = PluginStatus.Malfunctioned; } + + if (localManifest.Version != manifest.Version) + { + // Package information provides the version and is the source of truth. Pre-packages meta.json is assumed to be a mistake in this regard. + _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version); + } + + // Explicitly mapping properties instead of using reflection is preferred here. + manifest.Category = string.IsNullOrEmpty(localManifest.Category) ? manifest.Category : localManifest.Category; + manifest.AutoUpdate = localManifest.AutoUpdate; // Preserve whatever is local. Package info does not have this property. + manifest.Changelog = string.IsNullOrEmpty(localManifest.Changelog) ? manifest.Changelog : localManifest.Changelog; + manifest.Description = string.IsNullOrEmpty(localManifest.Description) ? manifest.Description : localManifest.Description; + manifest.Name = string.IsNullOrEmpty(localManifest.Name) ? manifest.Name : localManifest.Name; + manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Overview; + manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner; + manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest.TargetAbi; + manifest.Timestamp = localManifest.Timestamp.IsNullOrDefault() ? manifest.Timestamp : localManifest.Timestamp; + manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest.ImagePath; + manifest.Assemblies = localManifest.Assemblies; + + return true; + } + catch (Exception e) + { + _logger.LogWarning(e, "Unable to reconcile plugin manifest due to an error. {Path}", path); + return false; } } diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index 53e1d29b01..320199f984 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/MediaBrowser.Model/Updates/VersionInfo.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Text.Json.Serialization; using SysVersion = System.Version; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index 204d144214..d4b90dac02 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -172,6 +172,24 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assemblies = new[] { "Jellyfin.Test.dll" } }; + var expectedManifest = new PluginManifest + { + Id = partial.Id, + Name = packageInfo.Name, + AutoUpdate = partial.AutoUpdate, + Status = PluginStatus.Active, + Owner = packageInfo.Owner, + Assemblies = partial.Assemblies, + Category = packageInfo.Category, + Description = packageInfo.Description, + Overview = packageInfo.Overview, + TargetAbi = packageInfo.Versions[0].TargetAbi!, + Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Changelog = packageInfo.Versions[0].Changelog!, + Version = new Version(1, 0).ToString(), + ImagePath = string.Empty + }; + var metafilePath = Path.Combine(_pluginPath, "meta.json"); File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); @@ -183,22 +201,41 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins var result = JsonSerializer.Deserialize(resultBytes, _options); Assert.NotNull(result); - Assert.Equal(packageInfo.Name, result.Name); - Assert.Equal(packageInfo.Owner, result.Owner); - Assert.Equal(PluginStatus.Active, result.Status); - Assert.NotEmpty(result.Assemblies); - Assert.False(result.AutoUpdate); + Assert.Equivalent(expectedManifest, result); + } - // Preserved - Assert.Equal(packageInfo.Category, result.Category); - Assert.Equal(packageInfo.Description, result.Description); - Assert.Equal(packageInfo.Id, result.Id); - Assert.Equal(packageInfo.Overview, result.Overview); - Assert.Equal(partial.Assemblies[0], result.Assemblies[0]); - Assert.Equal(packageInfo.Versions[0].TargetAbi, result.TargetAbi); - Assert.Equal(DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), result.Timestamp); - Assert.Equal(packageInfo.Versions[0].Changelog, result.Changelog); - Assert.Equal(packageInfo.Versions[0].Version, result.Version); + [Fact] + public async Task PopulateManifest_NoMetafile_PreservesManifest() + { + var packageInfo = GenerateTestPackage(); + var expectedManifest = new PluginManifest + { + Id = packageInfo.Id, + Name = packageInfo.Name, + AutoUpdate = true, + Status = PluginStatus.Active, + Owner = packageInfo.Owner, + Assemblies = Array.Empty(), + Category = packageInfo.Category, + Description = packageInfo.Description, + Overview = packageInfo.Overview, + TargetAbi = packageInfo.Versions[0].TargetAbi!, + Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Changelog = packageInfo.Versions[0].Changelog!, + Version = packageInfo.Versions[0].Version, + ImagePath = string.Empty + }; + + var pluginManager = new PluginManager(new NullLogger(), null!, null!, null!, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equivalent(expectedManifest, result); } [Fact] From c7174255498e28272fbe6d4d6867a774a3327eff Mon Sep 17 00:00:00 2001 From: AmbulantRex <21176662+AmbulantRex@users.noreply.github.com> Date: Sun, 16 Apr 2023 18:47:57 -0600 Subject: [PATCH 7/7] Remove unnecessary type extension and handle feedback. --- .../Plugins/PluginManager.cs | 13 ++-- src/Jellyfin.Extensions/TypeExtensions.cs | 45 ------------ .../TypeExtensionsTests.cs | 68 ------------------- 3 files changed, 7 insertions(+), 119 deletions(-) delete mode 100644 src/Jellyfin.Extensions/TypeExtensions.cs delete mode 100644 tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 10d5ea906e..48584ae0cb 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -432,7 +432,7 @@ namespace Emby.Server.Implementations.Plugins ImagePath = imagePath }; - if (!ReconcileManifest(manifest, path)) + if (!await ReconcileManifest(manifest, path)) { // An error occurred during reconciliation and saving could be undesirable. return false; @@ -448,7 +448,7 @@ namespace Emby.Server.Implementations.Plugins /// The to reconcile against. /// The plugin path. /// The reconciled . - private bool ReconcileManifest(PluginManifest manifest, string path) + private async Task ReconcileManifest(PluginManifest manifest, string path) { try { @@ -459,8 +459,9 @@ namespace Emby.Server.Implementations.Plugins return true; } - var data = File.ReadAllBytes(metafile); - var localManifest = JsonSerializer.Deserialize(data, _jsonOptions) ?? new PluginManifest(); + using var metaStream = File.OpenRead(metafile); + var localManifest = await JsonSerializer.DeserializeAsync(metaStream, _jsonOptions); + localManifest ??= new PluginManifest(); if (!Equals(localManifest.Id, manifest.Id)) { @@ -483,7 +484,7 @@ namespace Emby.Server.Implementations.Plugins manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Overview; manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner; manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest.TargetAbi; - manifest.Timestamp = localManifest.Timestamp.IsNullOrDefault() ? manifest.Timestamp : localManifest.Timestamp; + manifest.Timestamp = localManifest.Timestamp.Equals(default) ? manifest.Timestamp : localManifest.Timestamp; manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest.ImagePath; manifest.Assemblies = localManifest.Assemblies; @@ -842,7 +843,7 @@ namespace Emby.Server.Implementations.Plugins var canonicalized = Path.Combine(plugin.Path, path).Canonicalize(); // Ensure we stay in the plugin directory. - if (!canonicalized.StartsWith(plugin.Path.NormalizePath()!, StringComparison.Ordinal)) + if (!canonicalized.StartsWith(plugin.Path.NormalizePath(), StringComparison.Ordinal)) { _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path); return false; diff --git a/src/Jellyfin.Extensions/TypeExtensions.cs b/src/Jellyfin.Extensions/TypeExtensions.cs deleted file mode 100644 index 5b1111d594..0000000000 --- a/src/Jellyfin.Extensions/TypeExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Globalization; - -namespace Jellyfin.Extensions; - -/// -/// Provides extensions methods for . -/// -public static class TypeExtensions -{ - /// - /// Checks if the supplied value is the default or null value for that type. - /// - /// The type of the value to compare. - /// The type. - /// The value to check. - /// if the value is the default for the type. Otherwise, . - public static bool IsNullOrDefault(this Type type, T value) - { - if (value is null) - { - return true; - } - - object? tmp = value; - object? defaultValue = type.IsValueType ? Activator.CreateInstance(type) : null; - if (type.IsAssignableTo(typeof(IConvertible))) - { - tmp = Convert.ChangeType(value, type, CultureInfo.InvariantCulture); - } - - return Equals(tmp, defaultValue); - } - - /// - /// Checks if the object is currently a default or null value. Boxed types will be unboxed prior to comparison. - /// - /// The object to check. - /// if the value is the default for the type. Otherwise, . - public static bool IsNullOrDefault(this object? obj) - { - // Unbox the type and check. - return obj?.GetType().IsNullOrDefault(obj) ?? true; - } -} diff --git a/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs deleted file mode 100644 index 747913fa1a..0000000000 --- a/tests/Jellyfin.Extensions.Tests/TypeExtensionsTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using Xunit; - -namespace Jellyfin.Extensions.Tests -{ - public class TypeExtensionsTests - { - [Theory] - [InlineData(typeof(byte), byte.MaxValue, false)] - [InlineData(typeof(short), short.MinValue, false)] - [InlineData(typeof(ushort), ushort.MaxValue, false)] - [InlineData(typeof(int), int.MinValue, false)] - [InlineData(typeof(uint), uint.MaxValue, false)] - [InlineData(typeof(long), long.MinValue, false)] - [InlineData(typeof(ulong), ulong.MaxValue, false)] - [InlineData(typeof(decimal), -1.0, false)] - [InlineData(typeof(bool), true, false)] - [InlineData(typeof(char), 'a', false)] - [InlineData(typeof(string), "", false)] - [InlineData(typeof(object), 1, false)] - [InlineData(typeof(byte), 0, true)] - [InlineData(typeof(short), 0, true)] - [InlineData(typeof(ushort), 0, true)] - [InlineData(typeof(int), 0, true)] - [InlineData(typeof(uint), 0, true)] - [InlineData(typeof(long), 0, true)] - [InlineData(typeof(ulong), 0, true)] - [InlineData(typeof(decimal), 0, true)] - [InlineData(typeof(bool), false, true)] - [InlineData(typeof(char), '\x0000', true)] - [InlineData(typeof(string), null, true)] - [InlineData(typeof(object), null, true)] - [InlineData(typeof(PhonyClass), null, true)] - [InlineData(typeof(DateTime), null, true)] // Special case handled within the test. - [InlineData(typeof(DateTime), null, false)] // Special case handled within the test. - [InlineData(typeof(byte?), null, true)] - [InlineData(typeof(short?), null, true)] - [InlineData(typeof(ushort?), null, true)] - [InlineData(typeof(int?), null, true)] - [InlineData(typeof(uint?), null, true)] - [InlineData(typeof(long?), null, true)] - [InlineData(typeof(ulong?), null, true)] - [InlineData(typeof(decimal?), null, true)] - [InlineData(typeof(bool?), null, true)] - [InlineData(typeof(char?), null, true)] - public void IsNullOrDefault_Matches_Expected(Type type, object? value, bool expectedResult) - { - if (type == typeof(DateTime)) - { - if (expectedResult) - { - value = default(DateTime); - } - else - { - value = DateTime.Now; - } - } - - Assert.Equal(expectedResult, type.IsNullOrDefault(value)); - Assert.Equal(expectedResult, value.IsNullOrDefault()); - } - - private class PhonyClass - { - } - } -}