refactor: remove legacy extension lifecycle
CI / build-and-test (push) Waiting to run
CI / state-transfer-tests (push) Waiting to run
CI / csharp-format (push) Waiting to run
CI / webui (push) Waiting to run
CI / release-manifest (push) Blocked by required conditions
CI / apple-contracts (push) Waiting to run
CI / compose-contracts (push) Waiting to run

This commit is contained in:
2026-08-01 03:25:15 -04:00
parent f8509d310e
commit 3d816907b7
10 changed files with 23 additions and 479 deletions
@@ -123,16 +123,11 @@ public sealed class ExtensionControllerControlPlaneTests : IAsyncLifetime
}
[Fact]
public async Task LegacyAndStagingEndpoints_DoNotBypassAdministratorAuthentication()
public async Task StagingEndpoints_DoNotBypassAdministratorAuthentication()
{
var controller = Controller();
Assert.IsType<UnauthorizedObjectResult>(controller.GetRepositories());
Assert.IsType<UnauthorizedObjectResult>(await controller.GetStoreExtensions(default));
Assert.IsType<UnauthorizedObjectResult>(controller.GetInstalledExtensions());
Assert.IsType<UnauthorizedObjectResult>(await controller.InstallExtension(new InstallRequest(), default));
Assert.IsType<UnauthorizedObjectResult>(controller.UninstallExtension("fixture-extension"));
Assert.IsType<UnauthorizedObjectResult>(controller.DisableExtension("fixture-extension"));
Assert.IsType<UnauthorizedObjectResult>(await controller.EnableExtension("fixture-extension"));
Assert.IsType<UnauthorizedObjectResult>(await controller.UninstallPackage(
Guid.CreateVersion7(), new RevisionRequest(), default));
Assert.IsType<UnauthorizedObjectResult>(await controller.RevokePermissionGrants(
@@ -1,4 +1,3 @@
using System.IO.Compression;
using System.Net;
using System.Text;
using allstarr.Core.Extensions;
@@ -94,81 +93,6 @@ public class ExtensionManagerSecurityTests
}
}
[Fact]
public async Task InstallExtensionAsync_IsDisabledByDefaultWithoutMakingARequest()
{
var testRoot = CreateTestRoot();
try
{
var httpClientFactory = new Mock<IHttpClientFactory>(MockBehavior.Strict);
var manager = CreateManager(testRoot, httpClientFactory.Object);
var result = await manager.InstallExtensionAsync("https://extensions.example/package.zip");
Assert.False(result);
Assert.False(manager.RemoteInstallEnabled);
httpClientFactory.VerifyNoOtherCalls();
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public async Task InstallExtensionAsync_RejectsParentDirectoryManifestBeforeDeletingAnything()
{
var testRoot = CreateTestRoot();
try
{
var sentinelPath = Path.Combine(testRoot, "sentinel.txt");
await File.WriteAllTextAsync(sentinelPath, "keep");
var package = CreatePackage("..");
var manager = CreateManager(
testRoot,
CreateHttpClientFactory(package),
allowRemoteInstall: true);
var result = await manager.InstallExtensionAsync("https://extensions.example/malicious.zip");
Assert.False(result);
Assert.Equal("keep", await File.ReadAllTextAsync(sentinelPath));
Assert.DoesNotContain(
Directory.GetDirectories(Path.Combine(testRoot, "extensions")),
path => Path.GetFileName(path).StartsWith(".install-", StringComparison.Ordinal));
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public async Task InstallExtensionAsync_WithoutChecksumNeverInstallsEvenWithRemoteOptIn()
{
var testRoot = CreateTestRoot();
try
{
var package = CreatePackage("safe-extension");
var logger = new CapturingLogger<ExtensionManager>();
var manager = CreateManager(
testRoot,
CreateHttpClientFactory(package),
allowRemoteInstall: true,
logger: logger);
var result = await manager.InstallExtensionAsync("https://extensions.example/safe.zip");
Assert.False(result);
Assert.True(manager.RemoteInstallEnabled);
Assert.Null(manager.GetExtension("safe-extension"));
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public async Task LocalPackageFolders_DoNotBypassDurableSdkReview()
{
@@ -190,7 +114,6 @@ public class ExtensionManagerSecurityTests
Assert.False(manager.RemoteInstallEnabled);
Assert.Null(manager.GetExtension("safe-local"));
Assert.False(await manager.EnableExtensionAsync("safe-local"));
}
finally
{
@@ -222,36 +145,6 @@ public class ExtensionManagerSecurityTests
Assert.Equal("safe-extension", item.Id);
}
[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("../outside")]
[InlineData("/outside")]
[InlineData("nested/extension")]
[InlineData("nested\\extension")]
[InlineData("Upper-Case")]
public async Task LifecycleOperations_RejectUnsafeIds(string id)
{
var testRoot = CreateTestRoot();
try
{
var sentinelPath = Path.Combine(testRoot, "sentinel.txt");
await File.WriteAllTextAsync(sentinelPath, "keep");
var manager = CreateManager(
testRoot,
new Mock<IHttpClientFactory>(MockBehavior.Strict).Object);
Assert.False(manager.DisableExtension(id));
Assert.False(await manager.EnableExtensionAsync(id));
Assert.False(manager.UninstallExtension(id));
Assert.Equal("keep", await File.ReadAllTextAsync(sentinelPath));
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public void RuntimeBridge_EnforcesNetworkCacheAndSecretPermissions()
{
@@ -292,27 +185,20 @@ public class ExtensionManagerSecurityTests
private static ExtensionManager CreateManager(
string testRoot,
IHttpClientFactory httpClientFactory,
bool? allowRemoteInstall = null,
ILogger<ExtensionManager>? logger = null)
IHttpClientFactory httpClientFactory)
{
var extensionsDirectory = Path.Combine(testRoot, "extensions");
var settings = new Dictionary<string, string?>
{
["Extensions:Directory"] = extensionsDirectory
};
if (allowRemoteInstall.HasValue)
{
settings["Extensions:AllowRemoteInstall"] = allowRemoteInstall.Value.ToString();
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
return new ExtensionManager(
httpClientFactory,
logger ?? Mock.Of<ILogger<ExtensionManager>>(),
Mock.Of<ILogger<ExtensionManager>>(),
configuration);
}
@@ -324,31 +210,6 @@ public class ExtensionManagerSecurityTests
return factory.Object;
}
private static byte[] CreatePackage(string extensionId)
{
using var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
WriteEntry(
archive,
"manifest.json",
$$"""{ "id": "{{extensionId}}", "displayName": "Test Extension", "version": "1.0.0" }""");
WriteEntry(
archive,
"index.js",
"registerExtension({ searchTracks: function() { return []; } });");
}
return stream.ToArray();
}
private static void WriteEntry(ZipArchive archive, string name, string content)
{
var entry = archive.CreateEntry(name);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
writer.Write(content);
}
private static string CreateTestRoot()
{
var testRoot = Path.Combine(Path.GetTempPath(), $"allstarr-extension-tests-{Guid.NewGuid():N}");
@@ -389,22 +250,4 @@ public class ExtensionManagerSecurityTests
}
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add($"{logLevel}: {formatter(state, exception)} {exception}");
}
}
}
@@ -4,24 +4,6 @@ namespace allstarr.Tests;
public class ExtensionManagerTests
{
[Fact]
public void ParseRepositoryList_DoesNotAddAThirdPartyRegistryByDefault()
{
Assert.Empty(ExtensionManager.ParseRepositoryList(null));
Assert.Empty(ExtensionManager.ParseRepositoryList(" "));
}
[Fact]
public void ParseRepositoryList_ReturnsOnlyExplicitlyConfiguredRegistries()
{
var repositories = ExtensionManager.ParseRepositoryList(
"https://one.example/registry.json, https://two.example/registry.json");
Assert.Equal(
["https://one.example/registry.json", "https://two.example/registry.json"],
repositories);
}
[Fact]
public void ParseStoreRegistry_SupportsExtensionsWrapperAndSnakeCaseFields()
{
@@ -16,11 +16,28 @@ using allstarr.Core.Capabilities;
using allstarr.Core.Storage;
using allstarr.Filters;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc.Routing;
namespace allstarr.Tests;
public sealed class HostCompositionTests
{
[Fact]
public void ExtensionLifecycleUsesOnlyDurablePackageRoutes()
{
var routes = typeof(ExtensionController).GetMethods()
.SelectMany(method => method.GetCustomAttributes(typeof(HttpMethodAttribute), true)
.Cast<HttpMethodAttribute>())
.Select(attribute => attribute.Template)
.ToArray();
Assert.Contains("packages/{packageId:guid}/activate", routes);
Assert.Contains("packages/{packageId:guid}/disable", routes);
Assert.Contains("packages/{packageId:guid}", routes);
Assert.DoesNotContain(routes, route => route is "repos" or "installed" or
"uninstall/{id}" or "disable/{id}" or "enable/{id}");
}
[Theory]
[InlineData("Jellyfin", typeof(JellyfinController), typeof(SubsonicController))]
[InlineData("Subsonic", typeof(SubsonicController), typeof(JellyfinController))]
@@ -147,6 +164,7 @@ public sealed class HostCompositionTests
Assert.NotEmpty(schema.Providers);
Assert.NotEmpty(schema.ProviderSupportMatrix);
Assert.NotEmpty(schema.ConfigSections);
Assert.Equal("/api/admin/extensions/packages", schema.ExtensionStore.InstalledEndpoint);
}
[Fact]
+1 -1
View File
@@ -111,7 +111,7 @@ public class AdminUiController : ControllerBase
Repositories = [],
RegistryEnvKey = "",
StoreEndpoint = "/api/admin/extensions/store",
InstalledEndpoint = "/api/admin/extensions/installed"
InstalledEndpoint = "/api/admin/extensions/packages"
},
PluginCapabilities =
[
@@ -363,13 +363,6 @@ public class ExtensionController : ControllerBase
catch (Exception exception) { return ControlPlaneError(exception); }
}
[HttpGet("repos")]
public IActionResult GetRepositories()
{
if (RequireAdministrator() is { } error) return error;
return Ok(Array.Empty<string>());
}
[HttpGet("store")]
public async Task<IActionResult> GetStoreExtensions(CancellationToken cancellationToken)
{
@@ -378,25 +371,6 @@ public class ExtensionController : ControllerBase
return Ok(catalog);
}
[HttpGet("installed")]
public IActionResult GetInstalledExtensions()
{
if (RequireAdministrator() is { } error) return error;
var items = _extensionManager.GetInstalledExtensions()
.Select(e => new
{
e.Id,
e.Name,
e.DisplayName,
e.Description,
e.Version,
e.Types,
e.Enabled
})
.ToList();
return Ok(items);
}
[HttpPost("install")]
public async Task<IActionResult> InstallExtension([FromBody] InstallRequest request, CancellationToken cancellationToken)
{
@@ -458,47 +432,6 @@ public class ExtensionController : ControllerBase
}
}
[HttpDelete("uninstall/{id}")]
public IActionResult UninstallExtension(string id)
{
if (RequireAdministrator() is { } error) return error;
var success = _extensionManager.UninstallExtension(id);
if (success)
{
return Ok(new { success = true, message = "Extension uninstalled successfully." });
}
else
{
return NotFound(new { success = false, message = "Extension not found or failed to delete." });
}
}
[HttpPost("disable/{id}")]
public IActionResult DisableExtension(string id)
{
if (RequireAdministrator() is { } error) return error;
var success = _extensionManager.DisableExtension(id);
if (success)
{
return Ok(new { success = true, message = "Extension disabled successfully." });
}
return NotFound(new { success = false, message = "Extension not found." });
}
[HttpPost("enable/{id}")]
public async Task<IActionResult> EnableExtension(string id)
{
if (RequireAdministrator() is { } error) return error;
var success = await _extensionManager.EnableExtensionAsync(id);
if (success)
{
return Ok(new { success = true, message = "Extension enabled successfully." });
}
return NotFound(new { success = false, message = "Extension not found or failed to load." });
}
private IActionResult? RequireAdministrator() =>
TryGetAdministrator(out _, out var error) ? null : error;
+1 -1
View File
@@ -311,7 +311,7 @@ public sealed class AdminUiExtensionStore
public string StoreEndpoint { get; set; } = "/api/admin/extensions/store";
[JsonPropertyName("installedEndpoint")]
public string InstalledEndpoint { get; set; } = "/api/admin/extensions/installed";
public string InstalledEndpoint { get; set; } = "/api/admin/extensions/packages";
}
public sealed class AdminUiPluginCapability
@@ -20,7 +20,6 @@ namespace allstarr.Services.Common;
public class ExtensionManager : IDisposable
{
private const string DisabledMarkerFile = ".disabled";
private const int MaximumExtensionIdLength = 128;
private const int MaximumRegistryBytes = 4 * 1024 * 1024;
private static readonly Regex ExtensionIdPattern = new(
@@ -66,21 +65,6 @@ public class ExtensionManager : IDisposable
public bool RemoteInstallEnabled =>
_configuration.GetValue("Extensions:AllowRemoteInstall", false);
public IReadOnlyCollection<InstalledExtensionInfo> GetInstalledExtensions()
{
if (!Directory.Exists(_extensionsDir))
{
return [];
}
return Directory.GetDirectories(_extensionsDir)
.Select(ReadInstalledExtensionInfo)
.Where(item => item != null)
.Select(item => item!)
.OrderBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public ExtensionSandbox? GetExtension(string id)
{
return TryValidateExtensionId(id, out var validId) &&
@@ -89,25 +73,6 @@ public class ExtensionManager : IDisposable
: null;
}
public List<string> GetConfiguredRepositories()
{
return ParseRepositoryList(_configuration["EXTENSION_REPOSITORIES"]);
}
public static List<string> ParseRepositoryList(string? repositories)
{
return string.IsNullOrWhiteSpace(repositories)
? []
: repositories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
public async Task<List<StoreExtensionItem>> FetchStoreExtensionsAsync(CancellationToken cancellationToken = default)
{
var catalog = await FetchStoreCatalogAsync(cancellationToken);
return catalog.Items;
}
public async Task<ExtensionStoreResponse> FetchStoreCatalogAsync(CancellationToken cancellationToken = default)
{
var catalog = new ExtensionStoreResponse();
@@ -291,13 +256,6 @@ public class ExtensionManager : IDisposable
return jsonBuilder.ToString();
}
public async Task<bool> InstallExtensionAsync(string downloadUrl, CancellationToken cancellationToken = default)
{
_logger.LogWarning("Blocked extension install without a mandatory registry package checksum");
await Task.CompletedTask;
return false;
}
public async Task<ExtensionPackageRecord> StageExtensionAsync(
string downloadUrl,
string expectedSha256,
@@ -369,105 +327,6 @@ public class ExtensionManager : IDisposable
}
}
public bool UninstallExtension(string id)
{
if (!TryResolveExtensionDirectory(id, out var folder))
{
return false;
}
_activeExtensions.TryRemove(id, out _);
if (Directory.Exists(folder))
{
try
{
Directory.Delete(folder, true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete extension directory {Path}", folder);
}
}
return false;
}
public bool DisableExtension(string id)
{
if (!TryResolveExtensionDirectory(id, out var folder) || !Directory.Exists(folder))
{
return false;
}
_activeExtensions.TryRemove(id, out _);
File.WriteAllText(Path.Combine(folder, DisabledMarkerFile), DateTime.UtcNow.ToString("O"));
_logger.LogInformation("Disabled extension {ExtensionId}", id);
return true;
}
public async Task<bool> EnableExtensionAsync(string id)
{
_logger.LogWarning("Blocked legacy folder activation for extension {ExtensionId}; stage and review an SDK v1 package instead", id);
await Task.CompletedTask;
return false;
}
private static bool IsExtensionDisabled(string folderPath)
{
return File.Exists(Path.Combine(folderPath, DisabledMarkerFile));
}
private InstalledExtensionInfo? ReadInstalledExtensionInfo(string folderPath)
{
try
{
if (!TryResolveInstalledExtensionFolder(folderPath, out var folderId, out var safeFolderPath))
{
return null;
}
var manifestPath = Path.Combine(safeFolderPath, "manifest.json");
if (!File.Exists(manifestPath))
{
return null;
}
var manifestJson = File.ReadAllText(manifestPath);
using var doc = JsonDocument.Parse(manifestJson);
var root = doc.RootElement;
if (!TryValidateExtensionId(ReadString(root, "id", "name"), out var id) ||
!id.Equals(folderId, StringComparison.Ordinal))
{
return null;
}
var active = _activeExtensions.TryGetValue(id, out var sandbox);
var displayName = sandbox?.DisplayName ?? ReadString(root, "displayName", "display_name", "title", "name");
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = id;
}
var version = sandbox?.Version ?? ReadString(root, "version");
return new InstalledExtensionInfo
{
Id = id,
Name = sandbox?.Name ?? ReadString(root, "name", "id"),
DisplayName = displayName,
Description = sandbox?.Description ?? ReadString(root, "description", "summary"),
Version = string.IsNullOrWhiteSpace(version) ? "1.0.0" : version,
Types = sandbox?.Types.ToList() ?? ReadStringList(root, "types", "type", "capabilities", "capability"),
Enabled = active && !IsExtensionDisabled(safeFolderPath)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read installed extension manifest from {Path}", folderPath);
return null;
}
}
public static List<StoreExtensionItem> ParseStoreRegistry(string json, string repoUrl = "")
{
var items = new List<StoreExtensionItem>();
@@ -633,60 +492,6 @@ public class ExtensionManager : IDisposable
id.IndexOf(Path.AltDirectorySeparatorChar) < 0;
}
private bool TryResolveExtensionDirectory(string id, out string folderPath)
{
folderPath = string.Empty;
if (!TryValidateExtensionId(id, out var validId))
{
return false;
}
folderPath = ResolveExtensionDirectory(validId);
return true;
}
private string ResolveExtensionDirectory(string id)
{
if (!TryValidateExtensionId(id, out var validId))
{
throw new InvalidDataException("Extension id must be a lowercase kebab-case identifier.");
}
return ResolveContainedPath(validId);
}
private bool TryResolveInstalledExtensionFolder(
string folderPath,
out string extensionId,
out string safeFolderPath)
{
extensionId = string.Empty;
safeFolderPath = string.Empty;
try
{
var candidate = EnsureContainedPath(folderPath);
var folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(candidate));
if (!TryValidateExtensionId(folderName, out extensionId))
{
return false;
}
var expected = ResolveExtensionDirectory(extensionId);
if (!PathsEqual(candidate, expected))
{
return false;
}
safeFolderPath = expected;
return true;
}
catch (Exception ex) when (ex is ArgumentException or InvalidDataException or NotSupportedException)
{
return false;
}
}
private string ResolveContainedPath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath))
@@ -713,14 +518,6 @@ public class ExtensionManager : IDisposable
return fullPath;
}
private static bool PathsEqual(string left, string right)
{
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return Path.GetFullPath(left).Equals(Path.GetFullPath(right), comparison);
}
}
public class ExtensionStoreResponse
@@ -755,17 +552,6 @@ public class StoreExtensionItem
public List<string> Types { get; set; } = [];
}
public class InstalledExtensionInfo
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Description { get; set; } = "";
public string Version { get; set; } = "";
public bool Enabled { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed record ExtensionRuntimePermissionSet(
IReadOnlySet<string> NetworkOrigins,
IReadOnlySet<string> CacheKeys,
@@ -21,11 +21,6 @@ public interface ILocalLibraryService
/// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary>
/// Gets the mapping between external ID and local ID
/// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Parses a song ID to determine if it is external or local
/// </summary>
@@ -83,14 +83,6 @@ public class LocalLibraryService : ILocalLibraryService
});
}
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
{
// For now, return null as we don't yet have integration
// with the Subsonic server to retrieve local ID after scan
await Task.CompletedTask;
return null;
}
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
{
var (isExternal, provider, _, externalId) = ParseExternalId(songId);