Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using TruthGate_Web.Services;

namespace TruthGate_Web.Tests;

public sealed class IpnsVersionRetentionTests
{
[Theory]
[InlineData("site", "site-v001", 1, false)]
[InlineData("site", "site-v001-connected", 1, true)]
[InlineData("My-Site", "my-site-v042-CONNECTED", 42, true)]
public void TryParseEntryName_RecognizesPointerAndConnectedNames(
string trackedName,
string entryName,
int expectedVersion,
bool expectedConnected)
{
var parsed = IpnsVersionRetention.TryParseEntryName(
trackedName,
entryName,
out var version,
out var connected);

Assert.True(parsed);
Assert.Equal(expectedVersion, version);
Assert.Equal(expectedConnected, connected);
}

[Theory]
[InlineData("site", "site-v")]
[InlineData("site", "site-v001-extra")]
[InlineData("site", "other-v001")]
[InlineData("site", "site-v001-connected-extra")]
public void TryParseEntryName_RejectsUnmanagedNames(string trackedName, string entryName)
=> Assert.False(IpnsVersionRetention.TryParseEntryName(trackedName, entryName, out _, out _));

[Fact]
public void GroupVersions_PairsPointerAndConnectedFolders()
{
var children = Children(
("site-v001", "pointer-1"),
("site-v001-connected", "target-1"),
("site-v002", "pointer-2"),
("site-v002-connected", "target-2"));

var pairs = IpnsVersionRetention.GroupVersions("site", children);

Assert.Equal(2, pairs.Count);
Assert.Equal("pointer-1", pairs[0].Pointer?.Cid);
Assert.Equal("target-1", pairs[0].Connected?.Cid);
Assert.Equal("pointer-2", pairs[1].Pointer?.Cid);
Assert.Equal("target-2", pairs[1].Connected?.Cid);
Assert.Equal(3, IpnsVersionRetention.ComputeNextVersion("site", children.Keys));
}

[Fact]
public void BuildPrunePlan_RemovesOldPointerAndConnectedPair()
{
var pairs = IpnsVersionRetention.GroupVersions(
"site",
Children(
("site-v001", "pointer-1"),
("site-v001-connected", "target-1"),
("site-v002", "pointer-2"),
("site-v002-connected", "target-2")));

var plan = IpnsVersionRetention.BuildPrunePlan(
pairs,
retainedVersion: 2,
new Dictionary<int, string?> { [1] = "target-1", [2] = "target-2" });

Assert.Contains("/production/pinned/site-v001", plan.PathsToRemove);
Assert.Contains("/production/pinned/site-v001-connected", plan.PathsToRemove);
Assert.DoesNotContain("/production/pinned/site-v002", plan.PathsToRemove);
Assert.Contains("pointer-1", plan.CidsToUnpin);
Assert.Contains("target-1", plan.CidsToUnpin);
Assert.DoesNotContain("pointer-2", plan.CidsToUnpin);
Assert.DoesNotContain("target-2", plan.CidsToUnpin);
}

[Fact]
public void BuildPrunePlan_DoesNotUnpinTargetSharedWithRetainedVersion()
{
var pairs = IpnsVersionRetention.GroupVersions(
"site",
Children(
("site-v001", "pointer-1"),
("site-v001-connected", "shared-target"),
("site-v002", "pointer-2"),
("site-v002-connected", "shared-target")));

var plan = IpnsVersionRetention.BuildPrunePlan(
pairs,
retainedVersion: 2,
new Dictionary<int, string?> { [1] = "shared-target", [2] = "shared-target" });

Assert.Contains("pointer-1", plan.CidsToUnpin);
Assert.DoesNotContain("shared-target", plan.CidsToUnpin);
}

[Fact]
public void BuildPrunePlan_RemovesOrphanConnectedFolderOutsideRetainedVersion()
{
var pairs = IpnsVersionRetention.GroupVersions(
"site",
Children(
("site-v002", "pointer-2"),
("site-v002-connected", "target-2"),
("site-v003-connected", "orphan-target")));

var latest = IpnsVersionRetention.GetLatestPointerPair(pairs);
Assert.NotNull(latest);
Assert.Equal(2, latest.Version);

var plan = IpnsVersionRetention.BuildPrunePlan(
pairs,
latest.Version,
new Dictionary<int, string?> { [2] = "target-2", [3] = "orphan-target" });

Assert.Contains("/production/pinned/site-v003-connected", plan.PathsToRemove);
Assert.Contains("orphan-target", plan.CidsToUnpin);
}

[Theory]
[InlineData("{"tgp":1,"current":"bafy-current"}", "bafy-current")]

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 124 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected
[InlineData("{"tgp":1,"current":"/ipfs/bafy-current"}", "bafy-current")]

Check failure on line 125 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 125 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 125 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected

Check failure on line 125 in TruthGate-Web/TruthGate-Web.Tests/IpnsVersionRetentionTests.cs

View workflow job for this annotation

GitHub Actions / test

Syntax error, ',' expected
[InlineData("{"tgp":2,"current":"bafy-current"}", null)]
[InlineData("not-json", null)]
public void TryReadTgpCurrentCid_ParsesVersionOnePointer(string json, string? expected)
=> Assert.Equal(expected, IpnsVersionRetention.TryReadTgpCurrentCid(json));

[Fact]
public void TryReadLegacyTargetCid_PreservesSidecarCompatibility()
{
const string json = "{"Kind":"tgp-meta","PointerCid":"pointer","TargetCid":"/ipfs/legacy-target"}";
Assert.Equal("legacy-target", IpnsVersionRetention.TryReadLegacyTargetCid(json));
}

private static Dictionary<string, (string Cid, string Path)> Children(
params (string Name, string Cid)[] entries)
=> entries.ToDictionary(
entry => entry.Name,
entry => (entry.Cid, $"/production/pinned/{entry.Name}"),
StringComparer.OrdinalIgnoreCase);
}
3 changes: 3 additions & 0 deletions TruthGate-Web/TruthGate-Web/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("TruthGate-Web.Tests")]
125 changes: 45 additions & 80 deletions TruthGate-Web/TruthGate-Web/Services/IpnsUpdateWorker.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using TruthGate_Web.Endpoints;
using TruthGate_Web.Models;
using TruthGate_Web.Utils;
Expand Down Expand Up @@ -43,11 +42,10 @@ public sealed class IpnsUpdateWorker : BackgroundService, IIpnsUpdateService
private readonly IpnsUpdateOptions _opts;
private readonly IApiKeyProvider _keys;

