Skip to content

Commit a608c88

Browse files
dhruuvsharmaclaude
andcommitted
feat(plugins): marketplace catalog backend — projection, download+verify install, revocation sync (#25)
The shared, headless-testable core behind the Plugin Manager's Catalog tab, so the ×3 shell UI stays thin: - PluginCatalog.Build joins the verified feed index to installed manifests into Install / Update / Installed rows, flags feed-revoked builds, and offers search. - PluginCatalogInstaller.InstallAsync downloads the .daxplugin, checks the bytes against the sha256 in the SIGNED index (binding trusted index to served bytes, size-capped), then delegates to PluginInstaller.InstallFromPackage so the whole package-integrity/manifest/SDK/trust/IL-scan gate chain applies unchanged. - PluginRevocationSync.Apply merges the feed's revoked[] into the local revoked.json kill-list the loader already enforces on every start (PluginRevocationList.Merge; existing local entries preserved, deduped). 16 headless tests (real ECDSA-free path: real .daxplugin packages + stub HTTP): install/update/up-to-date/revoked projection, search, checksum-match install, mismatch/bad-url/404/curated-gate refusals, revocation merge + dedup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d1362f6 commit a608c88

6 files changed

Lines changed: 606 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System.IO;
2+
3+
namespace TradingTerminal.Infrastructure.Plugins.Feed;
4+
5+
/// <summary>Where a feed-listed plugin stands relative to what's installed locally.</summary>
6+
public enum PluginInstallState
7+
{
8+
/// <summary>Nothing with this plugin id is installed.</summary>
9+
NotInstalled,
10+
11+
/// <summary>Installed at (or above) the feed's latest version.</summary>
12+
UpToDate,
13+
14+
/// <summary>Installed at an older version than the feed offers.</summary>
15+
UpdateAvailable,
16+
}
17+
18+
/// <summary>
19+
/// One browsable catalog card: a verified feed entry joined to local install state and the feed's own
20+
/// revocation view. It's a pure projection — computed from (verified index + installed manifests), doing
21+
/// no I/O beyond reading the plugin folders. The UI binds directly to this.
22+
/// </summary>
23+
public sealed record PluginCatalogItem(
24+
PluginFeedEntry Entry,
25+
PluginInstallState State,
26+
string? InstalledVersion,
27+
bool Revoked,
28+
string? RevokedReason)
29+
{
30+
public string Id => Entry.Id;
31+
public string Name => Entry.Name;
32+
public string Publisher => Entry.Publisher;
33+
public string Description => Entry.Description;
34+
public string LatestVersion => Entry.Latest.Version;
35+
public PluginFeedVersion Latest => Entry.Latest;
36+
public IReadOnlyList<string> Tags => Entry.Tags ?? [];
37+
public string? PaperUrl => Entry.PaperUrl;
38+
39+
/// <summary>A fresh install is offered only when nothing is installed and the build isn't revoked.</summary>
40+
public bool CanInstall => State == PluginInstallState.NotInstalled && !Revoked;
41+
42+
/// <summary>An update is offered only when an older build is installed and the new one isn't revoked.</summary>
43+
public bool CanUpdate => State == PluginInstallState.UpdateAvailable && !Revoked;
44+
}
45+
46+
/// <summary>
47+
/// Builds the marketplace catalog: joins the verified feed index to what's installed on disk so the UI can
48+
/// show Install / Update / Installed and grey out revoked builds. Reading installed manifests never throws
49+
/// (a broken or absent manifest just means "unknown installed version"), and the whole thing is a pure
50+
/// function of its inputs — trivially testable and safe to call on any thread.
51+
/// </summary>
52+
public static class PluginCatalog
53+
{
54+
/// <summary>Projects every feed entry into a catalog row carrying its local install state and whether
55+
/// the feed has revoked it. A null / empty index yields an empty catalog (feed off / not yet fetched).</summary>
56+
public static IReadOnlyList<PluginCatalogItem> Build(PluginIndex? index, string pluginsRoot)
57+
{
58+
if (index?.Plugins is not { Count: > 0 }) return [];
59+
60+
var installed = ReadInstalledVersions(pluginsRoot);
61+
var revoked = index.Revoked ?? [];
62+
63+
var items = new List<PluginCatalogItem>(index.Plugins.Count);
64+
foreach (var entry in index.Plugins)
65+
{
66+
installed.TryGetValue(entry.Id, out var installedVersion);
67+
var state = installedVersion is null
68+
? PluginInstallState.NotInstalled
69+
: IsOlder(installedVersion, entry.Latest.Version)
70+
? PluginInstallState.UpdateAvailable
71+
: PluginInstallState.UpToDate;
72+
73+
var isRevoked = IsRevoked(revoked, entry, out var reason);
74+
items.Add(new PluginCatalogItem(entry, state, installedVersion, isRevoked, reason));
75+
}
76+
return items;
77+
}
78+
79+
/// <summary>Case-insensitive substring filter over name / id / publisher / description / tags. A blank
80+
/// query returns the list unchanged.</summary>
81+
public static IReadOnlyList<PluginCatalogItem> Search(IReadOnlyList<PluginCatalogItem> items, string? query)
82+
{
83+
if (string.IsNullOrWhiteSpace(query)) return items;
84+
var q = query.Trim();
85+
bool Has(string? s) => s is not null && s.Contains(q, StringComparison.OrdinalIgnoreCase);
86+
return items
87+
.Where(i => Has(i.Name) || Has(i.Id) || Has(i.Publisher) || Has(i.Description) || i.Tags.Any(Has))
88+
.ToList();
89+
}
90+
91+
/// <summary>All rows that currently offer an update — the source for "Update all".</summary>
92+
public static IReadOnlyList<PluginCatalogItem> Updatable(IReadOnlyList<PluginCatalogItem> items) =>
93+
items.Where(i => i.CanUpdate).ToList();
94+
95+
private static Dictionary<string, string> ReadInstalledVersions(string pluginsRoot)
96+
{
97+
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
98+
if (!Directory.Exists(pluginsRoot)) return map;
99+
100+
foreach (var dir in Directory.EnumerateDirectories(pluginsRoot))
101+
{
102+
PluginManifest? manifest;
103+
try { manifest = PluginManifest.TryRead(dir); }
104+
catch { continue; } // a broken manifest isn't the catalog's problem — the loader reports it
105+
if (manifest is not null && !string.IsNullOrWhiteSpace(manifest.Id))
106+
map[manifest.Id] = manifest.Version;
107+
}
108+
return map;
109+
}
110+
111+
private static bool IsRevoked(IReadOnlyList<PluginFeedRevocation> revoked, PluginFeedEntry entry, out string? reason)
112+
{
113+
foreach (var r in revoked)
114+
{
115+
var idMatch = !string.IsNullOrWhiteSpace(r.Id)
116+
&& string.Equals(r.Id, entry.Id, StringComparison.OrdinalIgnoreCase);
117+
var hashMatch = !string.IsNullOrWhiteSpace(r.Sha256)
118+
&& string.Equals(r.Sha256, entry.Latest.Sha256, StringComparison.OrdinalIgnoreCase);
119+
if (idMatch || hashMatch)
120+
{
121+
reason = string.IsNullOrWhiteSpace(r.Reason) ? "This build has been revoked." : r.Reason;
122+
return true;
123+
}
124+
}
125+
reason = null;
126+
return false;
127+
}
128+
129+
/// <summary>True when <paramref name="installed"/> is a lower version than <paramref name="latest"/>,
130+
/// comparing the release core (ignoring any prerelease/build tag) — the same semantics the installer
131+
/// uses to describe an update vs a downgrade.</summary>
132+
private static bool IsOlder(string installed, string latest)
133+
{
134+
static string Core(string v) => v.Split('-', '+')[0];
135+
return Version.TryParse(Core(installed), out var iv)
136+
&& Version.TryParse(Core(latest), out var lv)
137+
&& iv < lv;
138+
}
139+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.IO;
2+
using System.Net.Http;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace TradingTerminal.Infrastructure.Plugins.Feed;
6+
7+
/// <summary>
8+
/// Installs a plugin chosen from the marketplace catalog. It downloads the <c>.daxplugin</c> from the
9+
/// feed's URL, checks the downloaded bytes against the sha256 the SIGNED index declared — binding the
10+
/// trusted index to the bytes actually served, so a swapped or corrupted download is refused — and then
11+
/// hands the verified package to <see cref="PluginInstaller.InstallFromPackage"/>. From there it runs the
12+
/// identical package-integrity / manifest / SDK / trust / IL-scan gate chain as a hand-picked file, and
13+
/// activates on the next restart like any other install. Never throws: every failure comes back as a
14+
/// <see cref="PluginInstallResult"/> with <c>Success = false</c>.
15+
/// </summary>
16+
public static class PluginCatalogInstaller
17+
{
18+
/// <summary>Ceiling on a downloaded package. A strategy plugin is a DLL plus a few private deps, not a
19+
/// bundle — anything past this is refused before it is committed to disk (guards a lying/absent
20+
/// Content-Length too).</summary>
21+
public const long MaxPackageBytes = 64L * 1024 * 1024;
22+
23+
private const int CopyBufferBytes = 81920;
24+
25+
/// <summary>Downloads, checksum-verifies, and installs <paramref name="version"/> into
26+
/// <paramref name="pluginsRoot"/> through the standard install gates.</summary>
27+
public static async Task<PluginInstallResult> InstallAsync(
28+
HttpClient http,
29+
PluginFeedVersion version,
30+
string pluginsRoot,
31+
PluginTrustPolicy policy,
32+
IPluginSignatureInspector inspector,
33+
PluginStateStore? state = null,
34+
ILogger? logger = null,
35+
CancellationToken ct = default)
36+
{
37+
if (string.IsNullOrWhiteSpace(version.Url))
38+
return new PluginInstallResult(false, "This plugin has no download URL in the feed.");
39+
if (string.IsNullOrWhiteSpace(version.Sha256))
40+
return new PluginInstallResult(false,
41+
"This plugin has no checksum in the feed — refusing to install unverified bytes.");
42+
43+
var tempPath = Path.Combine(
44+
Path.GetTempPath(), "daxalgo-dl-" + Guid.NewGuid().ToString("N") + DaxPluginPackage.Extension);
45+
try
46+
{
47+
var downloaded = await DownloadAsync(http, version.Url, tempPath, ct).ConfigureAwait(false);
48+
if (!downloaded.Success) return downloaded;
49+
50+
var actual = PluginIntegrity.Sha256(tempPath);
51+
if (!string.Equals(actual, version.Sha256, StringComparison.OrdinalIgnoreCase))
52+
{
53+
logger?.LogWarning(
54+
"Feed download checksum mismatch for {Url}: index says {Expected}, downloaded {Actual}.",
55+
version.Url, version.Sha256, actual);
56+
return new PluginInstallResult(false,
57+
"The downloaded package does not match the checksum in the signed feed — install refused.");
58+
}
59+
60+
return PluginInstaller.InstallFromPackage(tempPath, pluginsRoot, policy, inspector, state);
61+
}
62+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
63+
{
64+
return new PluginInstallResult(false, $"Download failed: {ex.Message}");
65+
}
66+
finally
67+
{
68+
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best effort */ }
69+
}
70+
}
71+
72+
private static async Task<PluginInstallResult> DownloadAsync(
73+
HttpClient http, string url, string destPath, CancellationToken ct)
74+
{
75+
using var response = await http
76+
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
77+
if (!response.IsSuccessStatusCode)
78+
return new PluginInstallResult(false, $"Download returned HTTP {(int)response.StatusCode} for {url}.");
79+
if (response.Content.Headers.ContentLength is > MaxPackageBytes)
80+
return new PluginInstallResult(false, $"Package is larger than the {MaxPackageBytes / (1024 * 1024)} MB limit.");
81+
82+
await using var source = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
83+
await using var dest = File.Create(destPath);
84+
85+
var buffer = new byte[CopyBufferBytes];
86+
long total = 0;
87+
int read;
88+
while ((read = await source.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
89+
{
90+
total += read;
91+
if (total > MaxPackageBytes)
92+
return new PluginInstallResult(false, $"Package exceeds the {MaxPackageBytes / (1024 * 1024)} MB limit.");
93+
await dest.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
94+
}
95+
return new PluginInstallResult(true, "Downloaded.");
96+
}
97+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace TradingTerminal.Infrastructure.Plugins.Feed;
2+
3+
/// <summary>
4+
/// Syncs the verified marketplace feed's <c>revoked[]</c> into the host's local kill-list
5+
/// (<see cref="PluginRevocationList"/> / <c>revoked.json</c>) so the loader enforces distribution-channel
6+
/// revocations on the next start — before any plugin code runs. This is the one place the "a plugin found
7+
/// bad after the fact" signal crosses from the feed into the load path. Only call it with an index that has
8+
/// already passed signature verification; a null / revocation-free index is a no-op.
9+
/// </summary>
10+
public static class PluginRevocationSync
11+
{
12+
/// <summary>Merges the feed's revocations into <c>revoked.json</c> under <paramref name="pluginsRoot"/>.
13+
/// Returns the number of entries now on the local kill-list (0 when the feed had none). Best-effort —
14+
/// never throws.</summary>
15+
public static int Apply(string pluginsRoot, PluginIndex? index)
16+
{
17+
var feedRevocations = index?.Revoked;
18+
if (feedRevocations is not { Count: > 0 }) return 0;
19+
20+
var mapped = feedRevocations.Select(r => new RevokedPlugin(r.Sha256, r.Id, r.Reason));
21+
return PluginRevocationList.Merge(pluginsRoot, mapped);
22+
}
23+
}

src/windows/Pipeline/TradingTerminal.Infrastructure/Plugins/PluginRevocationList.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public sealed class PluginRevocationList
2424
public const string FileName = "revoked.json";
2525

2626
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
27+
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
2728

2829
private readonly List<RevokedPlugin> _revoked;
2930

@@ -74,6 +75,37 @@ public bool IsRevoked(string sha256, string? pluginId, out string? reason)
7475
return false;
7576
}
7677

78+
/// <summary>Rewrites <see cref="FileName"/> to the union of what's already there and
79+
/// <paramref name="additional"/> (deduped by sha256+id; a later entry's reason text wins). This is how
80+
/// the marketplace feed's <c>revoked[]</c> is synced into the local kill-list — see
81+
/// <c>Feed.PluginRevocationSync</c>. Returns the number of entries now revoked. Best-effort and never
82+
/// throws: on an I/O or serialization failure the file is left untouched and the pre-existing count is
83+
/// returned.</summary>
84+
public static int Merge(string pluginsRoot, IEnumerable<RevokedPlugin> additional)
85+
{
86+
var existing = Load(pluginsRoot)._revoked;
87+
try
88+
{
89+
var byKey = new Dictionary<string, RevokedPlugin>(StringComparer.OrdinalIgnoreCase);
90+
foreach (var entry in existing.Concat(additional))
91+
{
92+
if (string.IsNullOrWhiteSpace(entry.Sha256) && string.IsNullOrWhiteSpace(entry.Id)) continue;
93+
byKey[$"{entry.Sha256}|{entry.Id}"] = entry;
94+
}
95+
96+
var merged = byKey.Values.ToList();
97+
Directory.CreateDirectory(pluginsRoot);
98+
File.WriteAllText(
99+
Path.Combine(pluginsRoot, FileName),
100+
JsonSerializer.Serialize(new RevokedDto { Revoked = merged }, WriteOptions));
101+
return merged.Count;
102+
}
103+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
104+
{
105+
return existing.Count;
106+
}
107+
}
108+
77109
private sealed class RevokedDto
78110
{
79111
[JsonPropertyName("revoked")] public List<RevokedPlugin>? Revoked { get; set; }

0 commit comments

Comments
 (0)