From 6366329ed7852ba20c5f94f3542e0020c55b5717 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 01:24:24 +0800 Subject: [PATCH 1/4] feat: add controlled plugin platform --- .../mock-server.mjs | 514 ++++-- .../settings/PluginSettingsSection.tsx | 403 +++++ .../settings/SettingsNavigation.tsx | 11 +- .../src/i18n/locales/en/settings.json | 49 +- .../src/i18n/locales/ja/settings.json | 49 +- .../src/i18n/locales/zh-CN/settings.json | 49 +- .../src/pages/SettingsPage.tsx | 3 + .../src/plugins/api.ts | 49 + .../src/plugins/hooks.ts | 5 + .../src/plugins/types.ts | 52 + .../IPluginCatalogRepository.cs | 31 + .../Plugin/IJavaScriptPluginLoader.cs | 18 +- .../Plugin/INotificationProvider.cs | 16 + .../Plugin/IPluginServices.cs | 4 - .../Plugin/PluginManifest.cs | 160 ++ .../Plugins/PluginApiTests.cs | 149 ++ .../WebDavWebApplicationFactory.cs | 3 + .../PluginControllerTests.cs | 33 + .../PluginDeploymentTests.cs | 17 + .../PluginEventTests.cs | 41 + .../PluginPlatformIntegrationTests.cs | 1382 +++++++++++++++++ .../SecondDimensionWatcherReDive.Test.csproj | 9 + .../Controllers/Converter.cs | 74 + .../External/AppJsonSerializerContext.cs | 8 + .../Controllers/External/PluginModels.cs | 78 + .../Controllers/PluginController.cs | 133 ++ .../Plugin/PluginEvent.cs | 47 +- .../Plugin/PluginHelper.cs | 4 +- .../Plugin/PluginServices.cs | 4 +- .../PluginPlatform/IPluginCapabilityBroker.cs | 13 + .../PluginPlatform/IPluginManager.cs | 26 + .../PluginPlatform/PluginCapabilityBroker.cs | 324 ++++ .../PluginLifecycleCoordinator.cs | 133 ++ .../PluginPlatform/PluginLifecycleJournal.cs | 32 + .../PluginPlatform/PluginManager.cs | 989 ++++++++++++ .../PluginPlatform/PluginManifestValidator.cs | 333 ++++ .../PluginNetworkConnectionFactory.cs | 119 ++ .../PluginPlatform/PluginPackageInspector.cs | 414 +++++ .../PluginPlatform/PluginPlatformOptions.cs | 27 + .../PluginPlatformServiceExtensions.cs | 71 + .../PluginPlatform/PluginProcessExecutor.cs | 291 ++++ .../PluginPlatform/PluginProviderRegistry.cs | 116 ++ .../PluginPlatform/PluginSafeFileAccess.cs | 438 ++++++ .../PluginPlatform/PluginWorkerHost.cs | 154 ++ .../PluginPlatform/PluginWorkerProtocol.cs | 34 + SecondDimensionWatcherReDive/Program.cs | 10 + .../Repositories/PluginCatalogRepository.cs | 191 +++ .../Utils/FileStore/FileStoreProvider.cs | 22 +- .../appsettings.example.json | 24 + deployments/podman-compose.yml | 1 + docs/container-deployment.md | 5 +- docs/plugin-platform.md | 40 + examples/plugins/scoped-storage/index.js | 35 + examples/plugins/scoped-storage/manifest.json | 36 + examples/plugins/webhook/index.js | 16 + examples/plugins/webhook/manifest.json | 31 + packaging/appsettings.yml | 23 + sdk/javascript/README.md | 13 + sdk/javascript/plugin-api.d.ts | 39 + 59 files changed, 7282 insertions(+), 113 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Client/src/components/settings/PluginSettingsSection.tsx create mode 100644 SecondDimensionWatcherReDive.Client/src/plugins/api.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/plugins/hooks.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/plugins/types.ts create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/IPluginCatalogRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Plugin/INotificationProvider.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Plugin/PluginManifest.cs create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/PluginControllerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/PluginDeploymentTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/PluginModels.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/PluginController.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/IPluginCapabilityBroker.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/IPluginManager.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginCapabilityBroker.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleCoordinator.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleJournal.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginManager.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginManifestValidator.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginNetworkConnectionFactory.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginPackageInspector.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginProviderRegistry.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginSafeFileAccess.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerHost.cs create mode 100644 SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerProtocol.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/PluginCatalogRepository.cs create mode 100644 docs/plugin-platform.md create mode 100644 examples/plugins/scoped-storage/index.js create mode 100644 examples/plugins/scoped-storage/manifest.json create mode 100644 examples/plugins/webhook/index.js create mode 100644 examples/plugins/webhook/manifest.json create mode 100644 sdk/javascript/README.md create mode 100644 sdk/javascript/plugin-api.d.ts diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4..b3d583a 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -692,50 +692,147 @@ let feeds = [ // Per-feed subscription automation policies and historical releases. const POLICY_CREATED_AT = new Date(Date.now() - 86400_000).toISOString(); const subscriptionPolicies = new Map([ - [feeds[0].id, { - feedId: feeds[0].id, - subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], - resolutions: ["1080p"], - codecs: ["HEVC"], - languages: ["简中", "繁中"], - minSizeBytes: 300 * 1024 * 1024, - maxSizeBytes: 1600 * 1024 * 1024, - excludedKeywords: ["合集", "NCOP"], - mode: "ManualConfirm", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), - }], - [feeds[1].id, { - feedId: feeds[1].id, - subtitleGroups: ["ANi", "SubsPlease"], - resolutions: ["1080p"], - codecs: [], - languages: ["繁中"], - minSizeBytes: null, - maxSizeBytes: 1400 * 1024 * 1024, - excludedKeywords: ["预告"], - mode: "AutoDownload", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), - }], + [ + feeds[0].id, + { + feedId: feeds[0].id, + subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], + resolutions: ["1080p"], + codecs: ["HEVC"], + languages: ["简中", "繁中"], + minSizeBytes: 300 * 1024 * 1024, + maxSizeBytes: 1600 * 1024 * 1024, + excludedKeywords: ["合集", "NCOP"], + mode: "ManualConfirm", + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), + }, + ], + [ + feeds[1].id, + { + feedId: feeds[1].id, + subtitleGroups: ["ANi", "SubsPlease"], + resolutions: ["1080p"], + codecs: [], + languages: ["繁中"], + minSizeBytes: null, + maxSizeBytes: 1400 * 1024 * 1024, + excludedKeywords: ["预告"], + mode: "AutoDownload", + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), + }, + ], ]); const RELEASE_HISTORY_BY_FEED = new Map([ - [feeds[0].id, [ - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), sizeBytes: 824 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), sizeBytes: 516 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 18.4 * 1024 * 1024 * 1024, subtitleGroup: "喵萌奶茶屋", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 2250 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "2160p", codec: "HEVC", languages: ["简中", "繁中"] }, - ]], - [feeds[1].id, [ - { id: randomUUID(), title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), sizeBytes: 612 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), sizeBytes: 1380 * 1024 * 1024, subtitleGroup: "SubsPlease", resolution: "1080p", codec: "AVC", languages: ["English"] }, - { id: randomUUID(), title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 92 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中"] }, - ]], - [feeds[2].id, [ - { id: randomUUID(), title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), sizeBytes: 745 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 324 * 1024 * 1024, subtitleGroup: "ANi", resolution: "720p", codec: "AVC", languages: ["繁中"] }, - ]], + [ + feeds[0].id, + [ + { + id: randomUUID(), + title: + "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", + publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), + sizeBytes: 824 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), + sizeBytes: 516 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 18.4 * 1024 * 1024 * 1024, + subtitleGroup: "喵萌奶茶屋", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 2250 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "2160p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + ], + ], + [ + feeds[1].id, + [ + { + id: randomUUID(), + title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), + sizeBytes: 612 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", + publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), + sizeBytes: 1380 * 1024 * 1024, + subtitleGroup: "SubsPlease", + resolution: "1080p", + codec: "AVC", + languages: ["English"], + }, + { + id: randomUUID(), + title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 92 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], + [ + feeds[2].id, + [ + { + id: randomUUID(), + title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), + sizeBytes: 745 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 324 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "720p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], ]); function simulatePolicy(feedId, policy) { @@ -755,28 +852,59 @@ function simulatePolicy(feedId, policy) { if (field === "resolution") { normalized = normalized.replace(/\s/g, ""); const aliases = { - "4K": "2160P", UHD: "2160P", "2160": "2160P", - "1440": "1440P", FHD: "1080P", "1080": "1080P", - HD: "720P", "720": "720P", "576": "576P", "480": "480P", + "4K": "2160P", + UHD: "2160P", + 2160: "2160P", + 1440: "1440P", + FHD: "1080P", + 1080: "1080P", + HD: "720P", + 720: "720P", + 576: "576P", + 480: "480P", }; return aliases[normalized] ?? normalized; } if (field === "codec") { normalized = normalized.replace(/[.\-\s]/g, ""); const aliases = { - H265: "HEVC", X265: "HEVC", H264: "AVC", X264: "AVC", + H265: "HEVC", + X265: "HEVC", + H264: "AVC", + X264: "AVC", }; return aliases[normalized] ?? normalized; } if (field === "languages") { normalized = normalized.replace(/[_\-\s]/g, ""); const aliases = { - CHS: "ZHHANS", SC: "ZHHANS", GB: "ZHHANS", ZHCN: "ZHHANS", - "简体": "ZHHANS", "简中": "ZHHANS", "簡中": "ZHHANS", "简体中文": "ZHHANS", - CHT: "ZHHANT", TC: "ZHHANT", BIG5: "ZHHANT", ZHTW: "ZHHANT", ZHHK: "ZHHANT", - "繁体": "ZHHANT", "繁體": "ZHHANT", "繁中": "ZHHANT", "繁體中文": "ZHHANT", - JPN: "JA", JAP: "JA", "日语": "JA", "日語": "JA", "日本語": "JA", JAPANESE: "JA", - ENG: "EN", "英语": "EN", "英語": "EN", ENGLISH: "EN", + CHS: "ZHHANS", + SC: "ZHHANS", + GB: "ZHHANS", + ZHCN: "ZHHANS", + 简体: "ZHHANS", + 简中: "ZHHANS", + 簡中: "ZHHANS", + 简体中文: "ZHHANS", + CHT: "ZHHANT", + TC: "ZHHANT", + BIG5: "ZHHANT", + ZHTW: "ZHHANT", + ZHHK: "ZHHANT", + 繁体: "ZHHANT", + 繁體: "ZHHANT", + 繁中: "ZHHANT", + 繁體中文: "ZHHANT", + JPN: "JA", + JAP: "JA", + 日语: "JA", + 日語: "JA", + 日本語: "JA", + JAPANESE: "JA", + ENG: "EN", + 英语: "EN", + 英語: "EN", + ENGLISH: "EN", }; return aliases[normalized] ?? normalized; } @@ -786,7 +914,13 @@ function simulatePolicy(feedId, policy) { const actual = actualValues.filter(Boolean); const expected = (expectedValues ?? []).filter(Boolean); if (expected.length === 0) { - return { field, passed: true, actual: actual.join(", ") || null, expected: null, message: "anyValueAllowed" }; + return { + field, + passed: true, + actual: actual.join(", ") || null, + expected: null, + message: "anyValueAllowed", + }; } const normalizedExpected = new Set( expected.map((value) => normalizeAllowedValue(field, value)), @@ -794,28 +928,47 @@ function simulatePolicy(feedId, policy) { const passed = actual.some((value) => normalizedExpected.has(normalizeAllowedValue(field, value)), ); - return { field, passed, actual: actual.join(", ") || null, expected: expected.join(", "), message: passed ? "allowedValueMatched" : "allowedValueMissed" }; + return { + field, + passed, + actual: actual.join(", ") || null, + expected: expected.join(", "), + message: passed ? "allowedValueMatched" : "allowedValueMissed", + }; }; const entries = history.map((item) => { const explanations = [ - checkAllowed("subtitleGroup", [item.subtitleGroup], policy.subtitleGroups), + checkAllowed( + "subtitleGroup", + [item.subtitleGroup], + policy.subtitleGroups, + ), checkAllowed("resolution", [item.resolution], policy.resolutions), checkAllowed("codec", [item.codec], policy.codecs), checkAllowed("languages", item.languages, policy.languages), ]; - const min = typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; - const max = typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; - const sizePassed = (min == null || item.sizeBytes >= min) && (max == null || item.sizeBytes <= max); + const min = + typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; + const max = + typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; + const sizePassed = + (min == null || item.sizeBytes >= min) && + (max == null || item.sizeBytes <= max); explanations.push({ field: "size", passed: sizePassed, actual: formatBytes(item.sizeBytes), - expected: min == null && max == null ? null : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, + expected: + min == null && max == null + ? null + : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, message: sizePassed ? "withinSizeRange" : "outsideSizeRange", }); const excluded = (policy.excludedKeywords ?? []).filter(Boolean); - const found = excluded.find((keyword) => item.title.toLowerCase().includes(keyword.toLowerCase())); + const found = excluded.find((keyword) => + item.title.toLowerCase().includes(keyword.toLowerCase()), + ); explanations.push({ field: "excludedKeywords", passed: !found, @@ -823,10 +976,21 @@ function simulatePolicy(feedId, policy) { expected: excluded.length > 0 ? excluded.join(", ") : null, message: found ? "excludedKeywordFound" : "noExcludedKeyword", }); - return { id: item.id, title: item.title, publishedAt: item.publishedAt, sizeBytes: item.sizeBytes, matched: explanations.every((reason) => reason.passed), explanations }; + return { + id: item.id, + title: item.title, + publishedAt: item.publishedAt, + sizeBytes: item.sizeBytes, + matched: explanations.every((reason) => reason.passed), + explanations, + }; }); - return { total: entries.length, matched: entries.filter((entry) => entry.matched).length, entries }; + return { + total: entries.length, + matched: entries.filter((entry) => entry.matched).length, + entries, + }; } // WebDAV access tokens @@ -903,6 +1067,60 @@ let systemSettings = { }, }; +const mockPluginManifest = { + id: "example.webhook", + name: "Webhook notifications", + version: "1.0.0", + apiVersion: "1.0", + entryPoint: "index.js", + description: "Mock notification provider for the controlled plugin UI.", + dependencies: [], + capabilities: { + networkDomains: ["hooks.example.com"], + fileRoots: [], + notifications: true, + downloadControl: false, + storageAccess: false, + backgroundTasks: false, + }, + platforms: ["any"], + fileSha256: { + "index.js": + "cb04e27dacbadf8de122b491b2c2d32cb553564fcc9c390a3ab4d922a2cd0e1b", + }, + signaturePublisher: null, + signatureAlgorithm: null, + providers: [ + { + kind: "notification", + name: "webhook", + handlers: { send: "sendNotification" }, + }, + ], + dataVersion: 1, + dataMigration: null, +}; + +let mockPlugins = []; + +function mockInstalledPlugin(manifest = mockPluginManifest) { + return { + manifest, + isEnabled: false, + approvedCapabilities: manifest.capabilities, + compatibilityErrors: [], + health: { + status: "healthy", + consecutiveFailures: 0, + lastSuccessAt: null, + lastFailureAt: null, + lastError: null, + circuitOpenUntil: null, + }, + hasConfiguration: false, + }; +} + const deploymentSecrets = { openAi: { isConfigured: true, source: "deployment" }, anthropic: { isConfigured: false, source: "none" }, @@ -1052,7 +1270,9 @@ function playablePaths() { !entry.isDirectory && /\.(mkv|mp4|webm|avi|flv|wmv|mov|m4v|ts|m2ts)$/i.test(entry.fileName) ) { - paths.push(directory ? `${directory}/${entry.fileName}` : entry.fileName); + paths.push( + directory ? `${directory}/${entry.fileName}` : entry.fileName, + ); } } } @@ -1122,7 +1342,9 @@ function associatedSubtitles(animation, videoPath) { entry.fileName.toLowerCase().startsWith(stem.toLowerCase()), ) .map((entry) => { - const path = directory ? `${directory}/${entry.fileName}` : entry.fileName; + const path = directory + ? `${directory}/${entry.fileName}` + : entry.fileName; const language = entry.fileName.includes("zh-Hans") ? "zh-Hans" : entry.fileName.includes(".en.") @@ -1153,7 +1375,8 @@ if (finishedForPlayback[0]) { }); } const previousEpisode = finishedForPlayback.find( - (animation) => animation.animation?.tmdbId === "209867" && animation.episode === 27, + (animation) => + animation.animation?.tmdbId === "209867" && animation.episode === 27, ); if (previousEpisode) { const path = "Season 1/EP01.mp4"; @@ -1172,7 +1395,8 @@ let mockIncidents = [ type: "feedFailure", severity: "error", title: "Mikan RSS returned HTTP 503", - detail: "The feed could not be refreshed during the last three sync attempts.", + detail: + "The feed could not be refreshed during the last three sync attempts.", sourceId: feeds[0]?.id ?? null, detectedAt: new Date(Date.now() - 42 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 12 * 60_000).toISOString(), @@ -1187,7 +1411,8 @@ let mockIncidents = [ type: "downloadStalled", severity: "warning", title: "Download has not progressed for 20 minutes", - detail: "No peers are currently available. Retry will reannounce the torrent.", + detail: + "No peers are currently available. Retry will reannounce the torrent.", sourceId: finishedForPlayback[1]?.id ?? null, detectedAt: new Date(Date.now() - 25 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 5 * 60_000).toISOString(), @@ -1217,7 +1442,8 @@ let mockIncidents = [ type: "fileMappingFailure", severity: "error", title: "Downloaded files could not be mapped", - detail: "The download completed, but no playable video mapping was produced.", + detail: + "The download completed, but no playable video mapping was produced.", sourceId: finishedForPlayback[2]?.id ?? null, detectedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), updatedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), @@ -1509,6 +1735,79 @@ async function route(method, pathname, searchParams, req, res) { } } + // --- Controlled plugins --- + + if (method === "GET" && pathname === "/api/plugins") { + return json(res, mockPlugins); + } + + if (method === "POST" && pathname === "/api/plugins/preview") { + await readBody(req); + return json(res, { + token: randomBytes(24).toString("hex"), + packageSha256: + "7d9fc7ef8ef86ffa9c4965f05b3e715b5f4ef872af326ed8bc4761a0003f71d3", + manifest: mockPluginManifest, + compatibilityErrors: [], + isSignatureTrusted: false, + signatureStatus: "Package is unsigned (mock development mode).", + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + }); + } + + if (method === "POST" && pathname === "/api/plugins/preview-remote") { + return json( + res, + { + code: "remote_install_disabled", + message: "Remote JavaScript installation is disabled.", + }, + 403, + ); + } + + if ( + method === "POST" && + (pathname === "/api/plugins/install" || + /^\/api\/plugins\/[^/]+\/upgrade$/.test(pathname)) + ) { + await readBody(req); + mockPlugins = [ + ...mockPlugins.filter( + (plugin) => plugin.manifest.id !== mockPluginManifest.id, + ), + mockInstalledPlugin(), + ]; + return json(res, { + id: mockPluginManifest.id, + version: mockPluginManifest.version, + isUpgrade: pathname.endsWith("/upgrade"), + compatibilityErrors: [], + }); + } + + { + const match = pathname.match(/^\/api\/plugins\/([^/]+)\/(enable|disable)$/); + if (match && method === "POST") { + const plugin = mockPlugins.find( + (candidate) => candidate.manifest.id === decodeURIComponent(match[1]), + ); + if (!plugin) return json(res, { code: "plugin_not_found" }, 404); + plugin.isEnabled = match[2] === "enable"; + return empty(res); + } + } + + { + const match = pathname.match(/^\/api\/plugins\/([^/]+)$/); + if (match && method === "DELETE") { + mockPlugins = mockPlugins.filter( + (plugin) => plugin.manifest.id !== decodeURIComponent(match[1]), + ); + return empty(res); + } + } + // --- Playback continuity --- if (method === "GET" && pathname === "/api/playback/continue") { @@ -1555,7 +1854,11 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/playback/context") { const animation = animations.get(searchParams.get("animationInfoId")); const path = searchParams.get("path"); - if (!animation || !animation.isDownloadFinished || !playablePaths().includes(path)) { + if ( + !animation || + !animation.isDownloadFinished || + !playablePaths().includes(path) + ) { return empty(res, 404); } return json(res, { @@ -1576,7 +1879,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/progress") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const positionSeconds = Math.max(0, Number(body.positionSeconds) || 0); const durationSeconds = Math.max(0, Number(body.durationSeconds) || 0); const key = playbackKey(animation.id, body.path); @@ -1586,7 +1890,10 @@ async function route(method, pathname, searchParams, req, res) { (durationSeconds > 0 && positionSeconds / durationSeconds >= 0.9); const updatedAt = new Date().toISOString(); const stored = { - positionSeconds: Math.min(positionSeconds, durationSeconds || positionSeconds), + positionSeconds: Math.min( + positionSeconds, + durationSeconds || positionSeconds, + ), durationSeconds, isWatched, updatedAt, @@ -1599,7 +1906,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/watched") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const key = playbackKey(animation.id, body.path); const previous = playbackProgress.get(key) ?? { positionSeconds: 0, @@ -1638,7 +1946,10 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/incidents") { const type = searchParams.get("type"); const includeResolved = searchParams.get("includeResolved") === "true"; - const skip = Math.max(0, parseInt(searchParams.get("skip") ?? "0", 10) || 0); + const skip = Math.max( + 0, + parseInt(searchParams.get("skip") ?? "0", 10) || 0, + ); const take = Math.min( 200, Math.max(1, parseInt(searchParams.get("take") ?? "50", 10) || 50), @@ -1680,7 +1991,8 @@ async function route(method, pathname, searchParams, req, res) { incident.lastRetryError = null; incident.canRetry = false; } else { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; } results.push({ incidentId: incident.id, @@ -1701,15 +2013,21 @@ async function route(method, pathname, searchParams, req, res) { if (method === "POST" && match) { const incident = mockIncidents.find((item) => item.id === match[1]); if (!incident) return empty(res, 404); - if (incident.resolvedAt) return json(res, { error: "Already resolved" }, 409); + if (incident.resolvedAt) + return json(res, { error: "Already resolved" }, 409); incident.retryCount += 1; incident.lastRetryAt = new Date().toISOString(); incident.updatedAt = incident.lastRetryAt; if (incident.type === "diskSpaceLow") { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; return json( res, - { incidentId: incident.id, success: false, error: incident.lastRetryError }, + { + incidentId: incident.id, + success: false, + error: incident.lastRetryError, + }, 422, ); } @@ -2026,8 +2344,7 @@ async function route(method, pathname, searchParams, req, res) { g.episodes.sort( (a, b) => new Date(b.publishTime).getTime() - - new Date(a.publishTime).getTime() || - b.id.localeCompare(a.id), + new Date(a.publishTime).getTime() || b.id.localeCompare(a.id), ); g.episodeCount = g.episodes.length; return g; @@ -2324,11 +2641,15 @@ async function route(method, pathname, searchParams, req, res) { // POST /api/subscription-policies/:feedId/simulate { - const m = pathname.match(/^\/api\/subscription-policies\/([^/]+)\/simulate$/); + const m = pathname.match( + /^\/api\/subscription-policies\/([^/]+)\/simulate$/, + ); if (method === "POST" && m) { const feedId = decodeURIComponent(m[1]); if (!feeds.some((feed) => feed.id === feedId)) return empty(res, 404); - return readBody(req).then((body) => json(res, simulatePolicy(feedId, body))); + return readBody(req).then((body) => + json(res, simulatePolicy(feedId, body)), + ); } } @@ -2349,18 +2670,34 @@ async function route(method, pathname, searchParams, req, res) { const existing = subscriptionPolicies.get(feedId); const policy = { feedId, - subtitleGroups: Array.isArray(body.subtitleGroups) ? body.subtitleGroups : [], - resolutions: Array.isArray(body.resolutions) ? body.resolutions : [], + subtitleGroups: Array.isArray(body.subtitleGroups) + ? body.subtitleGroups + : [], + resolutions: Array.isArray(body.resolutions) + ? body.resolutions + : [], codecs: Array.isArray(body.codecs) ? body.codecs : [], languages: Array.isArray(body.languages) ? body.languages : [], - minSizeBytes: typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, - maxSizeBytes: typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, - excludedKeywords: Array.isArray(body.excludedKeywords) ? body.excludedKeywords : [], - mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes(body.mode) ? body.mode : "ManualConfirm", + minSizeBytes: + typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, + maxSizeBytes: + typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, + excludedKeywords: Array.isArray(body.excludedKeywords) + ? body.excludedKeywords + : [], + mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes( + body.mode, + ) + ? body.mode + : "ManualConfirm", createdAt: existing?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), }; - if (policy.minSizeBytes != null && policy.maxSizeBytes != null && policy.minSizeBytes > policy.maxSizeBytes) { + if ( + policy.minSizeBytes != null && + policy.maxSizeBytes != null && + policy.minSizeBytes > policy.maxSizeBytes + ) { return json(res, { error: "Invalid size range" }, 400); } subscriptionPolicies.set(feedId, policy); @@ -2903,8 +3240,7 @@ server.listen(PORT, () => { (animation) => animation.isDownloadFinished, ).length; const downloadingCount = [...animations.values()].filter( - (animation) => - animation.isDownloadTracked && !animation.isDownloadFinished, + (animation) => animation.isDownloadTracked && !animation.isDownloadFinished, ).length; console.log( ` ${animations.size} anime entries (${finishedCount} finished, ${downloadingCount} active downloads, rest untracked)`, diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/PluginSettingsSection.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/PluginSettingsSection.tsx new file mode 100644 index 0000000..d72a105 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/PluginSettingsSection.tsx @@ -0,0 +1,403 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { + AlertTriangle, + CheckCircle2, + PackageCheck, + Plug, + ShieldCheck, + ShieldX, + Trash2, + Upload, +} from "lucide-react"; + +import { + installPlugin, + previewPlugin, + setPluginEnabled, + uninstallPlugin, +} from "../../plugins/api"; +import { usePlugins } from "../../plugins/hooks"; +import { PluginCapabilities, PluginPackagePreview } from "../../plugins/types"; +import { useToast } from "../ToastProvider"; +import { Button } from "../ui/Button"; +import { Card } from "../ui/Card"; +import { Spinner } from "../ui/Spinner"; + +export const PluginSettingsSection: React.FC = () => { + const { t } = useTranslation("settings"); + const { addToast } = useToast(); + const { data: plugins, error, mutate } = usePlugins(); + const [file, setFile] = React.useState(null); + const [preview, setPreview] = React.useState( + null, + ); + const [approved, setApproved] = React.useState(false); + const [busy, setBusy] = React.useState(false); + + const run = React.useCallback( + async (operation: () => Promise, success: string) => { + setBusy(true); + try { + await operation(); + await mutate(); + addToast({ title: success, color: "success" }); + } catch (operationError) { + addToast({ + title: t("system.plugins.operationFailed"), + text: + operationError instanceof Error + ? operationError.message + : String(operationError), + color: "danger", + }); + } finally { + setBusy(false); + } + }, + [addToast, mutate, t], + ); + + const inspect = async () => { + if (!file) return; + setBusy(true); + try { + setPreview(await previewPlugin(file)); + setApproved(false); + } catch (previewError) { + addToast({ + title: t("system.plugins.previewFailed"), + text: + previewError instanceof Error + ? previewError.message + : String(previewError), + color: "danger", + }); + } finally { + setBusy(false); + } + }; + + const isUpgrade = Boolean( + preview && + plugins?.some((plugin) => plugin.manifest.id === preview.manifest.id), + ); + + return ( +
+
+

+ {t("system.plugins.eyebrow")} +

+

+ {t("system.plugins.title")} +

+

+ {t("system.plugins.description")} +

+
+ + } + title={t("system.plugins.install.title")} + description={t("system.plugins.install.description")} + > +
+ { + setFile(event.target.files?.[0] ?? null); + setPreview(null); + setApproved(false); + }} + className="min-w-0 flex-1 text-sm text-muted file:mr-3 file:rounded-md file:border-0 file:bg-canvas file:px-3 file:py-2 file:text-sm file:text-foreground" + /> + +
+ + {preview ? ( +
+
+
+

+ {preview.manifest.name} {preview.manifest.version} +

+

+ {preview.manifest.id} · API {preview.manifest.apiVersion} · + SHA-256 {preview.packageSha256} +

+
+ + {preview.isSignatureTrusted ? ( + + ) : ( + + )} + {preview.signatureStatus} + +
+ + + + {preview.compatibilityErrors.length ? ( +
+ {preview.compatibilityErrors.map((message) => ( +

+ + {message} +

+ ))} +
+ ) : null} + + + +
+ ) : null} +
+ +
+

+ {t("system.plugins.installed.title")} +

