diff --git a/ext-common-sha.txt b/ext-common-sha.txt index fe38af40..0154d725 100644 --- a/ext-common-sha.txt +++ b/ext-common-sha.txt @@ -1 +1 @@ -3f8c4dfdf46ce0c9f623fac82d5e822dea80f8bf +9691bd89cc9df2641055fd7c55688fbd83ebd7f6 diff --git a/ext/Lidarr.Plugin.Common b/ext/Lidarr.Plugin.Common index 3f8c4dfd..9691bd89 160000 --- a/ext/Lidarr.Plugin.Common +++ b/ext/Lidarr.Plugin.Common @@ -1 +1 @@ -Subproject commit 3f8c4dfdf46ce0c9f623fac82d5e822dea80f8bf +Subproject commit 9691bd89cc9df2641055fd7c55688fbd83ebd7f6 diff --git a/tests/Tidalarr.Parity.Tests/CommonPinDriftTests.cs b/tests/Tidalarr.Parity.Tests/CommonPinDriftTests.cs new file mode 100644 index 00000000..c6fd1559 --- /dev/null +++ b/tests/Tidalarr.Parity.Tests/CommonPinDriftTests.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Xunit; + +namespace Tidalarr.Parity.Tests; + +/// +/// LOOP-001 drift gate: the pinned Common version is recorded in ext-common-sha.txt (the greppable, +/// reviewable sentinel), and the version actually compiled into the plugin is whatever the +/// ext/Lidarr.Plugin.Common submodule has checked out. Those MUST agree. When they diverge (the apple +/// failure mode: sentinel says one SHA, the submodule is another), reviews and the build disagree about which +/// Common is in the plugin. +/// +/// We compare the sentinel against the submodule's actual checked-out HEAD (git -C ext/... rev-parse HEAD) +/// rather than the committed gitlink: that holds in a dirty local re-pin (sentinel + submodule updated together, +/// pre-commit) AND in CI — a clean checkout materializes the submodule at the committed gitlink, so a commit +/// that bumped the sentinel but forgot to stage the submodule still surfaces here. +/// +[Trait("Category", "Parity")] +public class CommonPinDriftTests +{ + private static string RepoRoot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + [Fact] + public void ExtCommonShaSentinel_IsAFortyHexSha() + { + var shaFile = Path.Combine(RepoRoot, "ext-common-sha.txt"); + Assert.True(File.Exists(shaFile), $"ext-common-sha.txt sentinel missing at {shaFile}"); + + var declared = File.ReadAllText(shaFile).Trim(); + Assert.Matches("^[0-9a-f]{40}$", declared); + } + + [Fact] + public void ExtCommonSha_MatchesCheckedOutSubmodule() + { + var declared = File.ReadAllText(Path.Combine(RepoRoot, "ext-common-sha.txt")).Trim(); + + var submoduleHead = TryRevParseHead(Path.Combine(RepoRoot, "ext", "Lidarr.Plugin.Common")); + if (submoduleHead is null) + { + // git unavailable or submodule not initialized (e.g. an exported source snapshot) — inconclusive. + return; + } + + Assert.True( + string.Equals(declared, submoduleHead, StringComparison.OrdinalIgnoreCase), + $"Common pin drift: ext-common-sha.txt = {declared} but the checked-out submodule = {submoduleHead}. " + + "Re-pin both together (update the submodule AND the sentinel) so reviews and the build agree."); + } + + /// Returns the checked-out HEAD SHA of the git repo at , or null when git + /// is unavailable / the directory is not a git checkout. + private static string? TryRevParseHead(string repoDir) + { + try + { + var psi = new ProcessStartInfo("git", $"-C \"{repoDir}\" rev-parse HEAD") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + if (p is null) + { + return null; + } + + var output = p.StandardOutput.ReadToEnd(); + if (!p.WaitForExit(5000) || p.ExitCode != 0) + { + return null; + } + + var m = Regex.Match(output, "([0-9a-f]{40})"); + return m.Success ? m.Groups[1].Value : null; + } + catch + { + return null; + } + } +} diff --git a/tests/Tidalarr.Tests/TidalModuleEndToEndDiFlowsTests.cs b/tests/Tidalarr.Tests/TidalModuleEndToEndDiFlowsTests.cs index 235e267d..377faceb 100644 --- a/tests/Tidalarr.Tests/TidalModuleEndToEndDiFlowsTests.cs +++ b/tests/Tidalarr.Tests/TidalModuleEndToEndDiFlowsTests.cs @@ -107,7 +107,9 @@ public async Task DI_Flow_IndexerSearch_And_DownloadValidation_Work() // Override seams for deterministic behavior _ = services.AddScoped(); _ = services.AddScoped(); - _ = services.AddScoped(_ => new TidalChunkDownloader(new HttpClient(new OkHandler()))); + // Inject the resolving SSRF policy so the stub chunk host (https://chunk) classifies as public under the + // Strict guard (Common 9691bd8 validates the URL before the first send). Matches the R2-02 test sweep. + _ = services.AddScoped(_ => new TidalChunkDownloader(new HttpClient(new OkHandler()), segmentPolicy: TidalTestPolicies.Resolving)); _ = services.AddScoped(sp => new TidalSearchService(sp.GetRequiredService(), new Domain.Quality.TidalQualityDetector())); ServiceProvider provider = services.BuildServiceProvider();