private static readonly Regex VersionRx = new(@"-v(?<n>\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);

private const string ManagedRoot = "/production/pinned";
private const string StagingRoot = "/production/.staging/ipns";
private const string TgpMetaFile = "tgp.json"; // lives in each version folder
private const string TgpPointerFile = "tgp.json";
private const string LegacyTgpMetaFile = ".tgp-meta.json";

// concurrency
private readonly SemaphoreSlim _globalSlots;
Expand Down Expand Up @@ -79,34 +77,13 @@ public IpnsUpdateWorker(

_globalSlots = new SemaphoreSlim(_opts.MaxConcurrency, _opts.MaxConcurrency);
}
private sealed record VersionEntry(int N, string Name, string Path, string Cid, bool IsConnected);

private async Task<(VersionEntry? Pointer, VersionEntry? Connected)> GetLatestVersionPairAsync(string name, CancellationToken ct)
private async Task<(IpnsVersionRetention.VersionEntry? Pointer, IpnsVersionRetention.VersionEntry? Connected)> GetLatestVersionPairAsync(
string name,
CancellationToken ct)
{
var children = await ListMfsChildrenAsync(ManagedRoot, ct);
var prefix = $"{name}-v";
VersionEntry? latestPointer = null;
VersionEntry? latestConnected = null;

foreach (var kv in children)
{
if (!kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
var m = VersionRx.Match(kv.Key);
if (!m.Success || !int.TryParse(m.Groups["n"].Value, out var n)) continue;

var isConnected = kv.Key.EndsWith("-connected", StringComparison.OrdinalIgnoreCase);
var entry = new VersionEntry(n, kv.Key, kv.Value.Path, kv.Value.Cid, isConnected);

if (isConnected)
{
if (latestConnected is null || n > latestConnected.N) latestConnected = entry;
}
else
{
if (latestPointer is null || n > latestPointer.N) latestPointer = entry;
}
}
return (latestPointer, latestConnected);
var latest = IpnsVersionRetention.GetLatestPointerPair(name, children);
return (latest?.Pointer, latest?.Connected);
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Expand Down Expand Up @@ -338,13 +315,6 @@ private SemaphoreSlim GetKeyLock(string name)
=> _perKeyLocks.GetOrAdd(name ?? string.Empty, _ => new SemaphoreSlim(1, 1));

// ---------- TGP helpers ----------
private sealed class TgpMeta
{
public string Kind { get; set; } = "tgp";
public string? PointerCid { get; set; }
public string? TargetCid { get; set; }
}

private async Task<string?> TryReadTgpTargetCidAsync(string pointerCid, CancellationToken ct)
{
try
Expand Down Expand Up @@ -484,16 +454,7 @@ private async Task PinRmRecursiveAsync(string cid, CancellationToken ct)
private async Task<int> ComputeNextVersionAsync(string name, CancellationToken ct)
{
var children = await ListMfsChildrenAsync(ManagedRoot, ct);
var prefix = $"{name}-v";
var max = 0;
foreach (var k in children.Keys)
{
if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
var m = VersionRx.Match(k);
if (m.Success && int.TryParse(m.Groups["n"].Value, out var n))
max = Math.Max(max, n);
}
return max + 1;
return IpnsVersionRetention.ComputeNextVersion(name, children.Keys);
}
private async Task FilesWriteTextAsync(string mfsPath, string text, CancellationToken ct)
{
Expand All @@ -513,48 +474,52 @@ private async Task FilesWriteTextAsync(string mfsPath, string text, Cancellation
private async Task RemoveAllButLatestAsync(string name, CancellationToken ct)
{
var children = await ListMfsChildrenAsync(ManagedRoot, ct);
var prefix = $"{name}-v";
var versions = new List<(int n, string path, string cid)>();
foreach (var kv in children)
var pairs = IpnsVersionRetention.GroupVersions(name, children);
var latest = IpnsVersionRetention.GetLatestPointerPair(pairs);
if (latest is null) return;

var targetCidsByVersion = new Dictionary<int, string?>();
foreach (var pair in pairs)
{
if (!kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
var m = VersionRx.Match(kv.Key);
if (!m.Success || !int.TryParse(m.Groups["n"].Value, out var n)) continue;
versions.Add((n, kv.Value.Path, kv.Value.Cid));
string? targetCid = pair.Connected?.Cid;
if (string.IsNullOrWhiteSpace(targetCid) && pair.Pointer is not null)
{
var tgpJson = await FilesReadAllTextAsync($"{pair.Pointer.Path}/{TgpPointerFile}", ct);
targetCid = IpnsVersionRetention.TryReadTgpCurrentCid(tgpJson);

if (string.IsNullOrWhiteSpace(targetCid))
{
var legacyJson = await FilesReadAllTextAsync($"{pair.Pointer.Path}/{LegacyTgpMetaFile}", ct);
targetCid = IpnsVersionRetention.TryReadLegacyTargetCid(legacyJson);
}
}

targetCidsByVersion[pair.Version] = targetCid;
}
if (versions.Count <= 1) return;

var latest = versions.OrderByDescending(v => v.n).First();
foreach (var v in versions.Where(v => v.n != latest.n))
var plan = IpnsVersionRetention.BuildPrunePlan(pairs, latest.Version, targetCidsByVersion);

foreach (var path in plan.PathsToRemove)
{
try
{
// Read sidecar to learn any extra CIDs (TGP target) to unpin
string? metaJson = await FilesReadAllTextAsync($"{v.path}/{TgpMetaFile}", ct);
string? tgpTarget = null;
if (!string.IsNullOrWhiteSpace(metaJson))
{
try
{
var m = JsonSerializer.Deserialize<TgpMeta>(metaJson);
if (m?.TargetCid is string s && !string.IsNullOrWhiteSpace(s))
tgpTarget = s.Trim();
}
catch { /* ignore */ }
}
await FilesRmRecursiveAsync(path, ct);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to remove old IPNS version path {Path} for {Name}.", path, name);
}
}

// Remove folder then unpin both pointer and (if present) target
await FilesRmRecursiveAsync(v.path, ct);
await PinRmRecursiveAsync(v.cid, ct);
if (!string.IsNullOrWhiteSpace(tgpTarget))
{
try { await PinRmRecursiveAsync(tgpTarget!, ct); }
catch (Exception ex) { _log.LogWarning(ex, "Failed to unpin TGP target for {Name}-v{Version}", name, v.n); }
}
foreach (var cid in plan.CidsToUnpin)
{
try
{
await PinRmRecursiveAsync(cid, ct);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Failed to remove old version {Name}-v{Version}.", name, v.n);
_log.LogWarning(ex, "Failed to unpin old IPNS version CID {Cid} for {Name}.", cid, name);
}
}
}
Expand Down
Loading
Loading