+ {error ? ( +

{t("system.plugins.loadFailed")}

+ ) : !plugins ? ( +
+ +
+ ) : plugins.length === 0 ? ( +

+ {t("system.plugins.installed.empty")} +

+ ) : ( + plugins.map((plugin) => ( + + ) : ( + + ) + } + title={`${plugin.manifest.name} ${plugin.manifest.version}`} + description={plugin.manifest.description ?? plugin.manifest.id} + footer={ +
+

+ {t("system.plugins.installed.dataPolicy")} +

+
+ + + +
+
+ } + > +
+ + {t("system.plugins.installed.api", { + version: plugin.manifest.apiVersion, + })} + + + {t("system.plugins.installed.health", { + status: plugin.health.status, + })} + + + {t("system.plugins.installed.failures", { + count: plugin.health.consecutiveFailures, + })} + +
+ + {plugin.compatibilityErrors.map((message) => ( +

+ + {message} +

+ ))} + {plugin.health.lastError ? ( +

+ {plugin.health.lastError} +

+ ) : null} +
+ )) + )} +
+
+ ); +}; + +const CapabilityList: React.FC<{ + capabilities: PluginCapabilities; + compact?: boolean; +}> = ({ capabilities, compact = false }) => { + const { t } = useTranslation("settings"); + const values = [ + ...capabilities.networkDomains.map((domain) => + t("system.plugins.capabilities.network", { value: domain }), + ), + ...capabilities.fileRoots.map((root) => + t("system.plugins.capabilities.files", { value: root }), + ), + ...(capabilities.notifications + ? [t("system.plugins.capabilities.notifications")] + : []), + ...(capabilities.downloadControl + ? [t("system.plugins.capabilities.downloads")] + : []), + ...(capabilities.storageAccess + ? [t("system.plugins.capabilities.storage")] + : []), + ...(capabilities.backgroundTasks + ? [t("system.plugins.capabilities.background")] + : []), + ]; + return ( +
+ {!compact ? ( +

+ {t("system.plugins.capabilities.title")} +

+ ) : null} +
+ {(values.length ? values : [t("system.plugins.capabilities.none")]).map( + (value) => ( + + {value} + + ), + )} +
+
+ ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx b/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx index 741573f..7d523e5 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/settings/SettingsNavigation.tsx @@ -1,7 +1,14 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Activity, Bot, Database, Download, Network } from "lucide-react"; +import { + Activity, + Bot, + Database, + Download, + Network, + Puzzle, +} from "lucide-react"; import { cn } from "../../lib/cn"; import { Select } from "./SettingsControls"; @@ -12,6 +19,7 @@ export const settingsSectionIds = [ "media", "health", "access", + "plugins", ] as const; export type SettingsSectionId = (typeof settingsSectionIds)[number]; @@ -22,6 +30,7 @@ const sectionIcons: Record = { media: , health: , access: , + plugins: , }; export interface SettingsNavigationProps { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json index 9c91e39..e0171a5 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/settings.json @@ -10,7 +10,8 @@ "downloads": "Downloads & storage", "media": "Media & metadata", "health": "Health monitoring", - "access": "Access protocols" + "access": "Access protocols", + "plugins": "Plugins" }, "pendingRestart": { "title": "Some settings are waiting for a restart", @@ -182,6 +183,52 @@ "maxConnections": "Maximum connections", "restartHelp": "NFS binds its listening port when the application starts. Changes are saved now and used after a restart." } + }, + "plugins": { + "eyebrow": "Controlled extensions", + "title": "Plugin platform", + "description": "Install local, versioned packages through checksum, signature, compatibility, and explicit capability review. JavaScript runs in a short-lived isolated worker and never receives .NET services.", + "loadFailed": "Could not load installed plugins.", + "previewFailed": "Package inspection failed", + "operationFailed": "Plugin operation failed", + "enabled": "Plugin enabled", + "disabled": "Plugin disabled", + "uninstalled": "Plugin uninstalled; configuration and data were retained", + "deleted": "Plugin, configuration, and data permanently deleted", + "install": { + "title": "Inspect a local package", + "description": "Remote URL installation is disabled. Upload a .sdwpkg or .zip obtained through a trusted administrative channel; no code runs during inspection or installation.", + "inspect": "Inspect package", + "approve": "I reviewed and approve every capability shown above. Any changed package or manifest requires a new approval.", + "install": "Install disabled", + "upgrade": "Upgrade disabled", + "installed": "Plugin installed disabled; enable it after compatibility review", + "upgraded": "Plugin upgraded disabled; configuration was preserved and the declared data strategy was applied" + }, + "installed": { + "title": "Installed plugins", + "empty": "No plugin packages are installed.", + "enable": "Enable", + "disable": "Disable", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall {{name}}? Its configuration and plugin data will be retained for a future reinstall.", + "deleteData": "Delete all", + "deleteConfirm": "Permanently delete {{name}}, its configuration, and all plugin data? This cannot be undone.", + "dataPolicy": "Uninstall preserves configuration and data by default. Delete all is irreversible. Data-version changes require an explicit reset strategy.", + "api": "API {{version}}", + "health": "Health: {{status}}", + "failures": "Consecutive failures: {{count}}" + }, + "capabilities": { + "title": "Requested capabilities", + "none": "No privileged capabilities", + "network": "Network: {{value}}", + "files": "Files: {{value}}", + "notifications": "Publish notifications", + "downloads": "Control downloads", + "storage": "Plugin-scoped storage", + "background": "Background tasks" + } } }, "mediaLibrary": { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json index 40eebfe..c75398f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/settings.json @@ -10,7 +10,8 @@ "downloads": "ダウンロードと保存先", "media": "メディアとメタデータ", "health": "ヘルス監視", - "access": "アクセスプロトコル" + "access": "アクセスプロトコル", + "plugins": "プラグイン" }, "pendingRestart": { "title": "再起動待ちの設定があります", @@ -182,6 +183,52 @@ "maxConnections": "最大接続数", "restartHelp": "NFS はアプリケーション起動時に待受ポートをバインドします。変更は保存され、再起動後に使用されます。" } + }, + "plugins": { + "eyebrow": "制御された拡張", + "title": "プラグインプラットフォーム", + "description": "ローカルのバージョン付きパッケージは、チェックサム、署名、互換性、明示的な権限確認を経てインストールされます。JavaScript は短命な分離ワーカーで実行され、.NET サービスにはアクセスできません。", + "loadFailed": "インストール済みプラグインを読み込めませんでした。", + "previewFailed": "パッケージ検査に失敗しました", + "operationFailed": "プラグイン操作に失敗しました", + "enabled": "プラグインを有効にしました", + "disabled": "プラグインを無効にしました", + "uninstalled": "プラグインを削除し、設定とデータを保持しました", + "deleted": "プラグイン、設定、データを完全に削除しました", + "install": { + "title": "ローカルパッケージを検査", + "description": "リモート URL からのインストールは無効です。信頼できる管理経路で取得した .sdwpkg または .zip をアップロードしてください。検査・インストール中にコードは実行されません。", + "inspect": "パッケージを検査", + "approve": "上記のすべての権限を確認し、承認します。パッケージまたは manifest が変わった場合は再承認が必要です。", + "install": "無効状態でインストール", + "upgrade": "無効状態でアップグレード", + "installed": "プラグインを無効状態でインストールしました", + "upgraded": "プラグインを無効状態で更新し、設定と宣言済みデータ戦略を適用しました" + }, + "installed": { + "title": "インストール済みプラグイン", + "empty": "プラグインはまだインストールされていません。", + "enable": "有効化", + "disable": "無効化", + "uninstall": "アンインストール", + "uninstallConfirm": "{{name}} を削除しますか?設定とデータは再インストール用に保持されます。", + "deleteData": "すべて削除", + "deleteConfirm": "{{name}}、その設定、すべてのプラグインデータを完全に削除しますか?この操作は元に戻せません。", + "dataPolicy": "アンインストール時は設定とデータを既定で保持します。「すべて削除」は元に戻せません。データバージョン変更には明示的な reset 戦略が必要です。", + "api": "API {{version}}", + "health": "状態: {{status}}", + "failures": "連続失敗: {{count}}" + }, + "capabilities": { + "title": "要求された権限", + "none": "特権なし", + "network": "ネットワーク: {{value}}", + "files": "ファイル: {{value}}", + "notifications": "通知を発行", + "downloads": "ダウンロード制御", + "storage": "プラグイン専用ストレージ", + "background": "バックグラウンドタスク" + } } }, "mediaLibrary": { diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json index 1d101be..3dcdee7 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/settings.json @@ -10,7 +10,8 @@ "downloads": "下载与存储", "media": "媒体与元数据", "health": "健康监控", - "access": "访问协议" + "access": "访问协议", + "plugins": "插件" }, "pendingRestart": { "title": "存在等待重启生效的设置", @@ -182,6 +183,52 @@ "maxConnections": "最大连接数", "restartHelp": "NFS 在应用启动时绑定监听端口。本区更改会被保存,但需要重启应用后才会使用。" } + }, + "plugins": { + "eyebrow": "受控扩展", + "title": "插件平台", + "description": "本地版本化插件包必须经过校验和、签名、兼容性和显式权限审查。JavaScript 在短生命周期隔离进程中执行,无法获取 .NET 服务。", + "loadFailed": "无法加载已安装插件。", + "previewFailed": "插件包检查失败", + "operationFailed": "插件操作失败", + "enabled": "插件已启用", + "disabled": "插件已停用", + "uninstalled": "插件已卸载,配置和数据已保留", + "deleted": "插件、配置和数据已永久删除", + "install": { + "title": "检查本地插件包", + "description": "远程 URL 安装已禁用。请从可信管理渠道取得 .sdwpkg 或 .zip 后上传;检查和安装阶段不会执行代码。", + "inspect": "检查插件包", + "approve": "我已审阅并批准上方列出的全部权限。插件包或 manifest 发生变化后必须重新批准。", + "install": "安装(默认停用)", + "upgrade": "升级(默认停用)", + "installed": "插件已安装但尚未启用,请完成兼容性复核后启用", + "upgraded": "插件已升级但尚未启用;配置已保留,并已应用声明的数据策略" + }, + "installed": { + "title": "已安装插件", + "empty": "尚未安装插件包。", + "enable": "启用", + "disable": "停用", + "uninstall": "卸载", + "uninstallConfirm": "卸载 {{name}}?配置和插件数据会保留,供以后重新安装。", + "deleteData": "全部删除", + "deleteConfirm": "永久删除 {{name}}、其配置和所有插件数据?此操作无法撤销。", + "dataPolicy": "卸载默认保留配置和数据;“全部删除”无法撤销。数据版本变化必须显式声明 reset 策略。", + "api": "API {{version}}", + "health": "健康:{{status}}", + "failures": "连续失败:{{count}}" + }, + "capabilities": { + "title": "申请的权限", + "none": "无特权能力", + "network": "网络:{{value}}", + "files": "文件:{{value}}", + "notifications": "发布通知", + "downloads": "控制下载", + "storage": "插件专属存储", + "background": "后台任务" + } } }, "mediaLibrary": { diff --git a/SecondDimensionWatcherReDive.Client/src/pages/SettingsPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/SettingsPage.tsx index 55dae21..3cf31a9 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/SettingsPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { AiSettingsSection } from "../components/settings/AiSettingsSection"; import { DownloadSettingsSection } from "../components/settings/DownloadSettingsSection"; import { HealthSettingsSection } from "../components/settings/HealthSettingsSection"; import { MediaSettingsSection } from "../components/settings/MediaSettingsSection"; +import { PluginSettingsSection } from "../components/settings/PluginSettingsSection"; import { SettingsNavigation, SettingsSectionId, @@ -160,6 +161,8 @@ const ActiveSection: React.FC = ({ ); case "access": return ; + case "plugins": + return ; case "ai": default: return ; diff --git a/SecondDimensionWatcherReDive.Client/src/plugins/api.ts b/SecondDimensionWatcherReDive.Client/src/plugins/api.ts new file mode 100644 index 0000000..ca45af7 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/plugins/api.ts @@ -0,0 +1,49 @@ +import fetcher from "../auth/httpClient"; +import { + InstalledPlugin, + PluginCapabilities, + PluginPackagePreview, +} from "./types"; + +export const getPlugins = () => fetcher("/api/plugins"); + +export const previewPlugin = (packageFile: File) => { + const body = new FormData(); + body.append("package", packageFile); + return fetcher("/api/plugins/preview", { + method: "POST", + body, + }); +}; + +export const installPlugin = ( + preview: PluginPackagePreview, + upgrade: boolean, +) => + fetcher( + `/api/plugins${upgrade ? `/${preview.manifest.id}/upgrade` : "/install"}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + previewToken: preview.token, + expectedSha256: preview.packageSha256, + approvedCapabilities: preview.manifest + .capabilities satisfies PluginCapabilities, + }), + }, + ); + +export const setPluginEnabled = (id: string, enabled: boolean) => + fetcher( + `/api/plugins/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`, + { + method: "POST", + }, + ); + +export const uninstallPlugin = (id: string, deleteData = false) => + fetcher( + `/api/plugins/${encodeURIComponent(id)}?deleteData=${String(deleteData)}`, + { method: "DELETE" }, + ); diff --git a/SecondDimensionWatcherReDive.Client/src/plugins/hooks.ts b/SecondDimensionWatcherReDive.Client/src/plugins/hooks.ts new file mode 100644 index 0000000..4087ef2 --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/plugins/hooks.ts @@ -0,0 +1,5 @@ +import useSWR from "swr"; + +import { getPlugins } from "./api"; + +export const usePlugins = () => useSWR("/api/plugins", getPlugins); diff --git a/SecondDimensionWatcherReDive.Client/src/plugins/types.ts b/SecondDimensionWatcherReDive.Client/src/plugins/types.ts new file mode 100644 index 0000000..16e462a --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/plugins/types.ts @@ -0,0 +1,52 @@ +export interface PluginCapabilities { + networkDomains: string[]; + fileRoots: string[]; + notifications: boolean; + downloadControl: boolean; + storageAccess: boolean; + backgroundTasks: boolean; +} + +export interface PluginManifest { + id: string; + name: string; + version: string; + apiVersion: string; + entryPoint: string; + description?: string; + dependencies: { id: string; minimumVersion: string }[]; + capabilities: PluginCapabilities; + platforms: string[]; + fileSha256: Record; + signaturePublisher?: string; + signatureAlgorithm?: string; + providers: { kind: string; name: string; handlers: Record }[]; + dataVersion: number; + dataMigration?: { strategy: string; description?: string }; +} + +export interface PluginPackagePreview { + token: string; + packageSha256: string; + manifest: PluginManifest; + compatibilityErrors: string[]; + isSignatureTrusted: boolean; + signatureStatus: string; + expiresAt: string; +} + +export interface InstalledPlugin { + manifest: PluginManifest; + isEnabled: boolean; + approvedCapabilities: PluginCapabilities; + compatibilityErrors: string[]; + health: { + status: string; + consecutiveFailures: number; + lastSuccessAt?: string; + lastFailureAt?: string; + lastError?: string; + circuitOpenUntil?: string; + }; + hasConfiguration: boolean; +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IPluginCatalogRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IPluginCatalogRepository.cs new file mode 100644 index 0000000..13418d6 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IPluginCatalogRepository.cs @@ -0,0 +1,31 @@ +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public sealed record PluginCatalogEntry( + PluginManifest Manifest, + bool IsEnabled, + PluginCapabilities ApprovedCapabilities, + PluginHealth Health, + string PackageDirectory, + string ConfigurationJson, + int DataVersion, + string? PublisherFingerprint); + +public sealed record RetainedPluginData( + string Id, + string ConfigurationJson, + int DataVersion, + DateTimeOffset RetainedAt, + string? PublisherFingerprint); + +public interface IPluginCatalogRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken); + Task FindAsync(string id, CancellationToken cancellationToken); + Task SaveAsync(PluginCatalogEntry entry, CancellationToken cancellationToken); + Task RemoveAsync(string id, CancellationToken cancellationToken); + Task FindRetainedAsync(string id, CancellationToken cancellationToken); + Task SaveRetainedAsync(RetainedPluginData retained, CancellationToken cancellationToken); + Task RemoveRetainedAsync(string id, CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Plugin/IJavaScriptPluginLoader.cs b/SecondDimensionWatcherReDive.Framework/Plugin/IJavaScriptPluginLoader.cs index a51f69d..ecc50ea 100644 --- a/SecondDimensionWatcherReDive.Framework/Plugin/IJavaScriptPluginLoader.cs +++ b/SecondDimensionWatcherReDive.Framework/Plugin/IJavaScriptPluginLoader.cs @@ -1,6 +1,20 @@ namespace SecondDimensionWatcherReDive.Framework.Plugin; +/// +/// Provides the two-phase, local-package installation boundary for JavaScript plugins. +/// A package is never evaluated by either operation; execution is only possible after +/// an explicit capability approval and a separate enable operation. +/// public interface IJavaScriptPluginLoader { - public IPlugin LoadJavaScriptPlugin(string script); -} \ No newline at end of file + Task PreviewPackageAsync( + Stream package, + string fileName, + CancellationToken cancellationToken); + + Task InstallPackageAsync( + string previewToken, + string expectedSha256, + PluginCapabilities approvedCapabilities, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Plugin/INotificationProvider.cs b/SecondDimensionWatcherReDive.Framework/Plugin/INotificationProvider.cs new file mode 100644 index 0000000..9083a0d --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Plugin/INotificationProvider.cs @@ -0,0 +1,16 @@ +namespace SecondDimensionWatcherReDive.Framework.Plugin; + +public sealed record PluginNotification( + string Title, + string Message, + string Severity = "info", + IReadOnlyDictionary? Metadata = null); + +public interface INotificationProvider +{ + string Name { get; } + + Task SendAsync( + PluginNotification notification, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Plugin/IPluginServices.cs b/SecondDimensionWatcherReDive.Framework/Plugin/IPluginServices.cs index eedee23..021971a 100644 --- a/SecondDimensionWatcherReDive.Framework/Plugin/IPluginServices.cs +++ b/SecondDimensionWatcherReDive.Framework/Plugin/IPluginServices.cs @@ -17,8 +17,4 @@ public interface IPluginServices /// Thrown when TParams type is incorrect for the specified event name. IPluginEventRegister GetRegister(string eventName); - /// - /// Represents a service provider for plugin-related operations. - /// - IServiceProvider ServiceProvider { get; } } diff --git a/SecondDimensionWatcherReDive.Framework/Plugin/PluginManifest.cs b/SecondDimensionWatcherReDive.Framework/Plugin/PluginManifest.cs new file mode 100644 index 0000000..580166e --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Plugin/PluginManifest.cs @@ -0,0 +1,160 @@ +using System.Text; +using System.Text.Json; + +namespace SecondDimensionWatcherReDive.Framework.Plugin; + +public static class PluginApi +{ + public const string CurrentVersion = "1.0"; +} + +public sealed record PluginManifest +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string Version { get; init; } + public required string ApiVersion { get; init; } + public required string EntryPoint { get; init; } + public string? Description { get; init; } + public IReadOnlyList Dependencies { get; init; } = []; + public PluginCapabilities Capabilities { get; init; } = new(); + public IReadOnlyList Platforms { get; init; } = ["any"]; + public PluginIntegrity? Integrity { get; init; } + public PluginSignature? Signature { get; init; } + public IReadOnlyList Providers { get; init; } = []; + public int DataVersion { get; init; } = 1; + public PluginDataMigration? DataMigration { get; init; } +} + +public sealed record PluginDependency(string Id, string MinimumVersion); + +public sealed record PluginCapabilities +{ + public IReadOnlyList NetworkDomains { get; init; } = []; + public IReadOnlyList FileRoots { get; init; } = []; + public bool Notifications { get; init; } + public bool DownloadControl { get; init; } + public bool StorageAccess { get; init; } + public bool BackgroundTasks { get; init; } +} + +public sealed record PluginIntegrity +{ + /// + /// SHA-256 digests for every regular package file except manifest.json. Paths use '/' separators. + /// The exact path set and every digest are covered by the publisher signature. + /// + public IReadOnlyDictionary Files { get; init; } = + new Dictionary(StringComparer.Ordinal); +} + +public sealed record PluginSignature( + string Publisher, + string Algorithm, + string Value); + +public sealed record PluginProviderDeclaration +{ + public required string Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyDictionary Handlers { get; init; } +} + +public sealed record PluginDataMigration +{ + /// Preserve or Reset. A data version change requires an explicit Reset. + public required string Strategy { get; init; } + public string? Description { get; init; } +} + +public sealed record PluginPackagePreview( + string Token, + string PackageSha256, + PluginManifest Manifest, + IReadOnlyList CompatibilityErrors, + bool IsSignatureTrusted, + string SignatureStatus, + DateTimeOffset ExpiresAt); + +public sealed record PluginInstallResult( + string Id, + string Version, + bool IsUpgrade, + IReadOnlyList CompatibilityErrors); + +public sealed record PluginHealth( + string Status, + int ConsecutiveFailures, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError, + DateTimeOffset? CircuitOpenUntil); + +public sealed record InstalledPlugin( + PluginManifest Manifest, + bool IsEnabled, + PluginCapabilities ApprovedCapabilities, + IReadOnlyList CompatibilityErrors, + PluginHealth Health, + JsonElement Configuration, + bool DataRetainedFromUninstall = false); + +/// +/// Produces the unambiguous payload covered by an RSA-SHA256 publisher signature. +/// Every execution-relevant manifest field is included; the signature value itself is excluded. +/// +public static class PluginSignaturePayload +{ + public static byte[] Create(PluginManifest manifest) + { + var lines = new List { "sdw-plugin-signature-v2" }; + Add(lines, "id", manifest.Id); + Add(lines, "name", manifest.Name); + Add(lines, "description", manifest.Description ?? string.Empty); + Add(lines, "version", manifest.Version); + Add(lines, "api", manifest.ApiVersion); + Add(lines, "entry", manifest.EntryPoint); + foreach (var file in (manifest.Integrity?.Files ?? new Dictionary()) + .OrderBy(value => value.Key, StringComparer.Ordinal)) + { + Add(lines, "file-path", file.Key); + Add(lines, "file-sha256", file.Value.ToLowerInvariant()); + } + Add(lines, "data-version", manifest.DataVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Add(lines, "migration-strategy", manifest.DataMigration?.Strategy ?? string.Empty); + Add(lines, "migration-description", manifest.DataMigration?.Description ?? string.Empty); + Add(lines, "notifications", manifest.Capabilities.Notifications ? "1" : "0"); + Add(lines, "download-control", manifest.Capabilities.DownloadControl ? "1" : "0"); + Add(lines, "storage-access", manifest.Capabilities.StorageAccess ? "1" : "0"); + Add(lines, "background-tasks", manifest.Capabilities.BackgroundTasks ? "1" : "0"); + foreach (var value in manifest.Capabilities.NetworkDomains.Order(StringComparer.OrdinalIgnoreCase)) + Add(lines, "network-domain", value.ToLowerInvariant()); + foreach (var value in manifest.Capabilities.FileRoots.Order(StringComparer.Ordinal)) + Add(lines, "file-root", value); + foreach (var value in manifest.Platforms.Order(StringComparer.OrdinalIgnoreCase)) + Add(lines, "platform", value.ToLowerInvariant()); + foreach (var dependency in manifest.Dependencies + .OrderBy(value => value.Id, StringComparer.Ordinal) + .ThenBy(value => value.MinimumVersion, StringComparer.Ordinal)) + { + Add(lines, "dependency-id", dependency.Id); + Add(lines, "dependency-version", dependency.MinimumVersion); + } + foreach (var provider in manifest.Providers + .OrderBy(value => value.Kind, StringComparer.Ordinal) + .ThenBy(value => value.Name, StringComparer.Ordinal)) + { + Add(lines, "provider-kind", provider.Kind); + Add(lines, "provider-name", provider.Name); + foreach (var handler in provider.Handlers.OrderBy(value => value.Key, StringComparer.Ordinal)) + { + Add(lines, "handler-operation", handler.Key); + Add(lines, "handler-name", handler.Value); + } + } + return Encoding.UTF8.GetBytes(string.Join('\n', lines)); + } + + private static void Add(ICollection lines, string name, string value) + => lines.Add($"{name}:{Convert.ToBase64String(Encoding.UTF8.GetBytes(value))}"); +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs new file mode 100644 index 0000000..04449a9 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs @@ -0,0 +1,149 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Plugins; + +[TestClass] +public sealed class PluginApiTests +{ + [TestMethod] + public async Task ManagementApi_RequiresPreviewApproval_AndSupportsLifecycle() + { + await using var factory = new WebDavWebApplicationFactory(); + using var client = factory.CreateJwtClient(); + var id = $"test.api-{Guid.NewGuid():N}"; + await using var package = CreatePackage(id, "1.0"); + using var form = new MultipartFormDataContent(); + form.Add(new StreamContent(package), "package", $"{id}.sdwpkg"); + + using var previewResponse = await client.PostAsync("/api/plugins/preview", form); + Assert.AreEqual(HttpStatusCode.OK, previewResponse.StatusCode, + await previewResponse.Content.ReadAsStringAsync()); + using var preview = JsonDocument.Parse(await previewResponse.Content.ReadAsStringAsync()); + var previewRoot = preview.RootElement; + Assert.AreEqual(id, previewRoot.GetProperty("manifest").GetProperty("id").GetString()); + Assert.IsFalse(previewRoot.GetProperty("isSignatureTrusted").GetBoolean()); + var capabilities = previewRoot.GetProperty("manifest").GetProperty("capabilities").Clone(); + + using var installResponse = await client.PostAsJsonAsync("/api/plugins/install", new + { + previewToken = previewRoot.GetProperty("token").GetString(), + expectedSha256 = previewRoot.GetProperty("packageSha256").GetString(), + approvedCapabilities = capabilities + }); + Assert.AreEqual(HttpStatusCode.OK, installResponse.StatusCode, + await installResponse.Content.ReadAsStringAsync()); + + using var enableResponse = await client.PostAsync($"/api/plugins/{id}/enable", null); + Assert.AreEqual(HttpStatusCode.OK, enableResponse.StatusCode, + await enableResponse.Content.ReadAsStringAsync()); + using var listResponse = await client.GetAsync("/api/plugins"); + Assert.AreEqual(HttpStatusCode.OK, listResponse.StatusCode); + using var list = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + var installed = list.RootElement.EnumerateArray().Single(item => + item.GetProperty("manifest").GetProperty("id").GetString() == id); + Assert.IsTrue(installed.GetProperty("isEnabled").GetBoolean()); + Assert.IsFalse(installed.TryGetProperty("configuration", out _), + "Plugin configuration must not leak from the management response."); + + using var disableResponse = await client.PostAsync($"/api/plugins/{id}/disable", null); + Assert.AreEqual(HttpStatusCode.OK, disableResponse.StatusCode); + using var deleteResponse = await client.DeleteAsync($"/api/plugins/{id}"); + Assert.AreEqual(HttpStatusCode.OK, deleteResponse.StatusCode); + } + + [TestMethod] + public async Task ManagementApi_DisablesRemoteInstall_AndExplainsApiIncompatibility() + { + await using var factory = new WebDavWebApplicationFactory(); + using var client = factory.CreateJwtClient(); + using var remote = await client.PostAsJsonAsync("/api/plugins/preview-remote", new + { + url = "https://untrusted.example/plugin.js", + expectedSha256 = (string?)null + }); + Assert.AreEqual(HttpStatusCode.Forbidden, remote.StatusCode); + StringAssert.Contains(await remote.Content.ReadAsStringAsync(), "remote_install_disabled"); + + var id = $"test.future-{Guid.NewGuid():N}"; + await using var package = CreatePackage(id, "9.0"); + using var form = new MultipartFormDataContent(); + form.Add(new StreamContent(package), "package", $"{id}.sdwpkg"); + using var previewResponse = await client.PostAsync("/api/plugins/preview", form); + using var preview = JsonDocument.Parse(await previewResponse.Content.ReadAsStringAsync()); + var root = preview.RootElement; + using var install = await client.PostAsJsonAsync("/api/plugins/install", new + { + previewToken = root.GetProperty("token").GetString(), + expectedSha256 = root.GetProperty("packageSha256").GetString(), + approvedCapabilities = root.GetProperty("manifest").GetProperty("capabilities").Clone() + }); + Assert.AreEqual(HttpStatusCode.OK, install.StatusCode, await install.Content.ReadAsStringAsync()); + + using var enable = await client.PostAsync($"/api/plugins/{id}/enable", null); + Assert.AreEqual(HttpStatusCode.Conflict, enable.StatusCode); + var error = await enable.Content.ReadAsStringAsync(); + StringAssert.Contains(error, "incompatible"); + StringAssert.Contains(error, "9.0"); + await client.DeleteAsync($"/api/plugins/{id}?deleteData=true"); + } + + [TestMethod] + public async Task ManagementApi_ReturnsBadRequestForMalformedPluginId() + { + await using var factory = new WebDavWebApplicationFactory(); + using var client = factory.CreateJwtClient(); + + using var response = await client.PostAsync("/api/plugins/bad!id/enable", null); + + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + StringAssert.Contains(await response.Content.ReadAsStringAsync(), "invalid_plugin_request"); + } + + private static MemoryStream CreatePackage(string id, string apiVersion) + { + const string script = "globalThis.sdwPlugin={handlers:{ping:()=>({ok:true})}};"; + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(script))).ToLowerInvariant(); + var manifest = JsonSerializer.Serialize(new + { + id, + name = id, + version = "1.0.0", + apiVersion, + entryPoint = "index.js", + dependencies = Array.Empty(), + capabilities = new + { + networkDomains = Array.Empty(), + fileRoots = Array.Empty(), + notifications = false, + downloadControl = false, + storageAccess = false, + backgroundTasks = false + }, + platforms = new[] { "any" }, + integrity = new { files = new Dictionary { ["index.js"] = digest } }, + providers = Array.Empty(), + dataVersion = 1 + }); + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "manifest.json", manifest); + WriteEntry(archive, "index.js", script); + } + stream.Position = 0; + return stream; + } + + private static void WriteEntry(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index ae5cf79..fffafb2 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -46,6 +46,9 @@ static WebDavWebApplicationFactory() Environment.SetEnvironmentVariable("AI__Anthropic__ApiKey", string.Empty); Environment.SetEnvironmentVariable("TmdbApiKey", string.Empty); Environment.SetEnvironmentVariable("Valkey__ConnectionString", string.Empty); + Environment.SetEnvironmentVariable("PluginPlatform__RootPath", + Path.Combine(Path.GetTempPath(), $"sdw-plugin-api-tests-{Environment.ProcessId}")); + Environment.SetEnvironmentVariable("PluginPlatform__AllowUnsignedLocalPackages", "true"); } public List Mappings { get; } = new(); diff --git a/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs b/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs new file mode 100644 index 0000000..eaa3eab --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Framework.Plugin; +using SecondDimensionWatcherReDive.PluginPlatform; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class PluginControllerTests +{ + [TestMethod] + public async Task Preview_WhenStagingCapacityIsReached_ReturnsConflict() + { + var loader = new Mock(); + loader.Setup(value => value.PreviewPackageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("The plugin preview staging limit has been reached.")); + var controller = new PluginController(null!, loader.Object); + await using var content = new MemoryStream([1]); + var package = new FormFile(content, 0, content.Length, "package", "test.sdwpkg"); + + var result = await controller.Preview(package, CancellationToken.None); + + var conflict = Assert.IsInstanceOfType(result.Result); + Assert.AreEqual(StatusCodes.Status409Conflict, conflict.StatusCode); + var error = Assert.IsInstanceOfType(conflict.Value); + Assert.AreEqual("plugin_preview_capacity_reached", error.Code); + } +} diff --git a/SecondDimensionWatcherReDive.Test/PluginDeploymentTests.cs b/SecondDimensionWatcherReDive.Test/PluginDeploymentTests.cs new file mode 100644 index 0000000..dc2ac20 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/PluginDeploymentTests.cs @@ -0,0 +1,17 @@ +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class PluginDeploymentTests +{ + [TestMethod] + public void PodmanCompose_PersistsPluginPlatformUnderApplicationDataVolume() + { + var compose = File.ReadAllText(Path.Combine( + AppContext.BaseDirectory, + "Deployment", + "podman-compose.yml")); + + StringAssert.Contains(compose, "PluginPlatform__RootPath: \"/app/data/plugins\""); + StringAssert.Contains(compose, "- appdata:/app/data"); + } +} diff --git a/SecondDimensionWatcherReDive.Test/PluginEventTests.cs b/SecondDimensionWatcherReDive.Test/PluginEventTests.cs index 94690b8..026ea5e 100644 --- a/SecondDimensionWatcherReDive.Test/PluginEventTests.cs +++ b/SecondDimensionWatcherReDive.Test/PluginEventTests.cs @@ -62,4 +62,45 @@ public async Task Invoke_WithNoHandlers_DoesNotThrow() // If we get here without exception, the test passes } + + [TestMethod] + public async Task Invoke_WhenHandlerFailsOrTimesOut_ContinuesWithRemainingHandlers() + { + var errors = new List(); + var pluginEvent = new PluginEvent( + TimeSpan.FromMilliseconds(40), + errors.Add); + var calls = new List(); + pluginEvent.Register((_, _) => throw new InvalidOperationException("broken")); + pluginEvent.Register(async (_, _) => await Task.Delay(TimeSpan.FromSeconds(5))); + pluginEvent.Register((_, _) => + { + calls.Add(3); + return Task.CompletedTask; + }); + + await pluginEvent.InvokeAsync( + new FileDownloadCompleteParam(Guid.NewGuid(), "/path", "local"), + CancellationToken.None); + + CollectionAssert.AreEqual(new[] { 3 }, calls); + Assert.HasCount(2, errors); + } + + [TestMethod] + public async Task Invoke_WithSingleHandler_PropagatesCallerCancellation() + { + var errors = new List(); + var pluginEvent = new PluginEvent( + TimeSpan.FromSeconds(5), + errors.Add); + pluginEvent.Register(async (_, cancellationToken) => + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken)); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsExactlyAsync(() => pluginEvent.InvokeAsync( + new FileDownloadCompleteParam(Guid.NewGuid(), "/path", "local"), cancellation.Token)); + + Assert.IsEmpty(errors); + } } diff --git a/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs new file mode 100644 index 0000000..029b682 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs @@ -0,0 +1,1382 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Plugin; +using SecondDimensionWatcherReDive.PluginPlatform; +using SecondDimensionWatcherReDive.Repositories; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class PluginPlatformIntegrationTests +{ + private const string PingScript = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + ping(input, configuration) { return { value: input.value, marker: configuration.marker || null }; }, + inspectHost() { return { requireType: typeof require, fetchType: typeof fetch, getTypeType: typeof __sdwHost.GetType }; } + }}; + """; + + [TestMethod] + public async Task Enable_RejectsMissingDependenciesAndIncompatibleApi_WithClearReasons() + { + await using var fixture = new PluginPlatformFixture(); + var dependent = Manifest("test.dependent", capabilities: new PluginCapabilities()) with + { + Dependencies = [new PluginDependency("test.dependency", "1.2.0")] + }; + await fixture.InstallAsync(dependent, PingScript); + + var missing = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.EnableAsync(dependent.Id, CancellationToken.None)); + StringAssert.Contains(missing.Message, "test.dependency"); + StringAssert.Contains(missing.Message, "not installed"); + + await fixture.InstallAndEnableAsync(Manifest("test.dependency", version: "1.2.0"), PingScript); + await fixture.Manager.EnableAsync(dependent.Id, CancellationToken.None); + + var incompatible = Manifest("test.future-api", apiVersion: "2.0"); + await fixture.InstallAsync(incompatible, PingScript); + var apiError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.EnableAsync(incompatible.Id, CancellationToken.None)); + StringAssert.Contains(apiError.Message, "API 2.0"); + StringAssert.Contains(apiError.Message, PluginApi.CurrentVersion); + } + + [TestMethod] + public async Task Install_RequiresExactApproval_AndTrustedSignatureByDefault() + { + using var signingKey = RSA.Create(2048); + await using var fixture = new PluginPlatformFixture(options => + { + options.AllowUnsignedLocalPackages = false; + options.TrustedPublisherPublicKeys["test-publisher"] = signingKey.ExportSubjectPublicKeyInfoPem(); + }); + + var unsigned = await fixture.PreviewAsync(Manifest("test.unsigned"), PingScript); + await Assert.ThrowsExactlyAsync(() => fixture.Manager.InstallPackageAsync( + unsigned.Token, + unsigned.PackageSha256, + unsigned.Manifest.Capabilities, + CancellationToken.None)); + + var signedManifest = Sign(Manifest("test.signed"), PingScript, "test-publisher", signingKey); + var signed = await fixture.PreviewAsync(signedManifest, PingScript); + Assert.IsTrue(signed.IsSignatureTrusted, signed.SignatureStatus); + + var tampered = signedManifest with + { + Capabilities = signedManifest.Capabilities with { StorageAccess = true } + }; + var tamperedPreview = await fixture.PreviewAsync(tampered, PingScript); + Assert.IsFalse(tamperedPreview.IsSignatureTrusted, + "Changing an approved capability must invalidate the publisher signature."); + + await Assert.ThrowsExactlyAsync(() => fixture.Manager.InstallPackageAsync( + signed.Token, + signed.PackageSha256, + signed.Manifest.Capabilities with { StorageAccess = true }, + CancellationToken.None)); + + // A failed approval does not consume the staged package; exact approval succeeds. + await fixture.Manager.InstallPackageAsync( + signed.Token, + signed.PackageSha256, + signed.Manifest.Capabilities, + CancellationToken.None); + + var signedWithAsset = Sign( + Manifest("test.signed-asset"), PingScript, "test-publisher", signingKey, + ("assets/prompt.txt", "publisher content")); + await using var tamperedAssetPackage = BuildPackage( + signedWithAsset, PingScript, ("assets/prompt.txt", "attacker content")); + var assetError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.PreviewPackageAsync( + tamperedAssetPackage, "tampered-asset.sdwpkg", CancellationToken.None)); + StringAssert.Contains(assetError.Message, "assets/prompt.txt"); + } + + [TestMethod] + public async Task PackageInspection_RejectsArchiveTraversalBeforeExecution() + { + await using var fixture = new PluginPlatformFixture(); + var manifest = WithIntegrity(Manifest("test.traversal"), PingScript); + await using var package = BuildPackage(manifest, PingScript, ("../escape.js", "malicious")); + await Assert.ThrowsExactlyAsync(() => fixture.Manager.PreviewPackageAsync( + package, + "traversal.sdwpkg", + CancellationToken.None)); + Assert.IsFalse(File.Exists(Path.Combine(fixture.RootPath, "escape.js"))); + } + + [TestMethod] + public async Task PackageInspection_RejectsVersionTraversalBeforeExtraction() + { + await using var fixture = new PluginPlatformFixture(); + const string maliciousVersion = "1.0.0+/../../outside"; + var manifest = Manifest("test.version-traversal", version: maliciousVersion); + var escapedPath = Path.GetFullPath(Path.Combine( + fixture.RootPath, "packages", manifest.Id, maliciousVersion)); + + var error = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(manifest, PingScript)); + + StringAssert.Contains(error.Message, "valid semantic version"); + Assert.IsFalse(Directory.Exists(escapedPath), + "An invalid manifest version must be rejected before any extraction path is created."); + } + + [TestMethod] + public async Task Manifest_RejectsAmbiguousDependencyVersionsAndUnsafeDisplayIdentifiers() + { + await using var fixture = new PluginPlatformFixture(); + var invalidDependency = Manifest("test.invalid-dependency") with + { + Dependencies = [new PluginDependency("test.dependency", "1.0.0+/../../outside")] + }; + var dependencyError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(invalidDependency, PingScript)); + StringAssert.Contains(dependencyError.Message, "invalid minimum version"); + + var invalidProvider = Manifest("test.invalid-provider", capabilities: new PluginCapabilities + { + Notifications = true + }) with + { + Name = "Unsafe\u0001name", + Providers = [new PluginProviderDeclaration + { + Kind = "notification", + Name = "../../local", + Handlers = new Dictionary { ["send"] = "sendNotification" } + }] + }; + var providerError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(invalidProvider, PingScript)); + StringAssert.Contains(providerError.Message, "ASCII identifier"); + StringAssert.Contains(providerError.Message, "control characters"); + } + + [TestMethod] + public async Task Manifest_RejectsAmbiguousIdsAndUnknownMigrationStrategy() + { + await using var fixture = new PluginPlatformFixture(); + foreach (var invalidId in new[] { "foo.", "foo..bar" }) + { + var idError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(Manifest(invalidId), PingScript)); + StringAssert.Contains(idError.Message, "Plugin id"); + } + + var invalidMigration = Manifest("test.invalid-migration") with + { + DataMigration = new PluginDataMigration { Strategy = "garbage" } + }; + var migrationError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(invalidMigration, PingScript)); + StringAssert.Contains(migrationError.Message, "preserve"); + StringAssert.Contains(migrationError.Message, "reset"); + } + + [TestMethod] + public async Task PackageStaging_EnforcesCountByteAndApprovalInputBounds() + { + var firstManifest = Manifest("test.stage-one"); + await using var probe = BuildPackage(firstManifest, PingScript); + var packageBytes = probe.Length; + + await using (var countFixture = new PluginPlatformFixture(options => + { + options.MaximumStagedPackages = 1; + })) + { + var preview = await countFixture.PreviewAsync(firstManifest, PingScript); + var countError = await Assert.ThrowsExactlyAsync(() => + countFixture.PreviewAsync(Manifest("test.stage-two"), PingScript)); + StringAssert.Contains(countError.Message, "staging limit"); + + await Assert.ThrowsExactlyAsync(() => + countFixture.Manager.InstallPackageAsync(null!, preview.PackageSha256, + preview.Manifest.Capabilities, CancellationToken.None)); + await Assert.ThrowsExactlyAsync(() => + countFixture.Manager.InstallPackageAsync(preview.Token, "not-a-checksum", + preview.Manifest.Capabilities, CancellationToken.None)); + } + + await using var byteFixture = new PluginPlatformFixture(options => + { + options.MaximumStagedPackages = 4; + options.MaximumPackageBytes = packageBytes + 1_024; + options.MaximumStagedPackageBytes = packageBytes + 32; + }); + await byteFixture.PreviewAsync(firstManifest, PingScript); + var byteError = await Assert.ThrowsExactlyAsync(() => + byteFixture.PreviewAsync(Manifest("test.stage-two"), PingScript)); + StringAssert.Contains(byteError.Message, "staging byte limit"); + Assert.HasCount(1, Directory.EnumerateFiles( + Path.Combine(byteFixture.RootPath, "staging"), "*.sdwpkg").ToArray()); + } + + [TestMethod] + public async Task Manifest_RejectsProviderWithoutCapabilityAndInvalidHandlerBeforeInstall() + { + await using var fixture = new PluginPlatformFixture(); + var missingCapability = Manifest("test.notification-capability") with + { + Providers = [new PluginProviderDeclaration + { + Kind = "notification", + Name = "notifier", + Handlers = new Dictionary { ["send"] = "sendNotification" } + }] + }; + var capabilityError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(missingCapability, PingScript)); + StringAssert.Contains(capabilityError.Message, "notifications capability"); + + var invalidHandler = missingCapability with + { + Capabilities = new PluginCapabilities { Notifications = true }, + Providers = [missingCapability.Providers[0] with + { + Handlers = new Dictionary { ["send"] = "../escape" } + }] + }; + var handlerError = await Assert.ThrowsExactlyAsync(() => + fixture.PreviewAsync(invalidHandler, PingScript)); + StringAssert.Contains(handlerError.Message, "invalid handler name"); + } + + [TestMethod] + public async Task Worker_DeniesUnapprovedNetworkAndFiles_AndDoesNotExposeClrOrWebGlobals() + { + const string script = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + network() { return sdw.request('network.request', { method: 'GET', url: 'https://example.com/' }); }, + file() { return sdw.request('file.read', { path: '/etc/passwd' }); }, + inspect() { return { + requireType: typeof require, + fetchType: typeof fetch, + exposedHostObjects: Object.getOwnPropertyNames(globalThis).filter(name => { + try { return globalThis[name] && typeof globalThis[name].GetType === 'function'; } catch { return false; } + }) + }; }, + catchDenied() { + try { sdw.request('network.request', { method: 'GET', url: 'https://example.com/' }); } + catch (error) { return { + hostExceptionType: typeof error.hostException, + getTypeType: typeof error.GetType, + constructorName: error.constructor && error.constructor.name + }; } + } + }}; + """; + await using var fixture = new PluginPlatformFixture(); + var manifest = Manifest("test.denied"); + await fixture.InstallAndEnableAsync(manifest, script); + + var network = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "network")); + StringAssert.Contains(network.Message, "not approved"); + var file = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "file")); + StringAssert.Contains(file.Message, "No file roots"); + + var inspected = await fixture.InvokeAsync(manifest.Id, "inspect"); + Assert.AreEqual("undefined", inspected.GetProperty("requireType").GetString()); + Assert.AreEqual("undefined", inspected.GetProperty("fetchType").GetString()); + Assert.AreEqual(0, inspected.GetProperty("exposedHostObjects").GetArrayLength()); + var caught = await fixture.InvokeAsync(manifest.Id, "catchDenied"); + Assert.AreEqual("undefined", caught.GetProperty("hostExceptionType").GetString()); + Assert.AreEqual("undefined", caught.GetProperty("getTypeType").GetString()); + Assert.AreEqual("Error", caught.GetProperty("constructorName").GetString()); + } + + [TestMethod] + [DataRow("127.0.0.1")] + [DataRow("169.254.169.254")] + [DataRow("192.168.1.10")] + [DataRow("192.88.99.2")] + [DataRow("64:ff9b::a9fe:a9fe")] + [DataRow("::192.168.1.10")] + [DataRow("::ffff:0:127.0.0.1")] + [DataRow("::ffff:0:169.254.169.254")] + [DataRow("100:0:0:1::1")] + [DataRow("2001:5::1")] + [DataRow("2001:10::1")] + [DataRow("3fff::1")] + [DataRow("5f00::1")] + [DataRow("fec0::1")] + [DataRow("fe00::1")] + [DataRow("4000::1")] + public async Task NetworkCapability_RejectsApprovedHostResolvingToNonPublicAddress(string address) + { + using var networkHandler = PluginNetworkConnectionFactory.Create( + new FixedDnsResolver(IPAddress.Parse(address))); + await using var fixture = new PluginPlatformFixture(httpHandler: networkHandler); + const string script = """ + globalThis.sdwPlugin={handlers:{request:()=>sdw.request('network.request', + {method:'GET',url:'http://approved.example/resource'})}}; + """; + var manifest = Manifest("test.ssrf", capabilities: new PluginCapabilities + { + NetworkDomains = ["approved.example"] + }); + await fixture.InstallAndEnableAsync(manifest, script); + + var error = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "request")); + StringAssert.Contains(error.Message, "non-public address"); + } + + [TestMethod] + public async Task NetworkCapability_RejectsMixedPublicAndPrivateDnsAnswers() + { + using var networkHandler = PluginNetworkConnectionFactory.Create( + new FixedDnsResolver(IPAddress.Parse("2606:4700:4700::1111"), IPAddress.Loopback)); + await using var fixture = new PluginPlatformFixture(httpHandler: networkHandler); + const string script = """ + globalThis.sdwPlugin={handlers:{request:()=>sdw.request('network.request', + {method:'GET',url:'http://approved.example/resource'})}}; + """; + var manifest = Manifest("test.mixed-dns", capabilities: new PluginCapabilities + { + NetworkDomains = ["approved.example"] + }); + await fixture.InstallAndEnableAsync(manifest, script); + + var error = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "request")); + StringAssert.Contains(error.Message, "non-public address"); + } + + [TestMethod] + [DataRow("2606:4700:4700::1111")] + [DataRow("2001:4860:4860::8888")] + public void NetworkCapability_AcceptsOrdinaryGlobalUnicastAddress(string address) + => Assert.IsTrue(PluginNetworkConnectionFactory.IsPublicAddress(IPAddress.Parse(address))); + + [TestMethod] + public async Task SafeFileAccess_RejectsDirectoryToSymlinkSwapBetweenApprovalAndOpen() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Assert.Inconclusive("The deterministic openat race test requires a POSIX host."); + return; + } + var sandbox = Path.Combine(Path.GetTempPath(), $"sdw-plugin-race-{Guid.NewGuid():N}"); + var approved = Directory.CreateDirectory(Path.Combine(sandbox, "approved")).FullName; + var live = Directory.CreateDirectory(Path.Combine(approved, "live")).FullName; + var parked = Path.Combine(approved, "parked"); + var outside = Directory.CreateDirectory(Path.Combine(sandbox, "outside")).FullName; + var target = Path.Combine(live, "secret.txt"); + var outsideSecret = Path.Combine(outside, "secret.txt"); + await File.WriteAllTextAsync(target, "approved"); + await File.WriteAllTextAsync(outsideSecret, "outside secret"); + var access = new PluginSafeFileAccess(); + var swapped = 0; + access.BeforeOpenForTesting = () => + { + if (Interlocked.Exchange(ref swapped, 1) != 0) return; + Directory.Move(live, parked); + Directory.CreateSymbolicLink(live, outside); + }; + try + { + await Assert.ThrowsExactlyAsync(() => + access.ReadAsync(approved, target, 1_024, CancellationToken.None)); + Assert.AreEqual("outside secret", await File.ReadAllTextAsync(outsideSecret)); + } + finally + { + if (Directory.Exists(live) || File.Exists(live)) Directory.Delete(live); + if (Directory.Exists(sandbox)) Directory.Delete(sandbox, recursive: true); + } + } + + [TestMethod] + public async Task PluginDataQuota_RejectsGrowthAtomicallyAndPreservesExistingFile() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(options => + { + options.MaximumPluginDataBytes = 1_024; + options.MaximumPluginDataFiles = 1; + options.MaximumPluginDataPathDepth = 2; + options.CircuitBreakerFailures = 20; + }); + var manifest = StorageManifest("test.quota"); + await fixture.InstallAndEnableAsync(manifest, storageScript); + var original = Enumerable.Repeat((byte)0x41, 700).ToArray(); + await fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "state.bin", + base64 = Convert.ToBase64String(original) + }); + + var extraError = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "extra.bin", + base64 = Convert.ToBase64String(new byte[400]) + })); + StringAssert.Contains(extraError.Message, "quota"); + var overwriteError = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "state.bin", + base64 = Convert.ToBase64String(new byte[1_100]) + })); + StringAssert.Contains(overwriteError.Message, "quota"); + await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "one/two/three.bin", + base64 = Convert.ToBase64String(new byte[1]) + })); + + var dataRoot = Path.Combine(fixture.RootPath, "data", manifest.Id); + CollectionAssert.AreEqual(original, await File.ReadAllBytesAsync(Path.Combine(dataRoot, "state.bin"))); + Assert.IsFalse(File.Exists(Path.Combine(dataRoot, "extra.bin"))); + } + + [TestMethod] + public async Task Worker_ContainsTimeoutCrashAndResourceExhaustion_ThenOpensCircuit() + { + const string hostileScript = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + timeout() { while (true) {} }, + crash() { throw new Error('intentional crash'); }, + memory() { const values = []; while (true) values.push(new ArrayBuffer(1048576)); } + }}; + """; + await using var fixture = new PluginPlatformFixture(options => + { + options.InvocationTimeoutMilliseconds = 400; + options.MaximumWorkerCpuMilliseconds = 300; + options.MaximumWorkerMemoryMegabytes = 64; + options.MaximumConcurrentWorkers = 1; + options.MaximumConcurrentWorkersPerPlugin = 1; + options.CircuitBreakerFailures = 3; + }); + var hostile = Manifest("test.hostile"); + await fixture.InstallAndEnableAsync(hostile, hostileScript); + var healthy = Manifest("test.healthy"); + await fixture.InstallAndEnableAsync(healthy, PingScript); + var stopwatch = Stopwatch.StartNew(); + + var firstTimeout = fixture.InvokeAsync(hostile.Id, "timeout"); + await Task.Delay(100); + var rejected = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ => + { + try + { + await fixture.InvokeAsync(hostile.Id, "timeout"); + return null; + } + catch (Exception exception) + { + return exception; + } + })); + Assert.IsTrue(rejected.All(exception => exception is PluginCapacityExceededException)); + await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(healthy.Id, "ping", new { value = 1 })); + await Assert.ThrowsExactlyAsync(() => firstTimeout); + var healthAfterCapacity = (await fixture.Manager.GetAllAsync(CancellationToken.None)) + .Single(plugin => plugin.Manifest.Id == hostile.Id).Health; + Assert.AreEqual(1, healthAfterCapacity.ConsecutiveFailures, + "Capacity rejections must not degrade plugin health."); + var healthyHealth = (await fixture.Manager.GetAllAsync(CancellationToken.None)) + .Single(plugin => plugin.Manifest.Id == healthy.Id).Health; + Assert.AreEqual(0, healthyHealth.ConsecutiveFailures, + "A global capacity rejection must not be attributed to another plugin."); + await Assert.ThrowsExactlyAsync(() => fixture.InvokeAsync(hostile.Id, "crash")); + await Assert.ThrowsAsync(() => fixture.InvokeAsync(hostile.Id, "memory")); + Assert.IsLessThan(TimeSpan.FromSeconds(8), stopwatch.Elapsed); + + var circuit = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(hostile.Id, "crash")); + StringAssert.Contains(circuit.Message, "circuit is open"); + + var result = await fixture.InvokeAsync(healthy.Id, "ping", new { value = 7 }); + Assert.AreEqual(7, result.GetProperty("value").GetInt32()); + } + + [TestMethod] + public async Task NotificationAndStorageExamples_PassProviderIntegrationFlow() + { + var http = new RecordingHttpMessageHandler(); + await using var fixture = new PluginPlatformFixture(httpHandler: http); + + var (webhook, webhookScript) = LoadExample("webhook"); + await fixture.InstallAndEnableAsync(webhook, webhookScript); + await fixture.Manager.UpdateConfigurationAsync( + webhook.Id, + JsonSerializer.SerializeToElement(new { url = "https://hooks.example.com/sdw" }), + CancellationToken.None); + + var (storage, storageScript) = LoadExample("scoped-storage"); + await fixture.InstallAndEnableAsync(storage, storageScript); + + var registry = new PluginProviderRegistry(fixture.Manager); + var notificationProvider = registry.GetNotificationProviders().Single(); + Assert.AreEqual("plugin:example.webhook:webhook", notificationProvider.Name); + await notificationProvider.SendAsync(new PluginNotification("Ready", "Library scan completed"), + CancellationToken.None); + Assert.AreEqual("https://hooks.example.com/sdw", http.LastRequestUri?.ToString()); + StringAssert.Contains(http.LastBody ?? string.Empty, "Library scan completed"); + + var bytes = Encoding.UTF8.GetBytes("isolated storage"); + await fixture.Manager.InvokeAsync(storage.Id, "seed", JsonSerializer.SerializeToElement(new + { + path = "folder/item.txt", + base64 = Convert.ToBase64String(bytes) + }), CancellationToken.None); + var fileStore = registry.GetFileStores().Single(); + Assert.AreEqual("plugin:example.scoped-storage:example-scoped", fileStore.Name); + Assert.IsTrue(await fileStore.ExistAsync("folder/item.txt", CancellationToken.None)); + var info = await fileStore.FileInfoAsync("folder/item.txt", CancellationToken.None); + Assert.AreEqual(bytes.Length, info.Length); + await using var stream = await fileStore.OpenReadStreamAsync("folder/item.txt", CancellationToken.None); + using var reader = new StreamReader(stream); + Assert.AreEqual("isolated storage", await reader.ReadToEndAsync()); + var listed = await fileStore.EnumerateDirectory("folder").ToListAsync(); + Assert.HasCount(1, listed); + Assert.AreEqual("item.txt", listed[0].FileName); + } + + [TestMethod] + public async Task ProviderIdentity_IsQualifiedAndStableAcrossDisableUninstallAndReinstall() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var first = StorageManifest("test.provider-one", "shared"); + var second = StorageManifest("test.provider-two", "shared"); + await fixture.InstallAndEnableAsync(first, storageScript); + await fixture.InstallAndEnableAsync(second, storageScript); + var registry = new PluginProviderRegistry(fixture.Manager); + + CollectionAssert.AreEquivalent( + new[] { "plugin:test.provider-one:shared", "plugin:test.provider-two:shared" }, + registry.GetFileStores().Select(store => store.Name).ToArray()); + Assert.IsFalse(registry.GetFileStores().Any(store => store.Name == "local")); + + await fixture.Manager.DisableAsync(first.Id, CancellationToken.None); + CollectionAssert.AreEqual( + new[] { "plugin:test.provider-two:shared" }, + registry.GetFileStores().Select(store => store.Name).ToArray()); + + await fixture.Manager.UninstallAsync(first.Id, deleteData: false, CancellationToken.None); + await fixture.InstallAndEnableAsync(first, storageScript); + Assert.IsTrue(registry.GetFileStores().Any(store => + store.Name == "plugin:test.provider-one:shared")); + } + + [TestMethod] + public async Task DeleteDataUninstall_DoesNotDeleteAnotherPluginWithMigrationLikeId() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var removed = StorageManifest("test.a"); + var neighbor = StorageManifest("test.a.migration-b"); + await fixture.InstallAndEnableAsync(removed, storageScript); + await fixture.InstallAndEnableAsync(neighbor, storageScript); + await fixture.InvokeAsync(neighbor.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("neighbor")) + }); + + await fixture.Manager.UninstallAsync(removed.Id, deleteData: true, CancellationToken.None); + + var exists = await fixture.InvokeAsync(neighbor.Id, "exists", new { path = "state.txt" }); + Assert.IsTrue(exists.GetProperty("exists").GetBoolean()); + Assert.IsTrue(Directory.Exists(Path.Combine(fixture.RootPath, "data", neighbor.Id))); + } + + [TestMethod] + public async Task UpgradeAndUninstall_ApplyExplicitDataAndConfigurationStrategy() + { + const string storageScript = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + config(input, configuration) { return configuration; }, + seed(input) { return sdw.request('data.write', input); }, + exists(input) { return sdw.request('data.exists', input); } + }}; + """; + await using var fixture = new PluginPlatformFixture(); + var versionOne = StorageManifest("test.lifecycle"); + await fixture.InstallAndEnableAsync(versionOne, storageScript); + await fixture.Manager.UpdateConfigurationAsync(versionOne.Id, + JsonSerializer.SerializeToElement(new { marker = "preserved" }), CancellationToken.None); + await fixture.Manager.InvokeAsync(versionOne.Id, "seed", JsonSerializer.SerializeToElement(new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("old")) + }), CancellationToken.None); + + await fixture.Manager.UninstallAsync(versionOne.Id, deleteData: false, CancellationToken.None); + await fixture.InstallAndEnableAsync(versionOne, storageScript); + var restoredConfig = await fixture.InvokeAsync(versionOne.Id, "config"); + Assert.AreEqual("preserved", restoredConfig.GetProperty("marker").GetString()); + var retained = await fixture.InvokeAsync(versionOne.Id, "exists", new { path = "state.txt" }); + Assert.IsTrue(retained.GetProperty("exists").GetBoolean()); + + var unsafeUpgrade = versionOne with { Version = "2.0.0", DataVersion = 2 }; + var unsafePreview = await fixture.PreviewAsync(unsafeUpgrade, storageScript); + var migrationError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UpgradeAsync(versionOne.Id, unsafePreview.Token, unsafePreview.PackageSha256, + unsafePreview.Manifest.Capabilities, CancellationToken.None)); + StringAssert.Contains(migrationError.Message, "dataMigration.strategy = 'reset'"); + + var resetUpgrade = unsafeUpgrade with + { + DataMigration = new PluginDataMigration + { + Strategy = "reset", + Description = "Version 2 intentionally starts with an empty cache." + } + }; + var resetPreview = await fixture.PreviewAsync(resetUpgrade, storageScript); + await fixture.Manager.UpgradeAsync(versionOne.Id, resetPreview.Token, resetPreview.PackageSha256, + resetPreview.Manifest.Capabilities, CancellationToken.None); + await fixture.Manager.EnableAsync(versionOne.Id, CancellationToken.None); + var reset = await fixture.InvokeAsync(versionOne.Id, "exists", new { path = "state.txt" }); + Assert.IsFalse(reset.GetProperty("exists").GetBoolean()); + var preservedConfig = await fixture.InvokeAsync(versionOne.Id, "config"); + Assert.AreEqual("preserved", preservedConfig.GetProperty("marker").GetString()); + } + + [TestMethod] + public async Task UpgradeAndUninstall_CancelAndDrainOldWorkersBeforeMovingDataOrPackage() + { + var (_, baseStorageScript) = LoadExample("scoped-storage"); + var slowStorageScript = baseStorageScript + """ + + globalThis.sdwPlugin.handlers.slowSeed = function(input) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) {} + return sdw.request('data.write', input); + }; + """; + await using var fixture = new PluginPlatformFixture(options => + { + options.InvocationTimeoutMilliseconds = 8_000; + options.MaximumWorkerCpuMilliseconds = 7_000; + }); + var versionOne = StorageManifest("test.lifecycle-drain"); + await fixture.InstallAndEnableAsync(versionOne, slowStorageScript); + await fixture.InvokeAsync(versionOne.Id, "seed", new + { + path = "old.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("old")) + }); + + var oldInvocation = fixture.InvokeAsync(versionOne.Id, "slowSeed", new + { + path = "stale-after-upgrade.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("stale")) + }); + await Task.Delay(150); + var versionTwo = versionOne with + { + Version = "2.0.0", + DataVersion = 2, + DataMigration = new PluginDataMigration { Strategy = "reset" } + }; + var preview = await fixture.PreviewAsync(versionTwo, slowStorageScript); + await fixture.Manager.UpgradeAsync(versionOne.Id, preview.Token, preview.PackageSha256, + preview.Manifest.Capabilities, CancellationToken.None); + await Assert.ThrowsExactlyAsync(() => oldInvocation); + await fixture.Manager.EnableAsync(versionOne.Id, CancellationToken.None); + Assert.IsFalse(File.Exists(Path.Combine( + fixture.RootPath, "data", versionOne.Id, "stale-after-upgrade.txt"))); + + var uninstallInvocation = fixture.InvokeAsync(versionOne.Id, "slowSeed", new + { + path = "stale-after-uninstall.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("stale")) + }); + await Task.Delay(150); + await fixture.Manager.UninstallAsync(versionOne.Id, deleteData: true, CancellationToken.None); + await Assert.ThrowsExactlyAsync(() => uninstallInvocation); + Assert.IsFalse(Directory.Exists(Path.Combine(fixture.RootPath, "data", versionOne.Id))); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, versionTwo.Version))); + } + + [TestMethod] + public async Task CascadingDisableAndUninstall_CancelDependentWorkersBeforeReturning() + { + var (_, baseStorageScript) = LoadExample("scoped-storage"); + var slowStorageScript = baseStorageScript + """ + + globalThis.sdwPlugin.handlers.slowSeed = function(input) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) {} + return sdw.request('data.write', input); + }; + """; + await using var fixture = new PluginPlatformFixture(options => + { + options.InvocationTimeoutMilliseconds = 8_000; + options.MaximumWorkerCpuMilliseconds = 7_000; + }); + var dependency = Manifest("test.cascade-root"); + var dependent = StorageManifest("test.cascade-dependent") with + { + Dependencies = [new PluginDependency(dependency.Id, dependency.Version)] + }; + await fixture.InstallAndEnableAsync(dependency, PingScript); + await fixture.InstallAndEnableAsync(dependent, slowStorageScript); + + var disableInvocation = fixture.InvokeAsync(dependent.Id, "slowSeed", new + { + path = "stale-after-disable.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("stale")) + }); + await Task.Delay(150); + await fixture.Manager.DisableAsync(dependency.Id, CancellationToken.None); + await Assert.ThrowsExactlyAsync(() => disableInvocation); + Assert.IsFalse((await fixture.Manager.GetAllAsync(CancellationToken.None)) + .Single(plugin => plugin.Manifest.Id == dependent.Id).IsEnabled); + Assert.IsFalse(File.Exists(Path.Combine( + fixture.RootPath, "data", dependent.Id, "stale-after-disable.txt"))); + + await fixture.Manager.EnableAsync(dependency.Id, CancellationToken.None); + await fixture.Manager.EnableAsync(dependent.Id, CancellationToken.None); + var uninstallInvocation = fixture.InvokeAsync(dependent.Id, "slowSeed", new + { + path = "stale-after-uninstall.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("stale")) + }); + await Task.Delay(150); + await fixture.Manager.UninstallAsync(dependency.Id, deleteData: true, CancellationToken.None); + await Assert.ThrowsExactlyAsync(() => uninstallInvocation); + Assert.IsFalse((await fixture.Manager.GetAllAsync(CancellationToken.None)) + .Single(plugin => plugin.Manifest.Id == dependent.Id).IsEnabled); + Assert.IsFalse(File.Exists(Path.Combine( + fixture.RootPath, "data", dependent.Id, "stale-after-uninstall.txt"))); + } + + [TestMethod] + public async Task StartupRecovery_RollsBackPreparedUninstall() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var manifest = StorageManifest("test.recover-uninstall-prepared"); + var unaffected = Manifest("test.recover-unaffected"); + await fixture.InstallAndEnableAsync(manifest, storageScript); + await fixture.InstallAndEnableAsync(unaffected, PingScript); + await fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("preserve")) + }); + fixture.Manager.LifecycleCheckpointForTesting = checkpoint => + { + if (checkpoint == PluginLifecycleCheckpoint.AfterMove) + throw new PluginProcessCrashSimulationException(); + }; + + await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UninstallAsync(manifest.Id, deleteData: true, CancellationToken.None)); + GetSingleLifecycleTransaction(fixture.RootPath, "uninstall", manifest.Id); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", manifest.Id, manifest.Version))); + Assert.IsFalse(Directory.Exists(Path.Combine(fixture.RootPath, "data", manifest.Id))); + var overlapError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UninstallAsync(unaffected.Id, deleteData: true, CancellationToken.None)); + StringAssert.Contains(overlapError.Message, "pending lifecycle recovery"); + var invokeError = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(unaffected.Id, "ping", new { value = 1 })); + StringAssert.Contains(invokeError.Message, "pending lifecycle recovery"); + + var restarted = fixture.CreateRestartedManager(); + var restartedPlugins = await restarted.GetAllAsync(CancellationToken.None); + var restored = restartedPlugins.Single(plugin => + plugin.Manifest.Id == manifest.Id); + Assert.AreEqual(manifest.Version, restored.Manifest.Version); + Assert.IsTrue(restored.IsEnabled); + Assert.IsTrue(restartedPlugins.Single(plugin => plugin.Manifest.Id == unaffected.Id).IsEnabled); + var exists = await restarted.InvokeAsync(manifest.Id, "exists", + JsonSerializer.SerializeToElement(new { path = "state.txt" }), CancellationToken.None); + Assert.IsTrue(exists.GetProperty("exists").GetBoolean()); + AssertNoLifecycleTransactions(fixture.RootPath); + } + + [TestMethod] + public async Task StartupRecovery_FinalizesCommittedUninstallWithPartiallyMissingPayload() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var manifest = StorageManifest("test.recover-uninstall-committed"); + await fixture.InstallAndEnableAsync(manifest, storageScript); + await fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("delete")) + }); + fixture.Manager.LifecycleCheckpointForTesting = checkpoint => + { + if (checkpoint != PluginLifecycleCheckpoint.AfterCommit) return; + var transactionPath = GetSingleLifecycleTransaction( + fixture.RootPath, "uninstall", manifest.Id); + Directory.Delete(Path.Combine(transactionPath, "package"), recursive: true); + File.Delete(Path.Combine(transactionPath, "data", "state.txt")); + throw new PluginProcessCrashSimulationException(); + }; + + await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UninstallAsync(manifest.Id, deleteData: true, CancellationToken.None)); + var pendingPreview = await fixture.PreviewAsync(manifest, storageScript); + var pendingError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.InstallPackageAsync(pendingPreview.Token, pendingPreview.PackageSha256, + pendingPreview.Manifest.Capabilities, CancellationToken.None)); + StringAssert.Contains(pendingError.Message, "pending lifecycle recovery"); + + var restarted = fixture.CreateRestartedManager(); + Assert.IsEmpty(await restarted.GetAllAsync(CancellationToken.None)); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", manifest.Id, manifest.Version))); + Assert.IsFalse(Directory.Exists(Path.Combine(fixture.RootPath, "data", manifest.Id))); + AssertNoLifecycleTransactions(fixture.RootPath); + } + + [TestMethod] + public async Task StartupRecovery_RollsBackPreparedResetUpgrade() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var versionOne = StorageManifest("test.recover-upgrade-prepared"); + await fixture.InstallAndEnableAsync(versionOne, storageScript); + await fixture.InvokeAsync(versionOne.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("preserve")) + }); + var versionTwo = versionOne with + { + Version = "2.0.0", + DataVersion = 2, + DataMigration = new PluginDataMigration { Strategy = "reset" } + }; + var preview = await fixture.PreviewAsync(versionTwo, storageScript); + fixture.Manager.LifecycleCheckpointForTesting = checkpoint => + { + if (checkpoint == PluginLifecycleCheckpoint.AfterMove) + throw new PluginProcessCrashSimulationException(); + }; + + await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UpgradeAsync(versionOne.Id, preview.Token, preview.PackageSha256, + preview.Manifest.Capabilities, CancellationToken.None)); + GetSingleLifecycleTransaction(fixture.RootPath, "upgrade", versionOne.Id); + Assert.IsFalse(Directory.Exists(Path.Combine(fixture.RootPath, "data", versionOne.Id))); + + var restarted = fixture.CreateRestartedManager(); + var restored = (await restarted.GetAllAsync(CancellationToken.None)).Single(plugin => + plugin.Manifest.Id == versionOne.Id); + Assert.AreEqual(versionOne.Version, restored.Manifest.Version); + Assert.IsTrue(restored.IsEnabled); + var exists = await restarted.InvokeAsync(versionOne.Id, "exists", + JsonSerializer.SerializeToElement(new { path = "state.txt" }), CancellationToken.None); + Assert.IsTrue(exists.GetProperty("exists").GetBoolean()); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, versionTwo.Version))); + AssertNoLifecycleTransactions(fixture.RootPath); + } + + [TestMethod] + public async Task StartupRecovery_FinalizesCommittedResetUpgradeAndBlocksOverlappingLifecycle() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(); + var versionOne = StorageManifest("test.recover-upgrade-committed"); + await fixture.InstallAndEnableAsync(versionOne, storageScript); + await fixture.InvokeAsync(versionOne.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("delete")) + }); + var versionTwo = versionOne with + { + Version = "2.0.0", + DataVersion = 2, + DataMigration = new PluginDataMigration { Strategy = "reset" } + }; + var preview = await fixture.PreviewAsync(versionTwo, storageScript); + fixture.Manager.LifecycleCheckpointForTesting = checkpoint => + { + if (checkpoint != PluginLifecycleCheckpoint.AfterCommit) return; + var transactionPath = GetSingleLifecycleTransaction( + fixture.RootPath, "upgrade", versionOne.Id); + File.Delete(Path.Combine(transactionPath, "data", "state.txt")); + throw new IOException("Simulated committed-cleanup interruption."); + }; + + await Assert.ThrowsExactlyAsync(() => fixture.Manager.UpgradeAsync( + versionOne.Id, preview.Token, preview.PackageSha256, + preview.Manifest.Capabilities, CancellationToken.None)); + var pendingInvocation = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(versionOne.Id, "exists", new { path = "state.txt" })); + StringAssert.Contains(pendingInvocation.Message, "pending lifecycle recovery"); + var pendingUninstall = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UninstallAsync(versionOne.Id, deleteData: true, CancellationToken.None)); + StringAssert.Contains(pendingUninstall.Message, "pending lifecycle recovery"); + var versionThree = versionTwo with { Version = "3.0.0" }; + var versionThreePreview = await fixture.PreviewAsync(versionThree, storageScript); + var pendingUpgrade = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UpgradeAsync(versionOne.Id, versionThreePreview.Token, + versionThreePreview.PackageSha256, versionThreePreview.Manifest.Capabilities, + CancellationToken.None)); + StringAssert.Contains(pendingUpgrade.Message, "pending lifecycle recovery"); + GetSingleLifecycleTransaction(fixture.RootPath, "upgrade", versionOne.Id); + + var restarted = fixture.CreateRestartedManager(); + var installed = (await restarted.GetAllAsync(CancellationToken.None)).Single(plugin => + plugin.Manifest.Id == versionOne.Id); + Assert.AreEqual(versionTwo.Version, installed.Manifest.Version); + Assert.IsFalse(installed.IsEnabled); + Assert.IsFalse(Directory.Exists(Path.Combine(fixture.RootPath, "data", versionOne.Id))); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, versionOne.Version))); + Assert.IsTrue(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, versionTwo.Version))); + AssertNoLifecycleTransactions(fixture.RootPath); + + await restarted.UninstallAsync(versionOne.Id, deleteData: true, CancellationToken.None); + var restartedAgain = fixture.CreateRestartedManager(); + Assert.IsEmpty(await restartedAgain.GetAllAsync(CancellationToken.None)); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, versionTwo.Version))); + AssertNoLifecycleTransactions(fixture.RootPath); + } + + [TestMethod] + public async Task Invoke_AcquiresLifecycleLeaseBeforeManagementGateCanUninstallStaleEntry() + { + const string slowScript = """ + globalThis.sdwPlugin={handlers:{slow:()=>{const end=Date.now()+3000;while(Date.now() + { + options.InvocationTimeoutMilliseconds = 8_000; + options.MaximumWorkerCpuMilliseconds = 7_000; + }); + var manifest = Manifest("test.invoke-ordering"); + await fixture.InstallAndEnableAsync(manifest, slowScript); + using var reachedLeasePoint = new ManualResetEventSlim(); + using var releaseLeasePoint = new ManualResetEventSlim(); + fixture.Manager.BeforeInvocationLeaseForTesting = () => + { + fixture.Manager.BeforeInvocationLeaseForTesting = null; + reachedLeasePoint.Set(); + releaseLeasePoint.Wait(TimeSpan.FromSeconds(5)); + }; + + var invocation = Task.Run(() => fixture.InvokeAsync(manifest.Id, "slow")); + Assert.IsTrue(reachedLeasePoint.Wait(TimeSpan.FromSeconds(2))); + var uninstall = fixture.Manager.UninstallAsync(manifest.Id, deleteData: true, CancellationToken.None); + await Task.Delay(100); + Assert.IsFalse(uninstall.IsCompleted, + "Uninstall must not pass the management gate before the invocation owns its lifecycle lease."); + releaseLeasePoint.Set(); + await uninstall; + await Assert.ThrowsExactlyAsync(() => invocation); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", manifest.Id, manifest.Version))); + } + + [TestMethod] + public async Task Upgrade_WhenPostCatalogStepFails_RestoresOldCatalogPackageAndSnapshot() + { + const string versionOneScript = "globalThis.sdwPlugin={handlers:{version:()=>({value:1})}};"; + const string versionTwoScript = "globalThis.sdwPlugin={handlers:{version:()=>({value:2})}};"; + await using var fixture = new PluginPlatformFixture(enableFailureInjection: true); + var versionOne = Manifest("test.rollback", version: "1.0.0"); + await fixture.InstallAndEnableAsync(versionOne, versionOneScript); + fixture.FailingRepository!.FailNextRetainedRemoval = true; + var versionTwo = versionOne with { Version = "2.0.0" }; + var preview = await fixture.PreviewAsync(versionTwo, versionTwoScript); + + await Assert.ThrowsExactlyAsync(() => fixture.Manager.UpgradeAsync( + versionOne.Id, + preview.Token, + preview.PackageSha256, + preview.Manifest.Capabilities, + CancellationToken.None)); + + var installed = (await fixture.Manager.GetAllAsync(CancellationToken.None)).Single(plugin => + plugin.Manifest.Id == versionOne.Id); + Assert.AreEqual("1.0.0", installed.Manifest.Version); + Assert.IsTrue(installed.IsEnabled); + var invoked = await fixture.InvokeAsync(versionOne.Id, "version"); + Assert.AreEqual(1, invoked.GetProperty("value").GetInt32()); + Assert.IsTrue(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, "1.0.0"))); + Assert.IsFalse(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", versionOne.Id, "2.0.0"))); + } + + [TestMethod] + public async Task Uninstall_WhenCatalogRemovalFails_RestoresPackageDataCatalogAndSnapshot() + { + var (_, storageScript) = LoadExample("scoped-storage"); + await using var fixture = new PluginPlatformFixture(enableFailureInjection: true); + var manifest = StorageManifest("test.uninstall-rollback"); + await fixture.InstallAndEnableAsync(manifest, storageScript); + await fixture.InvokeAsync(manifest.Id, "seed", new + { + path = "state.txt", + base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("preserve me")) + }); + fixture.FailingRepository!.FailNextCatalogRemoval = true; + + await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UninstallAsync(manifest.Id, deleteData: true, CancellationToken.None)); + + var restored = (await fixture.Manager.GetAllAsync(CancellationToken.None)).Single(plugin => + plugin.Manifest.Id == manifest.Id); + Assert.IsTrue(restored.IsEnabled); + Assert.IsTrue(Directory.Exists(Path.Combine( + fixture.RootPath, "packages", manifest.Id, manifest.Version))); + var exists = await fixture.InvokeAsync(manifest.Id, "exists", new { path = "state.txt" }); + Assert.IsTrue(exists.GetProperty("exists").GetBoolean()); + } + + [TestMethod] + public async Task UpgradeAndRetainedReinstall_RejectPublisherKeySubstitution() + { + using var originalKey = RSA.Create(2048); + using var substituteKey = RSA.Create(2048); + await using var fixture = new PluginPlatformFixture(options => + { + options.AllowUnsignedLocalPackages = false; + options.TrustedPublisherPublicKeys["original"] = originalKey.ExportSubjectPublicKeyInfoPem(); + options.TrustedPublisherPublicKeys["substitute"] = substituteKey.ExportSubjectPublicKeyInfoPem(); + }); + var versionOne = Sign(Manifest("test.publisher-owner"), PingScript, "original", originalKey); + await fixture.InstallAndEnableAsync(versionOne, PingScript); + var versionTwo = Sign(versionOne with { Version = "2.0.0", Signature = null }, PingScript, + "substitute", substituteKey); + var substituteUpgrade = await fixture.PreviewAsync(versionTwo, PingScript); + + var upgradeError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.UpgradeAsync(versionOne.Id, substituteUpgrade.Token, + substituteUpgrade.PackageSha256, substituteUpgrade.Manifest.Capabilities, + CancellationToken.None)); + StringAssert.Contains(upgradeError.Message, "publisher identity"); + + await fixture.Manager.UninstallAsync(versionOne.Id, deleteData: false, CancellationToken.None); + var reinstallManifest = Sign( + Manifest(versionOne.Id), PingScript, "substitute", substituteKey); + var substituteReinstall = await fixture.PreviewAsync(reinstallManifest, PingScript); + var reinstallError = await Assert.ThrowsExactlyAsync(() => + fixture.Manager.InstallPackageAsync(substituteReinstall.Token, + substituteReinstall.PackageSha256, substituteReinstall.Manifest.Capabilities, + CancellationToken.None)); + StringAssert.Contains(reinstallError.Message, "retained owner"); + } + + private static PluginManifest Manifest( + string id, + string version = "1.0.0", + string apiVersion = PluginApi.CurrentVersion, + PluginCapabilities? capabilities = null) + => new() + { + Id = id, + Name = id, + Version = version, + ApiVersion = apiVersion, + EntryPoint = "index.js", + Capabilities = capabilities ?? new PluginCapabilities(), + Integrity = new PluginIntegrity + { + Files = new Dictionary + { + ["index.js"] = new string('0', 64) + } + } + }; + + private static string GetSingleLifecycleTransaction(string rootPath, string operation, string pluginId) + { + var transactionRoot = Path.Combine(rootPath, "transactions"); + var transactions = Directory.Exists(transactionRoot) + ? Directory.EnumerateDirectories(transactionRoot, $"{operation}-{pluginId}-*").ToArray() + : []; + Assert.HasCount(1, transactions); + Assert.IsTrue(File.Exists($"{transactions[0]}.journal.json")); + return transactions[0]; + } + + private static void AssertNoLifecycleTransactions(string rootPath) + { + var transactionRoot = Path.Combine(rootPath, "transactions"); + if (!Directory.Exists(transactionRoot)) return; + Assert.IsEmpty(Directory.EnumerateDirectories(transactionRoot).ToArray()); + Assert.IsEmpty(Directory.EnumerateFiles( + transactionRoot, "*.journal.json", SearchOption.TopDirectoryOnly).ToArray()); + } + + private static PluginManifest StorageManifest(string id, string? providerName = null) + => Manifest(id, capabilities: new PluginCapabilities { StorageAccess = true }) with + { + Providers = [new PluginProviderDeclaration + { + Kind = "storage", + Name = providerName ?? $"{id}-store", + Handlers = new Dictionary + { + ["exists"] = "exists", + ["info"] = "info", + ["read"] = "read", + ["list"] = "list" + } + }] + }; + + private static (PluginManifest Manifest, string Script) LoadExample(string name) + { + var directory = Path.Combine(AppContext.BaseDirectory, "Examples", name); + var manifest = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(directory, "manifest.json")), + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException($"Example manifest '{name}' is invalid."); + return (manifest, File.ReadAllText(Path.Combine(directory, manifest.EntryPoint))); + } + + private static PluginManifest WithIntegrity( + PluginManifest manifest, + string script, + params (string Path, string Content)[] extraEntries) + => manifest with + { + Integrity = new PluginIntegrity + { + Files = new[] { (manifest.EntryPoint, script) } + .Concat(extraEntries) + .ToDictionary( + entry => entry.Item1, + entry => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(entry.Item2))) + .ToLowerInvariant(), + StringComparer.Ordinal) + } + }; + + private static PluginManifest Sign( + PluginManifest manifest, + string script, + string publisher, + RSA key, + params (string Path, string Content)[] extraEntries) + { + manifest = WithIntegrity(manifest, script, extraEntries); + var payload = PluginSignaturePayload.Create(manifest); + return manifest with + { + Signature = new PluginSignature( + publisher, + "RSA-SHA256", + Convert.ToBase64String(key.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))) + }; + } + + private static MemoryStream BuildPackage( + PluginManifest manifest, + string script, + params (string Path, string Content)[] extraEntries) + { + manifest = manifest.Integrity?.Files.TryGetValue(manifest.EntryPoint, out var entryDigest) != true || + entryDigest == new string('0', 64) + ? WithIntegrity(manifest, script, extraEntries) + : manifest; + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "manifest.json", JsonSerializer.Serialize(manifest, + new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true })); + WriteEntry(archive, manifest.EntryPoint, script); + foreach (var entry in extraEntries) WriteEntry(archive, entry.Path, entry.Content); + } + stream.Position = 0; + return stream; + } + + private static void WriteEntry(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path, CompressionLevel.Fastest); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(content); + } + + private sealed class PluginPlatformFixture : IAsyncDisposable + { + private readonly IOptions _options; + private readonly HttpMessageHandler _httpHandler; + + public PluginPlatformFixture( + Action? configure = null, + HttpMessageHandler? httpHandler = null, + bool enableFailureInjection = false) + { + RootPath = Path.Combine(Path.GetTempPath(), $"sdw-plugin-tests-{Guid.NewGuid():N}"); + var options = new PluginPlatformOptions + { + RootPath = RootPath, + AllowUnsignedLocalPackages = true, + InvocationTimeoutMilliseconds = 2_000, + MaximumWorkerCpuMilliseconds = 1_500, + MaximumWorkerMemoryMegabytes = 256, + CircuitBreakerFailures = 3, + CircuitBreakerSeconds = 60 + }; + configure?.Invoke(options); + _options = Options.Create(options); + _httpHandler = httpHandler ?? new RecordingHttpMessageHandler(); + IPluginCatalogRepository repository = new PluginCatalogRepository(_options); + if (enableFailureInjection) + { + FailingRepository = new FailingPluginCatalogRepository(repository); + repository = FailingRepository; + } + SafeFileAccess = new PluginSafeFileAccess(); + Manager = CreateManager(repository); + } + + public string RootPath { get; } + public PluginManager Manager { get; } + public PluginSafeFileAccess SafeFileAccess { get; } + public FailingPluginCatalogRepository? FailingRepository { get; } + + public PluginManager CreateRestartedManager() + => CreateManager(new PluginCatalogRepository(_options)); + + public async Task PreviewAsync(PluginManifest manifest, string script) + { + await using var package = BuildPackage(manifest, script); + return await Manager.PreviewPackageAsync(package, $"{manifest.Id}.sdwpkg", CancellationToken.None); + } + + public async Task InstallAsync(PluginManifest manifest, string script) + { + var preview = await PreviewAsync(manifest, script); + await Manager.InstallPackageAsync(preview.Token, preview.PackageSha256, + preview.Manifest.Capabilities, CancellationToken.None); + } + + public async Task InstallAndEnableAsync(PluginManifest manifest, string script) + { + await InstallAsync(manifest, script); + await Manager.EnableAsync(manifest.Id, CancellationToken.None); + } + + public Task InvokeAsync(string id, string handler) + => InvokeAsync(id, handler, new { }); + + public Task InvokeAsync(string id, string handler, object input) + => Manager.InvokeAsync(id, handler, JsonSerializer.SerializeToElement(input), CancellationToken.None); + + public ValueTask DisposeAsync() + { + if (Directory.Exists(RootPath)) Directory.Delete(RootPath, recursive: true); + return ValueTask.CompletedTask; + } + + private PluginManager CreateManager(IPluginCatalogRepository repository) + { + var inspector = new PluginPackageInspector(_options); + var broker = new PluginCapabilityBroker( + new FixedHttpClientFactory(_httpHandler), _options, SafeFileAccess); + var executor = new PluginProcessExecutor(broker, _options); + return new PluginManager(repository, inspector, executor, _options, TimeProvider.System); + } + } + + private sealed class FailingPluginCatalogRepository(IPluginCatalogRepository inner) + : IPluginCatalogRepository + { + public bool FailNextRetainedRemoval { get; set; } + public bool FailNextCatalogRemoval { get; set; } + + public Task> GetAllAsync(CancellationToken cancellationToken) + => inner.GetAllAsync(cancellationToken); + + public Task FindAsync(string id, CancellationToken cancellationToken) + => inner.FindAsync(id, cancellationToken); + + public Task SaveAsync(PluginCatalogEntry entry, CancellationToken cancellationToken) + => inner.SaveAsync(entry, cancellationToken); + + public Task RemoveAsync(string id, CancellationToken cancellationToken) + { + if (FailNextCatalogRemoval) + { + FailNextCatalogRemoval = false; + throw new IOException("Injected catalog removal failure."); + } + return inner.RemoveAsync(id, cancellationToken); + } + + public Task FindRetainedAsync(string id, CancellationToken cancellationToken) + => inner.FindRetainedAsync(id, cancellationToken); + + public Task SaveRetainedAsync(RetainedPluginData retained, CancellationToken cancellationToken) + => inner.SaveRetainedAsync(retained, cancellationToken); + + public Task RemoveRetainedAsync(string id, CancellationToken cancellationToken) + { + if (FailNextRetainedRemoval) + { + FailNextRetainedRemoval = false; + throw new IOException("Injected failure after new catalog write."); + } + return inner.RemoveRetainedAsync(id, cancellationToken); + } + } + + private sealed class FixedHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private sealed class FixedDnsResolver(params IPAddress[] addresses) : IPluginDnsResolver + { + public Task ResolveAsync(string host, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.AreEqual("approved.example", host); + return Task.FromResult(addresses); + } + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + public Uri? LastRequestUri { get; private set; } + public string? LastBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri; + LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.NoContent) + { + Content = new StringContent(string.Empty) + }; + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/SecondDimensionWatcherReDive.Test.csproj b/SecondDimensionWatcherReDive.Test/SecondDimensionWatcherReDive.Test.csproj index 4ce878a..c6fe06e 100644 --- a/SecondDimensionWatcherReDive.Test/SecondDimensionWatcherReDive.Test.csproj +++ b/SecondDimensionWatcherReDive.Test/SecondDimensionWatcherReDive.Test.csproj @@ -28,4 +28,13 @@ + + + + + diff --git a/SecondDimensionWatcherReDive/Controllers/Converter.cs b/SecondDimensionWatcherReDive/Controllers/Converter.cs index 742d740..976f952 100644 --- a/SecondDimensionWatcherReDive/Controllers/Converter.cs +++ b/SecondDimensionWatcherReDive/Controllers/Converter.cs @@ -1,10 +1,84 @@ using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; +using SecondDimensionWatcherReDive.Framework.Plugin; namespace SecondDimensionWatcherReDive.Controllers; internal static class Converter { + public static External.PluginCapabilities ToExternal(this PluginCapabilities capabilities) => + new(capabilities.NetworkDomains, + capabilities.FileRoots, + capabilities.Notifications, + capabilities.DownloadControl, + capabilities.StorageAccess, + capabilities.BackgroundTasks); + + public static PluginCapabilities ToDomain(this External.PluginCapabilities capabilities) => + new() + { + NetworkDomains = capabilities.NetworkDomains, + FileRoots = capabilities.FileRoots, + Notifications = capabilities.Notifications, + DownloadControl = capabilities.DownloadControl, + StorageAccess = capabilities.StorageAccess, + BackgroundTasks = capabilities.BackgroundTasks + }; + + public static External.PluginManifest ToExternal(this PluginManifest manifest) => + new(manifest.Id, + manifest.Name, + manifest.Version, + manifest.ApiVersion, + manifest.EntryPoint, + manifest.Description, + manifest.Dependencies.Select(dependency => + new External.PluginDependency(dependency.Id, dependency.MinimumVersion)).ToArray(), + manifest.Capabilities.ToExternal(), + manifest.Platforms, + manifest.Integrity?.Files ?? new Dictionary(), + manifest.Signature?.Publisher, + manifest.Signature?.Algorithm, + manifest.Providers.Select(provider => new External.PluginProvider( + provider.Kind, + provider.Name, + provider.Handlers)).ToArray(), + manifest.DataVersion, + manifest.DataMigration is null + ? null + : new External.PluginDataMigration( + manifest.DataMigration.Strategy, + manifest.DataMigration.Description)); + + public static External.PluginHealth ToExternal(this PluginHealth health) => + new(health.Status, + health.ConsecutiveFailures, + health.LastSuccessAt, + health.LastFailureAt, + health.LastError, + health.CircuitOpenUntil); + + public static External.InstalledPlugin ToExternal(this InstalledPlugin plugin) => + new(plugin.Manifest.ToExternal(), + plugin.IsEnabled, + plugin.ApprovedCapabilities.ToExternal(), + plugin.CompatibilityErrors, + plugin.Health.ToExternal(), + plugin.Configuration.ValueKind == System.Text.Json.JsonValueKind.Object && + plugin.Configuration.EnumerateObject().Any()); + + public static External.PluginPackagePreview ToExternal(this PluginPackagePreview preview) => + new(preview.Token, + preview.PackageSha256, + preview.Manifest.ToExternal(), + preview.CompatibilityErrors, + preview.IsSignatureTrusted, + preview.SignatureStatus, + preview.ExpiresAt); + + public static External.PluginInstallResult ToExternal(this PluginInstallResult result) => + new(result.Id, result.Version, result.IsUpgrade, result.CompatibilityErrors); + public static External.AnimationInfo ToExternal(this AnimationInfo record) => new(record.Id, record.Title, diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..d30733e 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -65,4 +65,12 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(PluginPackagePreview))] +[JsonSerializable(typeof(PluginInstallResult))] +[JsonSerializable(typeof(InstalledPlugin))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(InstallPluginRequest))] +[JsonSerializable(typeof(UpdatePluginConfigurationRequest))] +[JsonSerializable(typeof(RemotePluginInstallRequest))] +[JsonSerializable(typeof(PluginOperationError))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/PluginModels.cs b/SecondDimensionWatcherReDive/Controllers/External/PluginModels.cs new file mode 100644 index 0000000..8d787e0 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/PluginModels.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record InstallPluginRequest( + string PreviewToken, + string ExpectedSha256, + PluginCapabilities ApprovedCapabilities); + +internal sealed record UpdatePluginConfigurationRequest(JsonElement Configuration); + +internal sealed record RemotePluginInstallRequest(string Url, string? ExpectedSha256); + +internal sealed record PluginOperationError(string Code, string Message); + +internal sealed record PluginCapabilities( + IReadOnlyList NetworkDomains, + IReadOnlyList FileRoots, + bool Notifications, + bool DownloadControl, + bool StorageAccess, + bool BackgroundTasks); + +internal sealed record PluginDependency(string Id, string MinimumVersion); + +internal sealed record PluginProvider( + string Kind, + string Name, + IReadOnlyDictionary Handlers); + +internal sealed record PluginDataMigration(string Strategy, string? Description); + +internal sealed record PluginManifest( + string Id, + string Name, + string Version, + string ApiVersion, + string EntryPoint, + string? Description, + IReadOnlyList Dependencies, + PluginCapabilities Capabilities, + IReadOnlyList Platforms, + IReadOnlyDictionary FileSha256, + string? SignaturePublisher, + string? SignatureAlgorithm, + IReadOnlyList Providers, + int DataVersion, + PluginDataMigration? DataMigration); + +internal sealed record PluginHealth( + string Status, + int ConsecutiveFailures, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError, + DateTimeOffset? CircuitOpenUntil); + +internal sealed record InstalledPlugin( + PluginManifest Manifest, + bool IsEnabled, + PluginCapabilities ApprovedCapabilities, + IReadOnlyList CompatibilityErrors, + PluginHealth Health, + bool HasConfiguration); + +internal sealed record PluginPackagePreview( + string Token, + string PackageSha256, + PluginManifest Manifest, + IReadOnlyList CompatibilityErrors, + bool IsSignatureTrusted, + string SignatureStatus, + DateTimeOffset ExpiresAt); + +internal sealed record PluginInstallResult( + string Id, + string Version, + bool IsUpgrade, + IReadOnlyList CompatibilityErrors); diff --git a/SecondDimensionWatcherReDive/Controllers/PluginController.cs b/SecondDimensionWatcherReDive/Controllers/PluginController.cs new file mode 100644 index 0000000..a00e74a --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/PluginController.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.Plugin; +using SecondDimensionWatcherReDive.PluginPlatform; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/plugins")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class PluginController( + IPluginManager manager, + IJavaScriptPluginLoader packageLoader) : ControllerBase +{ + [HttpGet] + public async Task>> GetAll(CancellationToken cancellationToken) + => Ok((await manager.GetAllAsync(cancellationToken)).Select(plugin => plugin.ToExternal()).ToArray()); + + [HttpPost("preview")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(8 * 1024 * 1024)] + public async Task> Preview( + [FromForm] IFormFile package, + CancellationToken cancellationToken) + { + if (package.Length == 0) return BadRequest(Error("empty_package", "A non-empty plugin package is required.")); + try + { + await using var stream = package.OpenReadStream(); + return Ok((await packageLoader.PreviewPackageAsync(stream, package.FileName, cancellationToken)).ToExternal()); + } + catch (Exception exception) when (exception is InvalidDataException or IOException or JsonException) + { + return BadRequest(Error("invalid_package", exception.Message)); + } + catch (InvalidOperationException exception) + { + return Conflict(Error("plugin_preview_capacity_reached", exception.Message)); + } + } + + [HttpPost("preview-remote")] + public ActionResult PreviewRemote([FromBody] RemotePluginInstallRequest request) + => StatusCode(StatusCodes.Status403Forbidden, Error( + "remote_install_disabled", + $"Remote JavaScript installation is disabled. Download '{request.Url}' through a trusted administrative channel, verify its provenance, then upload it for checksum, signature and capability review.")); + + [HttpPost("install")] + public async Task> Install( + [FromBody] InstallPluginRequest request, + CancellationToken cancellationToken) + => await ExecuteMutationAsync(async () => (await packageLoader.InstallPackageAsync( + request.PreviewToken, + request.ExpectedSha256, + request.ApprovedCapabilities.ToDomain(), + cancellationToken)).ToExternal()); + + [HttpPost("{id}/upgrade")] + public async Task> Upgrade( + string id, + [FromBody] InstallPluginRequest request, + CancellationToken cancellationToken) + => await ExecuteMutationAsync(async () => (await manager.UpgradeAsync( + id, + request.PreviewToken, + request.ExpectedSha256, + request.ApprovedCapabilities.ToDomain(), + cancellationToken)).ToExternal()); + + [HttpPost("{id}/enable")] + public async Task Enable(string id, CancellationToken cancellationToken) + => await ExecuteEmptyMutationAsync(() => manager.EnableAsync(id, cancellationToken)); + + [HttpPost("{id}/disable")] + public async Task Disable(string id, CancellationToken cancellationToken) + => await ExecuteEmptyMutationAsync(() => manager.DisableAsync(id, cancellationToken)); + + [HttpPut("{id}/configuration")] + public async Task UpdateConfiguration( + string id, + [FromBody] UpdatePluginConfigurationRequest request, + CancellationToken cancellationToken) + => await ExecuteEmptyMutationAsync(() => manager.UpdateConfigurationAsync( + id, + request.Configuration, + cancellationToken)); + + [HttpDelete("{id}")] + public async Task Uninstall( + string id, + CancellationToken cancellationToken, + [FromQuery] bool deleteData = false) + => await ExecuteEmptyMutationAsync(() => manager.UninstallAsync(id, deleteData, cancellationToken)); + + private async Task> ExecuteMutationAsync(Func> operation) + { + try + { + return Ok(await operation()); + } + catch (KeyNotFoundException exception) + { + return NotFound(Error("plugin_not_found", exception.Message)); + } + catch (ArgumentException exception) + { + return BadRequest(Error("invalid_plugin_request", exception.Message)); + } + catch (UnauthorizedAccessException exception) + { + return StatusCode(StatusCodes.Status403Forbidden, Error("capability_or_trust_denied", exception.Message)); + } + catch (Exception exception) when (exception is InvalidDataException or InvalidOperationException or IOException) + { + return Conflict(Error("plugin_operation_rejected", exception.Message)); + } + } + + private async Task ExecuteEmptyMutationAsync(Func operation) + { + var result = await ExecuteMutationAsync(async () => + { + await operation(); + return true; + }); + return result.Result ?? NoContent(); + } + + private static PluginOperationError Error(string code, string message) => new(code, message); +} diff --git a/SecondDimensionWatcherReDive/Plugin/PluginEvent.cs b/SecondDimensionWatcherReDive/Plugin/PluginEvent.cs index 8d25dfe..4f55d5c 100644 --- a/SecondDimensionWatcherReDive/Plugin/PluginEvent.cs +++ b/SecondDimensionWatcherReDive/Plugin/PluginEvent.cs @@ -5,12 +5,53 @@ namespace SecondDimensionWatcherReDive.Plugin; public class PluginEvent : IPluginEventRegister, IPluginEventTrigger { private readonly List> _handlers = []; + private readonly object _gate = new(); + private readonly TimeSpan _handlerTimeout; + private readonly Action? _onHandlerError; - public void Register(Func action) => _handlers.Add(action); + public PluginEvent(TimeSpan? handlerTimeout = null, Action? onHandlerError = null) + { + _handlerTimeout = handlerTimeout ?? TimeSpan.FromSeconds(5); + _onHandlerError = onHandlerError; + } + + public void Register(Func action) + { + ArgumentNullException.ThrowIfNull(action); + lock (_gate) _handlers.Add(action); + } public async Task InvokeAsync(T value, CancellationToken cancellationToken = default) { - foreach (var handler in _handlers) - await handler(value, cancellationToken); + Func[] handlers; + lock (_gate) handlers = _handlers.ToArray(); + foreach (var handler in handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + using var handlerCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + handlerCancellation.CancelAfter(_handlerTimeout); + try + { + await handler(value, handlerCancellation.Token) + .WaitAsync(_handlerTimeout, cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _onHandlerError?.Invoke(new TimeoutException( + $"Plugin event handler exceeded {_handlerTimeout.TotalMilliseconds:0} ms.")); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (TimeoutException exception) + { + _onHandlerError?.Invoke(exception); + } + catch (Exception exception) + { + _onHandlerError?.Invoke(exception); + } + } } } diff --git a/SecondDimensionWatcherReDive/Plugin/PluginHelper.cs b/SecondDimensionWatcherReDive/Plugin/PluginHelper.cs index c2805ad..135bd8d 100644 --- a/SecondDimensionWatcherReDive/Plugin/PluginHelper.cs +++ b/SecondDimensionWatcherReDive/Plugin/PluginHelper.cs @@ -14,9 +14,9 @@ public static WebApplicationBuilder InitializePlugin(this WebApplicationBuilder webApplicationBuilder.Services.AddSingleton>(beforeDownloadStarted); webApplicationBuilder.Services.AddSingleton>(onFileDownloadCompleted); - webApplicationBuilder.Services.AddSingleton(sp => + webApplicationBuilder.Services.AddSingleton(_ => { - var services = new PluginServices(sp); + var services = new PluginServices(); services.AddEvent(PluginEventName.BeforeDownloadStarted, beforeDownloadStarted); services.AddEvent(PluginEventName.OnFileDownloadCompleted, onFileDownloadCompleted); return services; diff --git a/SecondDimensionWatcherReDive/Plugin/PluginServices.cs b/SecondDimensionWatcherReDive/Plugin/PluginServices.cs index f23873b..a5f6794 100644 --- a/SecondDimensionWatcherReDive/Plugin/PluginServices.cs +++ b/SecondDimensionWatcherReDive/Plugin/PluginServices.cs @@ -3,12 +3,10 @@ namespace SecondDimensionWatcherReDive.Plugin; -public class PluginServices(IServiceProvider serviceProvider) : IPluginServices +public class PluginServices : IPluginServices { private readonly Dictionary _events = new(); - public IServiceProvider ServiceProvider => serviceProvider; - public void AddEvent(string eventName, PluginEvent pluginEvent) => _events[eventName] = pluginEvent; diff --git a/SecondDimensionWatcherReDive/PluginPlatform/IPluginCapabilityBroker.cs b/SecondDimensionWatcherReDive/PluginPlatform/IPluginCapabilityBroker.cs new file mode 100644 index 0000000..0e94dd6 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/IPluginCapabilityBroker.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal interface IPluginCapabilityBroker +{ + Task ExecuteAsync( + PluginCatalogEntry plugin, + string capability, + JsonElement payload, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/IPluginManager.cs b/SecondDimensionWatcherReDive/PluginPlatform/IPluginManager.cs new file mode 100644 index 0000000..5033a43 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/IPluginManager.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal interface IPluginManager +{ + Task InitializeAsync(CancellationToken cancellationToken); + Task> GetAllAsync(CancellationToken cancellationToken); + Task EnableAsync(string id, CancellationToken cancellationToken); + Task DisableAsync(string id, CancellationToken cancellationToken); + Task UpgradeAsync( + string id, + string previewToken, + string expectedSha256, + PluginCapabilities approvedCapabilities, + CancellationToken cancellationToken); + Task UninstallAsync(string id, bool deleteData, CancellationToken cancellationToken); + Task UpdateConfigurationAsync(string id, JsonElement configuration, CancellationToken cancellationToken); + Task InvokeAsync( + string id, + string handler, + JsonElement input, + CancellationToken cancellationToken); + IReadOnlyList GetSnapshot(); +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginCapabilityBroker.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginCapabilityBroker.cs new file mode 100644 index 0000000..add9fc7 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginCapabilityBroker.cs @@ -0,0 +1,324 @@ +using System.Net; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed class PluginCapabilityBroker( + IHttpClientFactory httpClientFactory, + IOptions options, + PluginSafeFileAccess fileAccess) : IPluginCapabilityBroker +{ + private static readonly JsonSerializerOptions WebJsonOptions = new(JsonSerializerDefaults.Web); + private readonly PluginPlatformOptions _options = options.Value; + private readonly string _dataRoot = Path.Combine(Path.GetFullPath(options.Value.RootPath), "data"); + private readonly ConcurrentDictionary _dataGates = new(StringComparer.Ordinal); + + public Task ExecuteAsync( + PluginCatalogEntry plugin, + string capability, + JsonElement payload, + CancellationToken cancellationToken) + => capability switch + { + "network.request" => NetworkRequestAsync(plugin, payload, cancellationToken), + "file.read" => ReadFileAsync(plugin, payload, cancellationToken), + "file.list" => ListFilesAsync(plugin, payload, cancellationToken), + "data.read" => ReadDataAsync(plugin, payload, cancellationToken), + "data.write" => WriteDataAsync(plugin, payload, cancellationToken), + "data.list" => ListDataAsync(plugin, payload, cancellationToken), + "data.exists" => DataExistsAsync(plugin, payload, cancellationToken), + "data.info" => DataInfoAsync(plugin, payload, cancellationToken), + _ => throw new UnauthorizedAccessException($"Capability operation '{capability}' is not available.") + }; + + private async Task NetworkRequestAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + var request = payload.Deserialize(WebJsonOptions) + ?? throw new InvalidDataException("Invalid network request."); + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host)) + throw new UnauthorizedAccessException("Only absolute HTTP(S) URLs are allowed."); + if (!IsDomainAllowed(uri.IdnHost, plugin.ApprovedCapabilities.NetworkDomains)) + throw new UnauthorizedAccessException($"Network target '{uri.IdnHost}' was not approved."); + if (!Enum.TryParse(request.Method, ignoreCase: true, out var methodName)) + throw new InvalidDataException("Unsupported HTTP method."); + + using var message = new HttpRequestMessage(new HttpMethod(methodName.ToString().ToUpperInvariant()), uri); + if (request.Body is not null) + message.Content = new StringContent(request.Body, Encoding.UTF8, request.ContentType ?? "application/json"); + using var response = await httpClientFactory.CreateClient("PluginPlatform").SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + var body = await ReadBoundedAsync(await response.Content.ReadAsStreamAsync(cancellationToken), + _options.MaximumResponseBytes, cancellationToken); + return JsonSerializer.SerializeToElement(new + { + status = (int)response.StatusCode, + contentType = response.Content.Headers.ContentType?.ToString(), + body = Encoding.UTF8.GetString(body) + }); + } + + private async Task ReadFileAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + var path = GetRequiredString(payload, "path"); + var (root, resolved) = ResolveApprovedFilePath(path, plugin.ApprovedCapabilities.FileRoots); + var bytes = await fileAccess.ReadAsync(root, resolved, _options.MaximumResponseBytes, cancellationToken); + return JsonSerializer.SerializeToElement(new { base64 = Convert.ToBase64String(bytes) }); + } + + private Task ListFilesAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var (root, path) = ResolveApprovedFilePath(GetRequiredString(payload, "path"), + plugin.ApprovedCapabilities.FileRoots); + var entries = fileAccess.List(root, path, 1_000) + .Select(item => new + { + name = item.Name, + isDirectory = item.IsDirectory + }) + .ToArray(); + return Task.FromResult(JsonSerializer.SerializeToElement(entries)); + } + + private async Task ReadDataAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + EnsureStorageCapability(plugin); + var root = GetPluginDataRoot(plugin.Manifest.Id); + var path = ResolvePluginDataPath(plugin.Manifest.Id, GetRequiredString(payload, "path")); + var bytes = await fileAccess.ReadAsync(root, path, _options.MaximumResponseBytes, cancellationToken); + return JsonSerializer.SerializeToElement(new { base64 = Convert.ToBase64String(bytes) }); + } + + private async Task WriteDataAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + EnsureStorageCapability(plugin); + var root = GetPluginDataRoot(plugin.Manifest.Id); + var path = ResolvePluginDataPath(plugin.Manifest.Id, GetRequiredString(payload, "path")); + var base64 = GetRequiredString(payload, "base64"); + byte[] bytes; + try + { + bytes = Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new InvalidDataException("Data payload must be valid base64."); + } + if (bytes.Length > _options.MaximumResponseBytes) + throw new InvalidDataException("Data write exceeds the configured size limit."); + var gate = _dataGates.GetOrAdd(plugin.Manifest.Id, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + var usage = MeasureUsage(root); + var existing = fileAccess.Info(root, path); + var projectedFiles = usage.Files + (existing is null ? 1 : 0); + var projectedBytes = checked(usage.Bytes - (existing?.Length ?? 0) + bytes.Length); + if (projectedFiles > _options.MaximumPluginDataFiles || + projectedBytes > _options.MaximumPluginDataBytes) + throw new InvalidDataException("Plugin data quota would be exceeded."); + await fileAccess.WriteAsync(root, path, bytes, cancellationToken); + } + finally + { + gate.Release(); + } + + return JsonSerializer.SerializeToElement(new { written = bytes.Length }); + } + + private Task ListDataAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + EnsureStorageCapability(plugin); + cancellationToken.ThrowIfCancellationRequested(); + var root = GetPluginDataRoot(plugin.Manifest.Id); + var path = ResolvePluginDataPath(plugin.Manifest.Id, GetRequiredString(payload, "path", allowEmpty: true)); + if (!fileAccess.Exists(root, path)) + return Task.FromResult(JsonSerializer.SerializeToElement(Array.Empty())); + var entries = fileAccess.List(root, path, 1_000) + .Select(item => new + { + name = item.Name, + isDirectory = item.IsDirectory, + length = item.Length, + lastModifiedUtc = item.LastModifiedUtc + }) + .ToArray(); + return Task.FromResult(JsonSerializer.SerializeToElement(entries)); + } + + private Task DataExistsAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + EnsureStorageCapability(plugin); + cancellationToken.ThrowIfCancellationRequested(); + var root = GetPluginDataRoot(plugin.Manifest.Id); + var path = ResolvePluginDataPath(plugin.Manifest.Id, GetRequiredString(payload, "path", allowEmpty: true)); + var info = fileAccess.Info(root, path); + return Task.FromResult(JsonSerializer.SerializeToElement(new + { + exists = info is not null, + isDirectory = info?.IsDirectory ?? false + })); + } + + private Task DataInfoAsync( + PluginCatalogEntry plugin, + JsonElement payload, + CancellationToken cancellationToken) + { + EnsureStorageCapability(plugin); + cancellationToken.ThrowIfCancellationRequested(); + var relativePath = GetRequiredString(payload, "path", allowEmpty: true); + var root = GetPluginDataRoot(plugin.Manifest.Id); + var path = ResolvePluginDataPath(plugin.Manifest.Id, relativePath); + var info = fileAccess.Info(root, path) + ?? throw new FileNotFoundException("Plugin data path does not exist."); + return Task.FromResult(JsonSerializer.SerializeToElement(new + { + isDirectory = info.IsDirectory, + path = relativePath, + fileName = info.Name, + length = info.Length, + lastModifiedUtc = info.LastModifiedUtc + })); + } + + private (string Root, string Path) ResolveApprovedFilePath(string path, IReadOnlyList approvedRoots) + { + if (approvedRoots.Count == 0) throw new UnauthorizedAccessException("No file roots were approved."); + if (!Path.IsPathFullyQualified(path)) throw new UnauthorizedAccessException("File paths must be absolute."); + var candidate = Path.GetFullPath(path); + var root = approvedRoots.Select(Path.GetFullPath).FirstOrDefault(value => IsWithin(candidate, value)); + if (root is null) throw new UnauthorizedAccessException($"File path '{path}' is outside approved roots."); + return (root, candidate); + } + + private string ResolvePluginDataPath(string pluginId, string relativePath) + { + if (!PluginManifestValidator.IsSafeRelativePath(relativePath) && !string.IsNullOrEmpty(relativePath)) + throw new UnauthorizedAccessException("Plugin data paths must be relative and cannot contain traversal."); + var root = GetPluginDataRoot(pluginId); + var depth = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries).Length; + if (depth > _options.MaximumPluginDataPathDepth) + throw new InvalidDataException("Plugin data path exceeds the configured depth limit."); + var candidate = Path.GetFullPath(Path.Combine(root, relativePath)); + if (!IsWithin(candidate, root)) throw new UnauthorizedAccessException("Plugin data path escapes its root."); + return candidate; + } + + private string GetPluginDataRoot(string pluginId) => Path.Combine(_dataRoot, pluginId); + + private static void EnsureStorageCapability(PluginCatalogEntry plugin) + { + if (!plugin.ApprovedCapabilities.StorageAccess) + throw new UnauthorizedAccessException("Storage access was not approved for this plugin."); + } + + private static string GetRequiredString(JsonElement payload, string property, bool allowEmpty = false) + { + if (!payload.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.String) + throw new InvalidDataException($"Capability request requires string property '{property}'."); + var result = value.GetString() ?? string.Empty; + if (!allowEmpty && string.IsNullOrWhiteSpace(result)) + throw new InvalidDataException($"Capability request property '{property}' cannot be empty."); + return result; + } + + private static bool IsDomainAllowed(string host, IEnumerable domains) + => domains.Any(pattern => pattern.StartsWith("*.", StringComparison.Ordinal) + ? host.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase) && + host.Length > pattern.Length - 1 + : host.Equals(pattern, StringComparison.OrdinalIgnoreCase)); + + private static bool IsWithin(string candidate, string root) + { + var relative = Path.GetRelativePath(root, candidate); + return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !Path.IsPathFullyQualified(relative); + } + + private (long Bytes, int Files) MeasureUsage(string root) + { + if (!Directory.Exists(root)) return (0, 0); + long bytes = 0; + var files = 0; + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out var directory)) + { + foreach (var entry in fileAccess.List(root, directory, _options.MaximumPluginDataFiles + 1)) + { + var path = Path.Combine(directory, entry.Name); + if (entry.IsDirectory) pending.Push(path); + else + { + files = checked(files + 1); + bytes = checked(bytes + (entry.Length ?? 0)); + } + } + } + return (bytes, files); + } + + private static async Task ReadBoundedAsync( + Stream stream, + int maximumBytes, + CancellationToken cancellationToken) + { + using var memory = new MemoryStream(Math.Min(maximumBytes, 64 * 1024)); + var buffer = new byte[64 * 1024]; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0) + { + if (memory.Length + read > maximumBytes) + throw new InvalidDataException("Capability response exceeds the configured size limit."); + await memory.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + return memory.ToArray(); + } + + private sealed record NetworkCapabilityRequest( + string Method, + string Url, + string? Body, + string? ContentType); + + private enum HttpMethodName + { + Get, + Post, + Put, + Patch, + Delete + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleCoordinator.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleCoordinator.cs new file mode 100644 index 0000000..f95305d --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleCoordinator.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed class PluginInvocationInterruptedException(string message) : InvalidOperationException(message); + +internal sealed class PluginLifecycleCoordinator +{ + private readonly ConcurrentDictionary _gates = new(StringComparer.Ordinal); + + public InvocationLease EnterInvocation(string pluginId, CancellationToken callerCancellationToken) + => _gates.GetOrAdd(pluginId, _ => new Gate()).Enter(callerCancellationToken); + + public Task BeginLifecycleAsync( + string pluginId, + TimeSpan timeout, + CancellationToken cancellationToken) + => _gates.GetOrAdd(pluginId, _ => new Gate()).BeginLifecycleAsync(timeout, cancellationToken); + + internal sealed class InvocationLease : IDisposable + { + private readonly Gate _owner; + private readonly CancellationTokenSource _linkedCancellation; + private int _disposed; + + public InvocationLease( + Gate owner, + CancellationToken lifecycleCancellationToken, + CancellationToken callerCancellationToken) + { + _owner = owner; + LifecycleCancellationToken = lifecycleCancellationToken; + _linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + lifecycleCancellationToken, callerCancellationToken); + } + + public CancellationToken Token => _linkedCancellation.Token; + public CancellationToken LifecycleCancellationToken { get; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _linkedCancellation.Dispose(); + _owner.ExitInvocation(); + } + } + + internal sealed class Gate + { + private readonly object _sync = new(); + private CancellationTokenSource _lifecycleCancellation = new(); + private TaskCompletionSource? _drained; + private int _active; + private bool _lifecycleActive; + + public InvocationLease Enter(CancellationToken callerCancellationToken) + { + lock (_sync) + { + if (_lifecycleActive) + throw new PluginCapacityExceededException( + "Plugin is being disabled, upgraded, or uninstalled."); + _active++; + return new InvocationLease(this, _lifecycleCancellation.Token, callerCancellationToken); + } + } + + public async Task BeginLifecycleAsync( + TimeSpan timeout, + CancellationToken cancellationToken) + { + Task drained; + lock (_sync) + { + if (_lifecycleActive) + throw new InvalidOperationException("A lifecycle operation is already in progress for this plugin."); + _lifecycleActive = true; + _lifecycleCancellation.Cancel(); + if (_active == 0) + { + drained = Task.CompletedTask; + } + else + { + _drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + drained = _drained.Task; + } + } + + try + { + await drained.WaitAsync(timeout, cancellationToken); + return new LifecycleLease(this); + } + catch + { + EndLifecycle(); + throw; + } + } + + public void ExitInvocation() + { + lock (_sync) + { + _active--; + if (_active == 0) _drained?.TrySetResult(); + } + } + + private void EndLifecycle() + { + lock (_sync) + { + if (!_lifecycleActive) return; + _lifecycleCancellation.Dispose(); + _lifecycleCancellation = new CancellationTokenSource(); + _drained = null; + _lifecycleActive = false; + } + } + + private sealed class LifecycleLease(Gate owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) owner.EndLifecycle(); + } + } + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleJournal.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleJournal.cs new file mode 100644 index 0000000..f7f59ba --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginLifecycleJournal.cs @@ -0,0 +1,32 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal static class PluginLifecycleJournalValues +{ + public const string Upgrade = "upgrade"; + public const string Uninstall = "uninstall"; + public const string Prepared = "prepared"; + public const string Committed = "committed"; +} + +internal sealed record PluginLifecycleJournal( + string Operation, + string PluginId, + string Phase, + PluginCatalogEntry[] OriginalEntries, + RetainedPluginData? OriginalRetained, + RetainedPluginData? IntendedRetained, + bool DeleteData); + +internal enum PluginLifecycleCheckpoint +{ + AfterMove, + AfterCommit +} + +/// +/// Test-only abrupt-termination signal. The manager deliberately bypasses its in-process +/// rollback for this exception so a new manager can exercise durable startup recovery. +/// +internal sealed class PluginProcessCrashSimulationException() : Exception("Simulated plugin host termination."); diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginManager.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginManager.cs new file mode 100644 index 0000000..27b6159 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginManager.cs @@ -0,0 +1,989 @@ +using System.Security.Cryptography; +using System.Runtime.ExceptionServices; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed class PluginManager( + IPluginCatalogRepository repository, + PluginPackageInspector packageInspector, + IPluginProcessExecutor processExecutor, + IOptions options, + TimeProvider timeProvider) : IPluginManager, IJavaScriptPluginLoader +{ + private const string LifecycleJournalSuffix = ".journal.json"; + + private static readonly JsonSerializerOptions JournalJsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly PluginLifecycleCoordinator _lifecycle = new(); + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly HashSet _pendingLifecyclePluginIds = new(StringComparer.Ordinal); + private InstalledPlugin[] _snapshot = []; + private readonly PluginPlatformOptions _options = options.Value; + private readonly string _rootPath = Path.GetFullPath(options.Value.RootPath); + private bool _initialized; + + internal Action? BeforeInvocationLeaseForTesting { get; set; } + internal Action? LifecycleCheckpointForTesting { get; set; } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (_initialized) return; + Directory.CreateDirectory(_rootPath); + RestrictDirectory(_rootPath); + await RecoverLifecycleTransactionsAsync(cancellationToken); + foreach (var entry in await repository.GetAllAsync(cancellationToken)) + _entries[entry.Manifest.Id] = entry; + await DisableMissingPackagePluginsAsync(cancellationToken); + await DisableIncompatiblePluginsAsync(cancellationToken); + CleanupUnreferencedPackages(); + UpdateSnapshot(); + _initialized = true; + } + finally + { + _gate.Release(); + } + } + + public async Task> GetAllAsync(CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + UpdateSnapshot(); + return _snapshot; + } + finally + { + _gate.Release(); + } + } + + public IReadOnlyList GetSnapshot() => Volatile.Read(ref _snapshot); + + public async Task PreviewPackageAsync( + Stream package, + string fileName, + CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + var inspected = await packageInspector.StageAndInspectAsync(package, fileName, cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + return new PluginPackagePreview( + inspected.Token, + inspected.PackageSha256, + inspected.Manifest, + GetCompatibilityErrors(inspected.Manifest), + inspected.IsSignatureTrusted, + inspected.SignatureStatus, + inspected.ExpiresAt); + } + finally + { + _gate.Release(); + } + } + + public Task InstallPackageAsync( + string previewToken, + string expectedSha256, + PluginCapabilities approvedCapabilities, + CancellationToken cancellationToken) + => InstallOrUpgradeAsync(null, previewToken, expectedSha256, approvedCapabilities, cancellationToken); + + public Task UpgradeAsync( + string id, + string previewToken, + string expectedSha256, + PluginCapabilities approvedCapabilities, + CancellationToken cancellationToken) + => InstallOrUpgradeAsync(id, previewToken, expectedSha256, approvedCapabilities, cancellationToken); + + public async Task EnableAsync(string id, CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + var entry = GetRequiredEntry(id); + EnsureNoPendingLifecycleManagement(); + var errors = GetCompatibilityErrors(entry.Manifest); + if (errors.Count > 0) + throw new InvalidOperationException($"Plugin cannot be enabled: {string.Join(" ", errors)}"); + if (entry.IsEnabled) return; + entry = entry with + { + IsEnabled = true, + Health = entry.Health with + { + Status = "healthy", + ConsecutiveFailures = 0, + CircuitOpenUntil = null, + LastError = null + } + }; + await repository.SaveAsync(entry, cancellationToken); + _entries[id] = entry; + UpdateSnapshot(); + } + finally + { + _gate.Release(); + } + } + + public async Task DisableAsync(string id, CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + var entry = GetRequiredEntry(id); + EnsureNoPendingLifecycleManagement(); + using var lifecycle = await _lifecycle.BeginLifecycleAsync( + id, LifecycleWaitTimeout, cancellationToken); + if (entry.IsEnabled) + { + entry = entry with { IsEnabled = false }; + await repository.SaveAsync(entry, cancellationToken); + _entries[id] = entry; + } + await DisableIncompatiblePluginsAsync(cancellationToken); + UpdateSnapshot(); + } + finally + { + _gate.Release(); + } + } + + public async Task UninstallAsync(string id, bool deleteData, CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + await _gate.WaitAsync(cancellationToken); + string? transactionPath = null; + string? packageBackupPath = null; + string? dataBackupPath = null; + RetainedPluginData? originalRetained = null; + PluginLifecycleJournal? journal = null; + var journalCommitted = false; + var originalEntries = _entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + try + { + var entry = GetRequiredEntry(id); + EnsureNoPendingLifecycleManagement(); + using var lifecycle = await _lifecycle.BeginLifecycleAsync( + id, LifecycleWaitTimeout, cancellationToken); + originalRetained = await repository.FindRetainedAsync(id, cancellationToken); + var intendedRetained = deleteData + ? null + : new RetainedPluginData( + id, + entry.ConfigurationJson, + entry.DataVersion, + timeProvider.GetUtcNow(), + entry.PublisherFingerprint); + transactionPath = CreateTransactionPath(PluginLifecycleJournalValues.Uninstall, id); + journal = new PluginLifecycleJournal( + PluginLifecycleJournalValues.Uninstall, + id, + PluginLifecycleJournalValues.Prepared, + originalEntries.Values.ToArray(), + originalRetained, + intendedRetained, + deleteData); + WriteLifecycleJournal(transactionPath, journal); + packageBackupPath = MoveToTransaction(entry.PackageDirectory, + Path.Combine(transactionPath, "package")); + if (deleteData) + dataBackupPath = MoveToTransaction(GetDataPath(id), Path.Combine(transactionPath, "data")); + LifecycleCheckpointForTesting?.Invoke(PluginLifecycleCheckpoint.AfterMove); + + if (!deleteData) + { + await repository.SaveRetainedAsync(intendedRetained!, cancellationToken); + } + else + { + await repository.RemoveRetainedAsync(id, cancellationToken); + } + + await repository.RemoveAsync(id, cancellationToken); + _entries.Remove(id); + await DisableIncompatiblePluginsAsync(cancellationToken); + UpdateSnapshot(); + journal = journal with { Phase = PluginLifecycleJournalValues.Committed }; + WriteLifecycleJournal(transactionPath, journal); + journalCommitted = true; + LifecycleCheckpointForTesting?.Invoke(PluginLifecycleCheckpoint.AfterCommit); + DeleteLifecycleTransaction(transactionPath, id); + } + catch (PluginProcessCrashSimulationException) + { + throw; + } + catch (Exception) when (journalCommitted) + { + // The catalog change is durably committed. Keep the journal so startup can + // retry idempotent cleanup rather than attempting an unsafe partial rollback. + throw; + } + catch (Exception failure) + { + try + { + foreach (var originalEntry in originalEntries.Values) + await repository.SaveAsync(originalEntry, CancellationToken.None); + if (originalRetained is null) + await repository.RemoveRetainedAsync(id, CancellationToken.None); + else + await repository.SaveRetainedAsync(originalRetained, CancellationToken.None); + RestoreTransactionDirectory(packageBackupPath, + originalEntries.TryGetValue(id, out var original) ? original.PackageDirectory : null); + RestoreTransactionDirectory(dataBackupPath, GetDataPath(id)); + _entries.Clear(); + foreach (var originalEntry in originalEntries) + _entries[originalEntry.Key] = originalEntry.Value; + UpdateSnapshot(); + DeleteLifecycleTransactionBestEffort(transactionPath, id); + } + catch (Exception rollbackFailure) + { + throw new AggregateException("Plugin uninstall failed and rollback was incomplete.", + failure, rollbackFailure); + } + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task UpdateConfigurationAsync( + string id, + JsonElement configuration, + CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + if (configuration.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Plugin configuration must be a JSON object."); + var json = configuration.GetRawText(); + if (json.Length > 64 * 1024) throw new InvalidDataException("Plugin configuration is too large."); + await _gate.WaitAsync(cancellationToken); + try + { + var entry = GetRequiredEntry(id) with { ConfigurationJson = json }; + EnsureNoPendingLifecycleManagement(); + await repository.SaveAsync(entry, cancellationToken); + _entries[id] = entry; + UpdateSnapshot(); + } + finally + { + _gate.Release(); + } + } + + public async Task InvokeAsync( + string id, + string handler, + JsonElement input, + CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + if (!PluginManifestValidator.IsValidHandlerName(handler)) + throw new InvalidDataException("Invalid plugin handler name."); + PluginCatalogEntry entry; + PluginLifecycleCoordinator.InvocationLease? invocation = null; + await _gate.WaitAsync(cancellationToken); + try + { + entry = GetRequiredEntry(id); + EnsureNoPendingLifecycleManagement(); + if (!entry.IsEnabled) throw new InvalidOperationException($"Plugin '{id}' is disabled."); + var errors = GetCompatibilityErrors(entry.Manifest); + if (errors.Count > 0) + throw new InvalidOperationException($"Plugin '{id}' is incompatible: {string.Join(" ", errors)}"); + if (entry.Health.CircuitOpenUntil is { } openUntil && openUntil > timeProvider.GetUtcNow()) + throw new InvalidOperationException($"Plugin '{id}' circuit is open until {openUntil:O}."); + BeforeInvocationLeaseForTesting?.Invoke(); + invocation = _lifecycle.EnterInvocation(id, cancellationToken); + } + finally + { + _gate.Release(); + } + + JsonElement result = default; + Exception? failure = null; + var interruptedByLifecycle = false; + using (var activeInvocation = invocation ?? throw new UnreachableException()) + { + try + { + result = await processExecutor.InvokeAsync(entry, handler, input, activeInvocation.Token); + interruptedByLifecycle = activeInvocation.LifecycleCancellationToken.IsCancellationRequested && + !cancellationToken.IsCancellationRequested; + if (interruptedByLifecycle) + failure = new OperationCanceledException("Invocation completed during lifecycle cancellation."); + } + catch (Exception exception) + { + failure = exception; + interruptedByLifecycle = activeInvocation.LifecycleCancellationToken.IsCancellationRequested && + !cancellationToken.IsCancellationRequested; + } + } + + if (failure is null) + { + await RecordSuccessAsync(id, cancellationToken); + return result; + } + + if (interruptedByLifecycle) + throw new PluginInvocationInterruptedException( + $"Plugin '{id}' invocation was cancelled by a lifecycle operation."); + if (failure is not PluginCapacityExceededException && + (failure is not OperationCanceledException || !cancellationToken.IsCancellationRequested)) + await RecordFailureAsync(id, failure, CancellationToken.None); + ExceptionDispatchInfo.Capture(failure).Throw(); + throw new UnreachableException(); + } + + private async Task InstallOrUpgradeAsync( + string? upgradeId, + string previewToken, + string expectedSha256, + PluginCapabilities approvedCapabilities, + CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + var inspected = await packageInspector.InspectStagedAsync(previewToken, expectedSha256, cancellationToken); + if (!PluginManifestValidator.CapabilitiesEqual(inspected.Manifest.Capabilities, approvedCapabilities)) + throw new UnauthorizedAccessException("Approved capabilities do not exactly match the reviewed manifest."); + if (!inspected.IsSignatureTrusted && + !(_options.AllowUnsignedLocalPackages && inspected.Manifest.Signature is null)) + throw new UnauthorizedAccessException( + $"Package is not signed by a trusted publisher. {inspected.SignatureStatus}"); + + await _gate.WaitAsync(cancellationToken); + string? extractedPath = null; + string? transactionPath = null; + string? dataBackupPath = null; + PluginCatalogEntry? existing = null; + RetainedPluginData? retained = null; + PluginLifecycleJournal? journal = null; + IDisposable? lifecycle = null; + var catalogWritten = false; + var journalCommitted = false; + var originalEntries = _entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + try + { + _entries.TryGetValue(inspected.Manifest.Id, out existing); + EnsureNoPendingLifecycleManagement(); + if (upgradeId is null && existing is not null) + throw new InvalidOperationException("Plugin is already installed; use the upgrade operation."); + if (upgradeId is not null) + { + if (existing is null || !string.Equals(upgradeId, inspected.Manifest.Id, StringComparison.Ordinal)) + throw new InvalidOperationException("Upgrade package id does not match the installed plugin."); + if (!PluginManifestValidator.TryParseVersion(existing.Manifest.Version, out var oldVersion) || + !PluginManifestValidator.TryParseVersion(inspected.Manifest.Version, out var newVersion) || + newVersion <= oldVersion) + throw new InvalidOperationException("Upgrade version must be newer than the installed version."); + EnsurePublisherContinuity(existing.PublisherFingerprint, inspected.PublisherFingerprint); + lifecycle = await _lifecycle.BeginLifecycleAsync( + inspected.Manifest.Id, LifecycleWaitTimeout, cancellationToken); + } + + retained = existing is null + ? await repository.FindRetainedAsync(inspected.Manifest.Id, cancellationToken) + : null; + if (retained is not null) + EnsurePublisherContinuity(retained.PublisherFingerprint, inspected.PublisherFingerprint); + var previousDataVersion = existing?.DataVersion ?? retained?.DataVersion; + if (previousDataVersion is not null && previousDataVersion != inspected.Manifest.DataVersion) + { + if (!string.Equals(inspected.Manifest.DataMigration?.Strategy, "reset", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Data version changes from {previousDataVersion} to {inspected.Manifest.DataVersion}; manifest must explicitly declare dataMigration.strategy = 'reset'."); + transactionPath = CreateTransactionPath( + PluginLifecycleJournalValues.Upgrade, inspected.Manifest.Id); + journal = new PluginLifecycleJournal( + PluginLifecycleJournalValues.Upgrade, + inspected.Manifest.Id, + PluginLifecycleJournalValues.Prepared, + originalEntries.Values.ToArray(), + retained, + null, + DeleteData: false); + WriteLifecycleJournal(transactionPath, journal); + dataBackupPath = MoveToTransaction( + GetDataPath(inspected.Manifest.Id), Path.Combine(transactionPath, "data")); + LifecycleCheckpointForTesting?.Invoke(PluginLifecycleCheckpoint.AfterMove); + } + + extractedPath = await packageInspector.ExtractAsync(inspected, cancellationToken); + var configuration = existing?.ConfigurationJson ?? retained?.ConfigurationJson ?? "{}"; + var entry = new PluginCatalogEntry( + inspected.Manifest, + false, + approvedCapabilities, + new PluginHealth("healthy", 0, null, null, null, null), + extractedPath, + configuration, + inspected.Manifest.DataVersion, + inspected.PublisherFingerprint); + await repository.SaveAsync(entry, cancellationToken); + _entries[inspected.Manifest.Id] = entry; + catalogWritten = true; + await DisableIncompatiblePluginsAsync(cancellationToken); + await repository.RemoveRetainedAsync(inspected.Manifest.Id, cancellationToken); + UpdateSnapshot(); + + if (journal is not null) + { + journal = journal with { Phase = PluginLifecycleJournalValues.Committed }; + WriteLifecycleJournal(transactionPath!, journal); + journalCommitted = true; + LifecycleCheckpointForTesting?.Invoke(PluginLifecycleCheckpoint.AfterCommit); + DeleteLifecycleTransaction(transactionPath, inspected.Manifest.Id); + } + + if (existing is not null && !PathsEqual(existing.PackageDirectory, extractedPath) && + Directory.Exists(existing.PackageDirectory)) + { + try { Directory.Delete(existing.PackageDirectory, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + try { packageInspector.Consume(inspected); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + return new PluginInstallResult( + inspected.Manifest.Id, + inspected.Manifest.Version, + existing is not null, + GetCompatibilityErrors(inspected.Manifest)); + } + catch (PluginProcessCrashSimulationException) + { + throw; + } + catch (Exception) when (journalCommitted) + { + // The new catalog is committed; startup will finish journal cleanup. + throw; + } + catch (Exception failure) + { + try + { + if (catalogWritten) + { + if (existing is null) + await repository.RemoveAsync(inspected.Manifest.Id, CancellationToken.None); + foreach (var original in originalEntries.Values) + await repository.SaveAsync(original, CancellationToken.None); + if (retained is not null) + await repository.SaveRetainedAsync(retained, CancellationToken.None); + } + _entries.Clear(); + foreach (var original in originalEntries) _entries[original.Key] = original.Value; + UpdateSnapshot(); + if (extractedPath is not null && Directory.Exists(extractedPath)) + Directory.Delete(extractedPath, recursive: true); + RestoreTransactionDirectory(dataBackupPath, GetDataPath(inspected.Manifest.Id)); + DeleteLifecycleTransactionBestEffort(transactionPath, inspected.Manifest.Id); + } + catch (Exception rollbackFailure) + { + throw new AggregateException("Plugin installation failed and rollback was incomplete.", + failure, rollbackFailure); + } + throw; + } + finally + { + lifecycle?.Dispose(); + _gate.Release(); + } + } + + private async Task RecordSuccessAsync(string id, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (_pendingLifecyclePluginIds.Count > 0) return; + if (!_entries.TryGetValue(id, out var entry)) return; + entry = entry with + { + Health = entry.Health with + { + Status = "healthy", + ConsecutiveFailures = 0, + LastSuccessAt = timeProvider.GetUtcNow(), + LastError = null, + CircuitOpenUntil = null + } + }; + await repository.SaveAsync(entry, cancellationToken); + _entries[id] = entry; + UpdateSnapshot(); + } + finally + { + _gate.Release(); + } + } + + private async Task RecordFailureAsync(string id, Exception exception, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (_pendingLifecyclePluginIds.Count > 0) return; + if (!_entries.TryGetValue(id, out var entry)) return; + var failures = checked(entry.Health.ConsecutiveFailures + 1); + DateTimeOffset? openUntil = failures >= _options.CircuitBreakerFailures + ? timeProvider.GetUtcNow().AddSeconds(_options.CircuitBreakerSeconds) + : null; + entry = entry with + { + Health = entry.Health with + { + Status = openUntil is null ? "degraded" : "circuit-open", + ConsecutiveFailures = failures, + LastFailureAt = timeProvider.GetUtcNow(), + LastError = Truncate(exception.Message, 1_024), + CircuitOpenUntil = openUntil + } + }; + await repository.SaveAsync(entry, cancellationToken); + _entries[id] = entry; + UpdateSnapshot(); + } + finally + { + _gate.Release(); + } + } + + private async Task DisableIncompatiblePluginsAsync(CancellationToken cancellationToken) + { + bool changed; + do + { + changed = false; + foreach (var pair in _entries.OrderBy(value => value.Key, StringComparer.Ordinal).ToArray()) + { + if (!pair.Value.IsEnabled || GetCompatibilityErrors(pair.Value.Manifest).Count == 0) continue; + using var lifecycle = await _lifecycle.BeginLifecycleAsync( + pair.Key, LifecycleWaitTimeout, cancellationToken); + var disabled = pair.Value with { IsEnabled = false }; + await repository.SaveAsync(disabled, cancellationToken); + _entries[pair.Key] = disabled; + changed = true; + } + } while (changed); + } + + private IReadOnlyList GetCompatibilityErrors(PluginManifest manifest) + { + var installed = _entries.ToDictionary( + pair => pair.Key, + pair => new PluginCatalogEntryView(pair.Value.Manifest.Version, pair.Value.IsEnabled), + StringComparer.Ordinal); + return PluginManifestValidator.GetCompatibilityErrors(manifest, installed); + } + + private IReadOnlyList CreateSnapshot() + => _entries.Values + .OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase) + .Select(entry => new InstalledPlugin( + entry.Manifest, + entry.IsEnabled, + entry.ApprovedCapabilities, + GetCompatibilityErrors(entry.Manifest), + entry.Health, + ParseConfiguration(entry.ConfigurationJson))) + .ToArray(); + + private void UpdateSnapshot() => Volatile.Write(ref _snapshot, CreateSnapshot().ToArray()); + + private PluginCatalogEntry GetRequiredEntry(string id) + { + if (!PluginManifestValidator.IsValidId(id)) throw new ArgumentException("Invalid plugin id.", nameof(id)); + return _entries.TryGetValue(id, out var entry) + ? entry + : throw new KeyNotFoundException($"Plugin '{id}' is not installed."); + } + + private void EnsureNoPendingLifecycleManagement() + { + if (_pendingLifecyclePluginIds.Count > 0) + throw new InvalidOperationException( + $"A pending lifecycle recovery exists for '{string.Join("', '", _pendingLifecyclePluginIds.Order())}'. " + + "Restart the service to finalize it before another plugin operation."); + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (!_initialized) await InitializeAsync(cancellationToken); + } + + private string CreateTransactionPath(string operation, string id) + { + if (!PluginManifestValidator.IsValidId(id)) throw new ArgumentException("Invalid plugin id.", nameof(id)); + var transactionRoot = Path.Combine(_rootPath, "transactions"); + Directory.CreateDirectory(transactionRoot); + RestrictDirectory(transactionRoot); + var transactionPath = Path.Combine(transactionRoot, $"{operation}-{id}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(transactionPath); + RestrictDirectory(transactionPath); + return transactionPath; + } + + private void WriteLifecycleJournal(string transactionPath, PluginLifecycleJournal journal) + { + var transactionRoot = Path.GetFullPath(Path.Combine(_rootPath, "transactions")); + var fullTransactionPath = Path.GetFullPath(transactionPath); + if (!IsStrictlyWithin(fullTransactionPath, transactionRoot)) + throw new UnauthorizedAccessException("Plugin lifecycle transaction is outside the transaction root."); + var journalPath = GetLifecycleJournalPath(fullTransactionPath); + var temporaryPath = $"{journalPath}.{Guid.NewGuid():N}.tmp"; + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 16 * 1024, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, journal, JournalJsonOptions); + stream.Flush(flushToDisk: true); + } + RestrictFile(temporaryPath); + File.Move(temporaryPath, journalPath, overwrite: true); + _pendingLifecyclePluginIds.Add(journal.PluginId); + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + } + } + + private async Task RecoverLifecycleTransactionsAsync(CancellationToken cancellationToken) + { + var transactionRoot = Path.Combine(_rootPath, "transactions"); + if (!Directory.Exists(transactionRoot)) return; + RestrictDirectory(transactionRoot); + var pending = new List<(string TransactionPath, PluginLifecycleJournal Journal)>(); + foreach (var journalPath in Directory.EnumerateFiles( + transactionRoot, $"*{LifecycleJournalSuffix}", SearchOption.TopDirectoryOnly) + .Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + var transactionPath = journalPath[..^LifecycleJournalSuffix.Length]; + if (!IsStrictlyWithin(Path.GetFullPath(transactionPath), Path.GetFullPath(transactionRoot))) + throw new InvalidDataException("Plugin lifecycle transaction path is invalid."); + + PluginLifecycleJournal? journal; + await using (var stream = File.OpenRead(journalPath)) + journal = await JsonSerializer.DeserializeAsync( + stream, JournalJsonOptions, cancellationToken); + ValidateLifecycleJournal(journal, transactionPath); + pending.Add((transactionPath, journal!)); + } + + if (pending.GroupBy(item => item.Journal.PluginId, StringComparer.Ordinal) + .Any(group => group.Count() > 1)) + throw new InvalidDataException("Multiple pending lifecycle journals exist for the same plugin."); + foreach (var item in pending) _pendingLifecyclePluginIds.Add(item.Journal.PluginId); + + foreach (var item in pending) + { + var current = await repository.FindAsync(item.Journal.PluginId, cancellationToken); + var catalogCommitted = CatalogReflectsCommittedJournal(item.Journal, current); + if (item.Journal.Phase == PluginLifecycleJournalValues.Committed && catalogCommitted) + await FinalizeCommittedJournalAsync( + item.Journal, item.TransactionPath, cancellationToken); + else + await RollbackPreparedJournalAsync( + item.Journal, item.TransactionPath, cancellationToken); + } + + foreach (var temporaryJournal in Directory.EnumerateFiles( + transactionRoot, $"*{LifecycleJournalSuffix}.*.tmp", SearchOption.TopDirectoryOnly)) + File.Delete(temporaryJournal); + foreach (var transactionPath in Directory.EnumerateDirectories(transactionRoot).ToArray()) + { + if (File.Exists(GetLifecycleJournalPath(transactionPath))) continue; + if (Directory.Exists(Path.Combine(transactionPath, "package")) || + Directory.Exists(Path.Combine(transactionPath, "data"))) + throw new InvalidDataException( + $"Plugin lifecycle transaction '{Path.GetFileName(transactionPath)}' has payload but no recovery journal."); + DeleteTransaction(transactionPath); + } + } + + private void ValidateLifecycleJournal(PluginLifecycleJournal? journal, string transactionPath) + { + if (journal is null || + journal.Operation is not (PluginLifecycleJournalValues.Upgrade or PluginLifecycleJournalValues.Uninstall) || + journal.Phase is not (PluginLifecycleJournalValues.Prepared or PluginLifecycleJournalValues.Committed) || + !PluginManifestValidator.IsValidId(journal.PluginId) || + journal.OriginalEntries.GroupBy(entry => entry.Manifest.Id, StringComparer.Ordinal) + .Any(group => group.Count() > 1)) + throw new InvalidDataException( + $"Plugin lifecycle transaction '{Path.GetFileName(transactionPath)}' has an invalid journal."); + + var packagesRoot = Path.GetFullPath(Path.Combine(_rootPath, "packages")); + foreach (var entry in journal.OriginalEntries) + { + if (!PluginManifestValidator.IsValidId(entry.Manifest.Id) || + !IsStrictlyWithin(Path.GetFullPath(entry.PackageDirectory), packagesRoot)) + throw new InvalidDataException("Plugin lifecycle journal contains an invalid catalog path."); + } + + var original = journal.OriginalEntries.SingleOrDefault(entry => entry.Manifest.Id == journal.PluginId); + if (journal.Operation == PluginLifecycleJournalValues.Uninstall && original is null) + throw new InvalidDataException("Uninstall recovery journal is missing the original plugin catalog entry."); + if (journal.OriginalRetained is { } retained && retained.Id != journal.PluginId || + journal.IntendedRetained is { } intended && intended.Id != journal.PluginId) + throw new InvalidDataException("Plugin lifecycle journal contains retained data for another plugin."); + } + + private static bool CatalogReflectsCommittedJournal( + PluginLifecycleJournal journal, + PluginCatalogEntry? current) + { + if (journal.Operation == PluginLifecycleJournalValues.Uninstall) return current is null; + if (current is null) return false; + var original = journal.OriginalEntries.SingleOrDefault(entry => entry.Manifest.Id == journal.PluginId); + return original is null || + !string.Equals(current.Manifest.Version, original.Manifest.Version, StringComparison.Ordinal) || + !PathsEqual(current.PackageDirectory, original.PackageDirectory); + } + + private async Task RollbackPreparedJournalAsync( + PluginLifecycleJournal journal, + string transactionPath, + CancellationToken cancellationToken) + { + var original = journal.OriginalEntries.SingleOrDefault(entry => entry.Manifest.Id == journal.PluginId); + if (journal.Operation == PluginLifecycleJournalValues.Uninstall) + RestoreTransactionDirectory( + Path.Combine(transactionPath, "package"), original!.PackageDirectory); + RestoreTransactionDirectory( + Path.Combine(transactionPath, "data"), GetDataPath(journal.PluginId)); + + if (journal.OriginalRetained is null) + await repository.RemoveRetainedAsync(journal.PluginId, cancellationToken); + else + await repository.SaveRetainedAsync(journal.OriginalRetained, cancellationToken); + + if (original is null) + await repository.RemoveAsync(journal.PluginId, cancellationToken); + foreach (var entry in journal.OriginalEntries) + await repository.SaveAsync(entry, cancellationToken); + DeleteLifecycleTransaction(transactionPath, journal.PluginId); + } + + private async Task FinalizeCommittedJournalAsync( + PluginLifecycleJournal journal, + string transactionPath, + CancellationToken cancellationToken) + { + if (journal.Operation == PluginLifecycleJournalValues.Uninstall) + { + await repository.RemoveAsync(journal.PluginId, cancellationToken); + if (journal.DeleteData) + { + await repository.RemoveRetainedAsync(journal.PluginId, cancellationToken); + DeleteDirectoryWithinRoot(GetDataPath(journal.PluginId)); + } + else if (journal.IntendedRetained is not null) + { + await repository.SaveRetainedAsync(journal.IntendedRetained, cancellationToken); + } + } + else + { + await repository.RemoveRetainedAsync(journal.PluginId, cancellationToken); + } + + DeleteLifecycleTransaction(transactionPath, journal.PluginId); + } + + private async Task DisableMissingPackagePluginsAsync(CancellationToken cancellationToken) + { + var packagesRoot = Path.GetFullPath(Path.Combine(_rootPath, "packages")); + foreach (var pair in _entries.OrderBy(value => value.Key, StringComparer.Ordinal).ToArray()) + { + var packagePath = Path.GetFullPath(pair.Value.PackageDirectory); + if (IsStrictlyWithin(packagePath, packagesRoot) && Directory.Exists(packagePath)) continue; + var disabled = pair.Value with + { + IsEnabled = false, + Health = pair.Value.Health with + { + Status = "missing-package", + LastError = "The installed plugin package directory is missing or invalid." + } + }; + await repository.SaveAsync(disabled, cancellationToken); + _entries[pair.Key] = disabled; + } + } + + private void CleanupUnreferencedPackages() + { + var packagesRoot = Path.GetFullPath(Path.Combine(_rootPath, "packages")); + if (!Directory.Exists(packagesRoot)) return; + var referenced = _entries.Values + .Select(entry => Path.GetFullPath(entry.PackageDirectory)) + .Where(path => IsStrictlyWithin(path, packagesRoot)) + .ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (var pluginDirectory in Directory.EnumerateDirectories(packagesRoot)) + { + foreach (var packageDirectory in Directory.EnumerateDirectories(pluginDirectory)) + { + if (!referenced.Contains(Path.GetFullPath(packageDirectory))) + DeleteTransactionBestEffort(packageDirectory); + } + if (!Directory.EnumerateFileSystemEntries(pluginDirectory).Any()) + DeleteTransactionBestEffort(pluginDirectory); + } + } + + private void DeleteDirectoryWithinRoot(string path) + { + var fullPath = Path.GetFullPath(path); + if (!IsStrictlyWithin(fullPath, _rootPath)) + throw new UnauthorizedAccessException("Plugin lifecycle cleanup path is outside the platform root."); + if (Directory.Exists(fullPath)) Directory.Delete(fullPath, recursive: true); + } + + private string GetDataPath(string id) => Path.Combine(_rootPath, "data", id); + + private TimeSpan LifecycleWaitTimeout => + TimeSpan.FromMilliseconds(_options.InvocationTimeoutMilliseconds + 2_000); + + private static void EnsurePublisherContinuity(string? existingFingerprint, string? incomingFingerprint) + { + if (string.Equals(existingFingerprint, incomingFingerprint, StringComparison.Ordinal)) return; + throw new UnauthorizedAccessException( + "Plugin publisher identity does not match the installed or retained owner. Delete retained data before transferring ownership."); + } + + private string? MoveToTransaction(string source, string destination) + { + if (!Directory.Exists(source)) return null; + var fullSource = Path.GetFullPath(source); + if (!IsWithin(fullSource, _rootPath)) + throw new UnauthorizedAccessException("Plugin lifecycle path is outside the plugin platform root."); + Directory.Move(fullSource, destination); + return destination; + } + + private static void RestoreTransactionDirectory(string? backup, string? destination) + { + if (backup is null || destination is null || !Directory.Exists(backup)) return; + if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + Directory.Move(backup, destination); + } + + private static void DeleteTransactionBestEffort(string? transactionPath) + { + if (transactionPath is null || !Directory.Exists(transactionPath)) return; + try { Directory.Delete(transactionPath, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static void DeleteTransaction(string? transactionPath) + { + if (transactionPath is not null && Directory.Exists(transactionPath)) + Directory.Delete(transactionPath, recursive: true); + } + + private static string GetLifecycleJournalPath(string transactionPath) + => $"{transactionPath}{LifecycleJournalSuffix}"; + + private void DeleteLifecycleTransaction(string? transactionPath, string pluginId) + { + if (transactionPath is null) return; + DeleteTransaction(transactionPath); + var journalPath = GetLifecycleJournalPath(transactionPath); + if (File.Exists(journalPath)) File.Delete(journalPath); + if (!Directory.Exists(transactionPath) && !File.Exists(journalPath)) + _pendingLifecyclePluginIds.Remove(pluginId); + } + + private void DeleteLifecycleTransactionBestEffort(string? transactionPath, string pluginId) + { + if (transactionPath is null) return; + DeleteTransactionBestEffort(transactionPath); + if (Directory.Exists(transactionPath)) return; + try + { + var journalPath = GetLifecycleJournalPath(transactionPath); + if (File.Exists(journalPath)) File.Delete(journalPath); + if (!File.Exists(journalPath)) _pendingLifecyclePluginIds.Remove(pluginId); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static JsonElement ParseConfiguration(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static bool PathsEqual(string left, string right) + => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static bool IsWithin(string candidate, string root) + { + var relative = Path.GetRelativePath(Path.GetFullPath(root), candidate); + return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !Path.IsPathFullyQualified(relative); + } + + private static bool IsStrictlyWithin(string candidate, string root) + => !PathsEqual(candidate, root) && IsWithin(candidate, root); + + private static string Truncate(string value, int length) => value.Length <= length ? value : value[..length]; + + private static void RestrictDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginManifestValidator.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginManifestValidator.cs new file mode 100644 index 0000000..dab1fe0 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginManifestValidator.cs @@ -0,0 +1,333 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal static partial class PluginManifestValidator +{ + [GeneratedRegex("^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$", + RegexOptions.CultureInvariant)] + private static partial Regex IdPattern(); + + [GeneratedRegex("^[A-Za-z][A-Za-z0-9_.-]{0,63}$", RegexOptions.CultureInvariant)] + private static partial Regex HandlerNamePattern(); + + private const int MaximumVersionLength = 128; + + public static bool IsValidId(string id) + => !string.IsNullOrWhiteSpace(id) && id.Length is >= 3 and <= 64 && IdPattern().IsMatch(id); + public static bool IsValidHandlerName(string name) + => !string.IsNullOrWhiteSpace(name) && HandlerNamePattern().IsMatch(name); + + public static IReadOnlyList Validate(PluginManifest manifest) + { + var errors = new List(); + if (!IsValidId(manifest.Id)) + errors.Add("Plugin id must be 3-64 lowercase ASCII characters in non-empty dot-separated segments."); + if (string.IsNullOrWhiteSpace(manifest.Name) || manifest.Name.Length > 128 || + manifest.Name.Any(char.IsControl)) + errors.Add("Plugin name is required, must be at most 128 characters, and cannot contain control characters."); + if (manifest.Description is { } description && + (description.Length > 2_048 || description.Any(IsDisallowedDescriptionCharacter))) + errors.Add("Plugin description must be at most 2048 characters and cannot contain unsafe control characters."); + if (!TryParseVersion(manifest.Version, out _)) errors.Add("Plugin version must be a valid semantic version."); + if (!TryParseApiVersion(manifest.ApiVersion, out _)) errors.Add("API version must be a valid API version."); + if (!IsSafeRelativePath(manifest.EntryPoint) || !manifest.EntryPoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + errors.Add("Entry point must be a relative JavaScript file path."); + if (manifest.DataVersion < 1) errors.Add("Data version must be at least 1."); + + foreach (var dependency in manifest.Dependencies) + { + if (!IsValidId(dependency.Id)) errors.Add($"Dependency id '{dependency.Id}' is invalid."); + if (!TryParseVersion(dependency.MinimumVersion, out _)) + errors.Add($"Dependency '{dependency.Id}' has an invalid minimum version."); + } + + if (manifest.Dependencies.GroupBy(x => x.Id, StringComparer.Ordinal).Any(group => group.Count() > 1)) + errors.Add("Dependencies must not contain duplicate ids."); + if (manifest.Providers.GroupBy(x => $"{x.Kind}:{x.Name}", StringComparer.Ordinal).Any(group => group.Count() > 1)) + errors.Add("Provider declarations must have unique kind/name pairs."); + + foreach (var provider in manifest.Providers) + { + if (provider.Kind is not ("notification" or "storage")) + errors.Add($"Provider '{provider.Name}' has unsupported kind '{provider.Kind}'."); + if (string.IsNullOrWhiteSpace(provider.Name) || provider.Handlers.Count == 0) + errors.Add("Provider declarations require a name and at least one handler."); + if (!IsValidIdentifier(provider.Name)) + errors.Add($"Provider '{provider.Name}' name must be a 1-64 character ASCII identifier."); + if (provider.Handlers.Keys.Any(operation => !IsValidIdentifier(operation))) + errors.Add($"Provider '{provider.Name}' contains an invalid operation name."); + if (provider.Handlers.Values.Any(handler => !IsValidHandlerName(handler))) + errors.Add($"Provider '{provider.Name}' contains an invalid handler name."); + if (provider.Kind == "notification" && !provider.Handlers.ContainsKey("send")) + errors.Add($"Notification provider '{provider.Name}' requires a send handler."); + if (provider.Kind == "storage" && + new[] { "exists", "info", "read", "list" }.Any(operation => !provider.Handlers.ContainsKey(operation))) + errors.Add($"Storage provider '{provider.Name}' requires exists, info, read and list handlers."); + } + + if (manifest.Providers.Any(provider => provider.Kind == "storage") && !manifest.Capabilities.StorageAccess) + errors.Add("Storage providers require the storageAccess capability."); + if (manifest.Providers.Any(provider => provider.Kind == "notification") && + !manifest.Capabilities.Notifications) + errors.Add("Notification providers require the notifications capability."); + + foreach (var domain in manifest.Capabilities.NetworkDomains) + { + if (!IsValidDomainPattern(domain)) errors.Add($"Network domain '{domain}' is invalid."); + } + + foreach (var root in manifest.Capabilities.FileRoots) + { + if (!Path.IsPathFullyQualified(root)) errors.Add($"File root '{root}' must be absolute."); + } + + if (manifest.Integrity?.Files is not { Count: > 0 } files) + { + errors.Add("Integrity metadata must contain a SHA-256 digest for every package file."); + } + else + { + foreach (var file in files) + { + if (!IsSafeArchivePath(file.Key) || + file.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)) + errors.Add($"Integrity path '{file.Key}' is invalid."); + if (!IsSha256(file.Value)) + errors.Add($"Integrity digest for '{file.Key}' must be a valid SHA-256 value."); + } + if (!files.ContainsKey(manifest.EntryPoint.Replace('\\', '/'))) + errors.Add("Integrity metadata must include the entry point."); + } + if (manifest.Signature is { Algorithm: not "RSA-SHA256" }) + errors.Add("Only RSA-SHA256 signatures are supported."); + if (manifest.Signature is { } signature && !IsValidIdentifier(signature.Publisher)) + errors.Add("Signature publisher must be a 1-64 character ASCII identifier."); + if (manifest.DataMigration is { } migration && + !string.Equals(migration.Strategy, "preserve", StringComparison.OrdinalIgnoreCase) && + !string.Equals(migration.Strategy, "reset", StringComparison.OrdinalIgnoreCase)) + errors.Add("Data migration strategy must be 'preserve' or 'reset'."); + if (manifest.DataMigration?.Description is { } migrationDescription && + (migrationDescription.Length > 2_048 || + migrationDescription.Any(IsDisallowedDescriptionCharacter))) + errors.Add("Data migration description must be at most 2048 characters and cannot contain unsafe control characters."); + + return errors; + } + + public static IReadOnlyList GetCompatibilityErrors( + PluginManifest manifest, + IReadOnlyDictionary installed) + { + var errors = new List(); + if (!TryParseApiVersion(manifest.ApiVersion, out var requested) || + !TryParseApiVersion(PluginApi.CurrentVersion, out var current) || + requested.Major != current.Major || requested > current) + { + errors.Add($"Plugin API {manifest.ApiVersion} is incompatible with host API {PluginApi.CurrentVersion}."); + } + + var platform = $"{GetOs()}-{RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()}"; + if (manifest.Platforms.Count > 0 && + !manifest.Platforms.Contains("any", StringComparer.OrdinalIgnoreCase) && + !manifest.Platforms.Contains(platform, StringComparer.OrdinalIgnoreCase)) + { + errors.Add($"Plugin does not support host platform '{platform}'."); + } + if (OperatingSystem.IsWindows() && + (manifest.Capabilities.StorageAccess || manifest.Capabilities.FileRoots.Count > 0 || + manifest.Providers.Any(provider => provider.Kind == "storage"))) + { + errors.Add("Plugin file and storage capabilities are not supported on Windows in API 1.0."); + } + + foreach (var dependency in manifest.Dependencies) + { + if (!installed.TryGetValue(dependency.Id, out var installedDependency)) + { + errors.Add($"Required dependency '{dependency.Id}' is not installed."); + continue; + } + + if (!installedDependency.IsEnabled) + errors.Add($"Required dependency '{dependency.Id}' is disabled."); + if (!TryParseVersion(installedDependency.Version, out var actual) || + !TryParseVersion(dependency.MinimumVersion, out var minimum) || actual < minimum) + { + errors.Add($"Dependency '{dependency.Id}' requires >= {dependency.MinimumVersion}; installed version is {installedDependency.Version}."); + } + } + + return errors; + } + + public static bool CapabilitiesEqual(PluginCapabilities left, PluginCapabilities right) + => left.Notifications == right.Notifications && + left.DownloadControl == right.DownloadControl && + left.StorageAccess == right.StorageAccess && + left.BackgroundTasks == right.BackgroundTasks && + SetEqual(left.NetworkDomains, right.NetworkDomains, StringComparer.OrdinalIgnoreCase) && + SetEqual(left.FileRoots.Select(Path.GetFullPath), right.FileRoots.Select(Path.GetFullPath), PathComparer); + + public static bool TryParseVersion(string value, out SemanticVersion version) + => TryParseSemanticVersion(value, requirePatch: true, out version); + + private static bool TryParseApiVersion(string value, out SemanticVersion version) + => TryParseSemanticVersion(value, requirePatch: false, out version); + + private static bool TryParseSemanticVersion( + string value, + bool requirePatch, + out SemanticVersion version) + { + version = default; + if (string.IsNullOrEmpty(value) || value.Length > MaximumVersionLength || + value.Any(character => character > 0x7f)) + return false; + + var buildSeparator = value.IndexOf('+'); + var withoutBuild = buildSeparator < 0 ? value : value[..buildSeparator]; + if (buildSeparator >= 0) + { + var build = value[(buildSeparator + 1)..]; + if (!AreValidIdentifiers(build, rejectNumericLeadingZero: false)) return false; + } + + var prereleaseSeparator = withoutBuild.IndexOf('-'); + var core = prereleaseSeparator < 0 ? withoutBuild : withoutBuild[..prereleaseSeparator]; + var prerelease = prereleaseSeparator < 0 ? [] : withoutBuild[(prereleaseSeparator + 1)..].Split('.'); + if (prereleaseSeparator >= 0 && !AreValidIdentifiers( + withoutBuild[(prereleaseSeparator + 1)..], rejectNumericLeadingZero: true)) + return false; + + var components = core.Split('.'); + if (components.Length != 3 && (requirePatch || components.Length != 2)) return false; + var patch = 0; + if (!TryParseNumericComponent(components[0], out var major) || + !TryParseNumericComponent(components[1], out var minor) || + (components.Length == 3 && !TryParseNumericComponent(components[2], out patch))) + return false; + + version = new SemanticVersion(major, minor, patch, prerelease); + return true; + } + + private static bool TryParseNumericComponent(string value, out int component) + { + component = 0; + return value.Length > 0 && (value.Length == 1 || value[0] != '0') && + value.All(IsAsciiDigit) && + int.TryParse(value, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out component); + } + + private static bool AreValidIdentifiers(string value, bool rejectNumericLeadingZero) + { + var identifiers = value.Split('.'); + return identifiers.All(identifier => + identifier.Length > 0 && + identifier.All(character => IsAsciiLetterOrDigit(character) || character == '-') && + (!rejectNumericLeadingZero || !identifier.All(IsAsciiDigit) || + identifier.Length == 1 || identifier[0] != '0')); + } + + private static bool IsAsciiDigit(char value) => value is >= '0' and <= '9'; + + private static bool IsAsciiLetterOrDigit(char value) + => IsAsciiDigit(value) || value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static bool IsValidIdentifier(string value) + => !string.IsNullOrEmpty(value) && HandlerNamePattern().IsMatch(value); + + private static bool IsDisallowedDescriptionCharacter(char value) + => char.IsControl(value) && value is not '\r' and not '\n' and not '\t'; + + public static bool IsSafeRelativePath(string value) + { + if (string.IsNullOrWhiteSpace(value) || Path.IsPathFullyQualified(value)) return false; + var normalized = value.Replace('\\', '/'); + return normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) + .All(segment => segment is not "." and not ".."); + } + + public static bool IsSafeArchivePath(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Contains('\\') || value.StartsWith('/') || + value.EndsWith('/') || value.Contains("//", StringComparison.Ordinal) || + value.Contains(':')) + return false; + return IsSafeRelativePath(value); + } + + private static bool IsSha256(string value) + => value.Length == 64 && value.All(Uri.IsHexDigit); + + private static bool IsValidDomainPattern(string value) + { + var domain = value.StartsWith("*.", StringComparison.Ordinal) ? value[2..] : value; + return Uri.CheckHostName(domain) == UriHostNameType.Dns && + !domain.Equals("localhost", StringComparison.OrdinalIgnoreCase); + } + + private static bool SetEqual(IEnumerable left, IEnumerable right, StringComparer comparer) + => new HashSet(left, comparer).SetEquals(right); + + private static string GetOs() + => OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : "linux"; + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} + +internal readonly record struct SemanticVersion( + int Major, + int Minor, + int Patch, + IReadOnlyList Prerelease) : IComparable +{ + public int CompareTo(SemanticVersion other) + { + var core = Major.CompareTo(other.Major); + if (core == 0) core = Minor.CompareTo(other.Minor); + if (core == 0) core = Patch.CompareTo(other.Patch); + if (core != 0) return core; + + if (Prerelease.Count == 0) return other.Prerelease.Count == 0 ? 0 : 1; + if (other.Prerelease.Count == 0) return -1; + for (var index = 0; index < Math.Min(Prerelease.Count, other.Prerelease.Count); index++) + { + var left = Prerelease[index]; + var right = other.Prerelease[index]; + var leftNumeric = left.All(character => character is >= '0' and <= '9'); + var rightNumeric = right.All(character => character is >= '0' and <= '9'); + int comparison; + if (leftNumeric && rightNumeric) + { + comparison = left.Length.CompareTo(right.Length); + if (comparison == 0) comparison = string.CompareOrdinal(left, right); + } + else if (leftNumeric != rightNumeric) + { + comparison = leftNumeric ? -1 : 1; + } + else + { + comparison = string.CompareOrdinal(left, right); + } + + if (comparison != 0) return comparison; + } + + return Prerelease.Count.CompareTo(other.Prerelease.Count); + } + + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; +} + +internal sealed record PluginCatalogEntryView(string Version, bool IsEnabled); diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginNetworkConnectionFactory.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginNetworkConnectionFactory.cs new file mode 100644 index 0000000..a681725 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginNetworkConnectionFactory.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Net.Sockets; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal interface IPluginDnsResolver +{ + Task ResolveAsync(string host, CancellationToken cancellationToken); +} + +internal sealed class SystemPluginDnsResolver : IPluginDnsResolver +{ + public Task ResolveAsync(string host, CancellationToken cancellationToken) + => IPAddress.TryParse(host, out var address) + ? Task.FromResult(new[] { address }) + : Dns.GetHostAddressesAsync(host, cancellationToken); +} + +internal static class PluginNetworkConnectionFactory +{ + public static SocketsHttpHandler Create(IPluginDnsResolver resolver) + => new() + { + AllowAutoRedirect = false, + UseCookies = false, + UseProxy = false, + ConnectCallback = (context, cancellationToken) => + ConnectAsync(context.DnsEndPoint, resolver, cancellationToken) + }; + + private static async ValueTask ConnectAsync( + DnsEndPoint endpoint, + IPluginDnsResolver resolver, + CancellationToken cancellationToken) + { + var addresses = await resolver.ResolveAsync(endpoint.Host, cancellationToken); + if (addresses.Length == 0) + throw new HttpRequestException($"Plugin network target '{endpoint.Host}' did not resolve."); + if (addresses.Any(address => !IsPublicAddress(address))) + throw new UnauthorizedAccessException( + $"Plugin network target '{endpoint.Host}' resolved to a non-public address."); + + List? failures = null; + foreach (var address in addresses) + { + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + try + { + await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + socket.Dispose(); + (failures ??= []).Add(exception); + } + } + + throw new HttpRequestException( + $"Could not connect to approved plugin network target '{endpoint.Host}'.", + failures is { Count: 1 } ? failures[0] : new AggregateException(failures ?? [])); + } + + internal static bool IsPublicAddress(IPAddress address) + { + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); + var bytes = address.GetAddressBytes(); + if (address.AddressFamily == AddressFamily.InterNetwork) + { + return !InCidr(bytes, [0, 0, 0, 0], 8) && + !InCidr(bytes, [10, 0, 0, 0], 8) && + !InCidr(bytes, [100, 64, 0, 0], 10) && + !InCidr(bytes, [127, 0, 0, 0], 8) && + !InCidr(bytes, [169, 254, 0, 0], 16) && + !InCidr(bytes, [172, 16, 0, 0], 12) && + !InCidr(bytes, [192, 0, 0, 0], 24) && + !InCidr(bytes, [192, 0, 2, 0], 24) && + !InCidr(bytes, [192, 88, 99, 0], 24) && + !InCidr(bytes, [192, 168, 0, 0], 16) && + !InCidr(bytes, [198, 18, 0, 0], 15) && + !InCidr(bytes, [198, 51, 100, 0], 24) && + !InCidr(bytes, [203, 0, 113, 0], 24) && + !InCidr(bytes, [224, 0, 0, 0], 4) && + !InCidr(bytes, [240, 0, 0, 0], 4); + } + + if (address.AddressFamily != AddressFamily.InterNetworkV6) return false; + return InCidr(bytes, [0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3) && + !address.Equals(IPAddress.IPv6Any) && + !address.Equals(IPAddress.IPv6Loopback) && + !address.IsIPv6LinkLocal && + !address.IsIPv6Multicast && + !InCidr(bytes, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 96) && + !InCidr(bytes, [0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0], 96) && + !InCidr(bytes, [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 96) && + !InCidr(bytes, [0x00, 0x64, 0xff, 0x9b, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 48) && + !InCidr(bytes, [0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 7) && + !InCidr(bytes, [0xfe, 0xc0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10) && + !InCidr(bytes, [0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 64) && + !InCidr(bytes, [0x20, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 23) && + !InCidr(bytes, [0x20, 0x01, 0x00, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 48) && + !InCidr(bytes, [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 32) && + !InCidr(bytes, [0x20, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 16) && + !InCidr(bytes, [0x3f, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 20); + } + + private static bool InCidr(ReadOnlySpan address, ReadOnlySpan network, int prefixLength) + { + var wholeBytes = prefixLength / 8; + var remainingBits = prefixLength % 8; + if (!address[..wholeBytes].SequenceEqual(network[..wholeBytes])) return false; + if (remainingBits == 0) return true; + var mask = (byte)(0xff << (8 - remainingBits)); + return (address[wholeBytes] & mask) == (network[wholeBytes] & mask); + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginPackageInspector.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginPackageInspector.cs new file mode 100644 index 0000000..6cf9717 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginPackageInspector.cs @@ -0,0 +1,414 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed class PluginPackageInspector(IOptions options) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + private readonly PluginPlatformOptions _options = options.Value; + private readonly string _rootPath = Path.GetFullPath(options.Value.RootPath); + private readonly SemaphoreSlim _stagingGate = new(1, 1); + + public async Task StageAndInspectAsync( + Stream package, + string fileName, + CancellationToken cancellationToken) + { + if (!fileName.EndsWith(".sdwpkg", StringComparison.OrdinalIgnoreCase) && + !fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Plugin packages must use the .sdwpkg or .zip extension."); + + await _stagingGate.WaitAsync(cancellationToken); + try + { + var stagingPath = Path.Combine(_rootPath, "staging"); + Directory.CreateDirectory(stagingPath); + RestrictDirectory(stagingPath); + CleanupExpiredPreviews(stagingPath); + var stagedPackages = Directory.EnumerateFiles( + stagingPath, "*.sdwpkg", SearchOption.TopDirectoryOnly).ToArray(); + if (stagedPackages.Length >= _options.MaximumStagedPackages) + throw new InvalidOperationException("The plugin preview staging limit has been reached."); + var stagedBytes = stagedPackages.Sum(path => new FileInfo(path).Length); + + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); + var packagePath = Path.Combine(stagingPath, $"{token}.sdwpkg"); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + try + { + await using (var target = new FileStream(packagePath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + var buffer = new byte[64 * 1024]; + long total = 0; + int read; + while ((read = await package.ReadAsync(buffer, cancellationToken)) > 0) + { + total = checked(total + read); + if (total > _options.MaximumPackageBytes) + throw new InvalidDataException( + $"Plugin package exceeds {_options.MaximumPackageBytes} bytes."); + if (stagedBytes + total > _options.MaximumStagedPackageBytes) + throw new InvalidOperationException("The plugin preview staging byte limit has been reached."); + hash.AppendData(buffer, 0, read); + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } + + RestrictFile(packagePath); + var sha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + return await InspectAsync(token, packagePath, sha256, cancellationToken); + } + catch + { + if (File.Exists(packagePath)) File.Delete(packagePath); + throw; + } + } + finally + { + _stagingGate.Release(); + } + } + + public async Task InspectStagedAsync( + string token, + string expectedSha256, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(token) || token.Length != 48 || + token.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidDataException("Invalid preview token."); + if (string.IsNullOrEmpty(expectedSha256) || expectedSha256.Length != 64 || + expectedSha256.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidDataException("Expected package checksum must be a 64-character SHA-256 value."); + var path = Path.Combine(_rootPath, "staging", $"{token}.sdwpkg"); + if (!File.Exists(path)) throw new FileNotFoundException("Plugin preview expired or does not exist."); + if (File.GetLastWriteTimeUtc(path).AddMinutes(_options.PreviewLifetimeMinutes) < DateTime.UtcNow) + { + File.Delete(path); + throw new InvalidDataException("Plugin preview has expired."); + } + + var actualSha256 = await ComputeSha256Async(path, cancellationToken); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(actualSha256), Encoding.ASCII.GetBytes(expectedSha256.ToLowerInvariant()))) + throw new InvalidDataException("Package checksum no longer matches the approved preview."); + + return await InspectAsync(token, path, actualSha256, cancellationToken); + } + + public async Task ExtractAsync( + InspectedPluginPackage package, + CancellationToken cancellationToken) + { + var packagesRoot = Path.GetFullPath(Path.Combine(_rootPath, "packages")); + var pluginRoot = GetContainedChildPath(packagesRoot, package.Manifest.Id, "plugin id"); + var finalPath = GetContainedChildPath(pluginRoot, package.Manifest.Version, "plugin version"); + var temporaryPath = GetContainedChildPath( + pluginRoot, + $".{package.Manifest.Version}.{Guid.NewGuid():N}.tmp", + "temporary package path"); + Directory.CreateDirectory(pluginRoot); + RestrictDirectory(pluginRoot); + if (Directory.Exists(finalPath)) + throw new InvalidOperationException("This plugin version is already present on disk."); + Directory.CreateDirectory(temporaryPath); + RestrictDirectory(temporaryPath); + + try + { + using var archive = ZipFile.OpenRead(package.PackagePath); + var extractedFiles = new Dictionary(StringComparer.Ordinal); + long extractedBytes = 0; + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateArchiveEntry(entry); + var destination = Path.GetFullPath(Path.Combine(temporaryPath, entry.FullName)); + if (!IsWithin(destination, temporaryPath)) + throw new InvalidDataException($"Archive entry '{entry.FullName}' escapes the package root."); + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destination); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await using var source = entry.Open(); + await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 64 * 1024, FileOptions.Asynchronous); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[64 * 1024]; + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) + { + extractedBytes = checked(extractedBytes + read); + if (extractedBytes > _options.MaximumExpandedBytes) + throw new InvalidDataException( + $"Expanded package exceeds {_options.MaximumExpandedBytes} bytes."); + hash.AppendData(buffer, 0, read); + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + RestrictFile(destination); + if (!entry.FullName.Equals("manifest.json", StringComparison.Ordinal)) + { + extractedFiles[entry.FullName] = + Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + } + + VerifyFileManifest(package.Manifest, extractedFiles); + + Directory.Move(temporaryPath, finalPath); + return finalPath; + } + catch + { + if (Directory.Exists(temporaryPath)) Directory.Delete(temporaryPath, recursive: true); + throw; + } + } + + public void Consume(InspectedPluginPackage package) + { + if (File.Exists(package.PackagePath)) File.Delete(package.PackagePath); + } + + private async Task InspectAsync( + string token, + string packagePath, + string sha256, + CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(packagePath); + if (archive.Entries.Count == 0 || archive.Entries.Count > _options.MaximumPackageFiles) + throw new InvalidDataException($"Plugin package must contain 1-{_options.MaximumPackageFiles} files."); + long expandedBytes = 0; + if (archive.Entries.GroupBy(entry => entry.FullName, StringComparer.OrdinalIgnoreCase) + .Any(group => group.Count() > 1)) + throw new InvalidDataException("Plugin package contains duplicate archive paths."); + foreach (var entry in archive.Entries) + { + ValidateArchiveEntry(entry); + expandedBytes = checked(expandedBytes + entry.Length); + if (expandedBytes > _options.MaximumExpandedBytes) + throw new InvalidDataException($"Expanded package exceeds {_options.MaximumExpandedBytes} bytes."); + } + + var manifestEntry = archive.GetEntry("manifest.json") + ?? throw new InvalidDataException("Plugin package is missing manifest.json."); + if (manifestEntry.Length > 256 * 1024) throw new InvalidDataException("Plugin manifest is too large."); + PluginManifest? manifest; + await using (var stream = manifestEntry.Open()) + manifest = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + if (manifest is null) throw new InvalidDataException("Plugin manifest is invalid."); + manifest = Normalize(manifest); + + var validationErrors = PluginManifestValidator.Validate(manifest); + if (validationErrors.Count > 0) throw new InvalidDataException(string.Join(" ", validationErrors)); + var actualFiles = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries.Where(entry => !string.IsNullOrEmpty(entry.Name) && + !entry.FullName.Equals("manifest.json", StringComparison.Ordinal))) + { + actualFiles[entry.FullName] = await ComputeSha256Async(entry, cancellationToken); + } + VerifyFileManifest(manifest, actualFiles); + + var (trusted, status, publisherFingerprint) = VerifySignature(manifest); + return new InspectedPluginPackage( + token, + packagePath, + sha256, + manifest, + trusted, + status, + publisherFingerprint, + new DateTimeOffset(File.GetLastWriteTimeUtc(packagePath).AddMinutes(_options.PreviewLifetimeMinutes), + TimeSpan.Zero)); + } + + private (bool IsTrusted, string Status, string? PublisherFingerprint) VerifySignature(PluginManifest manifest) + { + if (manifest.Signature is null) return (false, "Package is unsigned.", null); + if (!_options.TrustedPublisherPublicKeys.TryGetValue(manifest.Signature.Publisher, out var publicKey)) + return (false, $"Publisher '{manifest.Signature.Publisher}' is not trusted by this deployment.", null); + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); + if (rsa.KeySize < 2048) + return (false, "Trusted publisher RSA keys must be at least 2048 bits.", null); + var payload = PluginSignaturePayload.Create(manifest); + var signature = Convert.FromBase64String(manifest.Signature.Value); + var valid = rsa.VerifyData(payload, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var fingerprint = Convert.ToHexString(SHA256.HashData(rsa.ExportSubjectPublicKeyInfo())) + .ToLowerInvariant(); + return valid + ? (true, $"Signature verified for trusted publisher '{manifest.Signature.Publisher}'.", fingerprint) + : (false, "Package signature is invalid.", null); + } + catch (Exception exception) when (exception is CryptographicException or FormatException) + { + return (false, $"Package signature could not be verified: {exception.Message}", null); + } + } + + private static PluginManifest Normalize(PluginManifest manifest) + { + var capabilities = manifest.Capabilities ?? new PluginCapabilities(); + capabilities = capabilities with + { + NetworkDomains = capabilities.NetworkDomains?.Where(value => value is not null).ToArray() ?? [], + FileRoots = capabilities.FileRoots?.Where(value => value is not null).ToArray() ?? [] + }; + return manifest with + { + Id = manifest.Id ?? string.Empty, + Name = manifest.Name ?? string.Empty, + Version = manifest.Version ?? string.Empty, + ApiVersion = manifest.ApiVersion ?? string.Empty, + EntryPoint = manifest.EntryPoint ?? string.Empty, + Dependencies = manifest.Dependencies?.Where(value => value is not null).Select(value => + new PluginDependency(value.Id ?? string.Empty, value.MinimumVersion ?? string.Empty)).ToArray() ?? [], + Capabilities = capabilities, + Platforms = manifest.Platforms?.Where(value => value is not null).ToArray() ?? [], + Providers = manifest.Providers?.Where(value => value is not null).Select(value => value with + { + Kind = value.Kind ?? string.Empty, + Name = value.Name ?? string.Empty, + Handlers = value.Handlers ?? new Dictionary() + }).ToArray() ?? [], + Integrity = manifest.Integrity is null + ? null + : new PluginIntegrity + { + Files = manifest.Integrity.Files? + .Where(value => value.Key is not null && value.Value is not null) + .ToDictionary(value => value.Key, value => value.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal) + }, + Signature = manifest.Signature is null + ? null + : new PluginSignature( + manifest.Signature.Publisher ?? string.Empty, + manifest.Signature.Algorithm ?? string.Empty, + manifest.Signature.Value ?? string.Empty), + DataMigration = manifest.DataMigration is null + ? null + : manifest.DataMigration with { Strategy = manifest.DataMigration.Strategy ?? string.Empty } + }; + } + + private void CleanupExpiredPreviews(string stagingPath) + { + foreach (var path in Directory.EnumerateFiles(stagingPath, "*.sdwpkg")) + { + if (File.GetLastWriteTimeUtc(path).AddMinutes(_options.PreviewLifetimeMinutes) < DateTime.UtcNow) + File.Delete(path); + } + } + + private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var digest = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private static async Task ComputeSha256Async( + ZipArchiveEntry entry, + CancellationToken cancellationToken) + { + await using var stream = entry.Open(); + var digest = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private static void ValidateArchiveEntry(ZipArchiveEntry entry) + { + var path = string.IsNullOrEmpty(entry.Name) + ? entry.FullName.TrimEnd('/') + : entry.FullName; + if (!PluginManifestValidator.IsSafeArchivePath(path)) + throw new InvalidDataException($"Archive entry '{entry.FullName}' is unsafe."); + var unixFileType = (entry.ExternalAttributes >> 16) & 0xF000; + if (unixFileType == 0xA000) + throw new InvalidDataException($"Archive entry '{entry.FullName}' is a symbolic link."); + } + + private static void VerifyFileManifest( + PluginManifest manifest, + IReadOnlyDictionary actualFiles) + { + var expectedFiles = manifest.Integrity?.Files + ?? throw new InvalidDataException("Plugin integrity metadata is missing."); + var missing = expectedFiles.Keys.Except(actualFiles.Keys, StringComparer.Ordinal).Order().ToArray(); + var unlisted = actualFiles.Keys.Except(expectedFiles.Keys, StringComparer.Ordinal).Order().ToArray(); + if (missing.Length > 0 || unlisted.Length > 0) + { + throw new InvalidDataException( + $"Package file list does not match signed integrity metadata. Missing: {FormatPaths(missing)}; unlisted: {FormatPaths(unlisted)}."); + } + + foreach (var actual in actualFiles) + { + if (!actual.Value.Equals(expectedFiles[actual.Key], StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Package file '{actual.Key}' failed its integrity check."); + } + } + + private static string FormatPaths(IReadOnlyList paths) + => paths.Count == 0 ? "none" : string.Join(", ", paths); + + private static bool IsWithin(string candidate, string root) + { + var relative = Path.GetRelativePath(root, candidate); + return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !Path.IsPathFullyQualified(relative); + } + + private static string GetContainedChildPath(string root, string child, string fieldName) + { + var fullRoot = Path.GetFullPath(root); + var candidate = Path.GetFullPath(Path.Combine(fullRoot, child)); + if (!IsWithin(candidate, fullRoot) || + string.Equals(candidate, fullRoot, OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + throw new InvalidDataException($"The {fieldName} escapes its package directory."); + return candidate; + } + + private static void RestrictDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} + +internal sealed record InspectedPluginPackage( + string Token, + string PackagePath, + string PackageSha256, + PluginManifest Manifest, + bool IsSignatureTrusted, + string SignatureStatus, + string? PublisherFingerprint, + DateTimeOffset ExpiresAt); diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs new file mode 100644 index 0000000..06882a7 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs @@ -0,0 +1,27 @@ +namespace SecondDimensionWatcherReDive.PluginPlatform; + +public sealed class PluginPlatformOptions +{ + public const string SectionName = "PluginPlatform"; + + public string RootPath { get; set; } = "./plugin-data"; + public bool AllowUnsignedLocalPackages { get; set; } + public long MaximumPackageBytes { get; set; } = 4 * 1024 * 1024; + public long MaximumExpandedBytes { get; set; } = 16 * 1024 * 1024; + public int MaximumPackageFiles { get; set; } = 128; + public int MaximumStagedPackages { get; set; } = 32; + public long MaximumStagedPackageBytes { get; set; } = 64 * 1024 * 1024; + public int InvocationTimeoutMilliseconds { get; set; } = 5_000; + public int MaximumWorkerMemoryMegabytes { get; set; } = 256; + public int MaximumWorkerCpuMilliseconds { get; set; } = 4_000; + public int MaximumConcurrentWorkers { get; set; } = 4; + public int MaximumConcurrentWorkersPerPlugin { get; set; } = 1; + public int MaximumResponseBytes { get; set; } = 2 * 1024 * 1024; + public long MaximumPluginDataBytes { get; set; } = 64 * 1024 * 1024; + public int MaximumPluginDataFiles { get; set; } = 1_000; + public int MaximumPluginDataPathDepth { get; set; } = 8; + public int CircuitBreakerFailures { get; set; } = 3; + public int CircuitBreakerSeconds { get; set; } = 60; + public int PreviewLifetimeMinutes { get; set; } = 30; + public Dictionary TrustedPublisherPublicKeys { get; set; } = new(StringComparer.Ordinal); +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs new file mode 100644 index 0000000..dda4b9c --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Plugin; +using SecondDimensionWatcherReDive.Repositories; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal static class PluginPlatformServiceExtensions +{ + public static IServiceCollection AddPluginPlatform( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(PluginPlatformOptions.SectionName)) + .Validate(options => options.MaximumPackageBytes is >= 1_024 and <= 64 * 1024 * 1024, + "MaximumPackageBytes must be between 1 KiB and 64 MiB.") + .Validate(options => options.MaximumExpandedBytes >= options.MaximumPackageBytes, + "MaximumExpandedBytes must be at least MaximumPackageBytes.") + .Validate(options => options.MaximumExpandedBytes <= 256 * 1024 * 1024, + "MaximumExpandedBytes must not exceed 256 MiB.") + .Validate(options => options.MaximumPackageFiles is >= 1 and <= 4_096, + "MaximumPackageFiles must be between 1 and 4,096.") + .Validate(options => options.MaximumStagedPackages is >= 1 and <= 1_024, + "MaximumStagedPackages must be between 1 and 1,024.") + .Validate(options => options.MaximumStagedPackageBytes >= options.MaximumPackageBytes && + options.MaximumStagedPackageBytes <= 4L * 1024 * 1024 * 1024, + "MaximumStagedPackageBytes must be at least MaximumPackageBytes and no greater than 4 GiB.") + .Validate(options => options.InvocationTimeoutMilliseconds is >= 100 and <= 60_000, + "Invocation timeout must be between 100 ms and 60 seconds.") + .Validate(options => options.MaximumWorkerMemoryMegabytes is >= 32 and <= 1_024, + "Worker memory must be between 32 MiB and 1 GiB.") + .Validate(options => options.MaximumWorkerCpuMilliseconds is >= 100 and <= 60_000, + "Worker CPU time must be between 100 ms and 60 seconds.") + .Validate(options => options.MaximumConcurrentWorkers is >= 1 and <= 32, + "MaximumConcurrentWorkers must be between 1 and 32.") + .Validate(options => options.MaximumConcurrentWorkersPerPlugin >= 1 && + options.MaximumConcurrentWorkersPerPlugin <= options.MaximumConcurrentWorkers, + "The per-plugin worker limit must be positive and no greater than the global worker limit.") + .Validate(options => options.MaximumPluginDataBytes is >= 1_024 and <= 10L * 1024 * 1024 * 1024, + "MaximumPluginDataBytes must be between 1 KiB and 10 GiB.") + .Validate(options => options.MaximumPluginDataFiles is >= 1 and <= 100_000, + "MaximumPluginDataFiles must be between 1 and 100,000.") + .Validate(options => options.MaximumPluginDataPathDepth is >= 1 and <= 64, + "MaximumPluginDataPathDepth must be between 1 and 64.") + .Validate(options => options.MaximumResponseBytes is >= 1_024 and <= 8 * 1024 * 1024, + "MaximumResponseBytes must be between 1 KiB and 8 MiB.") + .Validate(options => options.CircuitBreakerFailures is >= 1 and <= 100, + "CircuitBreakerFailures must be between 1 and 100.") + .Validate(options => options.CircuitBreakerSeconds is >= 1 and <= 86_400, + "CircuitBreakerSeconds must be between 1 second and 1 day.") + .Validate(options => options.PreviewLifetimeMinutes is >= 1 and <= 1_440, + "PreviewLifetimeMinutes must be between 1 minute and 1 day.") + .ValidateOnStart(); + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddHttpClient("PluginPlatform") + .ConfigurePrimaryHttpMessageHandler(provider => + PluginNetworkConnectionFactory.Create(provider.GetRequiredService())); + return services; + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs new file mode 100644 index 0000000..578c0d0 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs @@ -0,0 +1,291 @@ +using System.Diagnostics; +using System.Collections.Concurrent; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal interface IPluginProcessExecutor +{ + Task InvokeAsync( + PluginCatalogEntry plugin, + string handler, + JsonElement input, + CancellationToken cancellationToken); +} + +internal sealed class PluginCapacityExceededException(string message) : InvalidOperationException(message); + +internal sealed class PluginProcessExecutor( + IPluginCapabilityBroker capabilityBroker, + IOptions options) : IPluginProcessExecutor +{ + private readonly PluginPlatformOptions _options = options.Value; + private readonly SemaphoreSlim _globalGate = new( + options.Value.MaximumConcurrentWorkers, + options.Value.MaximumConcurrentWorkers); + private readonly ConcurrentDictionary _pluginGates = new(StringComparer.Ordinal); + + public async Task InvokeAsync( + PluginCatalogEntry plugin, + string handler, + JsonElement input, + CancellationToken cancellationToken) + { + var pluginGate = _pluginGates.GetOrAdd(plugin.Manifest.Id, _ => new SemaphoreSlim( + _options.MaximumConcurrentWorkersPerPlugin, + _options.MaximumConcurrentWorkersPerPlugin)); + if (!await pluginGate.WaitAsync(0, cancellationToken)) + throw new PluginCapacityExceededException( + $"Plugin '{plugin.Manifest.Id}' has reached its concurrent worker limit."); + try + { + if (!await _globalGate.WaitAsync(0, cancellationToken)) + throw new PluginCapacityExceededException("The global plugin worker limit has been reached."); + try + { + return await InvokeCoreAsync(plugin, handler, input, cancellationToken); + } + finally + { + _globalGate.Release(); + } + } + finally + { + pluginGate.Release(); + } + } + + private async Task InvokeCoreAsync( + PluginCatalogEntry plugin, + string handler, + JsonElement input, + CancellationToken cancellationToken) + { + var entryBytes = await ReadAndVerifyPackageAsync(plugin, cancellationToken); + var script = System.Text.Encoding.UTF8.GetString(entryBytes); + using var configDocument = JsonDocument.Parse(plugin.ConfigurationJson); + var invocation = new PluginWorkerInvocation( + script, + handler, + input.Clone(), + configDocument.RootElement.Clone(), + Math.Clamp(_options.MaximumWorkerMemoryMegabytes / 4, 16, 64), + _options.MaximumResponseBytes); + + using var process = new Process { StartInfo = CreateStartInfo() }; + if (!process.Start()) throw new InvalidOperationException("Could not start plugin worker process."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMilliseconds(_options.InvocationTimeoutMilliseconds)); + var resourceViolation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var monitor = MonitorProcessAsync(process, resourceViolation, timeout.Token); + + try + { + var invocationJson = JsonSerializer.Serialize( + invocation, + PluginWorkerJsonContext.Default.PluginWorkerInvocation); + await process.StandardInput.WriteLineAsync(invocationJson.AsMemory(), timeout.Token); + await process.StandardInput.FlushAsync(timeout.Token); + + while (true) + { + var line = await process.StandardOutput.ReadLineAsync(timeout.Token); + if (line is null) + { + if (resourceViolation.Task.IsCompletedSuccessfully) + throw new TimeoutException(resourceViolation.Task.Result); + var stderr = await ReadErrorAsync(process); + throw new InvalidOperationException( + $"Plugin worker exited before returning a result (exit {process.ExitCode}): {stderr}"); + } + if (line.Length > _options.MaximumResponseBytes * 2) + throw new InvalidDataException("Plugin worker protocol message is too large."); + var message = JsonSerializer.Deserialize( + line, + PluginWorkerJsonContext.Default.PluginWorkerMessage) + ?? throw new InvalidDataException("Plugin worker sent invalid protocol data."); + switch (message.Type) + { + case "capability": + await HandleCapabilityAsync(process, plugin, message, timeout.Token); + break; + case "result" when message.Result is not null: + return message.Result.Value.Clone(); + case "error": + throw new InvalidOperationException(message.Error ?? "Plugin execution failed."); + default: + throw new InvalidDataException($"Unexpected plugin worker message '{message.Type}'."); + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Plugin invocation exceeded {_options.InvocationTimeoutMilliseconds} ms or a resource limit."); + } + finally + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + timeout.Cancel(); + try { await monitor; } catch (OperationCanceledException) { } + } + } + + private async Task HandleCapabilityAsync( + Process process, + PluginCatalogEntry plugin, + PluginWorkerMessage message, + CancellationToken cancellationToken) + { + PluginWorkerMessage response; + try + { + if (message.Id is null || message.Capability is null || message.Payload is null) + throw new InvalidDataException("Capability request is incomplete."); + var result = await capabilityBroker.ExecuteAsync( + plugin, + message.Capability, + message.Payload.Value, + cancellationToken); + response = new PluginWorkerMessage + { + Type = "capability-result", + Id = message.Id, + Result = result + }; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + response = new PluginWorkerMessage + { + Type = "capability-error", + Id = message.Id, + Error = exception.Message.Length <= 1_024 ? exception.Message : exception.Message[..1_024] + }; + } + + var json = JsonSerializer.Serialize(response, PluginWorkerJsonContext.Default.PluginWorkerMessage); + await process.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken); + await process.StandardInput.FlushAsync(cancellationToken); + } + + private async Task MonitorProcessAsync( + Process process, + TaskCompletionSource resourceViolation, + CancellationToken cancellationToken) + { + var maximumWorkingSet = (long)_options.MaximumWorkerMemoryMegabytes * 1024 * 1024; + var maximumCpu = TimeSpan.FromMilliseconds(_options.MaximumWorkerCpuMilliseconds); + while (!process.HasExited) + { + cancellationToken.ThrowIfCancellationRequested(); + process.Refresh(); + if (process.WorkingSet64 > maximumWorkingSet || process.TotalProcessorTime > maximumCpu) + { + resourceViolation.TrySetResult( + $"Plugin worker exceeded its CPU or {_options.MaximumWorkerMemoryMegabytes} MiB memory budget."); + process.Kill(entireProcessTree: true); + return; + } + await Task.Delay(25, cancellationToken); + } + } + + private static ProcessStartInfo CreateStartInfo() + { + var hostAssembly = typeof(PluginWorkerHost).Assembly; + var entryAssembly = Assembly.GetEntryAssembly(); + var processPath = entryAssembly == hostAssembly + ? Environment.ProcessPath + : null; + processPath ??= "dotnet"; + var startInfo = new ProcessStartInfo + { + FileName = processPath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + if (entryAssembly != hostAssembly || + string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase)) + startInfo.ArgumentList.Add(hostAssembly.Location); + startInfo.ArgumentList.Add(PluginWorkerHost.WorkerArgument); + return startInfo; + } + + private static async Task ReadErrorAsync(Process process) + { + var value = await process.StandardError.ReadToEndAsync(); + return value.Length <= 1_024 ? value : value[..1_024]; + } + + private static bool IsWithin(string candidate, string root) + { + var relative = Path.GetRelativePath(Path.GetFullPath(root), candidate); + return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !Path.IsPathFullyQualified(relative); + } + + private static async Task ReadAndVerifyPackageAsync( + PluginCatalogEntry plugin, + CancellationToken cancellationToken) + { + var root = Path.GetFullPath(plugin.PackageDirectory); + if (!Directory.Exists(root) || new DirectoryInfo(root).LinkTarget is not null) + throw new InvalidDataException("Installed plugin package directory is missing or unsafe."); + var expected = plugin.Manifest.Integrity?.Files + ?? throw new InvalidDataException("Installed plugin integrity metadata is missing."); + var actualPaths = new HashSet(StringComparer.Ordinal); + byte[]? entryBytes = null; + var entryPath = plugin.Manifest.EntryPoint.Replace('\\', '/'); + + foreach (var path in EnumerateRegularFiles(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + if (relative.Equals("manifest.json", StringComparison.Ordinal)) continue; + actualPaths.Add(relative); + if (!expected.TryGetValue(relative, out var expectedDigest)) + throw new InvalidDataException($"Installed plugin contains unlisted file '{relative}'."); + var bytes = await File.ReadAllBytesAsync(path, cancellationToken); + var actualDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!actualDigest.Equals(expectedDigest, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Installed plugin file '{relative}' failed its integrity check."); + if (relative.Equals(entryPath, StringComparison.Ordinal)) entryBytes = bytes; + } + + var missing = expected.Keys.Except(actualPaths, StringComparer.Ordinal).Order().ToArray(); + if (missing.Length > 0) + throw new InvalidDataException($"Installed plugin files are missing: {string.Join(", ", missing)}."); + return entryBytes ?? throw new InvalidDataException("Installed plugin entry point is missing or unsafe."); + } + + private static IEnumerable EnumerateRegularFiles(string root) + { + var pending = new Stack(); + pending.Push(new DirectoryInfo(root)); + while (pending.TryPop(out var directory)) + { + foreach (var entry in directory.EnumerateFileSystemInfos()) + { + if (entry.LinkTarget is not null) + throw new InvalidDataException("Symbolic links are not allowed in installed plugin packages."); + if (entry is DirectoryInfo child) + { + pending.Push(child); + } + else if (entry is FileInfo) + { + yield return entry.FullName; + } + } + } + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginProviderRegistry.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginProviderRegistry.cs new file mode 100644 index 0000000..2b79007 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginProviderRegistry.cs @@ -0,0 +1,116 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Framework.Plugin; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal static class PluginProviderIdentity +{ + public static string Create(string pluginId, string providerName) + => $"plugin:{pluginId}:{providerName}"; +} + +public interface IPluginProviderRegistry +{ + IReadOnlyList GetFileStores(); + IReadOnlyList GetNotificationProviders(); +} + +internal sealed class PluginProviderRegistry(IPluginManager manager) : IPluginProviderRegistry +{ + public IReadOnlyList GetFileStores() + => manager.GetSnapshot() + .Where(IsAvailable) + .SelectMany(plugin => plugin.Manifest.Providers + .Where(provider => provider.Kind == "storage") + .Select(provider => (IFileStore)new JavaScriptFileStore( + plugin.Manifest.Id, + provider, + manager))) + .ToArray(); + + public IReadOnlyList GetNotificationProviders() + => manager.GetSnapshot() + .Where(IsAvailable) + .SelectMany(plugin => plugin.Manifest.Providers + .Where(provider => provider.Kind == "notification") + .Select(provider => (INotificationProvider)new JavaScriptNotificationProvider( + plugin.Manifest.Id, + provider, + manager))) + .ToArray(); + + private static bool IsAvailable(InstalledPlugin plugin) + => plugin.IsEnabled && plugin.CompatibilityErrors.Count == 0 && + (plugin.Health.CircuitOpenUntil is null || plugin.Health.CircuitOpenUntil <= DateTimeOffset.UtcNow); +} + +internal sealed class JavaScriptNotificationProvider( + string pluginId, + PluginProviderDeclaration declaration, + IPluginManager manager) : INotificationProvider +{ + public string Name => PluginProviderIdentity.Create(pluginId, declaration.Name); + + public async Task SendAsync(PluginNotification notification, CancellationToken cancellationToken) + { + if (!declaration.Handlers.TryGetValue("send", out var handler)) + throw new InvalidOperationException($"Notification provider '{Name}' has no send handler."); + var input = JsonSerializer.SerializeToElement(notification); + var result = await manager.InvokeAsync(pluginId, handler, input, cancellationToken); + if (result.ValueKind == JsonValueKind.Object && + result.TryGetProperty("success", out var success) && success.ValueKind == JsonValueKind.False) + throw new InvalidOperationException($"Notification provider '{Name}' rejected the notification."); + } +} + +internal sealed class JavaScriptFileStore( + string pluginId, + PluginProviderDeclaration declaration, + IPluginManager manager) : IFileStore +{ + private static readonly JsonSerializerOptions WebJsonOptions = new(JsonSerializerDefaults.Web); + public string Name => PluginProviderIdentity.Create(pluginId, declaration.Name); + + public async Task OpenReadStreamAsync(string path, CancellationToken cancellationToken) + { + var result = await InvokeAsync("read", path, cancellationToken); + if (!result.TryGetProperty("base64", out var base64) || base64.ValueKind != JsonValueKind.String) + throw new InvalidDataException("Storage provider read result must contain base64 data."); + return new MemoryStream(Convert.FromBase64String(base64.GetString()!), writable: false); + } + + public async Task FileInfoAsync(string path, CancellationToken cancellationToken) + { + var result = await InvokeAsync("info", path, cancellationToken); + return result.Deserialize(WebJsonOptions) + ?? throw new InvalidDataException("Storage provider returned invalid file information."); + } + + public async Task ExistAsync(string path, CancellationToken cancellationToken) + { + var result = await InvokeAsync("exists", path, cancellationToken); + return result.TryGetProperty("exists", out var exists) && exists.GetBoolean(); + } + + public IAsyncEnumerable EnumerateDirectory(string path) + => EnumerateDirectoryCore(path, CancellationToken.None); + + private async IAsyncEnumerable EnumerateDirectoryCore( + string path, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var result = await InvokeAsync("list", path, cancellationToken); + var entries = result.Deserialize(WebJsonOptions) + ?? throw new InvalidDataException("Storage provider returned an invalid directory listing."); + foreach (var entry in entries) yield return entry; + } + + private Task InvokeAsync(string operation, string path, CancellationToken cancellationToken) + { + if (!declaration.Handlers.TryGetValue(operation, out var handler)) + throw new InvalidOperationException($"Storage provider '{Name}' has no {operation} handler."); + return manager.InvokeAsync(pluginId, handler, JsonSerializer.SerializeToElement(new { path }), cancellationToken); + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginSafeFileAccess.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginSafeFileAccess.cs new file mode 100644 index 0000000..b9d80d2 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginSafeFileAccess.cs @@ -0,0 +1,438 @@ +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed record PluginFileEntry( + string Name, + bool IsDirectory, + long? Length, + DateTimeOffset LastModifiedUtc); + +/// +/// Opens every POSIX path component relative to an already-open directory with O_NOFOLLOW. +/// This pins the object being accessed and closes rename/symlink races after lexical approval. +/// Windows reads validate the final path attached to the opened handle. +/// +internal sealed class PluginSafeFileAccess +{ + private const int ReadOnly = 0; + private const int WriteOnly = 1; + private const int LinuxCreate = 0x40; + private const int LinuxExclusive = 0x80; + private const int LinuxDirectory = 0x10000; + private const int LinuxNoFollow = 0x20000; + private const int LinuxCloseOnExec = 0x80000; + private const int LinuxNonBlocking = 0x800; + private const int MacCreate = 0x200; + private const int MacExclusive = 0x800; + private const int MacDirectory = 0x100000; + private const int MacNoFollow = 0x100; + private const int MacCloseOnExec = 0x1000000; + private const int MacNonBlocking = 0x4; + private const int MissingPathError = 2; + + internal Action? BeforeOpenForTesting { get; set; } + + public async Task ReadAsync( + string root, + string path, + int maximumBytes, + CancellationToken cancellationToken) + { + ValidateLexicalPath(root, path); + BeforeOpenForTesting?.Invoke(); + await using var stream = OpenRead(root, path); + using var memory = new MemoryStream(Math.Min(maximumBytes, 64 * 1024)); + var buffer = new byte[64 * 1024]; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0) + { + if (memory.Length + read > maximumBytes) + throw new InvalidDataException("Capability response exceeds the configured size limit."); + await memory.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + return memory.ToArray(); + } + + public IReadOnlyList List(string root, string path, int maximumEntries) + { + ValidateLexicalPath(root, path); + BeforeOpenForTesting?.Invoke(); + if (IsPosix) + { + using var directory = OpenPosixAbsolute(path, directory: true, out _); + var descriptorPath = GetDescriptorPath(directory); + var result = new List(); + foreach (var item in Directory.EnumerateFileSystemEntries(descriptorPath)) + { + if (result.Count >= maximumEntries) + throw new InvalidDataException("Directory contains too many entries."); + var name = Path.GetFileName(item); + SafeFileHandle child; + try + { + child = OpenPosixAt(directory, name, directory: false, out _); + } + catch (UnauthorizedAccessException exception) when (exception.InnerException is FileNotFoundException) + { + continue; + } + using (child) + { + var childPath = GetDescriptorPath(child); + var isDirectory = Directory.Exists(childPath); + result.Add(new PluginFileEntry( + name, + isDirectory, + isDirectory ? null : RandomAccess.GetLength(child), + new DateTimeOffset(File.GetLastWriteTimeUtc(childPath), TimeSpan.Zero))); + } + } + return result; + } + + throw new PlatformNotSupportedException( + "Plugin directory capabilities are disabled on Windows until handle-relative enumeration is available."); + } + + public PluginFileEntry? Info(string root, string path) + { + ValidateLexicalPath(root, path); + BeforeOpenForTesting?.Invoke(); + if (IsPosix) + { + SafeFileHandle handle; + try + { + handle = OpenPosixAbsolute(path, directory: false, out _); + } + catch (UnauthorizedAccessException exception) when (exception.InnerException is FileNotFoundException) + { + return null; + } + using (handle) + { + var descriptorPath = GetDescriptorPath(handle); + var isDirectory = Directory.Exists(descriptorPath); + return new PluginFileEntry( + Path.GetFileName(path), + isDirectory, + isDirectory ? null : RandomAccess.GetLength(handle), + new DateTimeOffset(File.GetLastWriteTimeUtc(descriptorPath), TimeSpan.Zero)); + } + } + + throw new PlatformNotSupportedException( + "Plugin metadata capabilities are disabled on Windows until handle-relative inspection is available."); + } + + public bool Exists(string root, string path) => Info(root, path) is not null; + + public async Task WriteAsync( + string root, + string path, + ReadOnlyMemory content, + CancellationToken cancellationToken) + { + ValidateLexicalPath(root, path); + BeforeOpenForTesting?.Invoke(); + if (IsPosix) + { + await WritePosixAsync(root, path, content, cancellationToken); + return; + } + + throw new PlatformNotSupportedException( + "Plugin data writes are disabled on Windows until handle-relative creation is available."); + } + + private static Stream OpenRead(string root, string path) + { + if (IsPosix) + { + var handle = OpenPosixAbsolute(path, directory: false, out _); + try { return new FileStream(handle, FileAccess.Read); } + catch { handle.Dispose(); throw; } + } + + var windowsHandle = OpenWindowsPath(root, path, directory: false); + try { return new FileStream(windowsHandle, FileAccess.Read); } + catch { windowsHandle.Dispose(); throw; } + } + + private static async Task WritePosixAsync( + string root, + string path, + ReadOnlyMemory content, + CancellationToken cancellationToken) + { + using var rootHandle = OpenOrCreatePosixDirectoryAbsolute(root); + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) throw new UnauthorizedAccessException("A plugin data root cannot be overwritten."); + + SafeFileHandle current = DuplicateHandleReference(rootHandle); + try + { + foreach (var segment in segments[..^1]) + { + SafeFileHandle next; + try + { + next = OpenPosixAt(current, segment, directory: true, out _); + } + catch (UnauthorizedAccessException exception) when (exception.InnerException is FileNotFoundException) + { + if (PosixMkdirAt(current.DangerousGetHandle().ToInt32(), segment, Convert.ToUInt32("700", 8)) != 0 && + Marshal.GetLastPInvokeError() != 17) + ThrowPosixError(Path.Combine(root, relative)); + next = OpenPosixAt(current, segment, directory: true, out _); + } + current.Dispose(); + current = next; + } + + var temporaryName = $".sdw-{Guid.NewGuid():N}.tmp"; + var descriptor = PosixOpenAt( + current.DangerousGetHandle().ToInt32(), + temporaryName, + WriteOnly | CreateFlag | ExclusiveFlag | NoFollowFlag | CloseOnExecFlag, + Convert.ToUInt32("600", 8)); + if (descriptor < 0) ThrowPosixError(path); + try + { + await using (var stream = new FileStream( + new SafeFileHandle((nint)descriptor, ownsHandle: false), FileAccess.Write)) + { + await stream.WriteAsync(content, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + if (PosixRenameAt( + current.DangerousGetHandle().ToInt32(), temporaryName, + current.DangerousGetHandle().ToInt32(), segments[^1]) != 0) + ThrowPosixError(path); + } + finally + { + _ = PosixUnlinkAt(current.DangerousGetHandle().ToInt32(), temporaryName, 0); + new SafeFileHandle((nint)descriptor, ownsHandle: true).Dispose(); + } + } + finally + { + current.Dispose(); + } + } + + private static SafeFileHandle DuplicateHandleReference(SafeFileHandle handle) + { + var duplicate = PosixOpenAt(handle.DangerousGetHandle().ToInt32(), ".", + ReadOnly | DirectoryFlag | NoFollowFlag | CloseOnExecFlag, 0); + if (duplicate < 0) ThrowPosixError("."); + return new SafeFileHandle((nint)duplicate, ownsHandle: true); + } + + private static SafeFileHandle OpenPosixAbsolute(string path, bool directory, out int error) + { + var normalized = Path.GetFullPath(path); + var root = Path.GetPathRoot(normalized)!; + var descriptor = PosixOpen(root, ReadOnly | DirectoryFlag | NoFollowFlag | CloseOnExecFlag, 0); + if (descriptor < 0) ThrowPosixError(path); + var current = new SafeFileHandle((nint)descriptor, ownsHandle: true); + try + { + var segments = Path.GetRelativePath(root, normalized) + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + for (var index = 0; index < segments.Length; index++) + { + var next = OpenPosixAt(current, segments[index], directory && index == segments.Length - 1, + out error); + current.Dispose(); + current = next; + } + error = 0; + var result = current; + current = new SafeFileHandle(nint.Zero, ownsHandle: false); + return result; + } + finally + { + current.Dispose(); + } + } + + private static SafeFileHandle OpenOrCreatePosixDirectoryAbsolute(string path) + { + var normalized = Path.GetFullPath(path); + var root = Path.GetPathRoot(normalized)!; + var descriptor = PosixOpen(root, ReadOnly | DirectoryFlag | NoFollowFlag | CloseOnExecFlag, 0); + if (descriptor < 0) ThrowPosixError(path); + var current = new SafeFileHandle((nint)descriptor, ownsHandle: true); + try + { + foreach (var segment in Path.GetRelativePath(root, normalized) + .Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + SafeFileHandle next; + try + { + next = OpenPosixAt(current, segment, directory: true, out _); + } + catch (UnauthorizedAccessException exception) when (exception.InnerException is FileNotFoundException) + { + if (PosixMkdirAt(current.DangerousGetHandle().ToInt32(), segment, Convert.ToUInt32("700", 8)) != 0 && + Marshal.GetLastPInvokeError() != 17) + ThrowPosixError(path); + next = OpenPosixAt(current, segment, directory: true, out _); + } + current.Dispose(); + current = next; + } + var result = current; + current = new SafeFileHandle(nint.Zero, ownsHandle: false); + return result; + } + finally + { + current.Dispose(); + } + } + + private static SafeFileHandle OpenPosixAt( + SafeFileHandle parent, + string name, + bool directory, + out int error) + { + var flags = ReadOnly | NoFollowFlag | CloseOnExecFlag | NonBlockingFlag; + if (directory) flags |= DirectoryFlag; + var descriptor = PosixOpenAt(parent.DangerousGetHandle().ToInt32(), name, flags, 0); + if (descriptor < 0) + { + error = Marshal.GetLastPInvokeError(); + ThrowPosixError(name, error); + } + error = 0; + return new SafeFileHandle((nint)descriptor, ownsHandle: true); + } + + private static void ValidateLexicalPath(string root, string path) + { + root = Path.GetFullPath(root); + path = Path.GetFullPath(path); + var relative = Path.GetRelativePath(root, path); + if (relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) || + Path.IsPathFullyQualified(relative)) + throw new UnauthorizedAccessException("Plugin file path escapes its approved root."); + } + + private static SafeFileHandle OpenWindowsPath(string root, string path, bool directory) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + const uint genericRead = 0x80000000; + const uint shareAll = 0x00000007; + const uint openExisting = 3; + const uint backupSemantics = 0x02000000; + const uint openReparsePoint = 0x00200000; + var handle = WindowsCreateFile(path, genericRead, shareAll, 0, openExisting, + backupSemantics | openReparsePoint, 0); + if (handle.IsInvalid) + { + handle.Dispose(); + throw new UnauthorizedAccessException($"Plugin file path '{path}' could not be opened safely."); + } + try + { + RejectWindowsReparsePoint(path); + var finalPath = GetWindowsFinalPath(handle); + ValidateLexicalPath(root, finalPath); + if (!Path.GetFullPath(path).Equals(Path.GetFullPath(finalPath), StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("Plugin file path changed while it was being opened."); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private static void RejectWindowsReparsePoint(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new UnauthorizedAccessException("Reparse points are not allowed in plugin file paths."); + } + + private static string GetWindowsFinalPath(SafeFileHandle handle) + { + const int maximumPath = 32768; + var buffer = new StringBuilder(maximumPath); + var length = WindowsGetFinalPathNameByHandle(handle, buffer, maximumPath, 0); + if (length == 0 || length >= maximumPath) + throw new UnauthorizedAccessException("Could not validate the opened plugin file handle."); + var path = buffer.ToString(); + const string uncPrefix = @"\\?\UNC\"; + const string devicePrefix = @"\\?\"; + if (path.StartsWith(uncPrefix, StringComparison.OrdinalIgnoreCase)) + return @"\\" + path[uncPrefix.Length..]; + return path.StartsWith(devicePrefix, StringComparison.OrdinalIgnoreCase) + ? path[devicePrefix.Length..] + : path; + } + + private static string GetDescriptorPath(SafeFileHandle handle) + => OperatingSystem.IsLinux() + ? $"/proc/self/fd/{handle.DangerousGetHandle().ToInt32()}" + : $"/dev/fd/{handle.DangerousGetHandle().ToInt32()}"; + + private static void ThrowPosixError(string path, int? knownError = null) + { + var error = knownError ?? Marshal.GetLastPInvokeError(); + Exception? inner = error == MissingPathError ? new FileNotFoundException(path) : null; + throw new UnauthorizedAccessException( + $"Plugin file path '{path}' could not be opened without following links (errno {error}).", inner); + } + + private static bool IsPosix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); + private static int DirectoryFlag => OperatingSystem.IsMacOS() ? MacDirectory : LinuxDirectory; + private static int NoFollowFlag => OperatingSystem.IsMacOS() ? MacNoFollow : LinuxNoFollow; + private static int CloseOnExecFlag => OperatingSystem.IsMacOS() ? MacCloseOnExec : LinuxCloseOnExec; + private static int CreateFlag => OperatingSystem.IsMacOS() ? MacCreate : LinuxCreate; + private static int ExclusiveFlag => OperatingSystem.IsMacOS() ? MacExclusive : LinuxExclusive; + private static int NonBlockingFlag => OperatingSystem.IsMacOS() ? MacNonBlocking : LinuxNonBlocking; + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + [DllImport("libc", EntryPoint = "open", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int PosixOpen(string path, int flags, uint mode); + + [DllImport("libc", EntryPoint = "openat", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int PosixOpenAt(int directoryDescriptor, string path, int flags, uint mode); + + [DllImport("libc", EntryPoint = "mkdirat", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int PosixMkdirAt(int directoryDescriptor, string path, uint mode); + + [DllImport("libc", EntryPoint = "renameat", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int PosixRenameAt(int oldDirectoryDescriptor, string oldPath, + int newDirectoryDescriptor, string newPath); + + [DllImport("libc", EntryPoint = "unlinkat", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int PosixUnlinkAt(int directoryDescriptor, string path, int flags); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, + CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern SafeFileHandle WindowsCreateFile( + string fileName, uint desiredAccess, uint shareMode, nint securityAttributes, + uint creationDisposition, uint flagsAndAttributes, nint templateFile); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, + CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern uint WindowsGetFinalPathNameByHandle( + SafeFileHandle file, StringBuilder path, int pathLength, uint flags); +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerHost.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerHost.cs new file mode 100644 index 0000000..9934581 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerHost.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using Microsoft.ClearScript; +using Microsoft.ClearScript.V8; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +public interface IPluginWorkerBridge +{ + string Request(string capability, string payloadJson); +} + +internal static class PluginWorkerHost +{ + public const string WorkerArgument = "--plugin-worker"; + + public static bool IsWorkerInvocation(string[] args) + => args.Length == 1 && string.Equals(args[0], WorkerArgument, StringComparison.Ordinal); + + public static async Task RunAsync(CancellationToken cancellationToken) + { + try + { + var line = await Console.In.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(line)) throw new InvalidDataException("Missing worker invocation."); + var invocation = JsonSerializer.Deserialize( + line, + PluginWorkerJsonContext.Default.PluginWorkerInvocation) + ?? throw new InvalidDataException("Invalid worker invocation."); + Execute(invocation); + return 0; + } + catch (Exception exception) + { + WriteMessage(new PluginWorkerMessage + { + Type = "error", + Error = SanitizeError(exception.Message) + }); + return 1; + } + } + + private static void Execute(PluginWorkerInvocation invocation) + { + var heapMiB = Math.Clamp(invocation.MaximumHeapMegabytes, 16, 512); + var constraints = new V8RuntimeConstraints + { + MaxOldSpaceSize = heapMiB, + MaxArrayBufferAllocation = checked((nuint)heapMiB * 1024 * 1024 / 2) + }; + using var runtime = new V8Runtime(constraints) + { + MaxHeapSize = checked((nuint)Math.Max(8, heapMiB - 8) * 1024 * 1024), + MaxStackUsage = 2 * 1024 * 1024, + EnableInterruptPropagation = true, + HeapSizeViolationPolicy = V8RuntimeViolationPolicy.Interrupt + }; + using var engine = runtime.CreateScriptEngine( + V8ScriptEngineFlags.DisableGlobalMembers | V8ScriptEngineFlags.HideHostExceptions); + var bridgeName = $"__sdwBridge_{Guid.NewGuid():N}"; + engine.AddRestrictedHostObject(bridgeName, new PluginWorkerBridge()); + engine.Execute("sdw-sdk.js", $$""" + 'use strict'; + globalThis.sdw = ((bridge) => { + return Object.freeze({ + request(capability, payload) { + const response = JSON.parse(bridge.Request(String(capability), JSON.stringify(payload ?? {}))); + if (!response.Ok) throw new Error(response.Error || 'Capability request was denied.'); + return response.Result; + } + }); + })(globalThis[{{JsonSerializer.Serialize(bridgeName)}}]); + delete globalThis[{{JsonSerializer.Serialize(bridgeName)}}]; + """); + engine.Execute("plugin.js", invocation.Script); + + var handlerJson = JsonSerializer.Serialize(invocation.Handler); + var inputJson = JsonSerializer.Serialize(invocation.Input.GetRawText()); + var configurationJson = JsonSerializer.Serialize(invocation.Configuration.GetRawText()); + var maximumResponseBytes = Math.Clamp(invocation.MaximumResponseBytes, 1024, 8 * 1024 * 1024); + var expression = $$""" + (() => { + if (!globalThis.sdwPlugin || typeof globalThis.sdwPlugin.handlers !== 'object') + throw new Error('Plugin must define globalThis.sdwPlugin.handlers.'); + const handler = globalThis.sdwPlugin.handlers[{{handlerJson}}]; + if (typeof handler !== 'function') + throw new Error('Plugin handler is not defined: ' + {{handlerJson}}); + const value = handler(JSON.parse({{inputJson}}), Object.freeze(JSON.parse({{configurationJson}}))); + if (value && typeof value.then === 'function') + throw new Error('Async JavaScript handlers are not supported; use synchronous sdw.request calls.'); + return JSON.stringify(value === undefined ? null : value); + })() + """; + var serialized = Convert.ToString(engine.Evaluate(expression), System.Globalization.CultureInfo.InvariantCulture) + ?? "null"; + if (System.Text.Encoding.UTF8.GetByteCount(serialized) > maximumResponseBytes) + throw new InvalidDataException("Plugin result exceeds the configured response limit."); + using var document = JsonDocument.Parse(serialized); + WriteMessage(new PluginWorkerMessage + { + Type = "result", + Result = document.RootElement.Clone() + }); + } + + private static void WriteMessage(PluginWorkerMessage message) + { + Console.Out.WriteLine(JsonSerializer.Serialize(message, PluginWorkerJsonContext.Default.PluginWorkerMessage)); + Console.Out.Flush(); + } + + private static string SanitizeError(string message) + => message.Length <= 1_024 ? message : message[..1_024]; + + private sealed class PluginWorkerBridge : IPluginWorkerBridge + { + public string Request(string capability, string payloadJson) + { + if (capability.Length > 64 || payloadJson.Length > 2 * 1024 * 1024) + throw new InvalidDataException("Capability request is too large."); + using var payloadDocument = JsonDocument.Parse(payloadJson); + var id = Guid.NewGuid().ToString("N"); + WriteMessage(new PluginWorkerMessage + { + Type = "capability", + Id = id, + Capability = capability, + Payload = payloadDocument.RootElement.Clone() + }); + + var responseLine = Console.In.ReadLine(); + if (string.IsNullOrWhiteSpace(responseLine)) throw new IOException("Capability broker disconnected."); + var response = JsonSerializer.Deserialize( + responseLine, + PluginWorkerJsonContext.Default.PluginWorkerMessage) + ?? throw new InvalidDataException("Invalid capability response."); + if (!string.Equals(response.Id, id, StringComparison.Ordinal)) + throw new InvalidDataException("Capability response id does not match request."); + if (response.Type == "capability-error") + return JsonSerializer.Serialize(new PluginWorkerBridgeResponse + { + Ok = false, + Error = response.Error ?? "Capability request was denied." + }, PluginWorkerJsonContext.Default.PluginWorkerBridgeResponse); + if (response.Type != "capability-result" || response.Result is null) + throw new InvalidDataException("Invalid capability response type."); + return JsonSerializer.Serialize(new PluginWorkerBridgeResponse + { + Ok = true, + Result = response.Result.Value.Clone() + }, PluginWorkerJsonContext.Default.PluginWorkerBridgeResponse); + } + } +} diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerProtocol.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerProtocol.cs new file mode 100644 index 0000000..87da455 --- /dev/null +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginWorkerProtocol.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SecondDimensionWatcherReDive.PluginPlatform; + +internal sealed record PluginWorkerInvocation( + string Script, + string Handler, + JsonElement Input, + JsonElement Configuration, + int MaximumHeapMegabytes, + int MaximumResponseBytes); + +internal sealed record PluginWorkerMessage +{ + public required string Type { get; init; } + public string? Id { get; init; } + public string? Capability { get; init; } + public JsonElement? Payload { get; init; } + public JsonElement? Result { get; init; } + public string? Error { get; init; } +} + +internal sealed record PluginWorkerBridgeResponse +{ + public required bool Ok { get; init; } + public JsonElement? Result { get; init; } + public string? Error { get; init; } +} + +[JsonSerializable(typeof(PluginWorkerInvocation))] +[JsonSerializable(typeof(PluginWorkerMessage))] +[JsonSerializable(typeof(PluginWorkerBridgeResponse))] +internal partial class PluginWorkerJsonContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..b187dda 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -26,6 +26,7 @@ using SecondDimensionWatcherReDive.Repositories; using SecondDimensionWatcherReDive.Chat; using SecondDimensionWatcherReDive.Plugin; +using SecondDimensionWatcherReDive.PluginPlatform; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.MigrationTasks; using SecondDimensionWatcherReDive.Utils.Feed; @@ -35,6 +36,12 @@ using SecondDimensionWatcherReDive.Utils.Incidents; using SecondDimensionWatcherReDive.Utils.Scraper; +if (PluginWorkerHost.IsWorkerInvocation(args)) +{ + Environment.ExitCode = await PluginWorkerHost.RunAsync(CancellationToken.None); + return; +} + var builder = WebApplication.CreateBuilder(args); builder.Host.UseSystemd(); @@ -84,6 +91,7 @@ .SetApplicationName("SecondDimensionWatcherReDive") .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyRingPath)); builder.Services.AddApplicationRuntimeSettings(runtimeSettingsProvider); +builder.Services.AddPluginPlatform(builder.Configuration); builder.Services.Configure( builder.Configuration.GetSection(MediaLibraryOptions.SectionName)); @@ -372,4 +380,6 @@ await app.Services.GetRequiredService() // half-migrated database. await app.Services.GetRequiredService().RunAsync(CancellationToken.None); +await app.Services.GetRequiredService().InitializeAsync(CancellationToken.None); + await app.RunAsync(); diff --git a/SecondDimensionWatcherReDive/Repositories/PluginCatalogRepository.cs b/SecondDimensionWatcherReDive/Repositories/PluginCatalogRepository.cs new file mode 100644 index 0000000..c1ae997 --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/PluginCatalogRepository.cs @@ -0,0 +1,191 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.PluginPlatform; + +namespace SecondDimensionWatcherReDive.Repositories; + +internal sealed class PluginCatalogRepository : IPluginCatalogRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly string _catalogPath; + private readonly string _retainedPath; + + public PluginCatalogRepository(IOptions options) + { + var root = Path.GetFullPath(options.Value.RootPath); + Directory.CreateDirectory(root); + RestrictDirectory(root); + _catalogPath = Path.Combine(root, "catalog"); + _retainedPath = Path.Combine(root, "retained"); + Directory.CreateDirectory(_catalogPath); + Directory.CreateDirectory(_retainedPath); + RestrictDirectory(_catalogPath); + RestrictDirectory(_retainedPath); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + var result = new List(); + foreach (var path in Directory.EnumerateFiles(_catalogPath, "*.json", SearchOption.TopDirectoryOnly)) + { + await using var stream = File.OpenRead(path); + var entry = await JsonSerializer.DeserializeAsync(stream, JsonOptions, + cancellationToken); + if (entry is not null) result.Add(entry); + } + + return result; + } + finally + { + _gate.Release(); + } + } + + public async Task FindAsync(string id, CancellationToken cancellationToken) + { + var path = GetPath(id); + await _gate.WaitAsync(cancellationToken); + try + { + if (!File.Exists(path)) return null; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task SaveAsync(PluginCatalogEntry entry, CancellationToken cancellationToken) + { + var path = GetPath(entry.Manifest.Id); + var temporaryPath = Path.Combine(_catalogPath, $".{entry.Manifest.Id}.{Guid.NewGuid():N}.tmp"); + + await _gate.WaitAsync(cancellationToken); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, entry, JsonOptions, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + RestrictFile(temporaryPath); + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + _gate.Release(); + } + } + + public async Task RemoveAsync(string id, CancellationToken cancellationToken) + { + var path = GetPath(id); + await _gate.WaitAsync(cancellationToken); + try + { + if (File.Exists(path)) File.Delete(path); + } + finally + { + _gate.Release(); + } + } + + public async Task FindRetainedAsync(string id, CancellationToken cancellationToken) + { + var path = GetRetainedPath(id); + await _gate.WaitAsync(cancellationToken); + try + { + if (!File.Exists(path)) return null; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task SaveRetainedAsync(RetainedPluginData retained, CancellationToken cancellationToken) + { + var path = GetRetainedPath(retained.Id); + var temporaryPath = Path.Combine(_retainedPath, $".{retained.Id}.{Guid.NewGuid():N}.tmp"); + await _gate.WaitAsync(cancellationToken); + try + { + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 16 * 1024, FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(stream, retained, JsonOptions, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + RestrictFile(temporaryPath); + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + _gate.Release(); + } + } + + public async Task RemoveRetainedAsync(string id, CancellationToken cancellationToken) + { + var path = GetRetainedPath(id); + await _gate.WaitAsync(cancellationToken); + try + { + if (File.Exists(path)) File.Delete(path); + } + finally + { + _gate.Release(); + } + } + + private string GetPath(string id) + { + if (!PluginManifestValidator.IsValidId(id)) throw new ArgumentException("Invalid plugin id.", nameof(id)); + return Path.Combine(_catalogPath, $"{id}.json"); + } + + private string GetRetainedPath(string id) + { + if (!PluginManifestValidator.IsValidId(id)) throw new ArgumentException("Invalid plugin id.", nameof(id)); + return Path.Combine(_retainedPath, $"{id}.json"); + } + + private static void RestrictDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} diff --git a/SecondDimensionWatcherReDive/Utils/FileStore/FileStoreProvider.cs b/SecondDimensionWatcherReDive/Utils/FileStore/FileStoreProvider.cs index 65e05bb..3ebe10b 100644 --- a/SecondDimensionWatcherReDive/Utils/FileStore/FileStoreProvider.cs +++ b/SecondDimensionWatcherReDive/Utils/FileStore/FileStoreProvider.cs @@ -1,16 +1,30 @@ using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.PluginPlatform; namespace SecondDimensionWatcherReDive.Utils.FileStore; -public class FileStoreProvider(IServiceProvider serviceProvider) : IFileStoreProvider +public class FileStoreProvider( + IServiceProvider serviceProvider, + IPluginProviderRegistry pluginProviderRegistry) : IFileStoreProvider { public IFileStore GetRequiredClient(string clientName) { - return serviceProvider.GetServices().First(c => c.Name == clientName); + return GetClient(clientName) + ?? throw new InvalidOperationException($"File store '{clientName}' is not registered."); } public IFileStore? GetClient(string clientName) { - return serviceProvider.GetServices().FirstOrDefault(c => c.Name == clientName); + var matches = GetClients().Where(client => client.Name == clientName).Take(2).ToArray(); + return matches.Length switch + { + 0 => null, + 1 => matches[0], + _ => throw new InvalidOperationException( + $"File store identity '{clientName}' is registered more than once.") + }; } -} \ No newline at end of file + + private IEnumerable GetClients() + => serviceProvider.GetServices().Concat(pluginProviderRegistry.GetFileStores()); +} diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index da9d5ab..ffcf1b1 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -18,6 +18,30 @@ "FileStore": { "Local": "/var/lib/sdw-redive/downloads" }, + // Controlled JavaScript plugins are stored separately from media. Unsigned packages are + // rejected by default. Trusted publisher values are PEM-encoded RSA public keys. + "PluginPlatform": { + "RootPath": "/var/lib/sdw-redive/plugins", + "AllowUnsignedLocalPackages": false, + "MaximumPackageBytes": 4194304, + "MaximumExpandedBytes": 16777216, + "MaximumPackageFiles": 128, + "MaximumStagedPackages": 32, + "MaximumStagedPackageBytes": 67108864, + "InvocationTimeoutMilliseconds": 5000, + "MaximumWorkerMemoryMegabytes": 256, + "MaximumWorkerCpuMilliseconds": 4000, + "MaximumConcurrentWorkers": 4, + "MaximumConcurrentWorkersPerPlugin": 1, + "MaximumResponseBytes": 2097152, + "MaximumPluginDataBytes": 67108864, + "MaximumPluginDataFiles": 1000, + "MaximumPluginDataPathDepth": 8, + "CircuitBreakerFailures": 3, + "CircuitBreakerSeconds": 60, + "PreviewLifetimeMinutes": 30, + "TrustedPublisherPublicKeys": {} + }, // Existing media library imports are read in place. Mount container paths read-only // and configure them from Settings; these values control polling, copy settling, // and how long database history survives a temporarily missing source item. diff --git a/deployments/podman-compose.yml b/deployments/podman-compose.yml index 7a4f463..97d8fb8 100644 --- a/deployments/podman-compose.yml +++ b/deployments/podman-compose.yml @@ -57,6 +57,7 @@ services: MediaLibrary__MissingGracePeriod: "1.00:00:00" PasswordFile: "/app/data/password.json" DataProtection__KeyRingPath: "/app/data/data-protection-keys" + PluginPlatform__RootPath: "/app/data/plugins" Torrent__Remote__Url: "http://qbittorrent:8080" Torrent__Remote__UserName: "" Torrent__Remote__Password: "" diff --git a/docs/container-deployment.md b/docs/container-deployment.md index f14757e..7e737f9 100644 --- a/docs/container-deployment.md +++ b/docs/container-deployment.md @@ -19,7 +19,7 @@ - `downloads` — sdw-redive 和 qbittorrent **共享**,用于下载文件的读写 - `pgdata` — PostgreSQL 数据持久化 - `valkeydata` — Valkey 缓存数据持久化 -- `appdata` — 登录密码文件与运行时敏感配置的 Data Protection 密钥环 +- `appdata` — 登录密码文件、运行时敏感配置的 Data Protection 密钥环,以及插件 catalog、安装包、配置与隔离数据 ## 快速开始 @@ -115,6 +115,7 @@ podman logs qbittorrent 2>&1 | grep "temporary password" | `ConnectionStrings__sdw` | PostgreSQL 连接字符串 | 必填 | | `JwtSecret` | JWT 签名密钥(>=32 字符) | 必填 | | `DataProtection__KeyRingPath` | 网页保存密钥/密码所用的持久化加密密钥环 | `/app/data/data-protection-keys` | +| `PluginPlatform__RootPath` | 插件 catalog、包、配置与隔离数据的持久化目录 | `/app/data/plugins` | | `FileStore__Local` | 下载文件存储路径 | `/downloads` | | `MediaLibrary__ScanInterval` | 持续监控目录的轮询间隔 | `00:05:00` | | `MediaLibrary__SettlingPeriod` | 新文件写入完成后的稳定等待时间 | `00:00:30` | @@ -137,7 +138,7 @@ podman logs qbittorrent 2>&1 | grep "temporary password" ### 网页运行时设置 -首次登录后可在「设置」中修改 AI/TMDB、qBittorrent、媒体库扫描、异常阈值和 NFS。网页值保存在 PostgreSQL,优先于上表的环境变量;敏感值加密后存储且不会通过 API 回显。`appdata` 卷中的 Data Protection 密钥环必须保留,否则重启后的应用无法解密已保存的密钥。 +首次登录后可在「设置」中修改 AI/TMDB、qBittorrent、媒体库扫描、异常阈值和 NFS。网页值保存在 PostgreSQL,优先于上表的环境变量;敏感值加密后存储且不会通过 API 回显。`appdata` 卷中的 Data Protection 密钥环必须保留,否则重启后的应用无法解密已保存的密钥。插件平台也必须位于持久卷中;随附 Compose 将 `PluginPlatform__RootPath` 设为 `/app/data/plugins`,因此重建容器不会丢失已安装包、catalog、配置或插件隔离数据。 如果运行多个应用副本并让它们连接同一个 PostgreSQL 数据库,必须把 `DataProtection__KeyRingPath` 指向所有副本共享的同一持久化密钥环(且都使用内置 application name `SecondDimensionWatcherReDive`)。实例各自使用本地密钥环会导致其他副本无法解密数据库中的运行时密钥和密码。 diff --git a/docs/plugin-platform.md b/docs/plugin-platform.md new file mode 100644 index 0000000..474ee29 --- /dev/null +++ b/docs/plugin-platform.md @@ -0,0 +1,40 @@ +# Controlled plugin platform + +The plugin platform extends notification and file-storage providers without loading third-party code into the web process. API version 1.0 uses a fresh worker process and a constrained V8 runtime for every handler invocation. The host enforces wall-clock, CPU, working-set, V8 heap, array-buffer, response-size, and cancellation limits. A worker crash or V8 fatal resource violation therefore ends only that invocation. Three consecutive failures open a per-plugin circuit breaker; health and the last failure are visible through `GET /api/plugins` and Settings → Plugins. + +## Trust and installation + +Only authenticated application operators can manage plugins. Packages are uploaded locally and inspected before any code can run. Inspection rejects path traversal, symbolic links, duplicate or oversized archive content, invalid manifests, and any missing, unlisted, or hash-mismatched file. `integrity.files` must enumerate every regular archive file except `manifest.json`; that exact path/digest set is covered by the publisher signature and is checked again while extracting and before every invocation. Inspection reports compatibility, signature trust, the immutable package checksum, and every requested capability. Installation succeeds only when the caller echoes both the checksum and the exact capability set. A changed byte requires another preview and approval. + +Preview staging is bounded by both package count and aggregate bytes (`MaximumStagedPackages` and `MaximumStagedPackageBytes`); expired previews are removed before admitting another upload. The installation boundary validates the echoed checksum shape before reading the staged package. + +Plugin and dependency versions use bounded, strict SemVer (`major.minor.patch` with optional legal prerelease/build identifiers); path separators, control/non-ASCII characters, empty identifiers, and ambiguous numeric forms are rejected before extraction. API versions use the same bounded grammar while permitting the API's `major.minor` form. Extraction also canonicalizes the version and temporary destinations and requires both to remain under that plugin's package directory. Publisher, provider, provider-operation, and handler names are bounded ASCII identifiers; display text is length/control-character checked. + +Unsigned local packages are rejected by default. Configure trusted publisher PEM public keys under `PluginPlatform:TrustedPublisherPublicKeys`. `AllowUnsignedLocalPackages` exists for local development and compatibility tests only; it does not accept invalid or untrusted signatures. The trusted public-key fingerprint is persisted as plugin ownership, so upgrades and reinstalls that would inherit retained configuration/data must use the same key. The remote-install endpoint is intentionally hard-disabled: the service never downloads or evaluates arbitrary JavaScript from a URL. + +## Isolation and capabilities + +Workers receive only JSON input, read-only JSON configuration, and a restricted host interface with one `Request` method. They cannot obtain `IServiceProvider`, CLR types, host objects, `fetch`, `require`, or the host filesystem. The parent validates every request: + +- `network.request` accepts HTTP(S), disables redirects, cookies, and proxies, requires an exact or `*.` domain approval, resolves once, pins the connection to the validated result, and requires every DNS answer to be public. It rejects loopback, private, link-local, metadata, multicast, unspecified, transition/special-use ranges, and fail-closes IPv6 to ordinary global-unicast space. Private-network access is not available in API 1.0. Host or container egress ACLs remain the final boundary for organization-specific NAT64, 6rd, ISATAP, or other custom translation prefixes that cannot be identified from an address alone. +- `file.read` and `file.list` require an approved absolute root. Linux/macOS paths are traversed with directory-relative `openat` plus `O_NOFOLLOW`, closing path/symbolic-link replacement races. Windows file reads validate the opened handle; directory listing, metadata, and data writes fail closed until equivalent handle-relative operations are available. +- `data.*` requires `storageAccess` and remains inside `PluginPlatform:RootPath/data/`. +- notification publication, download control, and background tasks are represented in the manifest capability model but have no generic broker operation in API 1.0; unknown operations are denied. + +Network and file responses are bounded. Plugin-scoped data also has configurable aggregate byte, file-count, and path-depth quotas; writes reserve quota under a per-plugin gate and atomically replace the destination. Plugins do not receive arbitrary request headers, credentials, database access, or a service container. + +`MaximumConcurrentWorkers` and `MaximumConcurrentWorkersPerPlugin` bound aggregate worker amplification and reject excess work without degrading plugin health. `MaximumPluginDataBytes`, `MaximumPluginDataFiles`, and `MaximumPluginDataPathDepth` bound persistent storage. Lifecycle operations close the invocation gate, cancel and drain active workers, and only then move package or data directories. + +## Compatibility and lifecycle + +A plugin can be installed while incompatible so an administrator can inspect it, but it cannot be enabled. Enable checks the API version, OS/architecture, minimum dependency versions, and whether dependencies are enabled. Disabling or uninstalling a dependency automatically disables dependents. Installs and upgrades start disabled. + +Configuration and plugin-scoped data survive upgrades. If `dataVersion` changes, the new manifest must explicitly declare `dataMigration.strategy` as `reset`; the host moves old data to a rollback directory until the catalog update commits. Other version changes preserve data. Uninstall preserves configuration and data by default for a future reinstall; `DELETE /api/plugins/{id}?deleteData=true` is the explicit irreversible removal path. + +Reset upgrades and uninstalls use an fsync'd lifecycle journal stored separately from their payload directory. Startup rolls back every prepared operation, finalizes committed cleanup, and removes unreferenced package versions. Journal deletion is the last cleanup step, so repeated termination during a large payload deletion remains recoverable and idempotent. Cascaded dependency disables acquire the same per-plugin lifecycle lease as direct disables, canceling and draining active workers before the disabled state is committed. + +Each event handler is invoked with an independent timeout and exception boundary. A failure or timeout is contained and does not prevent later handlers from running; caller cancellation still propagates immediately. + +## Provider contract and compatibility suite + +Manifest `providers` entries declare a `kind`, display `name`, and operation-to-handler map. Handler names are validated before installation. Notification providers must request the `notifications` capability, and storage providers must request `storageAccess`, so the approval screen never understates the host data delivered to a provider. Runtime provider identities are stable global keys in the form `plugin::`; they cannot collide with built-in stores or silently rebind to another plugin. API 1.0 wires `notification` providers through `INotificationProvider` and `storage` providers through `IFileStore`; manifests declaring unimplemented download or metadata adapters are rejected rather than appearing healthy but unusable. The example webhook notification and plugin-scoped storage packages are installed, enabled, and exercised end-to-end by `PluginPlatformIntegrationTests`. Package validation, missing dependencies, incompatible APIs, capability denial, worker timeout/crash/aggregate-concurrency containment, circuit breaking, event isolation, upgrade rollback/reset, quota enforcement, and retained uninstall state are covered by the same test suite. diff --git a/examples/plugins/scoped-storage/index.js b/examples/plugins/scoped-storage/index.js new file mode 100644 index 0000000..19766de --- /dev/null +++ b/examples/plugins/scoped-storage/index.js @@ -0,0 +1,35 @@ +'use strict'; + +function relative(path) { + return String(path || '').replace(/^\/+/, ''); +} + +globalThis.sdwPlugin = { + handlers: { + exists(input) { + return sdw.request('data.exists', { path: relative(input.path) }); + }, + info(input) { + return sdw.request('data.info', { path: relative(input.path) }); + }, + read(input) { + return sdw.request('data.read', { path: relative(input.path) }); + }, + list(input) { + const base = relative(input.path); + return sdw.request('data.list', { path: base }).map((entry) => ({ + isDirectory: entry.isDirectory, + path: base ? `${base}/${entry.name}` : entry.name, + fileName: entry.name, + length: entry.length, + lastModifiedUtc: entry.lastModifiedUtc, + })); + }, + seed(input) { + return sdw.request('data.write', { + path: relative(input.path), + base64: input.base64, + }); + }, + }, +}; diff --git a/examples/plugins/scoped-storage/manifest.json b/examples/plugins/scoped-storage/manifest.json new file mode 100644 index 0000000..dfa0b3b --- /dev/null +++ b/examples/plugins/scoped-storage/manifest.json @@ -0,0 +1,36 @@ +{ + "id": "example.scoped-storage", + "name": "Plugin-scoped storage", + "description": "A read-only FileStore provider backed only by this plugin's isolated data directory.", + "version": "1.0.0", + "apiVersion": "1.0", + "entryPoint": "index.js", + "dependencies": [], + "capabilities": { + "networkDomains": [], + "fileRoots": [], + "notifications": false, + "downloadControl": false, + "storageAccess": true, + "backgroundTasks": false + }, + "platforms": ["any"], + "integrity": { + "files": { + "index.js": "8daa9c1860964f8e33684744d827d720d1da42415755f0affcac5cce62457a9e" + } + }, + "providers": [ + { + "kind": "storage", + "name": "example-scoped", + "handlers": { + "exists": "exists", + "info": "info", + "read": "read", + "list": "list" + } + } + ], + "dataVersion": 1 +} diff --git a/examples/plugins/webhook/index.js b/examples/plugins/webhook/index.js new file mode 100644 index 0000000..1789c30 --- /dev/null +++ b/examples/plugins/webhook/index.js @@ -0,0 +1,16 @@ +'use strict'; + +globalThis.sdwPlugin = { + handlers: { + sendNotification(notification, configuration) { + if (!configuration.url) throw new Error('Webhook configuration requires url.'); + const response = sdw.request('network.request', { + method: 'POST', + url: configuration.url, + contentType: 'application/json', + body: JSON.stringify(notification), + }); + return { success: response.status >= 200 && response.status < 300 }; + }, + }, +}; diff --git a/examples/plugins/webhook/manifest.json b/examples/plugins/webhook/manifest.json new file mode 100644 index 0000000..7589d4c --- /dev/null +++ b/examples/plugins/webhook/manifest.json @@ -0,0 +1,31 @@ +{ + "id": "example.webhook", + "name": "Webhook notifications", + "description": "Sends SDW notifications to one explicitly approved webhook domain.", + "version": "1.0.0", + "apiVersion": "1.0", + "entryPoint": "index.js", + "dependencies": [], + "capabilities": { + "networkDomains": ["hooks.example.com"], + "fileRoots": [], + "notifications": true, + "downloadControl": false, + "storageAccess": false, + "backgroundTasks": false + }, + "platforms": ["any"], + "integrity": { + "files": { + "index.js": "cb04e27dacbadf8de122b491b2c2d32cb553564fcc9c390a3ab4d922a2cd0e1b" + } + }, + "providers": [ + { + "kind": "notification", + "name": "webhook", + "handlers": { "send": "sendNotification" } + } + ], + "dataVersion": 1 +} diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 4cf660f..4cba653 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -20,6 +20,29 @@ Torrent: FileStore: Local: /var/lib/sdw-redive/downloads +# 受控 JavaScript 插件。生产环境默认拒绝未签名包;公钥值为 PEM 编码 RSA 公钥。 +PluginPlatform: + RootPath: /var/lib/sdw-redive/plugins + AllowUnsignedLocalPackages: false + MaximumPackageBytes: 4194304 + MaximumExpandedBytes: 16777216 + MaximumPackageFiles: 128 + MaximumStagedPackages: 32 + MaximumStagedPackageBytes: 67108864 + InvocationTimeoutMilliseconds: 5000 + MaximumWorkerCpuMilliseconds: 4000 + MaximumWorkerMemoryMegabytes: 256 + MaximumConcurrentWorkers: 4 + MaximumConcurrentWorkersPerPlugin: 1 + MaximumResponseBytes: 2097152 + MaximumPluginDataBytes: 67108864 + MaximumPluginDataFiles: 1000 + MaximumPluginDataPathDepth: 8 + CircuitBreakerFailures: 3 + CircuitBreakerSeconds: 60 + PreviewLifetimeMinutes: 30 + TrustedPublisherPublicKeys: {} + # 现有媒体库扫描(目录在网页「设置」中添加,不会移动或删除原文件) # 缺失条目先撤下虚拟映射,超过 MissingGracePeriod 后才清理数据库记录 MediaLibrary: diff --git a/sdk/javascript/README.md b/sdk/javascript/README.md new file mode 100644 index 0000000..566edee --- /dev/null +++ b/sdk/javascript/README.md @@ -0,0 +1,13 @@ +# SDW JavaScript plugin SDK 1.0 + +A plugin package is a ZIP archive (normally named `.sdwpkg`) containing `manifest.json` at its root and one JavaScript entry point. The entry point assigns synchronous handlers to `globalThis.sdwPlugin.handlers`. There is no `require`, `fetch`, filesystem API, .NET reflection, or service container. Privileged work goes through `sdw.request`, whose broker checks the installed manifest's approved capabilities on every call. + +Use `plugin-api.d.ts` while authoring. Compute SHA-256 after the final edit of every regular archive file except `manifest.json`, and place the exact path-to-digest map in `integrity.files`. Unlisted and missing files are rejected. Production installations require an RSA-SHA256 signature from a publisher configured in `PluginPlatform:TrustedPublisherPublicKeys`. Generate the bytes with the public .NET helper `PluginSignaturePayload.Create(manifest)`, then sign them with RSA PKCS#1 v1.5 and SHA-256. The versioned, base64-tagged canonical payload covers identity, description, API and plugin versions, the complete file list and digests, every capability, platform, dependency, provider/handler declaration, and data-migration field; changing any execution-relevant manifest value or package file invalidates the signature. + +Use strict `major.minor.patch` SemVer for `version` and dependency minimum versions. Optional prerelease/build identifiers follow the SemVer ASCII grammar; path separators, empty identifiers, leading-zero numeric identifiers, controls, and non-ASCII forms are rejected. Publisher, provider, provider-operation, and handler names are 1-64 character ASCII identifiers beginning with a letter. + +Publisher ownership is continuous across upgrades and retained-data reinstalls. The host persists the trusted public-key fingerprint, not merely the publisher label, and rejects a package signed by a different key. Transferring ownership requires first uninstalling with retained data deletion. + +The local upload API is deliberately two-stage: `POST /api/plugins/preview` returns the checksum, signature status, compatibility result, and required capabilities; `POST /api/plugins/install` must echo that checksum and the exact capability object. New installs and upgrades remain disabled until explicitly enabled. URL-based remote installation always returns 403. + +Compatibility rules and lifecycle guarantees are documented in [plugin-platform.md](../../docs/plugin-platform.md). The examples under `examples/plugins` are executable fixtures for the compatibility suite. They are intentionally unsigned; production operators should copy and sign them under their own trusted publisher key. diff --git a/sdk/javascript/plugin-api.d.ts b/sdk/javascript/plugin-api.d.ts new file mode 100644 index 0000000..0d4c52b --- /dev/null +++ b/sdk/javascript/plugin-api.d.ts @@ -0,0 +1,39 @@ +export interface SdwPluginHost { + request(capability: "network.request", payload: { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + url: string; + body?: string; + contentType?: string; + }): { status: number; contentType?: string; body: string }; + request(capability: "file.read", payload: { path: string }): { base64: string }; + request(capability: "file.list", payload: { path: string }): Array<{ name: string; isDirectory: boolean }>; + request(capability: "data.read", payload: { path: string }): { base64: string }; + request(capability: "data.write", payload: { path: string; base64: string }): { written: number }; + request(capability: "data.exists", payload: { path: string }): { exists: boolean; isDirectory: boolean }; + request(capability: "data.info", payload: { path: string }): PluginDataInfo; + request(capability: "data.list", payload: { path: string }): PluginDataEntry[]; +} + +export interface PluginDataEntry { + name: string; + isDirectory: boolean; + length?: number; + lastModifiedUtc?: string; +} + +export interface PluginDataInfo { + path: string; + fileName: string; + isDirectory: boolean; + length?: number; + lastModifiedUtc?: string; +} + +export interface SdwPlugin { + handlers: Record>) => unknown>; +} + +declare global { + const sdw: Readonly; + var sdwPlugin: SdwPlugin; +} From 876afca0731791f655a2a79837c44cf6c95aead6 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 01:32:14 +0800 Subject: [PATCH 2/4] fix: isolate plugin worker runtime environment --- .../PluginPlatformIntegrationTests.cs | 28 +++++++++++++++++++ .../PluginPlatform/PluginProcessExecutor.cs | 24 ++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs index 029b682..8bf8ff8 100644 --- a/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs +++ b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs @@ -447,6 +447,34 @@ await Assert.ThrowsExactlyAsync(() => Assert.IsFalse(File.Exists(Path.Combine(dataRoot, "extra.bin"))); } + [TestMethod] + public void WorkerEnvironment_RemovesProfilerAndStartupHookInjection() + { + var startInfo = new ProcessStartInfo(); + string[] injectedVariables = + [ + "CORECLR_ENABLE_PROFILING", + "CORECLR_PROFILER", + "CORECLR_PROFILER_PATH", + "CORECLR_PROFILER_PATH_32", + "CORECLR_PROFILER_PATH_64", + "COR_ENABLE_PROFILING", + "COR_PROFILER", + "COR_PROFILER_PATH", + "COR_PROFILER_PATH_32", + "COR_PROFILER_PATH_64", + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "DOTNET_SHARED_STORE" + ]; + foreach (var variable in injectedVariables) startInfo.Environment[variable] = "injected"; + + PluginProcessExecutor.RemoveRuntimeInjectionEnvironmentVariables(startInfo); + + foreach (var variable in injectedVariables) + Assert.IsFalse(startInfo.Environment.ContainsKey(variable), $"{variable} must not reach the worker."); + } + [TestMethod] public async Task Worker_ContainsTimeoutCrashAndResourceExhaustion_ThenOpensCircuit() { diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs index 578c0d0..900c6bd 100644 --- a/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs @@ -23,6 +23,23 @@ internal sealed class PluginProcessExecutor( IPluginCapabilityBroker capabilityBroker, IOptions options) : IPluginProcessExecutor { + private static readonly string[] RuntimeInjectionEnvironmentVariables = + [ + "CORECLR_ENABLE_PROFILING", + "CORECLR_PROFILER", + "CORECLR_PROFILER_PATH", + "CORECLR_PROFILER_PATH_32", + "CORECLR_PROFILER_PATH_64", + "COR_ENABLE_PROFILING", + "COR_PROFILER", + "COR_PROFILER_PATH", + "COR_PROFILER_PATH_32", + "COR_PROFILER_PATH_64", + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "DOTNET_SHARED_STORE" + ]; + private readonly PluginPlatformOptions _options = options.Value; private readonly SemaphoreSlim _globalGate = new( options.Value.MaximumConcurrentWorkers, @@ -217,9 +234,16 @@ private static ProcessStartInfo CreateStartInfo() string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase)) startInfo.ArgumentList.Add(hostAssembly.Location); startInfo.ArgumentList.Add(PluginWorkerHost.WorkerArgument); + RemoveRuntimeInjectionEnvironmentVariables(startInfo); return startInfo; } + internal static void RemoveRuntimeInjectionEnvironmentVariables(ProcessStartInfo startInfo) + { + foreach (var variable in RuntimeInjectionEnvironmentVariables) + startInfo.Environment.Remove(variable); + } + private static async Task ReadErrorAsync(Process process) { var value = await process.StandardError.ReadToEndAsync(); From 2338e84c11789e4cfa6f98b7e63f9fcd5643f94d Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 01:40:25 +0800 Subject: [PATCH 3/4] fix: isolate plugin process tests from coverage --- .github/workflows/build.yml | 12 ++- .../PluginPlatformIntegrationTests.cs | 101 ++++++++++-------- .../PluginPlatform/PluginProcessExecutor.cs | 32 ++---- 3 files changed, 80 insertions(+), 65 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98cb3e1..139ad00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,9 +26,19 @@ jobs: with: dotnet-version: '10.0.x' - - name: Run tests + # These tests intentionally start and kill child processes that load the application assembly. + # Coverlet statically instruments that assembly with one shared hits file, so collecting the + # class in-process can corrupt coverage when a hostile worker is terminated by design. + - name: Run plugin platform process tests without coverage + run: >- + dotnet test SecondDimensionWatcherReDive.Test/SecondDimensionWatcherReDive.Test.csproj -c Release + --filter "FullyQualifiedName~SecondDimensionWatcherReDive.Test.PluginPlatformIntegrationTests" + --logger "trx;LogFileName=plugin-platform-test-results.trx" + + - name: Run remaining tests with coverage run: >- dotnet test SecondDimensionWatcherReDive.slnx -c Release + --filter "FullyQualifiedName!~SecondDimensionWatcherReDive.Test.PluginPlatformIntegrationTests" --logger "trx;LogFileName=test-results.trx" --collect "XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura diff --git a/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs index 8bf8ff8..d981d5e 100644 --- a/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs +++ b/SecondDimensionWatcherReDive.Test/PluginPlatformIntegrationTests.cs @@ -448,61 +448,53 @@ await Assert.ThrowsExactlyAsync(() => } [TestMethod] - public void WorkerEnvironment_RemovesProfilerAndStartupHookInjection() + public async Task Worker_ReportsScriptErrorsThroughProtocol_AndLeavesHealthyPluginAvailable() { - var startInfo = new ProcessStartInfo(); - string[] injectedVariables = - [ - "CORECLR_ENABLE_PROFILING", - "CORECLR_PROFILER", - "CORECLR_PROFILER_PATH", - "CORECLR_PROFILER_PATH_32", - "CORECLR_PROFILER_PATH_64", - "COR_ENABLE_PROFILING", - "COR_PROFILER", - "COR_PROFILER_PATH", - "COR_PROFILER_PATH_32", - "COR_PROFILER_PATH_64", - "DOTNET_STARTUP_HOOKS", - "DOTNET_ADDITIONAL_DEPS", - "DOTNET_SHARED_STORE" - ]; - foreach (var variable in injectedVariables) startInfo.Environment[variable] = "injected"; - - PluginProcessExecutor.RemoveRuntimeInjectionEnvironmentVariables(startInfo); - - foreach (var variable in injectedVariables) - Assert.IsFalse(startInfo.Environment.ContainsKey(variable), $"{variable} must not reach the worker."); + const string crashingScript = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + crash() { throw new Error('intentional crash'); } + }}; + """; + await using var fixture = new PluginPlatformFixture(); + var crashing = Manifest("test.crash-protocol"); + await fixture.InstallAndEnableAsync(crashing, crashingScript); + var healthy = Manifest("test.crash-healthy"); + await fixture.InstallAndEnableAsync(healthy, PingScript); + + var failure = await Assert.ThrowsExactlyAsync(() => + fixture.InvokeAsync(crashing.Id, "crash")); + StringAssert.Contains(failure.Message, "intentional crash"); + + var result = await fixture.InvokeAsync(healthy.Id, "ping", new { value = 7 }); + Assert.AreEqual(7, result.GetProperty("value").GetInt32()); } [TestMethod] - public async Task Worker_ContainsTimeoutCrashAndResourceExhaustion_ThenOpensCircuit() + public async Task WorkerCapacity_RejectionsDoNotDegradePluginHealth() { - const string hostileScript = """ + const string timeoutScript = """ 'use strict'; globalThis.sdwPlugin = { handlers: { - timeout() { while (true) {} }, - crash() { throw new Error('intentional crash'); }, - memory() { const values = []; while (true) values.push(new ArrayBuffer(1048576)); } + timeout() { while (true) {} } }}; """; await using var fixture = new PluginPlatformFixture(options => { - options.InvocationTimeoutMilliseconds = 400; - options.MaximumWorkerCpuMilliseconds = 300; - options.MaximumWorkerMemoryMegabytes = 64; + options.InvocationTimeoutMilliseconds = 2_000; + options.MaximumWorkerCpuMilliseconds = 1_500; + options.MaximumWorkerMemoryMegabytes = 256; options.MaximumConcurrentWorkers = 1; options.MaximumConcurrentWorkersPerPlugin = 1; options.CircuitBreakerFailures = 3; }); - var hostile = Manifest("test.hostile"); - await fixture.InstallAndEnableAsync(hostile, hostileScript); - var healthy = Manifest("test.healthy"); + var hostile = Manifest("test.capacity-hostile"); + await fixture.InstallAndEnableAsync(hostile, timeoutScript); + var healthy = Manifest("test.capacity-healthy"); await fixture.InstallAndEnableAsync(healthy, PingScript); - var stopwatch = Stopwatch.StartNew(); var firstTimeout = fixture.InvokeAsync(hostile.Id, "timeout"); - await Task.Delay(100); + await Task.Delay(25); var rejected = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ => { try @@ -527,16 +519,39 @@ await Assert.ThrowsExactlyAsync(() => .Single(plugin => plugin.Manifest.Id == healthy.Id).Health; Assert.AreEqual(0, healthyHealth.ConsecutiveFailures, "A global capacity rejection must not be attributed to another plugin."); - await Assert.ThrowsExactlyAsync(() => fixture.InvokeAsync(hostile.Id, "crash")); - await Assert.ThrowsAsync(() => fixture.InvokeAsync(hostile.Id, "memory")); + } + + [TestMethod] + public async Task Worker_ContainsTimeoutAndResourceExhaustion_ThenOpensCircuit() + { + const string hostileScript = """ + 'use strict'; + globalThis.sdwPlugin = { handlers: { + timeout() { while (true) {} }, + memory() { const values = []; while (true) values.push(new ArrayBuffer(1048576)); } + }}; + """; + await using var fixture = new PluginPlatformFixture(options => + { + options.InvocationTimeoutMilliseconds = 400; + options.MaximumWorkerCpuMilliseconds = 300; + options.MaximumWorkerMemoryMegabytes = 64; + options.CircuitBreakerFailures = 3; + }); + var hostile = Manifest("test.resource-hostile"); + await fixture.InstallAndEnableAsync(hostile, hostileScript); + var stopwatch = Stopwatch.StartNew(); + + await Assert.ThrowsExactlyAsync(() => fixture.InvokeAsync(hostile.Id, "timeout")); + await Assert.ThrowsExactlyAsync(() => fixture.InvokeAsync(hostile.Id, "timeout")); + var memoryFailure = await Assert.ThrowsAsync(() => fixture.InvokeAsync(hostile.Id, "memory")); + Assert.IsTrue(memoryFailure is TimeoutException or InvalidOperationException, + $"Unexpected resource failure type: {memoryFailure.GetType().Name}"); Assert.IsLessThan(TimeSpan.FromSeconds(8), stopwatch.Elapsed); var circuit = await Assert.ThrowsExactlyAsync(() => - fixture.InvokeAsync(hostile.Id, "crash")); + fixture.InvokeAsync(hostile.Id, "timeout")); StringAssert.Contains(circuit.Message, "circuit is open"); - - var result = await fixture.InvokeAsync(healthy.Id, "ping", new { value = 7 }); - Assert.AreEqual(7, result.GetProperty("value").GetInt32()); } [TestMethod] diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs index 900c6bd..d9d3a35 100644 --- a/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginProcessExecutor.cs @@ -23,23 +23,6 @@ internal sealed class PluginProcessExecutor( IPluginCapabilityBroker capabilityBroker, IOptions options) : IPluginProcessExecutor { - private static readonly string[] RuntimeInjectionEnvironmentVariables = - [ - "CORECLR_ENABLE_PROFILING", - "CORECLR_PROFILER", - "CORECLR_PROFILER_PATH", - "CORECLR_PROFILER_PATH_32", - "CORECLR_PROFILER_PATH_64", - "COR_ENABLE_PROFILING", - "COR_PROFILER", - "COR_PROFILER_PATH", - "COR_PROFILER_PATH_32", - "COR_PROFILER_PATH_64", - "DOTNET_STARTUP_HOOKS", - "DOTNET_ADDITIONAL_DEPS", - "DOTNET_SHARED_STORE" - ]; - private readonly PluginPlatformOptions _options = options.Value; private readonly SemaphoreSlim _globalGate = new( options.Value.MaximumConcurrentWorkers, @@ -132,8 +115,10 @@ private async Task InvokeCoreAsync( await HandleCapabilityAsync(process, plugin, message, timeout.Token); break; case "result" when message.Result is not null: + await WaitForWorkerExitAfterTerminalMessageAsync(process, timeout, monitor); return message.Result.Value.Clone(); case "error": + await WaitForWorkerExitAfterTerminalMessageAsync(process, timeout, monitor); throw new InvalidOperationException(message.Error ?? "Plugin execution failed."); default: throw new InvalidDataException($"Unexpected plugin worker message '{message.Type}'."); @@ -234,14 +219,19 @@ private static ProcessStartInfo CreateStartInfo() string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase)) startInfo.ArgumentList.Add(hostAssembly.Location); startInfo.ArgumentList.Add(PluginWorkerHost.WorkerArgument); - RemoveRuntimeInjectionEnvironmentVariables(startInfo); return startInfo; } - internal static void RemoveRuntimeInjectionEnvironmentVariables(ProcessStartInfo startInfo) + private static async Task WaitForWorkerExitAfterTerminalMessageAsync( + Process process, + CancellationTokenSource timeout, + Task monitor) { - foreach (var variable in RuntimeInjectionEnvironmentVariables) - startInfo.Environment.Remove(variable); + timeout.Cancel(); + try { await monitor; } catch (OperationCanceledException) { } + + using var exitGrace = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + try { await process.WaitForExitAsync(exitGrace.Token); } catch (OperationCanceledException) { } } private static async Task ReadErrorAsync(Process process) From cd08fc75adeefbecf5eb7d158d2e4fbe5477a49c Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 10:44:30 +0800 Subject: [PATCH 4/4] fix: honor plugin storage and upload limits --- .../Plugins/PluginApiTests.cs | 30 +++++++++- .../WebDavWebApplicationFactory.cs | 1 + .../PluginControllerTests.cs | 59 +++++++++++++++++++ .../Controllers/PluginController.cs | 3 +- .../PluginPlatform/PluginPlatformOptions.cs | 7 ++- .../PluginPlatformServiceExtensions.cs | 13 +++- SecondDimensionWatcherReDive/Program.cs | 4 +- docs/plugin-platform.md | 2 + 8 files changed, 112 insertions(+), 7 deletions(-) diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs index 04449a9..c5a219c 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/Plugins/PluginApiTests.cs @@ -10,6 +10,23 @@ namespace SecondDimensionWatcherReDive.IntegrationTest.Plugins; [TestClass] public sealed class PluginApiTests { + [TestMethod] + public async Task ManagementApi_AcceptsConfiguredPackagesAboveTheLegacyEightMiBLimit() + { + await using var factory = new WebDavWebApplicationFactory(); + using var client = factory.CreateJwtClient(); + var id = $"test.large-{Guid.NewGuid():N}"; + var payload = RandomNumberGenerator.GetBytes(8 * 1024 * 1024 + 64 * 1024); + await using var package = CreatePackage(id, "1.0", payload); + Assert.IsGreaterThan(8L * 1024 * 1024, package.Length); + using var form = new MultipartFormDataContent(); + form.Add(new StreamContent(package), "package", $"{id}.sdwpkg"); + + using var response = await client.PostAsync("/api/plugins/preview", form); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync()); + } + [TestMethod] public async Task ManagementApi_RequiresPreviewApproval_AndSupportsLifecycle() { @@ -104,10 +121,13 @@ public async Task ManagementApi_ReturnsBadRequestForMalformedPluginId() StringAssert.Contains(await response.Content.ReadAsStringAsync(), "invalid_plugin_request"); } - private static MemoryStream CreatePackage(string id, string apiVersion) + private static MemoryStream CreatePackage(string id, string apiVersion, byte[]? payload = null) { const string script = "globalThis.sdwPlugin={handlers:{ping:()=>({ok:true})}};"; var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(script))).ToLowerInvariant(); + var integrityFiles = new Dictionary { ["index.js"] = digest }; + if (payload is not null) + integrityFiles["payload.bin"] = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(); var manifest = JsonSerializer.Serialize(new { id, @@ -126,7 +146,7 @@ private static MemoryStream CreatePackage(string id, string apiVersion) backgroundTasks = false }, platforms = new[] { "any" }, - integrity = new { files = new Dictionary { ["index.js"] = digest } }, + integrity = new { files = integrityFiles }, providers = Array.Empty(), dataVersion = 1 }); @@ -135,6 +155,12 @@ private static MemoryStream CreatePackage(string id, string apiVersion) { WriteEntry(archive, "manifest.json", manifest); WriteEntry(archive, "index.js", script); + if (payload is not null) + { + var payloadEntry = archive.CreateEntry("payload.bin", CompressionLevel.NoCompression); + using var payloadStream = payloadEntry.Open(); + payloadStream.Write(payload); + } } stream.Position = 0; return stream; diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index fffafb2..0b17b94 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -49,6 +49,7 @@ static WebDavWebApplicationFactory() Environment.SetEnvironmentVariable("PluginPlatform__RootPath", Path.Combine(Path.GetTempPath(), $"sdw-plugin-api-tests-{Environment.ProcessId}")); Environment.SetEnvironmentVariable("PluginPlatform__AllowUnsignedLocalPackages", "true"); + Environment.SetEnvironmentVariable("PluginPlatform__MaximumPackageBytes", "10485760"); } public List Mappings { get; } = new(); diff --git a/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs b/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs index eaa3eab..71ca2cc 100644 --- a/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/PluginControllerTests.cs @@ -1,4 +1,9 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using Moq; using SecondDimensionWatcherReDive.Controllers; @@ -10,6 +15,60 @@ namespace SecondDimensionWatcherReDive.Test; [TestClass] public sealed class PluginControllerTests { + [TestMethod] + public void Preview_AllowsTheConfiguredPackageRangeAtTheMultipartBoundary() + { + var method = typeof(PluginController).GetMethod(nameof(PluginController.Preview)); + Assert.IsNotNull(method); + var requestLimit = method.GetCustomAttribute(); + var formLimit = method.GetCustomAttribute(); + + Assert.IsNotNull(requestLimit); + Assert.IsNotNull(formLimit); + Assert.AreEqual( + PluginPlatformOptions.MaximumUploadRequestBytes, + ((IRequestSizeLimitMetadata)requestLimit).MaxRequestBodySize); + Assert.AreEqual(PluginPlatformOptions.MaximumAllowedPackageBytes, formLimit.MultipartBodyLengthLimit); + Assert.IsGreaterThan( + PluginPlatformOptions.MaximumAllowedPackageBytes, + PluginPlatformOptions.MaximumUploadRequestBytes, + "The request envelope must leave room for multipart framing."); + } + + [TestMethod] + public void PluginPlatform_WhenRootIsMissing_FallsBackBesideThePasswordFile() + { + var passwordFile = Path.Combine(Path.GetTempPath(), "sdw-app-data", "password.json"); + var defaultRoot = PluginPlatformOptions.GetDefaultRootPath(passwordFile); + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddPluginPlatform(configuration, defaultRoot); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + Assert.AreEqual(Path.GetFullPath(defaultRoot), options.RootPath); + } + + [TestMethod] + public void PluginPlatform_WhenRootIsConfigured_PreservesTheConfiguredPath() + { + var configuredRoot = Path.Combine(Path.GetTempPath(), "sdw-configured-plugins"); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{PluginPlatformOptions.SectionName}:RootPath"] = configuredRoot + }) + .Build(); + var services = new ServiceCollection(); + services.AddPluginPlatform(configuration, Path.Combine(Path.GetTempPath(), "unused-default")); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + Assert.AreEqual(configuredRoot, options.RootPath); + } + [TestMethod] public async Task Preview_WhenStagingCapacityIsReached_ReturnsConflict() { diff --git a/SecondDimensionWatcherReDive/Controllers/PluginController.cs b/SecondDimensionWatcherReDive/Controllers/PluginController.cs index a00e74a..96fb150 100644 --- a/SecondDimensionWatcherReDive/Controllers/PluginController.cs +++ b/SecondDimensionWatcherReDive/Controllers/PluginController.cs @@ -21,7 +21,8 @@ internal sealed class PluginController( [HttpPost("preview")] [Consumes("multipart/form-data")] - [RequestSizeLimit(8 * 1024 * 1024)] + [RequestSizeLimit(PluginPlatformOptions.MaximumUploadRequestBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = PluginPlatformOptions.MaximumAllowedPackageBytes)] public async Task> Preview( [FromForm] IFormFile package, CancellationToken cancellationToken) diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs index 06882a7..8ee30fa 100644 --- a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformOptions.cs @@ -3,8 +3,13 @@ namespace SecondDimensionWatcherReDive.PluginPlatform; public sealed class PluginPlatformOptions { public const string SectionName = "PluginPlatform"; + public const long MaximumAllowedPackageBytes = 64L * 1024 * 1024; + public const long MaximumUploadRequestBytes = MaximumAllowedPackageBytes + 1024 * 1024; - public string RootPath { get; set; } = "./plugin-data"; + internal static string GetDefaultRootPath(string passwordFile) + => Path.Combine(Path.GetDirectoryName(Path.GetFullPath(passwordFile))!, "plugins"); + + public string RootPath { get; set; } = string.Empty; public bool AllowUnsignedLocalPackages { get; set; } public long MaximumPackageBytes { get; set; } = 4 * 1024 * 1024; public long MaximumExpandedBytes { get; set; } = 16 * 1024 * 1024; diff --git a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs index dda4b9c..7d349a5 100644 --- a/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs +++ b/SecondDimensionWatcherReDive/PluginPlatform/PluginPlatformServiceExtensions.cs @@ -9,11 +9,20 @@ internal static class PluginPlatformServiceExtensions { public static IServiceCollection AddPluginPlatform( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + string defaultRootPath) { services.AddOptions() .Bind(configuration.GetSection(PluginPlatformOptions.SectionName)) - .Validate(options => options.MaximumPackageBytes is >= 1_024 and <= 64 * 1024 * 1024, + .PostConfigure(options => + { + if (string.IsNullOrWhiteSpace(options.RootPath)) + options.RootPath = Path.GetFullPath(defaultRootPath); + }) + .Validate(options => !string.IsNullOrWhiteSpace(options.RootPath), + "RootPath must not be empty.") + .Validate(options => options.MaximumPackageBytes is >= 1_024 and + <= PluginPlatformOptions.MaximumAllowedPackageBytes, "MaximumPackageBytes must be between 1 KiB and 64 MiB.") .Validate(options => options.MaximumExpandedBytes >= options.MaximumPackageBytes, "MaximumExpandedBytes must be at least MaximumPackageBytes.") diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index b187dda..5408b14 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -91,7 +91,9 @@ .SetApplicationName("SecondDimensionWatcherReDive") .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyRingPath)); builder.Services.AddApplicationRuntimeSettings(runtimeSettingsProvider); -builder.Services.AddPluginPlatform(builder.Configuration); +builder.Services.AddPluginPlatform( + builder.Configuration, + PluginPlatformOptions.GetDefaultRootPath(passwordFile)); builder.Services.Configure( builder.Configuration.GetSection(MediaLibraryOptions.SectionName)); diff --git a/docs/plugin-platform.md b/docs/plugin-platform.md index 474ee29..88b50c8 100644 --- a/docs/plugin-platform.md +++ b/docs/plugin-platform.md @@ -8,6 +8,8 @@ Only authenticated application operators can manage plugins. Packages are upload Preview staging is bounded by both package count and aggregate bytes (`MaximumStagedPackages` and `MaximumStagedPackageBytes`); expired previews are removed before admitting another upload. The installation boundary validates the echoed checksum shape before reading the staged package. +The HTTP upload envelope accepts the platform's full configurable package range (up to 64 MiB, plus multipart framing), while `MaximumPackageBytes` remains the authoritative per-deployment package limit enforced during inspection. If `PluginPlatform:RootPath` is absent during an upgrade, the platform stores plugins in a `plugins` directory beside `PasswordFile`; packaged installations therefore fall back to `/var/lib/sdw-redive/plugins` instead of the read-only application directory. + Plugin and dependency versions use bounded, strict SemVer (`major.minor.patch` with optional legal prerelease/build identifiers); path separators, control/non-ASCII characters, empty identifiers, and ambiguous numeric forms are rejected before extraction. API versions use the same bounded grammar while permitting the API's `major.minor` form. Extraction also canonicalizes the version and temporary destinations and requires both to remain under that plugin's package directory. Publisher, provider, provider-operation, and handler names are bounded ASCII identifiers; display text is length/control-character checked. Unsigned local packages are rejected by default. Configure trusted publisher PEM public keys under `PluginPlatform:TrustedPublisherPublicKeys`. `AllowUnsignedLocalPackages` exists for local development and compatibility tests only; it does not accept invalid or untrusted signatures. The trusted public-key fingerprint is persisted as plugin ownership, so upgrades and reinstalls that would inherit retained configuration/data must use the same key. The remote-install endpoint is intentionally hard-disabled: the service never downloads or evaluates arbitrary JavaScript from a URL.