Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -201,6 +201,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plug
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "..\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{98908D4F-1A48-4CED-B2CF-92C3179B44FD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Epss", "__Libraries\StellaOps.Concelier.Connector.Epss\StellaOps.Concelier.Connector.Epss.csproj", "{E67A2843-584D-4DCD-914F-576A4EE58E5E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Storage", "..\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj", "{DF5F5B95-6B58-4B18-A6B9-58C23762369A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.EntryTrace", "..\Scanner\__Libraries\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj", "{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.FS", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.FS\StellaOps.Scanner.Surface.FS.csproj", "{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Validation", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj", "{320EF565-9618-488A-90E9-87237D2290C2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Env", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj", "{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.CallGraph", "..\Scanner\__Libraries\StellaOps.Scanner.CallGraph\StellaOps.Scanner.CallGraph.csproj", "{760F5F3F-D495-4A3A-B891-EE388938CE5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Reachability", "..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj", "{82AF1F82-52F6-4212-A2C7-13797B41FD6D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Cache", "..\Scanner\__Libraries\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj", "{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.ProofSpine", "..\Scanner\__Libraries\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj", "{E44A5997-5704-4E7B-A080-07D3D1F20A23}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Replay.Core", "..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj", "{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.SmartDiff", "..\Scanner\__Libraries\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj", "{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.Native", "..\Scanner\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj", "{66A67555-0AFB-456C-8C42-83B6624AD3EE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{869659FB-23E7-44AF-BA5A-6027915F05E0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Core", "..\Scanner\__Libraries\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj", "{60D11E11-13EF-4703-8802-86E42B58FED3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{C983496E-8141-4B5E-AAF3-60D8B59204AC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{824DBC37-9114-4761-98DE-40A4122EA0C0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{BEA842DB-D694-4BD5-9B80-66BE300A56AE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "..\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CA269E67-CA77-46EF-8239-84735246B403}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{40C584B3-E475-4945-9183-DCA9809B1731}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.ReachabilityDrift", "..\Scanner\__Libraries\StellaOps.Scanner.ReachabilityDrift\StellaOps.Scanner.ReachabilityDrift.csproj", "{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{64944BC8-47E8-467E-AAA8-3284FB674824}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "..\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{3A461958-04EB-4144-8109-BA83520D40CA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Epss.Tests", "__Tests\StellaOps.Concelier.Connector.Epss.Tests\StellaOps.Concelier.Connector.Epss.Tests.csproj", "{1B9790AC-7F93-409D-B81D-E6261DD97635}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Distro.Alpine", "__Libraries\StellaOps.Concelier.Connector.Distro.Alpine\StellaOps.Concelier.Connector.Distro.Alpine.csproj", "{3A95301F-0813-449A-B9EF-AB54272EC478}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Distro.Alpine.Tests", "__Tests\StellaOps.Concelier.Connector.Distro.Alpine.Tests\StellaOps.Concelier.Connector.Distro.Alpine.Tests.csproj", "{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Integration.Tests", "__Tests\StellaOps.Concelier.Integration.Tests\StellaOps.Concelier.Integration.Tests.csproj", "{C1F76AFB-8FBE-4652-A398-DF289FA594E5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -1363,6 +1425,378 @@ Global
|
||||
{98908D4F-1A48-4CED-B2CF-92C3179B44FD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{98908D4F-1A48-4CED-B2CF-92C3179B44FD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{98908D4F-1A48-4CED-B2CF-92C3179B44FD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DF5F5B95-6B58-4B18-A6B9-58C23762369A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4562CEB2-A8B1-4995-A316-2C01D5A4BD15}.Release|x86.Build.0 = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{697D8C78-1D3F-4996-82E7-3C0C5FBDD8DE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{320EF565-9618-488A-90E9-87237D2290C2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5967BE5C-24F2-4B82-B53E-721E4CC00C2A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{760F5F3F-D495-4A3A-B891-EE388938CE5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{82AF1F82-52F6-4212-A2C7-13797B41FD6D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4942609B-D1FF-4F2B-A094-2FEE8C9F9EA6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E44A5997-5704-4E7B-A080-07D3D1F20A23}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EA0A4C78-FB63-4AC2-90CD-BD439CD29526}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2A5195E6-E96F-4F1C-889B-9B120AF45D2D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{66A67555-0AFB-456C-8C42-83B6624AD3EE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{869659FB-23E7-44AF-BA5A-6027915F05E0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C0C97B48-A8CD-42A7-AE6B-2E9C7B2795B1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1E8BBFDB-DA14-43C8-ABCE-978E6399FC08}.Release|x86.Build.0 = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{60D11E11-13EF-4703-8802-86E42B58FED3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C983496E-8141-4B5E-AAF3-60D8B59204AC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{82A144D4-59E7-4CCF-A6D9-A71EFB0334B3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{824DBC37-9114-4761-98DE-40A4122EA0C0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BEA842DB-D694-4BD5-9B80-66BE300A56AE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CA269E67-CA77-46EF-8239-84735246B403}.Release|x86.Build.0 = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|x64.Build.0 = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{40C584B3-E475-4945-9183-DCA9809B1731}.Release|x86.Build.0 = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{86CB3500-3C2F-45ED-B4B1-40FCB2CBCAB6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|x64.Build.0 = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{64944BC8-47E8-467E-AAA8-3284FB674824}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ADC5972E-21C9-4C6F-8262-8FE8673C5B87}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3A461958-04EB-4144-8109-BA83520D40CA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -1446,5 +1880,10 @@ Global
|
||||
{664A2577-6DA1-42DA-A213-3253017FA4BF} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{39C1D44C-389F-4502-ADCF-E4AC359E8F8F} = {176B5A8A-7857-3ECD-1128-3C721BC7F5C6}
|
||||
{85D215EC-DCFE-4F7F-BB07-540DCF66BE8C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E67A2843-584D-4DCD-914F-576A4EE58E5E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{1B9790AC-7F93-409D-B81D-E6261DD97635} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{3A95301F-0813-449A-B9EF-AB54272EC478} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{F6E3EE95-7382-4CC4-8DAF-448E8B49E890} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C1F76AFB-8FBE-4652-A398-DF289FA594E5} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Concelier Alpine Connector Charter
|
||||
|
||||
## Mission
|
||||
Implement and maintain the Alpine secdb connector that ingests Alpine Linux package fix data into Concelier under the Aggregation-Only Contract (AOC). Preserve APK version semantics and provenance while keeping ingestion deterministic and offline-ready.
|
||||
|
||||
## Scope
|
||||
- Connector fetch/parse/map logic in `StellaOps.Concelier.Connector.Distro.Alpine`.
|
||||
- Alpine secdb JSON parsing and normalization of package fix entries.
|
||||
- Source cursor/fetch caching and deterministic mapping.
|
||||
- Unit/integration tests and fixtures for secdb parsing and mapping.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/ingestion/aggregation-only-contract.md`
|
||||
- `docs/modules/concelier/operations/connectors/alpine.md`
|
||||
- `docs/modules/concelier/operations/mirror.md`
|
||||
- `docs/product-advisories/archived/22-Dec-2025 - Getting Distro Backport Logic Right.md`
|
||||
|
||||
## Working Agreement
|
||||
1. **Status sync**: update task state to `DOING`/`DONE` in the sprint file and local `TASKS.md` before/after work.
|
||||
2. **AOC adherence**: do not derive severity or merge fields; persist upstream data with provenance.
|
||||
3. **Determinism**: sort packages, version keys, and CVE lists; normalize timestamps to UTC ISO-8601.
|
||||
4. **Offline readiness**: only fetch from allowlisted secdb hosts; document bundle usage for air-gapped runs.
|
||||
5. **Testing**: add fixtures for parsing and mapping; keep integration tests deterministic and opt-in.
|
||||
6. **Documentation**: update connector ops docs when configuration or mapping changes.
|
||||
@@ -0,0 +1,538 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
|
||||
public sealed class AlpineConnector : IFeedConnector
|
||||
{
|
||||
private const string SchemaVersion = "alpine.secdb.v1";
|
||||
|
||||
private readonly SourceFetchService _fetchService;
|
||||
private readonly RawDocumentStorage _rawDocumentStorage;
|
||||
private readonly IDocumentStore _documentStore;
|
||||
private readonly IDtoStore _dtoStore;
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly ISourceStateRepository _stateRepository;
|
||||
private readonly AlpineOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AlpineConnector> _logger;
|
||||
|
||||
private static readonly Action<ILogger, string, int, Exception?> LogMapped =
|
||||
LoggerMessage.Define<string, int>(
|
||||
LogLevel.Information,
|
||||
new EventId(1, "AlpineMapped"),
|
||||
"Alpine secdb {Stream} mapped {AdvisoryCount} advisories");
|
||||
|
||||
public AlpineConnector(
|
||||
SourceFetchService fetchService,
|
||||
RawDocumentStorage rawDocumentStorage,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
IAdvisoryStore advisoryStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
IOptions<AlpineOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<AlpineConnector> logger)
|
||||
{
|
||||
_fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
|
||||
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string SourceName => AlpineConnectorPlugin.SourceName;
|
||||
|
||||
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var pendingDocuments = new HashSet<Guid>(cursor.PendingDocuments);
|
||||
var pendingMappings = new HashSet<Guid>(cursor.PendingMappings);
|
||||
var fetchCache = new Dictionary<string, AlpineFetchCacheEntry>(cursor.FetchCache, StringComparer.OrdinalIgnoreCase);
|
||||
var touchedResources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var targets = BuildTargets().ToList();
|
||||
var maxDocuments = Math.Clamp(_options.MaxDocumentsPerFetch, 1, 200);
|
||||
var pruneCache = targets.Count <= maxDocuments;
|
||||
foreach (var target in targets.Take(maxDocuments))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var cacheKey = target.Uri.ToString();
|
||||
touchedResources.Add(cacheKey);
|
||||
|
||||
cursor.TryGetCache(cacheKey, out var cachedEntry);
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = BuildMetadata(target.Release, target.Repository, target.Stream, target.Uri);
|
||||
|
||||
var request = new SourceFetchRequest(AlpineOptions.HttpClientName, SourceName, target.Uri)
|
||||
{
|
||||
Metadata = metadata,
|
||||
AcceptHeaders = new[] { "application/json" },
|
||||
TimeoutOverride = _options.FetchTimeout,
|
||||
ETag = existing?.Etag ?? cachedEntry?.ETag,
|
||||
LastModified = existing?.LastModified ?? cachedEntry?.LastModified,
|
||||
};
|
||||
|
||||
SourceFetchResult result;
|
||||
try
|
||||
{
|
||||
result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Alpine secdb fetch failed for {Uri}", target.Uri);
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (result.IsNotModified)
|
||||
{
|
||||
if (existing is not null)
|
||||
{
|
||||
fetchCache[cacheKey] = new AlpineFetchCacheEntry(existing.Etag, existing.LastModified);
|
||||
if (string.Equals(existing.Status, DocumentStatuses.Mapped, StringComparison.Ordinal))
|
||||
{
|
||||
pendingDocuments.Remove(existing.Id);
|
||||
pendingMappings.Remove(existing.Id);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess || result.Document is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchCache[cacheKey] = AlpineFetchCacheEntry.FromDocument(result.Document);
|
||||
pendingDocuments.Add(result.Document.Id);
|
||||
pendingMappings.Remove(result.Document.Id);
|
||||
|
||||
if (_options.RequestDelay > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pruneCache && fetchCache.Count > 0 && touchedResources.Count > 0)
|
||||
{
|
||||
var staleKeys = fetchCache.Keys.Where(key => !touchedResources.Contains(key)).ToArray();
|
||||
foreach (var key in staleKeys)
|
||||
{
|
||||
fetchCache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithFetchCache(fetchCache);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingDocuments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = cursor.PendingDocuments.ToList();
|
||||
var pendingMappings = cursor.PendingMappings.ToList();
|
||||
|
||||
foreach (var documentId in cursor.PendingDocuments)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.PayloadId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Alpine secdb document {DocumentId} missing raw payload", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await _rawDocumentStorage.DownloadAsync(document.PayloadId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download Alpine secdb document {DocumentId}", document.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
AlpineSecDbDto dto;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
dto = AlpineSecDbParser.Parse(json);
|
||||
dto = ApplyMetadataFallbacks(dto, document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Alpine secdb payload for document {DocumentId}", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var payload = ToDocument(dto);
|
||||
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, SourceName, SchemaVersion, payload, _timeProvider.GetUtcNow());
|
||||
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
remaining.Remove(document.Id);
|
||||
if (!pendingMappings.Contains(document.Id))
|
||||
{
|
||||
pendingMappings.Add(document.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(remaining)
|
||||
.WithPendingMappings(pendingMappings);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingMappings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingMappings = cursor.PendingMappings.ToList();
|
||||
|
||||
foreach (var documentId in cursor.PendingMappings)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (dtoRecord is null || document is null)
|
||||
{
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
AlpineSecDbDto dto;
|
||||
try
|
||||
{
|
||||
dto = FromDocument(dtoRecord.Payload);
|
||||
dto = ApplyMetadataFallbacks(dto, document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize Alpine secdb DTO for document {DocumentId}", documentId);
|
||||
await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var advisories = AlpineMapper.Map(dto, document, _timeProvider.GetUtcNow());
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
|
||||
if (advisories.Count > 0)
|
||||
{
|
||||
var stream = BuildStream(dto);
|
||||
LogMapped(_logger, stream, advisories.Count, null);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedCursor = cursor.WithPendingMappings(pendingMappings);
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AlpineCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
|
||||
return state is null ? AlpineCursor.Empty : AlpineCursor.FromDocument(state.Cursor);
|
||||
}
|
||||
|
||||
private async Task UpdateCursorAsync(AlpineCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = cursor.ToDocumentObject();
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, document, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IEnumerable<AlpineTarget> BuildTargets()
|
||||
{
|
||||
var releases = NormalizeList(_options.Releases);
|
||||
var repositories = NormalizeList(_options.Repositories);
|
||||
|
||||
foreach (var release in releases)
|
||||
{
|
||||
foreach (var repository in repositories)
|
||||
{
|
||||
var stream = $"{release}/{repository}";
|
||||
var relative = $"{release}/{repository}.json";
|
||||
var uri = new Uri(_options.BaseUri, relative);
|
||||
yield return new AlpineTarget(release, repository, stream, uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] NormalizeList(string[] values)
|
||||
{
|
||||
if (values is null || values.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildMetadata(string release, string repository, string stream, Uri uri)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["alpine.release"] = release,
|
||||
["alpine.repo"] = repository,
|
||||
["source.stream"] = stream,
|
||||
["document.id"] = $"alpine:{stream}",
|
||||
["alpine.uri"] = uri.ToString(),
|
||||
};
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static AlpineSecDbDto ApplyMetadataFallbacks(AlpineSecDbDto dto, DocumentRecord document)
|
||||
{
|
||||
if (document.Metadata is null || document.Metadata.Count == 0)
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
|
||||
var distro = dto.DistroVersion;
|
||||
var repo = dto.RepoName;
|
||||
var prefix = dto.UrlPrefix;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(distro) && document.Metadata.TryGetValue("alpine.release", out var release))
|
||||
{
|
||||
distro = release;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repo) && document.Metadata.TryGetValue("alpine.repo", out var repoValue))
|
||||
{
|
||||
repo = repoValue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(prefix) && document.Metadata.TryGetValue("alpine.uri", out var uriValue))
|
||||
{
|
||||
if (Uri.TryCreate(uriValue, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
prefix = parsed.GetLeftPart(UriPartial.Authority) + "/";
|
||||
}
|
||||
}
|
||||
|
||||
return dto with
|
||||
{
|
||||
DistroVersion = distro ?? string.Empty,
|
||||
RepoName = repo ?? string.Empty,
|
||||
UrlPrefix = prefix ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildStream(AlpineSecDbDto dto)
|
||||
{
|
||||
var release = dto.DistroVersion?.Trim();
|
||||
var repo = dto.RepoName?.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(release) && !string.IsNullOrWhiteSpace(repo))
|
||||
{
|
||||
return $"{release}/{repo}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(release))
|
||||
{
|
||||
return release;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repo))
|
||||
{
|
||||
return repo;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static DocumentObject ToDocument(AlpineSecDbDto dto)
|
||||
{
|
||||
var packages = new DocumentArray();
|
||||
foreach (var package in dto.Packages)
|
||||
{
|
||||
var secfixes = new DocumentObject();
|
||||
foreach (var pair in package.Secfixes.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var cves = pair.Value ?? Array.Empty<string>();
|
||||
var ordered = cves
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
secfixes[pair.Key] = new DocumentArray(ordered);
|
||||
}
|
||||
|
||||
packages.Add(new DocumentObject
|
||||
{
|
||||
["name"] = package.Name,
|
||||
["secfixes"] = secfixes
|
||||
});
|
||||
}
|
||||
|
||||
var doc = new DocumentObject
|
||||
{
|
||||
["distroVersion"] = dto.DistroVersion,
|
||||
["repoName"] = dto.RepoName,
|
||||
["urlPrefix"] = dto.UrlPrefix,
|
||||
["packages"] = packages
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static AlpineSecDbDto FromDocument(DocumentObject document)
|
||||
{
|
||||
var distroVersion = document.GetValue("distroVersion", string.Empty).AsString;
|
||||
var repoName = document.GetValue("repoName", string.Empty).AsString;
|
||||
var urlPrefix = document.GetValue("urlPrefix", string.Empty).AsString;
|
||||
|
||||
var packages = new List<AlpinePackageDto>();
|
||||
if (document.TryGetValue("packages", out var packageValue) && packageValue is DocumentArray packageArray)
|
||||
{
|
||||
foreach (var element in packageArray.OfType<DocumentObject>())
|
||||
{
|
||||
var name = element.GetValue("name", string.Empty).AsString;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var secfixes = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
if (element.TryGetValue("secfixes", out var secfixesValue) && secfixesValue is DocumentObject secfixesDoc)
|
||||
{
|
||||
foreach (var entry in secfixesDoc.Elements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Value is not DocumentArray cveArray)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cves = cveArray
|
||||
.OfType<DocumentValue>()
|
||||
.Select(static value => value.ToString())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (cves.Length > 0)
|
||||
{
|
||||
secfixes[entry.Name] = cves;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packages.Add(new AlpinePackageDto(name.Trim(), secfixes));
|
||||
}
|
||||
}
|
||||
|
||||
var orderedPackages = packages
|
||||
.OrderBy(pkg => pkg.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static pkg => pkg with { Secfixes = OrderSecfixes(pkg.Secfixes) })
|
||||
.ToList();
|
||||
|
||||
return new AlpineSecDbDto(distroVersion, repoName, urlPrefix, orderedPackages);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string[]> OrderSecfixes(IReadOnlyDictionary<string, string[]> secfixes)
|
||||
{
|
||||
if (secfixes is null || secfixes.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var ordered = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in secfixes.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var values = pair.Value ?? Array.Empty<string>();
|
||||
ordered[pair.Key] = values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private sealed record AlpineTarget(string Release, string Repository, string Stream, Uri Uri);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
|
||||
public sealed class AlpineConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "distro-alpine";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return ActivatorUtilities.CreateInstance<AlpineConnector>(services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
|
||||
public sealed class AlpineDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:alpine";
|
||||
private const string FetchSchedule = "*/30 * * * *";
|
||||
private const string ParseSchedule = "7,37 * * * *";
|
||||
private const string MapSchedule = "12,42 * * * *";
|
||||
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan ParseTimeout = TimeSpan.FromMinutes(6);
|
||||
private static readonly TimeSpan MapTimeout = TimeSpan.FromMinutes(8);
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(4);
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddAlpineConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var scheduler = new JobSchedulerBuilder(services);
|
||||
scheduler
|
||||
.AddJob<AlpineFetchJob>(
|
||||
AlpineJobKinds.Fetch,
|
||||
cronExpression: FetchSchedule,
|
||||
timeout: FetchTimeout,
|
||||
leaseDuration: LeaseDuration)
|
||||
.AddJob<AlpineParseJob>(
|
||||
AlpineJobKinds.Parse,
|
||||
cronExpression: ParseSchedule,
|
||||
timeout: ParseTimeout,
|
||||
leaseDuration: LeaseDuration)
|
||||
.AddJob<AlpineMapJob>(
|
||||
AlpineJobKinds.Map,
|
||||
cronExpression: MapSchedule,
|
||||
timeout: MapTimeout,
|
||||
leaseDuration: LeaseDuration);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
|
||||
public static class AlpineServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAlpineConnector(this IServiceCollection services, Action<AlpineOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<AlpineOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
services.AddSourceHttpClient(AlpineOptions.HttpClientName, (sp, httpOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AlpineOptions>>().Value;
|
||||
var authority = options.BaseUri.GetLeftPart(UriPartial.Authority);
|
||||
httpOptions.BaseAddress = string.IsNullOrWhiteSpace(authority) ? options.BaseUri : new Uri(authority);
|
||||
httpOptions.Timeout = options.FetchTimeout;
|
||||
httpOptions.UserAgent = options.UserAgent;
|
||||
httpOptions.AllowedHosts.Clear();
|
||||
httpOptions.AllowedHosts.Add(options.BaseUri.Host);
|
||||
httpOptions.DefaultRequestHeaders["Accept"] = "application/json";
|
||||
});
|
||||
|
||||
services.AddTransient<AlpineConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Distro.Alpine.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
|
||||
public sealed class AlpineOptions
|
||||
{
|
||||
public const string HttpClientName = "concelier.alpine";
|
||||
|
||||
/// <summary>
|
||||
/// Base URI for Alpine secdb JSON content.
|
||||
/// </summary>
|
||||
public Uri BaseUri { get; set; } = new("https://secdb.alpinelinux.org/");
|
||||
|
||||
/// <summary>
|
||||
/// Releases to fetch (for example: v3.18, v3.19, v3.20, edge).
|
||||
/// </summary>
|
||||
public string[] Releases { get; set; } = new[] { "v3.18", "v3.19", "v3.20", "edge" };
|
||||
|
||||
/// <summary>
|
||||
/// Repository names to fetch (for example: main, community).
|
||||
/// </summary>
|
||||
public string[] Repositories { get; set; } = new[] { "main", "community" };
|
||||
|
||||
/// <summary>
|
||||
/// Cap on release+repo documents fetched in a single run.
|
||||
/// </summary>
|
||||
public int MaxDocumentsPerFetch { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Fetch timeout for each secdb request.
|
||||
/// </summary>
|
||||
public TimeSpan FetchTimeout { get; set; } = TimeSpan.FromSeconds(45);
|
||||
|
||||
/// <summary>
|
||||
/// Optional pacing delay between secdb requests.
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Custom user-agent for secdb requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps.Concelier.Alpine/0.1 (+https://stella-ops.org)";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (BaseUri is null || !BaseUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Alpine BaseUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (MaxDocumentsPerFetch <= 0 || MaxDocumentsPerFetch > 200)
|
||||
{
|
||||
throw new InvalidOperationException("MaxDocumentsPerFetch must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (FetchTimeout <= TimeSpan.Zero || FetchTimeout > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("FetchTimeout must be positive and less than five minutes.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero || RequestDelay > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
throw new InvalidOperationException("RequestDelay must be between 0 and 10 seconds.");
|
||||
}
|
||||
|
||||
if (Releases is null || Releases.Length == 0 || Releases.All(static value => string.IsNullOrWhiteSpace(value)))
|
||||
{
|
||||
throw new InvalidOperationException("At least one Alpine release must be configured.");
|
||||
}
|
||||
|
||||
if (Repositories is null || Repositories.Length == 0 || Repositories.All(static value => string.IsNullOrWhiteSpace(value)))
|
||||
{
|
||||
throw new InvalidOperationException("At least one Alpine repository must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
|
||||
internal sealed record AlpineSecDbDto(
|
||||
string DistroVersion,
|
||||
string RepoName,
|
||||
string UrlPrefix,
|
||||
IReadOnlyList<AlpinePackageDto> Packages);
|
||||
|
||||
internal sealed record AlpinePackageDto(
|
||||
string Name,
|
||||
IReadOnlyDictionary<string, string[]> Secfixes);
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
|
||||
internal sealed record AlpineCursor(
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, AlpineFetchCacheEntry> FetchCache)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuidList = Array.Empty<Guid>();
|
||||
private static readonly IReadOnlyDictionary<string, AlpineFetchCacheEntry> EmptyCache =
|
||||
new Dictionary<string, AlpineFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static AlpineCursor Empty { get; } = new(EmptyGuidList, EmptyGuidList, EmptyCache);
|
||||
|
||||
public static AlpineCursor FromDocument(DocumentObject? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var pendingDocuments = ReadGuidSet(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidSet(document, "pendingMappings");
|
||||
var cache = ReadCache(document);
|
||||
|
||||
return new AlpineCursor(pendingDocuments, pendingMappings, cache);
|
||||
}
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var doc = new DocumentObject
|
||||
{
|
||||
["pendingDocuments"] = new DocumentArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new DocumentArray(PendingMappings.Select(id => id.ToString()))
|
||||
};
|
||||
|
||||
if (FetchCache.Count > 0)
|
||||
{
|
||||
var cacheDoc = new DocumentObject();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDoc[key] = entry.ToDocumentObject();
|
||||
}
|
||||
|
||||
doc["fetchCache"] = cacheDoc;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
public AlpineCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidList };
|
||||
|
||||
public AlpineCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidList };
|
||||
|
||||
public AlpineCursor WithFetchCache(IDictionary<string, AlpineFetchCacheEntry>? cache)
|
||||
{
|
||||
if (cache is null || cache.Count == 0)
|
||||
{
|
||||
return this with { FetchCache = EmptyCache };
|
||||
}
|
||||
|
||||
return this with { FetchCache = new Dictionary<string, AlpineFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
|
||||
}
|
||||
|
||||
public bool TryGetCache(string key, out AlpineFetchCacheEntry entry)
|
||||
{
|
||||
if (FetchCache.Count == 0)
|
||||
{
|
||||
entry = AlpineFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return FetchCache.TryGetValue(key, out entry!);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidSet(DocumentObject document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not DocumentArray array)
|
||||
{
|
||||
return EmptyGuidList;
|
||||
}
|
||||
|
||||
var list = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
list.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, AlpineFetchCacheEntry> ReadCache(DocumentObject document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var value) || value is not DocumentObject cacheDoc || cacheDoc.ElementCount == 0)
|
||||
{
|
||||
return EmptyCache;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<string, AlpineFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var element in cacheDoc.Elements)
|
||||
{
|
||||
if (element.Value is DocumentObject entryDoc)
|
||||
{
|
||||
cache[element.Name] = AlpineFetchCacheEntry.FromDocument(entryDoc);
|
||||
}
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StorageContracts = StellaOps.Concelier.Storage.Contracts;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
|
||||
internal sealed record AlpineFetchCacheEntry(string? ETag, DateTimeOffset? LastModified)
|
||||
{
|
||||
public static AlpineFetchCacheEntry Empty { get; } = new(null, null);
|
||||
|
||||
public static AlpineFetchCacheEntry FromDocument(StorageContracts.StorageDocument document)
|
||||
=> new(document.Etag, document.LastModified);
|
||||
|
||||
public static AlpineFetchCacheEntry FromDocument(DocumentObject document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
string? etag = null;
|
||||
DateTimeOffset? lastModified = null;
|
||||
|
||||
if (document.TryGetValue("etag", out var etagValue) && etagValue.DocumentType == DocumentType.String)
|
||||
{
|
||||
etag = etagValue.AsString;
|
||||
}
|
||||
|
||||
if (document.TryGetValue("lastModified", out var modifiedValue))
|
||||
{
|
||||
lastModified = modifiedValue.DocumentType switch
|
||||
{
|
||||
DocumentType.DateTime => DateTime.SpecifyKind(modifiedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
DocumentType.String when DateTimeOffset.TryParse(modifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
return new AlpineFetchCacheEntry(etag, lastModified);
|
||||
}
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var doc = new DocumentObject();
|
||||
if (!string.IsNullOrWhiteSpace(ETag))
|
||||
{
|
||||
doc["etag"] = ETag;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue)
|
||||
{
|
||||
doc["lastModified"] = LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
public bool Matches(StorageContracts.StorageDocument document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(ETag, document.Etag, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue && document.LastModified.HasValue)
|
||||
{
|
||||
return LastModified.Value.UtcDateTime == document.LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return !LastModified.HasValue && !document.LastModified.HasValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
|
||||
internal static class AlpineMapper
|
||||
{
|
||||
public static IReadOnlyList<Advisory> Map(AlpineSecDbDto dto, DocumentRecord document, DateTimeOffset recordedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (dto.Packages is null || dto.Packages.Count == 0)
|
||||
{
|
||||
return Array.Empty<Advisory>();
|
||||
}
|
||||
|
||||
var platform = BuildPlatform(dto);
|
||||
var advisoryBuckets = new Dictionary<string, AdvisoryAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var package in dto.Packages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(package.Name) || package.Secfixes is null || package.Secfixes.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = package.Name.Trim();
|
||||
|
||||
foreach (var (fixedVersion, ids) in package.Secfixes.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fixedVersion) || ids is null || ids.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versionValue = fixedVersion.Trim();
|
||||
foreach (var rawId in ids)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedId = NormalizeAlias(rawId);
|
||||
var advisoryKey = BuildAdvisoryKey(normalizedId);
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!advisoryBuckets.TryGetValue(advisoryKey, out var bucket))
|
||||
{
|
||||
bucket = new AdvisoryAccumulator(advisoryKey, BuildAliases(advisoryKey, normalizedId));
|
||||
advisoryBuckets[advisoryKey] = bucket;
|
||||
}
|
||||
else
|
||||
{
|
||||
bucket.Aliases.Add(normalizedId);
|
||||
bucket.Aliases.Add(advisoryKey);
|
||||
}
|
||||
|
||||
var packageKey = BuildPackageKey(platform, packageName);
|
||||
if (!bucket.Packages.TryGetValue(packageKey, out var pkgAccumulator))
|
||||
{
|
||||
pkgAccumulator = new PackageAccumulator(packageName, platform);
|
||||
bucket.Packages[packageKey] = pkgAccumulator;
|
||||
}
|
||||
|
||||
var rangeProvenance = new AdvisoryProvenance(
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
"range",
|
||||
BuildRangeProvenanceKey(normalizedId, platform, packageName, versionValue),
|
||||
recordedAt);
|
||||
|
||||
var packageProvenance = new AdvisoryProvenance(
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
"affected",
|
||||
BuildPackageProvenanceKey(normalizedId, platform, packageName),
|
||||
recordedAt);
|
||||
|
||||
var vendorExtensions = BuildVendorExtensions(dto, versionValue);
|
||||
var primitives = vendorExtensions.Count == 0
|
||||
? null
|
||||
: new RangePrimitives(
|
||||
SemVer: null,
|
||||
Nevra: null,
|
||||
Evr: null,
|
||||
VendorExtensions: vendorExtensions);
|
||||
|
||||
var rangeExpression = $"fixed:{versionValue}";
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "apk",
|
||||
introducedVersion: null,
|
||||
fixedVersion: versionValue,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: rangeExpression,
|
||||
provenance: rangeProvenance,
|
||||
primitives: primitives);
|
||||
|
||||
pkgAccumulator.Ranges.Add(range);
|
||||
pkgAccumulator.Provenance.Add(packageProvenance);
|
||||
pkgAccumulator.Statuses.Add(new AffectedPackageStatus("resolved", packageProvenance));
|
||||
|
||||
var normalizedRule = range.ToNormalizedVersionRule(BuildNormalizedNote(platform));
|
||||
if (normalizedRule is not null)
|
||||
{
|
||||
pkgAccumulator.NormalizedRules.Add(normalizedRule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (advisoryBuckets.Count == 0)
|
||||
{
|
||||
return Array.Empty<Advisory>();
|
||||
}
|
||||
|
||||
var fetchProvenance = new AdvisoryProvenance(
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
"document",
|
||||
document.Uri,
|
||||
document.FetchedAt.ToUniversalTime());
|
||||
|
||||
var published = document.LastModified?.ToUniversalTime() ?? document.FetchedAt.ToUniversalTime();
|
||||
|
||||
var advisories = new List<Advisory>(advisoryBuckets.Count);
|
||||
foreach (var bucket in advisoryBuckets.Values.OrderBy(b => b.AdvisoryKey, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var aliases = bucket.Aliases
|
||||
.Where(static alias => !string.IsNullOrWhiteSpace(alias))
|
||||
.Select(static alias => alias.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var references = BuildReferences(document, recordedAt);
|
||||
var packages = bucket.Packages.Values
|
||||
.Select(static pkg => pkg.Build())
|
||||
.Where(static pkg => pkg.VersionRanges.Length > 0)
|
||||
.OrderBy(static pkg => pkg.Platform, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static pkg => pkg.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var mappingProvenance = new AdvisoryProvenance(
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
"mapping",
|
||||
bucket.AdvisoryKey,
|
||||
recordedAt);
|
||||
|
||||
advisories.Add(new Advisory(
|
||||
advisoryKey: bucket.AdvisoryKey,
|
||||
title: DetermineTitle(aliases, bucket.AdvisoryKey),
|
||||
summary: null,
|
||||
language: "en",
|
||||
published: published,
|
||||
modified: recordedAt > published ? recordedAt : published,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases: aliases,
|
||||
references: references,
|
||||
affectedPackages: packages,
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { fetchProvenance, mappingProvenance }));
|
||||
}
|
||||
|
||||
return advisories;
|
||||
}
|
||||
|
||||
private static string? BuildPlatform(AlpineSecDbDto dto)
|
||||
{
|
||||
var release = (dto.DistroVersion ?? string.Empty).Trim();
|
||||
var repo = (dto.RepoName ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(release) && string.IsNullOrWhiteSpace(repo))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(release))
|
||||
{
|
||||
return repo;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repo))
|
||||
{
|
||||
return release;
|
||||
}
|
||||
|
||||
return $"{release}/{repo}";
|
||||
}
|
||||
|
||||
private static string DetermineTitle(string[] aliases, string advisoryKey)
|
||||
{
|
||||
if (aliases.Length > 0)
|
||||
{
|
||||
return aliases[0];
|
||||
}
|
||||
|
||||
return advisoryKey;
|
||||
}
|
||||
|
||||
private static AdvisoryReference[] BuildReferences(DocumentRecord document, DateTimeOffset recordedAt)
|
||||
{
|
||||
var provenance = new AdvisoryProvenance(
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
"reference",
|
||||
document.Uri,
|
||||
recordedAt);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new AdvisoryReference(document.Uri, kind: "advisory", sourceTag: "secdb", summary: null, provenance: provenance)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildVendorExtensions(AlpineSecDbDto dto, string fixedVersion)
|
||||
{
|
||||
var extensions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
AddExtension(extensions, "alpine.distroversion", dto.DistroVersion);
|
||||
AddExtension(extensions, "alpine.repo", dto.RepoName);
|
||||
AddExtension(extensions, "alpine.fixed", fixedVersion);
|
||||
AddExtension(extensions, "alpine.urlprefix", dto.UrlPrefix);
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private static void AddExtension(IDictionary<string, string> extensions, string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
extensions[key] = value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeAlias(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string BuildAdvisoryKey(string normalizedId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"alpine/{normalizedId.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static HashSet<string> BuildAliases(string advisoryKey, string normalizedId)
|
||||
{
|
||||
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
aliases.Add(advisoryKey);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedId))
|
||||
{
|
||||
aliases.Add(normalizedId);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static string? BuildNormalizedNote(string? platform)
|
||||
=> string.IsNullOrWhiteSpace(platform) ? null : $"alpine:{platform.Trim()}";
|
||||
|
||||
private static string BuildPackageKey(string? platform, string package)
|
||||
=> string.IsNullOrWhiteSpace(platform) ? package : $"{platform}:{package}";
|
||||
|
||||
private static string BuildRangeProvenanceKey(string advisoryId, string? platform, string package, string fixedVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
return $"{advisoryId}:{package}:{fixedVersion}";
|
||||
}
|
||||
|
||||
return $"{advisoryId}:{platform}:{package}:{fixedVersion}";
|
||||
}
|
||||
|
||||
private static string BuildPackageProvenanceKey(string advisoryId, string? platform, string package)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
return $"{advisoryId}:{package}";
|
||||
}
|
||||
|
||||
return $"{advisoryId}:{platform}:{package}";
|
||||
}
|
||||
|
||||
private sealed class AdvisoryAccumulator
|
||||
{
|
||||
public AdvisoryAccumulator(string advisoryKey, HashSet<string> aliases)
|
||||
{
|
||||
AdvisoryKey = advisoryKey;
|
||||
Aliases = aliases;
|
||||
Packages = new Dictionary<string, PackageAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string AdvisoryKey { get; }
|
||||
|
||||
public HashSet<string> Aliases { get; }
|
||||
|
||||
public Dictionary<string, PackageAccumulator> Packages { get; }
|
||||
}
|
||||
|
||||
private sealed class PackageAccumulator
|
||||
{
|
||||
public PackageAccumulator(string identifier, string? platform)
|
||||
{
|
||||
Identifier = identifier;
|
||||
Platform = platform;
|
||||
}
|
||||
|
||||
public string Identifier { get; }
|
||||
|
||||
public string? Platform { get; }
|
||||
|
||||
public List<AffectedVersionRange> Ranges { get; } = new();
|
||||
|
||||
public List<AffectedPackageStatus> Statuses { get; } = new();
|
||||
|
||||
public List<AdvisoryProvenance> Provenance { get; } = new();
|
||||
|
||||
public List<NormalizedVersionRule> NormalizedRules { get; } = new();
|
||||
|
||||
public AffectedPackage Build()
|
||||
=> new(
|
||||
type: AffectedPackageTypes.Apk,
|
||||
identifier: Identifier,
|
||||
platform: Platform,
|
||||
versionRanges: Ranges,
|
||||
statuses: Statuses,
|
||||
provenance: Provenance,
|
||||
normalizedVersions: NormalizedRules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
|
||||
internal static class AlpineSecDbParser
|
||||
{
|
||||
public static AlpineSecDbDto Parse(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new ArgumentException("SecDB payload cannot be empty.", nameof(json));
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new FormatException("SecDB payload must be a JSON object.");
|
||||
}
|
||||
|
||||
var distroVersion = ReadString(root, "distroversion") ?? string.Empty;
|
||||
var repoName = ReadString(root, "reponame") ?? string.Empty;
|
||||
var urlPrefix = ReadString(root, "urlprefix") ?? string.Empty;
|
||||
|
||||
var packages = new List<AlpinePackageDto>();
|
||||
if (root.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var element in packagesElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!element.TryGetProperty("pkg", out var pkgElement) || pkgElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(pkgElement, "name");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var secfixes = ReadSecfixes(pkgElement);
|
||||
packages.Add(new AlpinePackageDto(name.Trim(), secfixes));
|
||||
}
|
||||
}
|
||||
|
||||
var orderedPackages = packages
|
||||
.OrderBy(pkg => pkg.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static pkg => pkg with { Secfixes = OrderSecfixes(pkg.Secfixes) })
|
||||
.ToList();
|
||||
|
||||
return new AlpineSecDbDto(distroVersion, repoName, urlPrefix, orderedPackages);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string[]> ReadSecfixes(JsonElement pkgElement)
|
||||
{
|
||||
if (!pkgElement.TryGetProperty("secfixes", out var fixesElement) || fixesElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in fixesElement.EnumerateObject())
|
||||
{
|
||||
var version = property.Name?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cves = ReadStringArray(property.Value);
|
||||
if (cves.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[version] = cves;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ReadStringArray(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var items = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in element.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = entry.GetString();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(value.Trim());
|
||||
}
|
||||
|
||||
return items.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string[]> OrderSecfixes(IReadOnlyDictionary<string, string[]> secfixes)
|
||||
{
|
||||
if (secfixes is null || secfixes.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var ordered = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in secfixes.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ordered[pair.Key] = pair.Value
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
|
||||
internal static class AlpineJobKinds
|
||||
{
|
||||
public const string Fetch = "source:alpine:fetch";
|
||||
public const string Parse = "source:alpine:parse";
|
||||
public const string Map = "source:alpine:map";
|
||||
}
|
||||
|
||||
internal sealed class AlpineFetchJob : IJob
|
||||
{
|
||||
private readonly AlpineConnector _connector;
|
||||
|
||||
public AlpineFetchJob(AlpineConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class AlpineParseJob : IJob
|
||||
{
|
||||
private readonly AlpineConnector _connector;
|
||||
|
||||
public AlpineParseJob(AlpineConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class AlpineMapJob : IJob
|
||||
{
|
||||
private readonly AlpineConnector _connector;
|
||||
|
||||
public AlpineMapJob(AlpineConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
# Concelier Alpine Connector Tasks
|
||||
|
||||
Local status mirror for `docs/implplan/SPRINT_2000_0003_0001_alpine_connector.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| T1 | DONE | APK version comparer + tests. |
|
||||
| T2 | DONE | SecDB parser. |
|
||||
| T3 | DOING | Alpine connector fetch/parse/map. |
|
||||
| T4 | TODO | DI + config + health check wiring. |
|
||||
| T5 | TODO | Tests, fixtures, and snapshots. |
|
||||
|
||||
Last synced: 2025-12-22 (UTC).
|
||||
@@ -0,0 +1,35 @@
|
||||
# AGENTS.md - EPSS Connector
|
||||
|
||||
## Purpose
|
||||
Ingests EPSS (Exploit Prediction Scoring System) scores from FIRST.org to provide exploitation probability signals for CVE prioritization.
|
||||
|
||||
## Data Source
|
||||
- **URL**: https://epss.empiricalsecurity.com/
|
||||
- **Format**: `epss_scores-YYYY-MM-DD.csv.gz` (gzip-compressed CSV)
|
||||
- **Update cadence**: Daily snapshot (typically published ~08:00 UTC)
|
||||
- **Offline bundle**: Directory or file path with optional `manifest.json`
|
||||
|
||||
## Data Flow
|
||||
1. Fetch daily snapshot via HTTP or air-gapped bundle path.
|
||||
2. Parse with `StellaOps.Scanner.Storage.Epss.EpssCsvStreamParser` for deterministic row counts and content hash.
|
||||
3. Map rows to `EpssObservation` records with band classification (Low/Medium/High/Critical).
|
||||
4. Store raw document + DTO metadata; mapping currently records counts and marks documents mapped.
|
||||
|
||||
## Configuration
|
||||
```yaml
|
||||
concelier:
|
||||
sources:
|
||||
epss:
|
||||
baseUri: "https://epss.empiricalsecurity.com/"
|
||||
fetchCurrent: true
|
||||
catchUpDays: 7
|
||||
httpTimeout: "00:02:00"
|
||||
maxRetries: 3
|
||||
airgapMode: false
|
||||
bundlePath: "/var/stellaops/bundles/epss"
|
||||
```
|
||||
|
||||
## Orchestrator Registration
|
||||
- ConnectorId: `epss`
|
||||
- Default Schedule: Daily 10:00 UTC
|
||||
- Egress Allowlist: `epss.empiricalsecurity.com`
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Configuration;
|
||||
|
||||
public sealed class EpssOptions
|
||||
{
|
||||
public const string SectionName = "Concelier:Epss";
|
||||
public const string HttpClientName = "source.epss";
|
||||
|
||||
public Uri BaseUri { get; set; } = new("https://epss.empiricalsecurity.com/", UriKind.Absolute);
|
||||
|
||||
public bool FetchCurrent { get; set; } = true;
|
||||
|
||||
public int CatchUpDays { get; set; } = 7;
|
||||
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
public bool AirgapMode { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string UserAgent { get; set; } = "StellaOps.Concelier.Epss/1.0";
|
||||
|
||||
[MemberNotNull(nameof(BaseUri), nameof(UserAgent))]
|
||||
public void Validate()
|
||||
{
|
||||
if (BaseUri is null || !BaseUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("BaseUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (CatchUpDays < 0)
|
||||
{
|
||||
throw new InvalidOperationException("CatchUpDays cannot be negative.");
|
||||
}
|
||||
|
||||
if (HttpTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("HttpTimeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (MaxRetries < 0)
|
||||
{
|
||||
throw new InvalidOperationException("MaxRetries cannot be negative.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(UserAgent))
|
||||
{
|
||||
throw new InvalidOperationException("UserAgent must be provided.");
|
||||
}
|
||||
|
||||
if (AirgapMode && string.IsNullOrWhiteSpace(BundlePath))
|
||||
{
|
||||
throw new InvalidOperationException("BundlePath must be provided when AirgapMode is enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Connector.Epss.Internal;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin entry point for EPSS feed connector.
|
||||
/// </summary>
|
||||
public sealed class EpssConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "epss";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<EpssConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<EpssConnector>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Epss.Configuration;
|
||||
using StellaOps.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss;
|
||||
|
||||
public sealed class EpssDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:epss";
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddEpssConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
services.AddTransient<EpssFetchJob>();
|
||||
services.AddTransient<EpssParseJob>();
|
||||
services.AddTransient<EpssMapJob>();
|
||||
|
||||
services.PostConfigure<JobSchedulerOptions>(options =>
|
||||
{
|
||||
EnsureJob(options, EpssJobKinds.Fetch, typeof(EpssFetchJob));
|
||||
EnsureJob(options, EpssJobKinds.Parse, typeof(EpssParseJob));
|
||||
EnsureJob(options, EpssJobKinds.Map, typeof(EpssMapJob));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureJob(JobSchedulerOptions options, string kind, Type jobType)
|
||||
{
|
||||
if (options.Definitions.ContainsKey(kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.Definitions[kind] = new JobDefinition(
|
||||
kind,
|
||||
jobType,
|
||||
options.DefaultTimeout,
|
||||
options.DefaultLeaseDuration,
|
||||
CronExpression: null,
|
||||
Enabled: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Epss.Configuration;
|
||||
using StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss;
|
||||
|
||||
public static class EpssServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddEpssConnector(this IServiceCollection services, Action<EpssOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<EpssOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static opts => opts.Validate());
|
||||
|
||||
services.AddSourceHttpClient(EpssOptions.HttpClientName, (sp, clientOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<EpssOptions>>().Value;
|
||||
clientOptions.BaseAddress = options.BaseUri;
|
||||
clientOptions.Timeout = options.HttpTimeout;
|
||||
clientOptions.UserAgent = options.UserAgent;
|
||||
clientOptions.MaxAttempts = Math.Max(1, options.MaxRetries + 1);
|
||||
clientOptions.AllowedHosts.Clear();
|
||||
clientOptions.AllowedHosts.Add(options.BaseUri.Host);
|
||||
clientOptions.DefaultRequestHeaders["Accept"] = "application/gzip,application/octet-stream,application/x-gzip";
|
||||
});
|
||||
|
||||
services.AddSingleton<EpssDiagnostics>();
|
||||
services.AddTransient<EpssConnector>();
|
||||
services.AddTransient<EpssFetchJob>();
|
||||
services.AddTransient<EpssParseJob>();
|
||||
services.AddTransient<EpssMapJob>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Epss.Configuration;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
public sealed class EpssConnector : IFeedConnector
|
||||
{
|
||||
private const string DtoSchemaVersion = "epss.snapshot.v1";
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private static readonly string[] AcceptTypes = { "application/gzip", "application/octet-stream", "application/x-gzip" };
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly RawDocumentStorage _rawDocumentStorage;
|
||||
private readonly IDocumentStore _documentStore;
|
||||
private readonly IDtoStore _dtoStore;
|
||||
private readonly ISourceStateRepository _stateRepository;
|
||||
private readonly EpssOptions _options;
|
||||
private readonly EpssDiagnostics _diagnostics;
|
||||
private readonly ICryptoHash _hash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<EpssConnector> _logger;
|
||||
private readonly EpssCsvStreamParser _parser = new();
|
||||
|
||||
public EpssConnector(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RawDocumentStorage rawDocumentStorage,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
IOptions<EpssOptions> options,
|
||||
EpssDiagnostics diagnostics,
|
||||
ICryptoHash hash,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<EpssConnector> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
|
||||
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string SourceName => EpssConnectorPlugin.SourceName;
|
||||
|
||||
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
var pendingDocuments = cursor.PendingDocuments.ToHashSet();
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var nowDate = DateOnly.FromDateTime(now.UtcDateTime);
|
||||
|
||||
var candidates = GetCandidateDates(cursor, nowDate).ToArray();
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_diagnostics.FetchAttempt();
|
||||
|
||||
EpssFetchResult? fetchResult = null;
|
||||
try
|
||||
{
|
||||
foreach (var date in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
fetchResult = _options.AirgapMode
|
||||
? await TryFetchFromBundleAsync(date, cancellationToken).ConfigureAwait(false)
|
||||
: await TryFetchFromHttpAsync(date, cursor, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (fetchResult is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchResult is null)
|
||||
{
|
||||
_logger.LogWarning("EPSS fetch: no snapshot found for {CandidateCount} candidate dates.", candidates.Length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchResult.IsNotModified)
|
||||
{
|
||||
_diagnostics.FetchUnchanged();
|
||||
var unchangedCursor = cursor.WithSnapshotMetadata(
|
||||
cursor.ModelVersion,
|
||||
cursor.LastProcessedDate,
|
||||
fetchResult.ETag ?? cursor.ETag,
|
||||
cursor.ContentHash,
|
||||
cursor.LastRowCount,
|
||||
now);
|
||||
await UpdateCursorAsync(unchangedCursor, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fetchResult.IsSuccess || fetchResult.Content is null)
|
||||
{
|
||||
_diagnostics.FetchFailure();
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), "EPSS fetch returned no content.", cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var record = await StoreSnapshotAsync(fetchResult, now, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Add(record.Id);
|
||||
pendingMappings.Remove(record.Id);
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithSnapshotMetadata(
|
||||
cursor.ModelVersion,
|
||||
cursor.LastProcessedDate,
|
||||
fetchResult.ETag,
|
||||
cursor.ContentHash,
|
||||
cursor.LastRowCount,
|
||||
now);
|
||||
|
||||
_diagnostics.FetchSuccess();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetched EPSS snapshot {SnapshotDate} ({Uri}) document {DocumentId} pendingDocuments={PendingDocuments} pendingMappings={PendingMappings}",
|
||||
fetchResult.SnapshotDate,
|
||||
fetchResult.SourceUri,
|
||||
record.Id,
|
||||
pendingDocuments.Count,
|
||||
pendingMappings.Count);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diagnostics.FetchFailure();
|
||||
_logger.LogError(ex, "EPSS fetch failed for {BaseUri}", _options.BaseUri);
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingDocuments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remainingDocuments = cursor.PendingDocuments.ToList();
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
var cursorState = cursor;
|
||||
|
||||
foreach (var documentId in cursor.PendingDocuments)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
remainingDocuments.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.PayloadId.HasValue)
|
||||
{
|
||||
_diagnostics.ParseFailure("missing_payload");
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remainingDocuments.Remove(documentId);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await _rawDocumentStorage.DownloadAsync(document.PayloadId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diagnostics.ParseFailure("download");
|
||||
_logger.LogError(ex, "EPSS parse failed downloading document {DocumentId}", document.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
EpssCsvStreamParser.EpssCsvParseSession session;
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(payload, writable: false);
|
||||
await using var parseSession = _parser.ParseGzip(stream);
|
||||
session = parseSession;
|
||||
await foreach (var _ in parseSession.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diagnostics.ParseFailure("parse");
|
||||
_logger.LogWarning(ex, "EPSS parse failed for document {DocumentId}", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remainingDocuments.Remove(documentId);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var publishedDate = session.PublishedDate ?? TryParseDateFromMetadata(document.Metadata) ?? DateOnly.FromDateTime(document.CreatedAt.UtcDateTime);
|
||||
var modelVersion = string.IsNullOrWhiteSpace(session.ModelVersionTag) ? "unknown" : session.ModelVersionTag!;
|
||||
var contentHash = session.DecompressedSha256 ?? string.Empty;
|
||||
|
||||
var payloadDoc = new DocumentObject
|
||||
{
|
||||
["modelVersion"] = modelVersion,
|
||||
["publishedDate"] = publishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
["rowCount"] = session.RowCount,
|
||||
["contentHash"] = contentHash
|
||||
};
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Guid.NewGuid(),
|
||||
document.Id,
|
||||
SourceName,
|
||||
DtoSchemaVersion,
|
||||
payloadDoc,
|
||||
_timeProvider.GetUtcNow());
|
||||
|
||||
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = document.Metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
: new Dictionary<string, string>(document.Metadata, StringComparer.Ordinal);
|
||||
metadata["epss.modelVersion"] = modelVersion;
|
||||
metadata["epss.publishedDate"] = publishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
metadata["epss.rowCount"] = session.RowCount.ToString(CultureInfo.InvariantCulture);
|
||||
metadata["epss.contentHash"] = contentHash;
|
||||
|
||||
var updatedDocument = document with { Metadata = metadata };
|
||||
await _documentStore.UpsertAsync(updatedDocument, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
remainingDocuments.Remove(documentId);
|
||||
pendingMappings.Add(documentId);
|
||||
|
||||
cursorState = cursorState.WithSnapshotMetadata(
|
||||
modelVersion,
|
||||
publishedDate,
|
||||
document.Etag,
|
||||
contentHash,
|
||||
session.RowCount,
|
||||
_timeProvider.GetUtcNow());
|
||||
|
||||
_diagnostics.ParseRows(session.RowCount, modelVersion);
|
||||
}
|
||||
|
||||
var updatedCursor = cursorState
|
||||
.WithPendingDocuments(remainingDocuments)
|
||||
.WithPendingMappings(pendingMappings);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingMappings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingMappings = cursor.PendingMappings.ToList();
|
||||
var cursorState = cursor;
|
||||
|
||||
foreach (var documentId in cursor.PendingMappings)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (dtoRecord is null || document is null)
|
||||
{
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var modelVersion = TryGetString(dtoRecord.Payload, "modelVersion") ?? "unknown";
|
||||
var publishedDate = TryGetDate(dtoRecord.Payload, "publishedDate")
|
||||
?? TryParseDateFromMetadata(document.Metadata)
|
||||
?? DateOnly.FromDateTime(document.CreatedAt.UtcDateTime);
|
||||
|
||||
if (!document.PayloadId.HasValue)
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await _rawDocumentStorage.DownloadAsync(document.PayloadId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "EPSS map failed downloading document {DocumentId}", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
int mappedRows = 0;
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(payload, writable: false);
|
||||
await using var session = _parser.ParseGzip(stream);
|
||||
await foreach (var row in session.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_ = EpssMapper.ToObservation(row, modelVersion, publishedDate);
|
||||
mappedRows++;
|
||||
}
|
||||
|
||||
cursorState = cursorState.WithSnapshotMetadata(
|
||||
modelVersion,
|
||||
publishedDate,
|
||||
document.Etag,
|
||||
TryGetString(dtoRecord.Payload, "contentHash"),
|
||||
mappedRows,
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "EPSS map failed for document {DocumentId}", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
_diagnostics.MapRows(mappedRows, modelVersion);
|
||||
}
|
||||
|
||||
var updatedCursor = cursorState.WithPendingMappings(pendingMappings);
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<EpssFetchResult?> TryFetchFromHttpAsync(
|
||||
DateOnly snapshotDate,
|
||||
EpssCursor cursor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = GetSnapshotFileName(snapshotDate);
|
||||
var uri = new Uri(_options.BaseUri, fileName);
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
var etag = existing?.Etag ?? cursor.ETag;
|
||||
var lastModified = existing?.LastModified;
|
||||
|
||||
var client = _httpClientFactory.CreateClient(EpssOptions.HttpClientName);
|
||||
client.Timeout = _options.HttpTimeout;
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await SendWithRetryAsync(() => CreateRequest(uri, etag, lastModified), client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
response.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
var notModified = new EpssFetchResult(
|
||||
SnapshotDate: snapshotDate,
|
||||
SourceUri: uri.ToString(),
|
||||
IsSuccess: false,
|
||||
IsNotModified: true,
|
||||
Content: null,
|
||||
ContentType: response.Content.Headers.ContentType?.ToString(),
|
||||
ETag: response.Headers.ETag?.Tag ?? etag,
|
||||
LastModified: response.Content.Headers.LastModified);
|
||||
response.Dispose();
|
||||
return notModified;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = new EpssFetchResult(
|
||||
SnapshotDate: snapshotDate,
|
||||
SourceUri: uri.ToString(),
|
||||
IsSuccess: true,
|
||||
IsNotModified: false,
|
||||
Content: bytes,
|
||||
ContentType: response.Content.Headers.ContentType?.ToString(),
|
||||
ETag: response.Headers.ETag?.Tag ?? etag,
|
||||
LastModified: response.Content.Headers.LastModified);
|
||||
response.Dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<EpssFetchResult?> TryFetchFromBundleAsync(DateOnly snapshotDate, CancellationToken cancellationToken)
|
||||
{
|
||||
var fileName = GetSnapshotFileName(snapshotDate);
|
||||
var bundlePath = ResolveBundlePath(_options.BundlePath, fileName);
|
||||
|
||||
if (bundlePath is null || !File.Exists(bundlePath))
|
||||
{
|
||||
_logger.LogWarning("EPSS bundle file not found: {Path}", bundlePath ?? fileName);
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
return new EpssFetchResult(
|
||||
SnapshotDate: snapshotDate,
|
||||
SourceUri: $"bundle://{Path.GetFileName(bundlePath)}",
|
||||
IsSuccess: true,
|
||||
IsNotModified: false,
|
||||
Content: bytes,
|
||||
ContentType: "application/gzip",
|
||||
ETag: null,
|
||||
LastModified: new DateTimeOffset(File.GetLastWriteTimeUtc(bundlePath)));
|
||||
}
|
||||
|
||||
private async Task<DocumentRecord> StoreSnapshotAsync(
|
||||
EpssFetchResult fetchResult,
|
||||
DateTimeOffset fetchedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sha256 = _hash.ComputeHashHex(fetchResult.Content, HashAlgorithms.Sha256);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["epss.date"] = fetchResult.SnapshotDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
["epss.file"] = GetSnapshotFileName(fetchResult.SnapshotDate)
|
||||
};
|
||||
|
||||
if (_options.AirgapMode)
|
||||
{
|
||||
TryApplyBundleManifest(fetchResult.SnapshotDate, fetchResult.Content, metadata);
|
||||
}
|
||||
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, fetchResult.SourceUri, cancellationToken).ConfigureAwait(false);
|
||||
var recordId = existing?.Id ?? Guid.NewGuid();
|
||||
|
||||
await _rawDocumentStorage.UploadAsync(
|
||||
SourceName,
|
||||
fetchResult.SourceUri,
|
||||
fetchResult.Content,
|
||||
fetchResult.ContentType,
|
||||
ExpiresAt: null,
|
||||
cancellationToken,
|
||||
recordId).ConfigureAwait(false);
|
||||
|
||||
var record = new DocumentRecord(
|
||||
recordId,
|
||||
SourceName,
|
||||
fetchResult.SourceUri,
|
||||
fetchedAt,
|
||||
sha256,
|
||||
DocumentStatuses.PendingParse,
|
||||
fetchResult.ContentType,
|
||||
Headers: null,
|
||||
Metadata: metadata,
|
||||
Etag: fetchResult.ETag,
|
||||
LastModified: fetchResult.LastModified,
|
||||
PayloadId: recordId,
|
||||
ExpiresAt: null,
|
||||
Payload: fetchResult.Content,
|
||||
FetchedAt: fetchedAt);
|
||||
|
||||
return await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void TryApplyBundleManifest(DateOnly snapshotDate, byte[] content, IDictionary<string, string> metadata)
|
||||
{
|
||||
var bundlePath = _options.BundlePath;
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var manifestPath = ResolveBundleManifestPath(bundlePath);
|
||||
if (manifestPath is null || !File.Exists(manifestPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = TryReadBundleManifestEntry(manifestPath, GetSnapshotFileName(snapshotDate));
|
||||
if (entry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.ModelVersion))
|
||||
{
|
||||
metadata["epss.manifest.modelVersion"] = entry.ModelVersion!;
|
||||
}
|
||||
|
||||
if (entry.RowCount.HasValue)
|
||||
{
|
||||
metadata["epss.manifest.rowCount"] = entry.RowCount.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Sha256))
|
||||
{
|
||||
var actual = _hash.ComputeHashHex(content, HashAlgorithms.Sha256);
|
||||
var expected = entry.Sha256!.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? entry.Sha256![7..]
|
||||
: entry.Sha256!;
|
||||
|
||||
metadata["epss.manifest.sha256"] = entry.Sha256!;
|
||||
|
||||
if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("EPSS bundle hash mismatch: expected {Expected}, actual {Actual}", entry.Sha256, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "EPSS bundle manifest parsing failed for {Path}", manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBundlePath(string? bundlePath, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Directory.Exists(bundlePath))
|
||||
{
|
||||
return Path.Combine(bundlePath, fileName);
|
||||
}
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private static string? ResolveBundleManifestPath(string bundlePath)
|
||||
{
|
||||
if (Directory.Exists(bundlePath))
|
||||
{
|
||||
return Path.Combine(bundlePath, ManifestFileName);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(bundlePath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(directory, ManifestFileName);
|
||||
}
|
||||
|
||||
private static BundleManifestEntry? TryReadBundleManifestEntry(string manifestPath, string fileName)
|
||||
{
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
using var doc = JsonDocument.Parse(stream);
|
||||
if (!doc.RootElement.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var entry in files.EnumerateArray())
|
||||
{
|
||||
if (!entry.TryGetProperty("name", out var nameValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name) || !string.Equals(name, fileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var modelVersion = entry.TryGetProperty("modelVersion", out var modelValue) ? modelValue.GetString() : null;
|
||||
var sha256 = entry.TryGetProperty("sha256", out var shaValue) ? shaValue.GetString() : null;
|
||||
var rowCount = entry.TryGetProperty("rowCount", out var rowValue) && rowValue.TryGetInt32(out var parsed)
|
||||
? parsed
|
||||
: (int?)null;
|
||||
|
||||
return new BundleManifestEntry(name, modelVersion, sha256, rowCount);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private IEnumerable<DateOnly> GetCandidateDates(EpssCursor cursor, DateOnly nowDate)
|
||||
{
|
||||
var startDate = _options.FetchCurrent
|
||||
? nowDate
|
||||
: cursor.LastProcessedDate?.AddDays(1) ?? nowDate.AddDays(-Math.Max(0, _options.CatchUpDays));
|
||||
|
||||
if (startDate > nowDate)
|
||||
{
|
||||
startDate = nowDate;
|
||||
}
|
||||
|
||||
var maxBackfill = Math.Max(0, _options.CatchUpDays);
|
||||
for (var i = 0; i <= maxBackfill; i++)
|
||||
{
|
||||
yield return startDate.AddDays(-i);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSnapshotFileName(DateOnly date)
|
||||
=> $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
|
||||
|
||||
private static HttpRequestMessage CreateRequest(Uri uri, string? etag, DateTimeOffset? lastModified)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
request.Headers.Accept.Clear();
|
||||
foreach (var acceptType in AcceptTypes)
|
||||
{
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptType));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(etag) && EntityTagHeaderValue.TryParse(etag, out var etagHeader))
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(etagHeader);
|
||||
}
|
||||
|
||||
if (lastModified.HasValue)
|
||||
{
|
||||
request.Headers.IfModifiedSince = lastModified.Value;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
HttpClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var maxAttempts = Math.Max(1, _options.MaxRetries + 1);
|
||||
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
using var request = requestFactory();
|
||||
try
|
||||
{
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (ShouldRetry(response) && attempt < maxAttempts)
|
||||
{
|
||||
response.Dispose();
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts && ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpRequestException("EPSS fetch exceeded retry attempts.");
|
||||
}
|
||||
|
||||
private static bool ShouldRetry(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var status = (int)response.StatusCode;
|
||||
return status >= 500 && status < 600;
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
var seconds = Math.Min(30, Math.Pow(2, attempt - 1));
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
private static string? TryGetString(DocumentObject payload, string key)
|
||||
=> payload.TryGetValue(key, out var value) ? value.AsString : null;
|
||||
|
||||
private static DateOnly? TryGetDate(DocumentObject payload, string key)
|
||||
{
|
||||
if (!payload.TryGetValue(key, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.DocumentType == DocumentType.DateTime)
|
||||
{
|
||||
return DateOnly.FromDateTime(value.ToUniversalTime());
|
||||
}
|
||||
|
||||
if (value.DocumentType == DocumentType.String &&
|
||||
DateOnly.TryParseExact(value.AsString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateOnly? TryParseDateFromMetadata(IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue("epss.date", out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<EpssCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
|
||||
return state is null ? EpssCursor.Empty : EpssCursor.FromDocument(state.Cursor);
|
||||
}
|
||||
|
||||
private Task UpdateCursorAsync(EpssCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = cursor.ToDocumentObject();
|
||||
return _stateRepository.UpdateCursorAsync(SourceName, document, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record EpssFetchResult(
|
||||
DateOnly SnapshotDate,
|
||||
string SourceUri,
|
||||
bool IsSuccess,
|
||||
bool IsNotModified,
|
||||
byte[]? Content,
|
||||
string? ContentType,
|
||||
string? ETag,
|
||||
DateTimeOffset? LastModified);
|
||||
|
||||
private sealed record BundleManifestEntry(
|
||||
string Name,
|
||||
string? ModelVersion,
|
||||
string? Sha256,
|
||||
int? RowCount);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Concelier.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
internal sealed record EpssCursor(
|
||||
string? ModelVersion,
|
||||
DateOnly? LastProcessedDate,
|
||||
string? ETag,
|
||||
string? ContentHash,
|
||||
int? LastRowCount,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuidCollection = Array.Empty<Guid>();
|
||||
|
||||
public static EpssCursor Empty { get; } = new(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
DateTimeOffset.MinValue,
|
||||
EmptyGuidCollection,
|
||||
EmptyGuidCollection);
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var document = new DocumentObject
|
||||
{
|
||||
["pendingDocuments"] = new DocumentArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new DocumentArray(PendingMappings.Select(id => id.ToString()))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ModelVersion))
|
||||
{
|
||||
document["modelVersion"] = ModelVersion;
|
||||
}
|
||||
|
||||
if (LastProcessedDate.HasValue)
|
||||
{
|
||||
document["lastProcessedDate"] = LastProcessedDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ETag))
|
||||
{
|
||||
document["etag"] = ETag;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ContentHash))
|
||||
{
|
||||
document["contentHash"] = ContentHash;
|
||||
}
|
||||
|
||||
if (LastRowCount.HasValue)
|
||||
{
|
||||
document["lastRowCount"] = LastRowCount.Value;
|
||||
}
|
||||
|
||||
if (UpdatedAt > DateTimeOffset.MinValue)
|
||||
{
|
||||
document["updatedAt"] = UpdatedAt.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static EpssCursor FromDocument(DocumentObject? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var modelVersion = document.TryGetValue("modelVersion", out var modelValue) ? modelValue.AsString : null;
|
||||
|
||||
DateOnly? lastProcessed = null;
|
||||
if (document.TryGetValue("lastProcessedDate", out var lastProcessedValue))
|
||||
{
|
||||
lastProcessed = lastProcessedValue.DocumentType switch
|
||||
{
|
||||
DocumentType.String when DateOnly.TryParseExact(lastProcessedValue.AsString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed,
|
||||
DocumentType.DateTime => DateOnly.FromDateTime(lastProcessedValue.ToUniversalTime()),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
var etag = document.TryGetValue("etag", out var etagValue) ? etagValue.AsString : null;
|
||||
var contentHash = document.TryGetValue("contentHash", out var hashValue) ? hashValue.AsString : null;
|
||||
|
||||
int? lastRowCount = null;
|
||||
if (document.TryGetValue("lastRowCount", out var countValue))
|
||||
{
|
||||
var count = countValue.AsInt32;
|
||||
if (count > 0)
|
||||
{
|
||||
lastRowCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset updatedAt = DateTimeOffset.MinValue;
|
||||
if (document.TryGetValue("updatedAt", out var updatedValue))
|
||||
{
|
||||
var parsed = updatedValue.AsDateTimeOffset;
|
||||
if (parsed > DateTimeOffset.MinValue)
|
||||
{
|
||||
updatedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new EpssCursor(
|
||||
string.IsNullOrWhiteSpace(modelVersion) ? null : modelVersion.Trim(),
|
||||
lastProcessed,
|
||||
string.IsNullOrWhiteSpace(etag) ? null : etag.Trim(),
|
||||
string.IsNullOrWhiteSpace(contentHash) ? null : contentHash.Trim(),
|
||||
lastRowCount,
|
||||
updatedAt,
|
||||
ReadGuidArray(document, "pendingDocuments"),
|
||||
ReadGuidArray(document, "pendingMappings"));
|
||||
}
|
||||
|
||||
public EpssCursor WithPendingDocuments(IEnumerable<Guid> documents)
|
||||
=> this with { PendingDocuments = documents?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public EpssCursor WithPendingMappings(IEnumerable<Guid> mappings)
|
||||
=> this with { PendingMappings = mappings?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public EpssCursor WithSnapshotMetadata(
|
||||
string? modelVersion,
|
||||
DateOnly? publishedDate,
|
||||
string? etag,
|
||||
string? contentHash,
|
||||
int? rowCount,
|
||||
DateTimeOffset updatedAt)
|
||||
=> this with
|
||||
{
|
||||
ModelVersion = string.IsNullOrWhiteSpace(modelVersion) ? null : modelVersion.Trim(),
|
||||
LastProcessedDate = publishedDate,
|
||||
ETag = string.IsNullOrWhiteSpace(etag) ? null : etag.Trim(),
|
||||
ContentHash = string.IsNullOrWhiteSpace(contentHash) ? null : contentHash.Trim(),
|
||||
LastRowCount = rowCount > 0 ? rowCount : null,
|
||||
UpdatedAt = updatedAt
|
||||
};
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(DocumentObject document, string key)
|
||||
{
|
||||
if (!document.TryGetValue(key, out var value) || value is not DocumentArray array)
|
||||
{
|
||||
return EmptyGuidCollection;
|
||||
}
|
||||
|
||||
var results = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
results.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
public sealed class EpssDiagnostics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Concelier.Connector.Epss";
|
||||
private const string MeterVersion = "1.0.0";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _fetchAttempts;
|
||||
private readonly Counter<long> _fetchSuccess;
|
||||
private readonly Counter<long> _fetchFailures;
|
||||
private readonly Counter<long> _fetchUnchanged;
|
||||
private readonly Counter<long> _parsedRows;
|
||||
private readonly Counter<long> _parseFailures;
|
||||
private readonly Counter<long> _mappedRows;
|
||||
|
||||
public EpssDiagnostics()
|
||||
{
|
||||
_meter = new Meter(MeterName, MeterVersion);
|
||||
_fetchAttempts = _meter.CreateCounter<long>(
|
||||
name: "epss.fetch.attempts",
|
||||
unit: "operations",
|
||||
description: "Number of EPSS fetch attempts performed.");
|
||||
_fetchSuccess = _meter.CreateCounter<long>(
|
||||
name: "epss.fetch.success",
|
||||
unit: "operations",
|
||||
description: "Number of EPSS fetch attempts that produced new content.");
|
||||
_fetchFailures = _meter.CreateCounter<long>(
|
||||
name: "epss.fetch.failures",
|
||||
unit: "operations",
|
||||
description: "Number of EPSS fetch attempts that failed.");
|
||||
_fetchUnchanged = _meter.CreateCounter<long>(
|
||||
name: "epss.fetch.unchanged",
|
||||
unit: "operations",
|
||||
description: "Number of EPSS fetch attempts returning unchanged content.");
|
||||
_parsedRows = _meter.CreateCounter<long>(
|
||||
name: "epss.parse.rows",
|
||||
unit: "rows",
|
||||
description: "Number of EPSS rows parsed from snapshots.");
|
||||
_parseFailures = _meter.CreateCounter<long>(
|
||||
name: "epss.parse.failures",
|
||||
unit: "documents",
|
||||
description: "Number of EPSS snapshot parse failures.");
|
||||
_mappedRows = _meter.CreateCounter<long>(
|
||||
name: "epss.map.rows",
|
||||
unit: "rows",
|
||||
description: "Number of EPSS rows mapped into observations.");
|
||||
}
|
||||
|
||||
public void FetchAttempt() => _fetchAttempts.Add(1);
|
||||
|
||||
public void FetchSuccess() => _fetchSuccess.Add(1);
|
||||
|
||||
public void FetchFailure() => _fetchFailures.Add(1);
|
||||
|
||||
public void FetchUnchanged() => _fetchUnchanged.Add(1);
|
||||
|
||||
public void ParseRows(int rowCount, string? modelVersion)
|
||||
{
|
||||
if (rowCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_parsedRows.Add(rowCount, new KeyValuePair<string, object?>("modelVersion", modelVersion ?? string.Empty));
|
||||
}
|
||||
|
||||
public void ParseFailure(string reason)
|
||||
=> _parseFailures.Add(1, new KeyValuePair<string, object?>("reason", reason));
|
||||
|
||||
public void MapRows(int rowCount, string? modelVersion)
|
||||
{
|
||||
if (rowCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_mappedRows.Add(rowCount, new KeyValuePair<string, object?>("modelVersion", modelVersion ?? string.Empty));
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
public static class EpssMapper
|
||||
{
|
||||
public static EpssObservation ToObservation(
|
||||
EpssScoreRow row,
|
||||
string modelVersion,
|
||||
DateOnly publishedDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(modelVersion))
|
||||
{
|
||||
throw new ArgumentException("Model version is required.", nameof(modelVersion));
|
||||
}
|
||||
|
||||
return new EpssObservation
|
||||
{
|
||||
CveId = row.CveId,
|
||||
Score = (decimal)row.Score,
|
||||
Percentile = (decimal)row.Percentile,
|
||||
ModelVersion = modelVersion,
|
||||
PublishedDate = publishedDate,
|
||||
Band = DetermineBand((decimal)row.Score)
|
||||
};
|
||||
}
|
||||
|
||||
private static EpssBand DetermineBand(decimal score) => score switch
|
||||
{
|
||||
>= 0.70m => EpssBand.Critical,
|
||||
>= 0.40m => EpssBand.High,
|
||||
>= 0.10m => EpssBand.Medium,
|
||||
_ => EpssBand.Low
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record EpssObservation
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required decimal Score { get; init; }
|
||||
public required decimal Percentile { get; init; }
|
||||
public required string ModelVersion { get; init; }
|
||||
public required DateOnly PublishedDate { get; init; }
|
||||
public required EpssBand Band { get; init; }
|
||||
}
|
||||
|
||||
public enum EpssBand
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
Critical = 3
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Epss.Internal;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss;
|
||||
|
||||
internal static class EpssJobKinds
|
||||
{
|
||||
public const string Fetch = "source:epss:fetch";
|
||||
public const string Parse = "source:epss:parse";
|
||||
public const string Map = "source:epss:map";
|
||||
}
|
||||
|
||||
internal sealed class EpssFetchJob : IJob
|
||||
{
|
||||
private readonly EpssConnector _connector;
|
||||
|
||||
public EpssFetchJob(EpssConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class EpssParseJob : IJob
|
||||
{
|
||||
private readonly EpssConnector _connector;
|
||||
|
||||
public EpssParseJob(EpssConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class EpssMapJob : IJob
|
||||
{
|
||||
private readonly EpssConnector _connector;
|
||||
|
||||
public EpssMapJob(EpssConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>StellaOps.Concelier.Connector.Epss.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -243,6 +243,23 @@ public static class WellKnownConnectors
|
||||
EgressAllowlist = ["www.cisa.gov"]
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EPSS (Exploit Prediction Scoring System) connector metadata.
|
||||
/// </summary>
|
||||
public static ConnectorMetadata Epss => new()
|
||||
{
|
||||
ConnectorId = "epss",
|
||||
Source = "epss",
|
||||
DisplayName = "EPSS",
|
||||
Description = "FIRST.org Exploit Prediction Scoring System",
|
||||
Capabilities = ["observations"],
|
||||
ArtifactKinds = ["raw-scores", "normalized"],
|
||||
DefaultCron = "0 10 * * *",
|
||||
DefaultRpm = 100,
|
||||
MaxLagMinutes = 1440,
|
||||
EgressAllowlist = ["epss.empiricalsecurity.com"]
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// ICS-CISA connector metadata.
|
||||
/// </summary>
|
||||
@@ -262,5 +279,5 @@ public static class WellKnownConnectors
|
||||
/// <summary>
|
||||
/// Gets metadata for all well-known connectors.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ConnectorMetadata> All => [Nvd, Ghsa, Osv, Kev, IcsCisa];
|
||||
public static IReadOnlyList<ConnectorMetadata> All => [Nvd, Ghsa, Osv, Kev, Epss, IcsCisa];
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ Deterministic merge and reconciliation engine; builds identity graph via aliases
|
||||
## Interfaces & contracts
|
||||
- AdvisoryMergeService.MergeAsync(ids or byKind): returns summary {processed, merged, overrides, conflicts}.
|
||||
- Precedence table configurable but with sane defaults: RedHat/Ubuntu/Debian/SUSE > Vendor PSIRT > GHSA/OSV > NVD; CERTs enrich; KEV sets flags.
|
||||
- Range selection uses comparers: NevraComparer, DebEvrComparer, SemVerRange; deterministic tie-breakers.
|
||||
- Range selection uses comparers: NevraComparer, DebianEvrComparer, ApkVersionComparer, SemVerRange; deterministic tie-breakers.
|
||||
- Provenance propagation merges unique entries; references deduped by (url, type).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
/// <summary>
|
||||
/// Compares Alpine APK package versions using apk-tools ordering rules.
|
||||
/// </summary>
|
||||
public sealed class ApkVersionComparer : IComparer<ApkVersion>, IComparer<string>
|
||||
{
|
||||
public static ApkVersionComparer Instance { get; } = new();
|
||||
|
||||
private ApkVersionComparer()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParsed = ApkVersion.TryParse(x, out var xVersion);
|
||||
var yParsed = ApkVersion.TryParse(y, out var yVersion);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xVersion, yVersion);
|
||||
}
|
||||
|
||||
if (xParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (yParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int Compare(ApkVersion? x, ApkVersion? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var compare = CompareVersionString(x.Version, y.Version);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = x.PkgRel.CompareTo(y.PkgRel);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
// When pkgrel values are equal, implicit (no -r) sorts before explicit -r0
|
||||
// e.g., "1.2.3" < "1.2.3-r0"
|
||||
if (!x.HasExplicitPkgRel && y.HasExplicitPkgRel)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (x.HasExplicitPkgRel && !y.HasExplicitPkgRel)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int CompareVersionString(string left, string right)
|
||||
{
|
||||
var leftIndex = 0;
|
||||
var rightIndex = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var leftToken = NextToken(left, ref leftIndex);
|
||||
var rightToken = NextToken(right, ref rightIndex);
|
||||
|
||||
if (leftToken.Type == TokenType.End && rightToken.Type == TokenType.End)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftToken.Type == TokenType.End)
|
||||
{
|
||||
return CompareEndToken(rightToken, isLeftEnd: true);
|
||||
}
|
||||
|
||||
if (rightToken.Type == TokenType.End)
|
||||
{
|
||||
return CompareEndToken(leftToken, isLeftEnd: false);
|
||||
}
|
||||
|
||||
if (leftToken.Type != rightToken.Type)
|
||||
{
|
||||
var compare = CompareDifferentTypes(leftToken, rightToken);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var compare = leftToken.Type switch
|
||||
{
|
||||
TokenType.Numeric => CompareNumeric(leftToken.NumericValue, rightToken.NumericValue),
|
||||
TokenType.Alpha => CompareAlpha(leftToken.Text, rightToken.Text),
|
||||
TokenType.Suffix => CompareSuffix(leftToken, rightToken),
|
||||
_ => 0
|
||||
};
|
||||
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CompareEndToken(VersionToken token, bool isLeftEnd)
|
||||
{
|
||||
if (token.Type == TokenType.Suffix)
|
||||
{
|
||||
// Compare suffix order: suffix token vs no-suffix (order=0)
|
||||
// If isLeftEnd=true: comparing END (left) vs suffix (right) → return CompareSuffixOrder(0, right.order)
|
||||
// If isLeftEnd=false: comparing suffix (left) vs END (right) → return CompareSuffixOrder(left.order, 0)
|
||||
var compare = isLeftEnd
|
||||
? CompareSuffixOrder(0, token.SuffixOrder)
|
||||
: CompareSuffixOrder(token.SuffixOrder, 0);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return isLeftEnd ? -1 : 1;
|
||||
}
|
||||
|
||||
return isLeftEnd ? -1 : 1;
|
||||
}
|
||||
|
||||
private static int CompareDifferentTypes(VersionToken left, VersionToken right)
|
||||
{
|
||||
if (left.Type == TokenType.Suffix || right.Type == TokenType.Suffix)
|
||||
{
|
||||
var leftOrder = left.Type == TokenType.Suffix ? left.SuffixOrder : 0;
|
||||
var rightOrder = right.Type == TokenType.Suffix ? right.SuffixOrder : 0;
|
||||
var compare = CompareSuffixOrder(leftOrder, rightOrder);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return TokenTypeRank(left.Type).CompareTo(TokenTypeRank(right.Type));
|
||||
}
|
||||
|
||||
return TokenTypeRank(left.Type).CompareTo(TokenTypeRank(right.Type));
|
||||
}
|
||||
|
||||
private static int TokenTypeRank(TokenType type)
|
||||
=> type switch
|
||||
{
|
||||
TokenType.Numeric => 3,
|
||||
TokenType.Alpha => 2,
|
||||
TokenType.Suffix => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static int CompareNumeric(string left, string right)
|
||||
{
|
||||
var leftTrimmed = TrimLeadingZeros(left);
|
||||
var rightTrimmed = TrimLeadingZeros(right);
|
||||
|
||||
if (leftTrimmed.Length != rightTrimmed.Length)
|
||||
{
|
||||
return leftTrimmed.Length.CompareTo(rightTrimmed.Length);
|
||||
}
|
||||
|
||||
return string.Compare(leftTrimmed, rightTrimmed, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareAlpha(string left, string right)
|
||||
=> string.Compare(left, right, StringComparison.Ordinal);
|
||||
|
||||
private static int CompareSuffix(VersionToken left, VersionToken right)
|
||||
{
|
||||
var compare = CompareSuffixOrder(left.SuffixOrder, right.SuffixOrder);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(left.SuffixName) || !string.IsNullOrEmpty(right.SuffixName))
|
||||
{
|
||||
compare = string.Compare(left.SuffixName, right.SuffixName, StringComparison.Ordinal);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
|
||||
if (!left.HasSuffixNumber && !right.HasSuffixNumber)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!left.HasSuffixNumber)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!right.HasSuffixNumber)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return CompareNumeric(left.SuffixNumber, right.SuffixNumber);
|
||||
}
|
||||
|
||||
private static int CompareSuffixOrder(int leftOrder, int rightOrder)
|
||||
=> leftOrder.CompareTo(rightOrder);
|
||||
|
||||
private static VersionToken NextToken(string value, ref int index)
|
||||
{
|
||||
while (index < value.Length)
|
||||
{
|
||||
var current = value[index];
|
||||
if (current == '_')
|
||||
{
|
||||
if (index + 1 < value.Length && char.IsLetter(value[index + 1]))
|
||||
{
|
||||
return ReadSuffixToken(value, ref index);
|
||||
}
|
||||
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsDigit(current))
|
||||
{
|
||||
return ReadNumericToken(value, ref index);
|
||||
}
|
||||
|
||||
if (char.IsLetter(current))
|
||||
{
|
||||
return ReadAlphaToken(value, ref index);
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return VersionToken.End;
|
||||
}
|
||||
|
||||
private static VersionToken ReadNumericToken(string value, ref int index)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsDigit(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var number = value.Substring(start, index - start);
|
||||
return VersionToken.Numeric(number);
|
||||
}
|
||||
|
||||
private static VersionToken ReadAlphaToken(string value, ref int index)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsLetter(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var text = value.Substring(start, index - start);
|
||||
return VersionToken.Alpha(text);
|
||||
}
|
||||
|
||||
private static VersionToken ReadSuffixToken(string value, ref int index)
|
||||
{
|
||||
index++;
|
||||
var nameStart = index;
|
||||
while (index < value.Length && char.IsLetter(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var name = value.Substring(nameStart, index - nameStart);
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return VersionToken.End;
|
||||
}
|
||||
|
||||
var normalizedName = name.ToLowerInvariant();
|
||||
var order = normalizedName switch
|
||||
{
|
||||
"alpha" => -4,
|
||||
"beta" => -3,
|
||||
"pre" => -2,
|
||||
"rc" => -1,
|
||||
"p" => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
var numberStart = index;
|
||||
while (index < value.Length && char.IsDigit(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var number = value.Substring(numberStart, index - numberStart);
|
||||
return VersionToken.Suffix(normalizedName, order, number);
|
||||
}
|
||||
|
||||
private static string TrimLeadingZeros(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
while (index < value.Length && value[index] == '0')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var trimmed = value[index..];
|
||||
return trimmed.Length == 0 ? "0" : trimmed;
|
||||
}
|
||||
|
||||
private enum TokenType
|
||||
{
|
||||
End,
|
||||
Numeric,
|
||||
Alpha,
|
||||
Suffix
|
||||
}
|
||||
|
||||
private readonly struct VersionToken
|
||||
{
|
||||
private VersionToken(TokenType type, string text, string numeric, string suffixName, int suffixOrder, string suffixNumber, bool hasSuffixNumber)
|
||||
{
|
||||
Type = type;
|
||||
Text = text;
|
||||
NumericValue = numeric;
|
||||
SuffixName = suffixName;
|
||||
SuffixOrder = suffixOrder;
|
||||
SuffixNumber = suffixNumber;
|
||||
HasSuffixNumber = hasSuffixNumber;
|
||||
}
|
||||
|
||||
public static VersionToken End { get; } = new(TokenType.End, string.Empty, string.Empty, string.Empty, 0, string.Empty, false);
|
||||
|
||||
public static VersionToken Numeric(string value)
|
||||
=> new(TokenType.Numeric, string.Empty, value ?? string.Empty, string.Empty, 0, string.Empty, false);
|
||||
|
||||
public static VersionToken Alpha(string value)
|
||||
=> new(TokenType.Alpha, value ?? string.Empty, string.Empty, string.Empty, 0, string.Empty, false);
|
||||
|
||||
public static VersionToken Suffix(string name, int order, string number)
|
||||
{
|
||||
var hasNumber = !string.IsNullOrEmpty(number);
|
||||
return new VersionToken(TokenType.Suffix, string.Empty, string.Empty, name ?? string.Empty, order, hasNumber ? TrimLeadingZeros(number) : string.Empty, hasNumber);
|
||||
}
|
||||
|
||||
public TokenType Type { get; }
|
||||
|
||||
public string Text { get; }
|
||||
|
||||
public string NumericValue { get; }
|
||||
|
||||
public string SuffixName { get; }
|
||||
|
||||
public int SuffixOrder { get; }
|
||||
|
||||
public string SuffixNumber { get; }
|
||||
|
||||
public bool HasSuffixNumber { get; }
|
||||
}
|
||||
}
|
||||
@@ -78,13 +78,7 @@ public sealed class DebianEvrComparer : IComparer<DebianEvr>, IComparer<string>
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = CompareSegment(x.Revision, y.Revision);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
return CompareSegment(x.Revision, y.Revision);
|
||||
}
|
||||
|
||||
private static int CompareSegment(string left, string right)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides version comparison with optional proof output.
|
||||
/// </summary>
|
||||
public interface IVersionComparator
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares two version strings.
|
||||
/// </summary>
|
||||
int Compare(string? left, string? right);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings and returns proof lines.
|
||||
/// </summary>
|
||||
VersionComparisonResult CompareWithProof(string? left, string? right);
|
||||
}
|
||||
@@ -90,13 +90,7 @@ public sealed class NevraComparer : IComparer<Nevra>, IComparer<string>
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = RpmVersionComparer.Compare(x.Release, y.Release);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
return RpmVersionComparer.Compare(x.Release, y.Release);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a version comparison with explainability proof lines.
|
||||
/// </summary>
|
||||
public sealed record VersionComparisonResult(
|
||||
int Comparison,
|
||||
ImmutableArray<string> ProofLines);
|
||||
@@ -13,5 +13,6 @@
|
||||
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -4,7 +4,7 @@ Canonical data model for normalized advisories and all downstream serialization.
|
||||
## Scope
|
||||
- Canonical types: Advisory, AdvisoryReference, CvssMetric, AffectedPackage, AffectedVersionRange, AdvisoryProvenance.
|
||||
- Invariants: stable ordering, culture-invariant serialization, UTC timestamps, deterministic equality semantics.
|
||||
- Field semantics: preserve all aliases/references; ranges per ecosystem (NEVRA/EVR/SemVer); provenance on every mapped field.
|
||||
- Field semantics: preserve all aliases/references; ranges per ecosystem (NEVRA/EVR/APK/SemVer); provenance on every mapped field.
|
||||
- Backward/forward compatibility: additive evolution; versioned DTOs where needed; no breaking field renames.
|
||||
- Detailed field coverage documented in `CANONICAL_RECORDS.md`; update alongside model changes.
|
||||
## Participants
|
||||
|
||||
@@ -90,6 +90,7 @@ public static class AffectedPackageTypes
|
||||
{
|
||||
public const string Rpm = "rpm";
|
||||
public const string Deb = "deb";
|
||||
public const string Apk = "apk";
|
||||
public const string Cpe = "cpe";
|
||||
public const string SemVer = "semver";
|
||||
public const string Vendor = "vendor";
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class AffectedVersionRangeExtensions
|
||||
NormalizedVersionSchemes.SemVer => BuildSemVerFallback(range, notes),
|
||||
NormalizedVersionSchemes.Nevra => BuildNevraFallback(range, notes),
|
||||
NormalizedVersionSchemes.Evr => BuildEvrFallback(range, notes),
|
||||
NormalizedVersionSchemes.Apk => BuildApkFallback(range, notes),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -218,4 +219,68 @@ public static class AffectedVersionRangeExtensions
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static NormalizedVersionRule? BuildApkFallback(AffectedVersionRange range, string? notes)
|
||||
{
|
||||
var resolvedNotes = Validation.TrimToNull(notes);
|
||||
var introduced = Validation.TrimToNull(range.IntroducedVersion);
|
||||
var fixedVersion = Validation.TrimToNull(range.FixedVersion);
|
||||
var lastAffected = Validation.TrimToNull(range.LastAffectedVersion);
|
||||
|
||||
if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(fixedVersion))
|
||||
{
|
||||
return new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.Apk,
|
||||
NormalizedVersionRuleTypes.Range,
|
||||
min: introduced,
|
||||
minInclusive: true,
|
||||
max: fixedVersion,
|
||||
maxInclusive: false,
|
||||
notes: resolvedNotes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(introduced) && !string.IsNullOrEmpty(lastAffected))
|
||||
{
|
||||
return new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.Apk,
|
||||
NormalizedVersionRuleTypes.Range,
|
||||
min: introduced,
|
||||
minInclusive: true,
|
||||
max: lastAffected,
|
||||
maxInclusive: true,
|
||||
notes: resolvedNotes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(introduced))
|
||||
{
|
||||
return new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.Apk,
|
||||
NormalizedVersionRuleTypes.GreaterThanOrEqual,
|
||||
min: introduced,
|
||||
minInclusive: true,
|
||||
notes: resolvedNotes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fixedVersion))
|
||||
{
|
||||
return new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.Apk,
|
||||
NormalizedVersionRuleTypes.LessThan,
|
||||
max: fixedVersion,
|
||||
maxInclusive: false,
|
||||
notes: resolvedNotes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(lastAffected))
|
||||
{
|
||||
return new NormalizedVersionRule(
|
||||
NormalizedVersionSchemes.Apk,
|
||||
NormalizedVersionRuleTypes.LessThanOrEqual,
|
||||
max: lastAffected,
|
||||
maxInclusive: true,
|
||||
notes: resolvedNotes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Deterministic ordering: by `role` (nulls first) then `displayName`.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `type` | string | yes | Semantic type (`semver`, `rpm`, `deb`, `purl`, `cpe`, etc.). Lowercase. |
|
||||
| `type` | string | yes | Semantic type (`semver`, `rpm`, `deb`, `apk`, `purl`, `cpe`, etc.). Lowercase. |
|
||||
| `identifier` | string | yes | Canonical identifier (package name, PURL, CPE, NEVRA, etc.). |
|
||||
| `platform` | string? | optional | Explicit platform / distro (e.g. `ubuntu`, `rhel-8`). |
|
||||
| `versionRanges` | AffectedVersionRange[] | yes | Deduplicated + sorted by introduced/fixed/last/expr/kind. |
|
||||
@@ -69,7 +69,7 @@ Deterministic ordering: packages sorted by `type`, then `identifier`, then `plat
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `rangeKind` | string | yes | Classification of range semantics (`semver`, `evr`, `nevra`, `version`, `purl`). Lowercase. |
|
||||
| `rangeKind` | string | yes | Classification of range semantics (`semver`, `evr`, `nevra`, `apk`, `version`, `purl`). Lowercase. |
|
||||
| `introducedVersion` | string? | optional | Inclusive lower bound when impact begins. |
|
||||
| `fixedVersion` | string? | optional | Exclusive bounding version containing the fix. |
|
||||
| `lastAffectedVersion` | string? | optional | Inclusive upper bound when no fix exists. |
|
||||
|
||||
@@ -172,6 +172,7 @@ public static class NormalizedVersionSchemes
|
||||
public const string SemVer = "semver";
|
||||
public const string Nevra = "nevra";
|
||||
public const string Evr = "evr";
|
||||
public const string Apk = "apk";
|
||||
}
|
||||
|
||||
public static class NormalizedVersionRuleTypes
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an Alpine APK version (<c>version-rpkgrel</c>) tuple.
|
||||
/// </summary>
|
||||
public sealed class ApkVersion
|
||||
{
|
||||
private ApkVersion(string version, int pkgRel, bool hasExplicitPkgRel, string original)
|
||||
{
|
||||
Version = version;
|
||||
PkgRel = pkgRel;
|
||||
HasExplicitPkgRel = hasExplicitPkgRel;
|
||||
Original = original;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version component before the <c>-r</c> release suffix.
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package release number (defaults to <c>0</c> when omitted).
|
||||
/// </summary>
|
||||
public int PkgRel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the <c>-r</c> suffix was explicitly present.
|
||||
/// </summary>
|
||||
public bool HasExplicitPkgRel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Original trimmed input value.
|
||||
/// </summary>
|
||||
public string Original { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an APK version string.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out ApkVersion? result)
|
||||
{
|
||||
result = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var releaseIndex = trimmed.LastIndexOf("-r", StringComparison.Ordinal);
|
||||
|
||||
if (releaseIndex < 0)
|
||||
{
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new ApkVersion(trimmed, 0, hasExplicitPkgRel: false, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (releaseIndex == 0 || releaseIndex >= trimmed.Length - 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var versionPart = trimmed[..releaseIndex];
|
||||
var pkgRelPart = trimmed[(releaseIndex + 2)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(versionPart))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(pkgRelPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pkgRel))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new ApkVersion(versionPart, pkgRel, hasExplicitPkgRel: true, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an APK version string or throws <see cref="FormatException"/>.
|
||||
/// </summary>
|
||||
public static ApkVersion Parse(string value)
|
||||
{
|
||||
if (!TryParse(value, out var version))
|
||||
{
|
||||
throw new FormatException($"Input '{value}' is not a valid APK version string.");
|
||||
}
|
||||
|
||||
return version!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a canonical APK version string.
|
||||
/// </summary>
|
||||
public string ToCanonicalString()
|
||||
{
|
||||
var suffix = HasExplicitPkgRel || PkgRel > 0 ? $"-r{PkgRel}" : string.Empty;
|
||||
return $"{Version}{suffix}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Original;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Vuln Schema Migration 006b: Complete merge_events Partition Migration
|
||||
-- Sprint: SPRINT_3422_0001_0001 - Time-Based Partitioning
|
||||
-- Task: 3.3 - Migrate data from existing table
|
||||
-- Category: C (data migration, requires maintenance window)
|
||||
--
|
||||
-- IMPORTANT: Run this during maintenance window AFTER 006_partition_merge_events.sql
|
||||
-- Prerequisites:
|
||||
-- 1. Stop concelier/vuln services (pause advisory merge operations)
|
||||
-- 2. Verify partitioned table exists: \d+ vuln.merge_events_partitioned
|
||||
--
|
||||
-- Execution time depends on data volume. For large tables (>1M rows), consider
|
||||
-- batched migration (see bottom of file).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 1: Verify partitioned table exists
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE n.nspname = 'vuln' AND c.relname = 'merge_events_partitioned'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Partitioned table vuln.merge_events_partitioned does not exist. Run 006_partition_merge_events.sql first.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 2: Record row counts for verification
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_source_count BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_source_count FROM vuln.merge_events;
|
||||
RAISE NOTICE 'Source table row count: %', v_source_count;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 3: Migrate data from old table to partitioned table
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO vuln.merge_events_partitioned (
|
||||
id, advisory_id, source_id, event_type, old_value, new_value, created_at
|
||||
)
|
||||
SELECT
|
||||
id, advisory_id, source_id, event_type, old_value, new_value, created_at
|
||||
FROM vuln.merge_events
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Verify row counts match
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_source_count BIGINT;
|
||||
v_target_count BIGINT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_source_count FROM vuln.merge_events;
|
||||
SELECT COUNT(*) INTO v_target_count FROM vuln.merge_events_partitioned;
|
||||
|
||||
IF v_source_count <> v_target_count THEN
|
||||
RAISE WARNING 'Row count mismatch: source=% target=%. Check for conflicts.', v_source_count, v_target_count;
|
||||
ELSE
|
||||
RAISE NOTICE 'Row counts match: % rows migrated successfully', v_target_count;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 5: Drop foreign key constraints referencing this table
|
||||
-- ============================================================================
|
||||
|
||||
-- Drop FK constraints first (advisory_id references advisories)
|
||||
ALTER TABLE vuln.merge_events DROP CONSTRAINT IF EXISTS merge_events_advisory_id_fkey;
|
||||
ALTER TABLE vuln.merge_events DROP CONSTRAINT IF EXISTS merge_events_source_id_fkey;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 6: Swap tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Rename old table to backup
|
||||
ALTER TABLE IF EXISTS vuln.merge_events RENAME TO merge_events_old;
|
||||
|
||||
-- Rename partitioned table to production name
|
||||
ALTER TABLE vuln.merge_events_partitioned RENAME TO merge_events;
|
||||
|
||||
-- Update sequence to continue from max ID
|
||||
DO $$
|
||||
DECLARE
|
||||
v_max_id BIGINT;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(id), 0) INTO v_max_id FROM vuln.merge_events;
|
||||
IF EXISTS (SELECT 1 FROM pg_sequences WHERE schemaname = 'vuln' AND sequencename = 'merge_events_id_seq') THEN
|
||||
PERFORM setval('vuln.merge_events_id_seq', v_max_id + 1, false);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 7: Add comment about partitioning strategy
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE vuln.merge_events IS
|
||||
'Advisory merge event log. Partitioned monthly by created_at. FK to advisories removed for partition support. Migrated: ' || NOW()::TEXT;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================================
|
||||
-- Cleanup (run manually after validation)
|
||||
-- ============================================================================
|
||||
|
||||
-- After confirming the migration is successful (wait 24-48h), drop the old table:
|
||||
-- DROP TABLE IF EXISTS vuln.merge_events_old;
|
||||
|
||||
-- ============================================================================
|
||||
-- Batched Migration Alternative (for very large tables)
|
||||
-- ============================================================================
|
||||
-- If the table has >10M rows, consider this batched approach instead:
|
||||
--
|
||||
-- DO $$
|
||||
-- DECLARE
|
||||
-- v_batch_size INT := 100000;
|
||||
-- v_offset INT := 0;
|
||||
-- v_migrated INT := 0;
|
||||
-- BEGIN
|
||||
-- LOOP
|
||||
-- INSERT INTO vuln.merge_events_partitioned
|
||||
-- SELECT * FROM vuln.merge_events
|
||||
-- ORDER BY id
|
||||
-- OFFSET v_offset LIMIT v_batch_size
|
||||
-- ON CONFLICT DO NOTHING;
|
||||
--
|
||||
-- GET DIAGNOSTICS v_migrated = ROW_COUNT;
|
||||
-- EXIT WHEN v_migrated < v_batch_size;
|
||||
-- v_offset := v_offset + v_batch_size;
|
||||
-- RAISE NOTICE 'Migrated % rows total', v_offset;
|
||||
-- COMMIT;
|
||||
-- END LOOP;
|
||||
-- END
|
||||
-- $$;
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
[Collection(ConcelierFixtureCollection.Name)]
|
||||
public sealed class AlpineConnectorTests
|
||||
{
|
||||
private static readonly Uri SecDbUri = new("https://secdb.alpinelinux.org/v3.20/main.json");
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
|
||||
public AlpineConnectorTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchParseMap_StoresAdvisoriesAndUpdatesCursor()
|
||||
{
|
||||
await using var harness = await BuildHarnessAsync();
|
||||
|
||||
harness.Handler.AddJsonResponse(SecDbUri, BuildMinimalSecDb());
|
||||
|
||||
var connector = harness.ServiceProvider.GetRequiredService<AlpineConnector>();
|
||||
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
|
||||
|
||||
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
|
||||
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var advisory = advisories.Single(item => item.AdvisoryKey == "alpine/cve-2021-36159");
|
||||
var package = Assert.Single(advisory.AffectedPackages);
|
||||
Assert.Equal(AffectedPackageTypes.Apk, package.Type);
|
||||
Assert.Equal("apk-tools", package.Identifier);
|
||||
Assert.Equal("v3.20/main", package.Platform);
|
||||
|
||||
var range = Assert.Single(package.VersionRanges);
|
||||
Assert.Equal("apk", range.RangeKind);
|
||||
Assert.Equal("2.12.6-r0", range.FixedVersion);
|
||||
|
||||
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
|
||||
var state = await stateRepository.TryGetAsync(AlpineConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs)
|
||||
&& pendingDocs.AsDocumentArray.Count == 0);
|
||||
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings)
|
||||
&& pendingMappings.AsDocumentArray.Count == 0);
|
||||
|
||||
harness.Handler.AssertNoPendingResponses();
|
||||
}
|
||||
|
||||
private async Task<ConnectorTestHarness> BuildHarnessAsync()
|
||||
{
|
||||
var initialTime = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero);
|
||||
var harness = new ConnectorTestHarness(_fixture, initialTime, AlpineOptions.HttpClientName);
|
||||
await harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddAlpineConnector(options =>
|
||||
{
|
||||
options.BaseUri = new Uri("https://secdb.alpinelinux.org/");
|
||||
options.Releases = new[] { "v3.20" };
|
||||
options.Repositories = new[] { "main" };
|
||||
options.MaxDocumentsPerFetch = 1;
|
||||
options.FetchTimeout = TimeSpan.FromSeconds(5);
|
||||
options.RequestDelay = TimeSpan.Zero;
|
||||
options.UserAgent = "StellaOps.Tests.Alpine/1.0";
|
||||
});
|
||||
});
|
||||
|
||||
return harness;
|
||||
}
|
||||
|
||||
private static string BuildMinimalSecDb()
|
||||
=> "{\"distroversion\":\"v3.20\",\"reponame\":\"main\",\"urlprefix\":\"https://dl-cdn.alpinelinux.org/alpine\",\"packages\":[{\"pkg\":{\"name\":\"apk-tools\",\"secfixes\":{\"2.12.6-r0\":[\"CVE-2021-36159\"]}}},{\"pkg\":{\"name\":\"busybox\",\"secfixes\":{\"1.36.1-r29\":[\"CVE-2023-42364\"]}}}]}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
public sealed class AlpineDependencyInjectionRoutineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_ConfiguresOptionsAndScheduler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOptions();
|
||||
services.AddSourceCommon();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["concelier:sources:alpine:baseUri"] = "https://secdb.alpinelinux.org/",
|
||||
["concelier:sources:alpine:releases:0"] = "v3.20",
|
||||
["concelier:sources:alpine:repositories:0"] = "main",
|
||||
["concelier:sources:alpine:maxDocumentsPerFetch"] = "5",
|
||||
["concelier:sources:alpine:fetchTimeout"] = "00:00:30",
|
||||
["concelier:sources:alpine:requestDelay"] = "00:00:00.100",
|
||||
["concelier:sources:alpine:userAgent"] = "StellaOps.Tests.Alpine/1.0"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var routine = new AlpineDependencyInjectionRoutine();
|
||||
routine.Register(services, configuration);
|
||||
services.Configure<JobSchedulerOptions>(_ => { });
|
||||
|
||||
using var provider = services.BuildServiceProvider(validateScopes: true);
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<AlpineOptions>>().Value;
|
||||
Assert.Equal(new Uri("https://secdb.alpinelinux.org/"), options.BaseUri);
|
||||
Assert.Equal(new[] { "v3.20" }, options.Releases);
|
||||
Assert.Equal(new[] { "main" }, options.Repositories);
|
||||
Assert.Equal(5, options.MaxDocumentsPerFetch);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.FetchTimeout);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(100), options.RequestDelay);
|
||||
Assert.Equal("StellaOps.Tests.Alpine/1.0", options.UserAgent);
|
||||
|
||||
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Fetch, out var fetchDefinition));
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Parse, out var parseDefinition));
|
||||
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Map, out var mapDefinition));
|
||||
|
||||
Assert.Equal(typeof(AlpineFetchJob), fetchDefinition.JobType);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), fetchDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(4), fetchDefinition.LeaseDuration);
|
||||
Assert.Equal("*/30 * * * *", fetchDefinition.CronExpression);
|
||||
Assert.True(fetchDefinition.Enabled);
|
||||
|
||||
Assert.Equal(typeof(AlpineParseJob), parseDefinition.JobType);
|
||||
Assert.Equal(TimeSpan.FromMinutes(6), parseDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(4), parseDefinition.LeaseDuration);
|
||||
Assert.Equal("7,37 * * * *", parseDefinition.CronExpression);
|
||||
Assert.True(parseDefinition.Enabled);
|
||||
|
||||
Assert.Equal(typeof(AlpineMapJob), mapDefinition.JobType);
|
||||
Assert.Equal(TimeSpan.FromMinutes(8), mapDefinition.Timeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(4), mapDefinition.LeaseDuration);
|
||||
Assert.Equal("12,42 * * * *", mapDefinition.CronExpression);
|
||||
Assert.True(mapDefinition.Enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
internal static class AlpineFixtureReader
|
||||
{
|
||||
private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static AlpineSecDbDto LoadDto(string filename)
|
||||
=> AlpineSecDbParser.Parse(ReadFixture(filename));
|
||||
|
||||
public static AlpineSecDbDto FilterPackages(
|
||||
AlpineSecDbDto dto,
|
||||
IReadOnlyCollection<string> packageNames,
|
||||
int maxVersionsPerPackage = 0)
|
||||
{
|
||||
if (packageNames is null || packageNames.Count == 0)
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
|
||||
var allowed = new HashSet<string>(
|
||||
packageNames.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(static name => name.Trim()),
|
||||
NameComparer);
|
||||
|
||||
var packages = dto.Packages
|
||||
.Where(pkg => allowed.Contains(pkg.Name))
|
||||
.Select(pkg => pkg with { Secfixes = TrimSecfixes(pkg.Secfixes, maxVersionsPerPackage) })
|
||||
.OrderBy(pkg => pkg.Name, NameComparer)
|
||||
.ToList();
|
||||
|
||||
return dto with { Packages = packages };
|
||||
}
|
||||
|
||||
public static string NormalizeSnapshot(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
|
||||
|
||||
public static string ReadFixture(string filename)
|
||||
{
|
||||
var path = ResolveFixturePath(filename);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
public static string GetWritableFixturePath(string filename)
|
||||
=> Path.Combine(GetProjectRoot(), "Source", "Distro", "Alpine", "Fixtures", filename);
|
||||
|
||||
private static string ResolveFixturePath(string filename)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Alpine", "Fixtures", filename),
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", filename),
|
||||
Path.Combine(GetProjectRoot(), "Source", "Distro", "Alpine", "Fixtures", filename),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string[]> TrimSecfixes(
|
||||
IReadOnlyDictionary<string, string[]> secfixes,
|
||||
int maxVersions)
|
||||
{
|
||||
if (secfixes is null || secfixes.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string[]>(NameComparer);
|
||||
}
|
||||
|
||||
if (maxVersions <= 0 || secfixes.Count <= maxVersions)
|
||||
{
|
||||
return new Dictionary<string, string[]>(secfixes, NameComparer);
|
||||
}
|
||||
|
||||
var comparer = Comparer<string>.Create((left, right) => ApkVersionComparer.Instance.Compare(left, right));
|
||||
var orderedKeys = secfixes.Keys.OrderBy(static key => key, comparer).ToList();
|
||||
var skip = Math.Max(0, orderedKeys.Count - maxVersions);
|
||||
var trimmed = new Dictionary<string, string[]>(NameComparer);
|
||||
for (var i = skip; i < orderedKeys.Count; i++)
|
||||
{
|
||||
var key = orderedKeys[i];
|
||||
trimmed[key] = secfixes[key];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string GetProjectRoot()
|
||||
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
public sealed class AlpineMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_BuildsApkAdvisoriesWithRanges()
|
||||
{
|
||||
var dto = new AlpineSecDbDto(
|
||||
DistroVersion: "v3.20",
|
||||
RepoName: "main",
|
||||
UrlPrefix: "https://dl-cdn.alpinelinux.org/alpine",
|
||||
Packages: new[]
|
||||
{
|
||||
new AlpinePackageDto(
|
||||
"apk-tools",
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["2.12.6-r0"] = new[] { "CVE-2021-36159" }
|
||||
}),
|
||||
new AlpinePackageDto(
|
||||
"busybox",
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["1.36.1-r29"] = new[] { "CVE-2023-42364" }
|
||||
})
|
||||
});
|
||||
|
||||
var recordedAt = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero);
|
||||
var document = BuildDocument("https://secdb.alpinelinux.org/v3.20/main.json", recordedAt);
|
||||
|
||||
var advisories = AlpineMapper.Map(dto, document, recordedAt);
|
||||
Assert.Equal(2, advisories.Count);
|
||||
|
||||
var apkToolsAdvisory = advisories.Single(advisory => advisory.AdvisoryKey == "alpine/cve-2021-36159");
|
||||
Assert.Contains("CVE-2021-36159", apkToolsAdvisory.Aliases);
|
||||
|
||||
var apkPackage = Assert.Single(apkToolsAdvisory.AffectedPackages);
|
||||
Assert.Equal(AffectedPackageTypes.Apk, apkPackage.Type);
|
||||
Assert.Equal("apk-tools", apkPackage.Identifier);
|
||||
Assert.Equal("v3.20/main", apkPackage.Platform);
|
||||
|
||||
var range = Assert.Single(apkPackage.VersionRanges);
|
||||
Assert.Equal("apk", range.RangeKind);
|
||||
Assert.Equal("2.12.6-r0", range.FixedVersion);
|
||||
Assert.Equal("fixed:2.12.6-r0", range.RangeExpression);
|
||||
Assert.NotNull(range.Primitives?.VendorExtensions);
|
||||
Assert.Equal("v3.20", range.Primitives!.VendorExtensions["alpine.distroversion"]);
|
||||
Assert.Equal("main", range.Primitives.VendorExtensions["alpine.repo"]);
|
||||
}
|
||||
|
||||
private static DocumentRecord BuildDocument(string uri, DateTimeOffset recordedAt)
|
||||
{
|
||||
return new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
uri,
|
||||
recordedAt,
|
||||
new string('0', 64),
|
||||
DocumentStatuses.Mapped,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: recordedAt,
|
||||
PayloadId: null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
public sealed class AlpineSecDbParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_SecDbFixture_ExtractsPackagesAndMetadata()
|
||||
{
|
||||
var dto = AlpineFixtureReader.LoadDto("v3.20-main.json");
|
||||
|
||||
Assert.Equal("v3.20", dto.DistroVersion);
|
||||
Assert.Equal("main", dto.RepoName);
|
||||
Assert.Equal("https://dl-cdn.alpinelinux.org/alpine", dto.UrlPrefix);
|
||||
Assert.NotEmpty(dto.Packages);
|
||||
|
||||
var apkTools = dto.Packages.Single(pkg => pkg.Name == "apk-tools");
|
||||
Assert.True(apkTools.Secfixes.ContainsKey("2.12.6-r0"));
|
||||
Assert.Contains("CVE-2021-36159", apkTools.Secfixes["2.12.6-r0"]);
|
||||
|
||||
var busybox = dto.Packages.Single(pkg => pkg.Name == "busybox");
|
||||
Assert.True(busybox.Secfixes.Keys.Any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
|
||||
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
|
||||
|
||||
public sealed class AlpineSnapshotTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("v3.18-main.json", "alpine-v3.18-main.snapshot.json", "2025-12-22T00:00:00Z")]
|
||||
[InlineData("v3.19-main.json", "alpine-v3.19-main.snapshot.json", "2025-12-22T00:10:00Z")]
|
||||
[InlineData("v3.20-main.json", "alpine-v3.20-main.snapshot.json", "2025-12-22T00:20:00Z")]
|
||||
public void Snapshot_FixturesMatchGolden(string fixtureFile, string snapshotFile, string recordedAt)
|
||||
{
|
||||
var dto = AlpineFixtureReader.LoadDto(fixtureFile);
|
||||
var filtered = AlpineFixtureReader.FilterPackages(
|
||||
dto,
|
||||
new[] { "apk-tools", "busybox", "zlib" },
|
||||
maxVersionsPerPackage: 2);
|
||||
|
||||
var recorded = DateTimeOffset.Parse(recordedAt);
|
||||
var document = BuildDocument(filtered, recorded);
|
||||
|
||||
var advisories = AlpineMapper.Map(filtered, document, recorded);
|
||||
var ordered = advisories
|
||||
.OrderBy(advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var snapshot = AlpineFixtureReader.NormalizeSnapshot(SnapshotSerializer.ToSnapshot(ordered));
|
||||
var snapshotPath = AlpineFixtureReader.GetWritableFixturePath(snapshotFile);
|
||||
|
||||
if (ShouldUpdateGoldens() || !File.Exists(snapshotPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||
File.WriteAllText(snapshotPath, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = AlpineFixtureReader.NormalizeSnapshot(File.ReadAllText(snapshotPath));
|
||||
Assert.Equal(expected, snapshot);
|
||||
}
|
||||
|
||||
private static DocumentRecord BuildDocument(AlpineSecDbDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
var uri = new Uri(new Uri("https://secdb.alpinelinux.org/"), $"{dto.DistroVersion}/{dto.RepoName}.json");
|
||||
return new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
AlpineConnectorPlugin.SourceName,
|
||||
uri.ToString(),
|
||||
recordedAt,
|
||||
new string('0', 64),
|
||||
DocumentStatuses.Mapped,
|
||||
"application/json",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: null,
|
||||
LastModified: recordedAt,
|
||||
PayloadId: null);
|
||||
}
|
||||
|
||||
private static bool ShouldUpdateGoldens()
|
||||
=> IsTruthy(Environment.GetEnvironmentVariable("UPDATE_GOLDENS"))
|
||||
|| IsTruthy(Environment.GetEnvironmentVariable("DOTNET_TEST_UPDATE_GOLDENS"));
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.Alpine/StellaOps.Concelier.Connector.Distro.Alpine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Source\Distro\Alpine\Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Connector.Epss;
|
||||
using StellaOps.Concelier.Connector.Epss.Configuration;
|
||||
using StellaOps.Concelier.Connector.Epss.Internal;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Storage.Epss;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Epss.Tests;
|
||||
|
||||
public sealed class EpssConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_StoresDocument_OnSuccess()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var date = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
|
||||
var uri = new Uri(options.BaseUri, fileName);
|
||||
var payload = BuildSampleGzip(date);
|
||||
|
||||
var handler = new CannedHttpMessageHandler();
|
||||
handler.AddResponse(uri, () =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(payload)
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"epss-etag\"");
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
|
||||
return response;
|
||||
});
|
||||
|
||||
var documentStore = new InMemoryDocumentStore();
|
||||
var dtoStore = new InMemoryDtoStore();
|
||||
var stateRepository = new InMemorySourceStateRepository();
|
||||
var connector = CreateConnector(handler, documentStore, dtoStore, stateRepository, options);
|
||||
|
||||
await connector.FetchAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
|
||||
|
||||
var record = await documentStore.FindBySourceAndUriAsync(EpssConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
|
||||
|
||||
var state = await stateRepository.TryGetAsync(EpssConnectorPlugin.SourceName, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
var cursor = EpssCursor.FromDocument(state!.Cursor);
|
||||
Assert.Contains(record.Id, cursor.PendingDocuments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_ReturnsNotModified_OnEtagMatch()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var date = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
|
||||
var uri = new Uri(options.BaseUri, fileName);
|
||||
|
||||
var documentStore = new InMemoryDocumentStore();
|
||||
var dtoStore = new InMemoryDtoStore();
|
||||
var stateRepository = new InMemorySourceStateRepository();
|
||||
|
||||
var existing = new DocumentRecord(
|
||||
Guid.NewGuid(),
|
||||
EpssConnectorPlugin.SourceName,
|
||||
uri.ToString(),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256-previous",
|
||||
DocumentStatuses.Mapped,
|
||||
"application/gzip",
|
||||
Headers: null,
|
||||
Metadata: null,
|
||||
Etag: "\"epss-etag\"",
|
||||
LastModified: DateTimeOffset.UtcNow,
|
||||
PayloadId: null,
|
||||
ExpiresAt: null,
|
||||
Payload: null);
|
||||
|
||||
await documentStore.UpsertAsync(existing, CancellationToken.None);
|
||||
await stateRepository.UpdateCursorAsync(
|
||||
EpssConnectorPlugin.SourceName,
|
||||
EpssCursor.Empty with { ETag = "\"epss-etag\"" }.ToDocumentObject(),
|
||||
DateTimeOffset.UtcNow,
|
||||
CancellationToken.None);
|
||||
|
||||
var handler = new CannedHttpMessageHandler();
|
||||
handler.AddResponse(uri, () => new HttpResponseMessage(HttpStatusCode.NotModified));
|
||||
|
||||
var connector = CreateConnector(handler, documentStore, dtoStore, stateRepository, options);
|
||||
|
||||
await connector.FetchAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
|
||||
|
||||
var record = await documentStore.FindBySourceAndUriAsync(EpssConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal("\"epss-etag\"", record!.Etag);
|
||||
|
||||
var state = await stateRepository.TryGetAsync(EpssConnectorPlugin.SourceName, CancellationToken.None);
|
||||
var cursor = EpssCursor.FromDocument(state!.Cursor);
|
||||
Assert.Empty(cursor.PendingDocuments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_CreatesDto_AndUpdatesStatus()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var date = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
|
||||
var uri = new Uri(options.BaseUri, fileName);
|
||||
var payload = BuildSampleGzip(date);
|
||||
|
||||
var documentStore = new InMemoryDocumentStore();
|
||||
var dtoStore = new InMemoryDtoStore();
|
||||
var stateRepository = new InMemorySourceStateRepository();
|
||||
|
||||
var recordId = Guid.NewGuid();
|
||||
var rawStorage = new RawDocumentStorage(documentStore);
|
||||
await rawStorage.UploadAsync(EpssConnectorPlugin.SourceName, uri.ToString(), payload, "application/gzip", CancellationToken.None, recordId);
|
||||
|
||||
var document = new DocumentRecord(
|
||||
recordId,
|
||||
EpssConnectorPlugin.SourceName,
|
||||
uri.ToString(),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256-test",
|
||||
DocumentStatuses.PendingParse,
|
||||
"application/gzip",
|
||||
Headers: null,
|
||||
Metadata: new Dictionary<string, string> { ["epss.date"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) },
|
||||
Etag: null,
|
||||
LastModified: null,
|
||||
PayloadId: recordId,
|
||||
ExpiresAt: null,
|
||||
Payload: payload);
|
||||
|
||||
await documentStore.UpsertAsync(document, CancellationToken.None);
|
||||
await stateRepository.UpdateCursorAsync(
|
||||
EpssConnectorPlugin.SourceName,
|
||||
EpssCursor.Empty with { PendingDocuments = new[] { recordId } }.ToDocumentObject(),
|
||||
DateTimeOffset.UtcNow,
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = CreateConnector(rawStorage, documentStore, dtoStore, stateRepository, options);
|
||||
|
||||
await connector.ParseAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
|
||||
|
||||
var dto = await dtoStore.FindByDocumentIdAsync(recordId, CancellationToken.None);
|
||||
Assert.NotNull(dto);
|
||||
|
||||
var updated = await documentStore.FindAsync(recordId, CancellationToken.None);
|
||||
Assert.Equal(DocumentStatuses.PendingMap, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAsync_MarksDocumentMapped()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var date = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
|
||||
var uri = new Uri(options.BaseUri, fileName);
|
||||
var payload = BuildSampleGzip(date);
|
||||
|
||||
var documentStore = new InMemoryDocumentStore();
|
||||
var dtoStore = new InMemoryDtoStore();
|
||||
var stateRepository = new InMemorySourceStateRepository();
|
||||
|
||||
var recordId = Guid.NewGuid();
|
||||
var rawStorage = new RawDocumentStorage(documentStore);
|
||||
await rawStorage.UploadAsync(EpssConnectorPlugin.SourceName, uri.ToString(), payload, "application/gzip", CancellationToken.None, recordId);
|
||||
|
||||
var document = new DocumentRecord(
|
||||
recordId,
|
||||
EpssConnectorPlugin.SourceName,
|
||||
uri.ToString(),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256-test",
|
||||
DocumentStatuses.PendingMap,
|
||||
"application/gzip",
|
||||
Headers: null,
|
||||
Metadata: new Dictionary<string, string> { ["epss.date"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) },
|
||||
Etag: null,
|
||||
LastModified: null,
|
||||
PayloadId: recordId,
|
||||
ExpiresAt: null,
|
||||
Payload: payload);
|
||||
|
||||
await documentStore.UpsertAsync(document, CancellationToken.None);
|
||||
|
||||
var dtoPayload = new DocumentObject
|
||||
{
|
||||
["modelVersion"] = $"v{date:yyyy.MM.dd}",
|
||||
["publishedDate"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
["rowCount"] = 2,
|
||||
["contentHash"] = "sha256:placeholder"
|
||||
};
|
||||
|
||||
await dtoStore.UpsertAsync(new DtoRecord(
|
||||
Guid.NewGuid(),
|
||||
recordId,
|
||||
EpssConnectorPlugin.SourceName,
|
||||
"epss.snapshot.v1",
|
||||
dtoPayload,
|
||||
DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
|
||||
await stateRepository.UpdateCursorAsync(
|
||||
EpssConnectorPlugin.SourceName,
|
||||
EpssCursor.Empty with { PendingMappings = new[] { recordId } }.ToDocumentObject(),
|
||||
DateTimeOffset.UtcNow,
|
||||
CancellationToken.None);
|
||||
|
||||
var connector = CreateConnector(rawStorage, documentStore, dtoStore, stateRepository, options);
|
||||
|
||||
await connector.MapAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
|
||||
|
||||
var updated = await documentStore.FindAsync(recordId, CancellationToken.None);
|
||||
Assert.Equal(DocumentStatuses.Mapped, updated!.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.75, EpssBand.Critical)]
|
||||
[InlineData(0.55, EpssBand.High)]
|
||||
[InlineData(0.25, EpssBand.Medium)]
|
||||
[InlineData(0.05, EpssBand.Low)]
|
||||
public void ToObservation_AssignsBand(double score, EpssBand expected)
|
||||
{
|
||||
var row = new EpssScoreRow("CVE-2025-0001", score, 0.5);
|
||||
|
||||
var observation = EpssMapper.ToObservation(row, "v2025.12.21", new DateOnly(2025, 12, 21));
|
||||
|
||||
Assert.Equal(expected, observation.Band);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EpssCursor_Empty_UsesMinValue()
|
||||
{
|
||||
var cursor = EpssCursor.Empty;
|
||||
|
||||
Assert.Equal(DateTimeOffset.MinValue, cursor.UpdatedAt);
|
||||
Assert.Empty(cursor.PendingDocuments);
|
||||
Assert.Empty(cursor.PendingMappings);
|
||||
}
|
||||
|
||||
private static EpssOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
BaseUri = new Uri("https://epss.example/"),
|
||||
FetchCurrent = true,
|
||||
CatchUpDays = 0,
|
||||
HttpTimeout = TimeSpan.FromSeconds(10),
|
||||
MaxRetries = 0,
|
||||
AirgapMode = false
|
||||
};
|
||||
|
||||
private static EpssConnector CreateConnector(
|
||||
CannedHttpMessageHandler handler,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
EpssOptions options)
|
||||
{
|
||||
var client = handler.CreateClient();
|
||||
var factory = new SingleClientFactory(client);
|
||||
var rawStorage = new RawDocumentStorage(documentStore);
|
||||
var diagnostics = new EpssDiagnostics();
|
||||
var hash = DefaultCryptoHash.CreateForTests();
|
||||
return new EpssConnector(
|
||||
factory,
|
||||
rawStorage,
|
||||
documentStore,
|
||||
dtoStore,
|
||||
stateRepository,
|
||||
Options.Create(options),
|
||||
diagnostics,
|
||||
hash,
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssConnector>.Instance);
|
||||
}
|
||||
|
||||
private static EpssConnector CreateConnector(
|
||||
RawDocumentStorage rawStorage,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
EpssOptions options)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
var factory = new SingleClientFactory(client);
|
||||
var diagnostics = new EpssDiagnostics();
|
||||
var hash = DefaultCryptoHash.CreateForTests();
|
||||
return new EpssConnector(
|
||||
factory,
|
||||
rawStorage,
|
||||
documentStore,
|
||||
dtoStore,
|
||||
stateRepository,
|
||||
Options.Create(options),
|
||||
diagnostics,
|
||||
hash,
|
||||
TimeProvider.System,
|
||||
NullLogger<EpssConnector>.Instance);
|
||||
}
|
||||
|
||||
private static byte[] BuildSampleGzip(DateOnly date)
|
||||
{
|
||||
var modelVersion = $"v{date:yyyy.MM.dd}";
|
||||
var lines = new[]
|
||||
{
|
||||
$"# model {modelVersion}",
|
||||
$"# date {date:yyyy-MM-dd}",
|
||||
"cve,epss,percentile",
|
||||
"CVE-2024-0001,0.42,0.91",
|
||||
"CVE-2024-0002,0.82,0.99"
|
||||
};
|
||||
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var writer = new StreamWriter(gzip, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true))
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
writer.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientFactory(HttpClient client)
|
||||
=> _client = client;
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Epss/StellaOps.Concelier.Connector.Epss.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Integration.Tests;
|
||||
|
||||
public sealed class DistroVersionCrossCheckTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
[IntegrationFact]
|
||||
public async Task CrossCheck_InstalledVersionsMatchComparers()
|
||||
{
|
||||
var fixtures = LoadFixtures();
|
||||
var groups = fixtures
|
||||
.GroupBy(fixture => fixture.Image, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
await using var container = new ContainerBuilder()
|
||||
.WithImage(group.Key)
|
||||
.WithCommand("sh", "-c", "sleep 3600")
|
||||
.Build();
|
||||
|
||||
await container.StartAsync();
|
||||
|
||||
foreach (var fixture in group)
|
||||
{
|
||||
var installed = await GetInstalledVersionAsync(container, fixture, CancellationToken.None);
|
||||
var actual = CompareVersions(fixture, installed);
|
||||
Assert.Equal(fixture.ExpectedComparison, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> GetInstalledVersionAsync(
|
||||
IContainer container,
|
||||
DistroVersionFixture fixture,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var output = fixture.Distro switch
|
||||
{
|
||||
"rpm" => await RunCommandAsync(container,
|
||||
$"rpm -q --qf '%{{NAME}}-%{{EPOCHNUM}}:%{{VERSION}}-%{{RELEASE}}.%{{ARCH}}' {fixture.Package}", ct),
|
||||
"deb" => await RunCommandAsync(container,
|
||||
$"dpkg-query -W -f='${{Version}}' {fixture.Package}", ct),
|
||||
"apk" => await RunCommandAsync(container, $"apk info -v {fixture.Package}", ct),
|
||||
_ => throw new InvalidOperationException($"Unsupported distro: {fixture.Distro}")
|
||||
};
|
||||
|
||||
return fixture.Distro switch
|
||||
{
|
||||
"apk" => ExtractApkVersion(fixture.Package, output),
|
||||
_ => output.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareVersions(DistroVersionFixture fixture, string installedVersion)
|
||||
{
|
||||
return fixture.Distro switch
|
||||
{
|
||||
"rpm" => Math.Sign(CompareRpm(installedVersion, fixture.FixedVersion)),
|
||||
"deb" => Math.Sign(DebianEvrComparer.Instance.Compare(installedVersion, fixture.FixedVersion)),
|
||||
"apk" => Math.Sign(ApkVersionComparer.Instance.Compare(installedVersion, fixture.FixedVersion)),
|
||||
_ => throw new InvalidOperationException($"Unsupported distro: {fixture.Distro}")
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareRpm(string installed, string fixedEvr)
|
||||
{
|
||||
if (!Nevra.TryParse(installed, out var nevra) || nevra is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to parse NEVRA '{installed}'.");
|
||||
}
|
||||
|
||||
var fixedNevra = $"{nevra.Name}-{fixedEvr}";
|
||||
if (!string.IsNullOrWhiteSpace(nevra.Architecture))
|
||||
{
|
||||
fixedNevra = $"{fixedNevra}.{nevra.Architecture}";
|
||||
}
|
||||
|
||||
return NevraComparer.Instance.Compare(installed, fixedNevra);
|
||||
}
|
||||
|
||||
private static async Task<string> RunCommandAsync(IContainer container, string command, CancellationToken ct)
|
||||
{
|
||||
var result = await container.ExecAsync(new[] { "sh", "-c", command }, ct);
|
||||
if (result.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Command failed ({result.ExitCode}): {command}\n{result.Stderr}");
|
||||
}
|
||||
|
||||
return result.Stdout.Trim();
|
||||
}
|
||||
|
||||
private static string ExtractApkVersion(string package, string output)
|
||||
{
|
||||
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var prefix = package + "-";
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed[prefix.Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return lines.Length > 0 ? lines[0].Trim() : string.Empty;
|
||||
}
|
||||
|
||||
private static List<DistroVersionFixture> LoadFixtures()
|
||||
{
|
||||
var path = ResolveFixturePath("distro-version-crosscheck.json");
|
||||
var payload = File.ReadAllText(path);
|
||||
var fixtures = JsonSerializer.Deserialize<List<DistroVersionFixture>>(payload, JsonOptions)
|
||||
?? new List<DistroVersionFixture>();
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
private static string ResolveFixturePath(string filename)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", filename),
|
||||
Path.Combine(GetProjectRoot(), "Fixtures", filename)
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
|
||||
}
|
||||
|
||||
private static string GetProjectRoot()
|
||||
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
|
||||
private sealed record DistroVersionFixture(
|
||||
string Image,
|
||||
string Distro,
|
||||
string Package,
|
||||
string FixedVersion,
|
||||
int ExpectedComparison,
|
||||
string? Note);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"image": "registry.access.redhat.com/ubi9:latest",
|
||||
"distro": "rpm",
|
||||
"package": "glibc",
|
||||
"fixedVersion": "0:0-0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "registry.access.redhat.com/ubi9:latest",
|
||||
"distro": "rpm",
|
||||
"package": "rpm",
|
||||
"fixedVersion": "0:0-0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "registry.access.redhat.com/ubi9:latest",
|
||||
"distro": "rpm",
|
||||
"package": "openssl-libs",
|
||||
"fixedVersion": "0:0-0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "debian:12-slim",
|
||||
"distro": "deb",
|
||||
"package": "dpkg",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "debian:12-slim",
|
||||
"distro": "deb",
|
||||
"package": "libc6",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "debian:12-slim",
|
||||
"distro": "deb",
|
||||
"package": "base-files",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"distro": "deb",
|
||||
"package": "dpkg",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"distro": "deb",
|
||||
"package": "libc6",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"distro": "deb",
|
||||
"package": "base-files",
|
||||
"fixedVersion": "0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "alpine:3.20",
|
||||
"distro": "apk",
|
||||
"package": "apk-tools",
|
||||
"fixedVersion": "0-r0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "alpine:3.20",
|
||||
"distro": "apk",
|
||||
"package": "busybox",
|
||||
"fixedVersion": "0-r0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
},
|
||||
{
|
||||
"image": "alpine:3.20",
|
||||
"distro": "apk",
|
||||
"package": "zlib",
|
||||
"fixedVersion": "0-r0",
|
||||
"expectedComparison": 1,
|
||||
"note": "baseline floor"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Integration.Tests;
|
||||
|
||||
internal static class IntegrationTestSettings
|
||||
{
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable("STELLAOPS_INTEGRATION_TESTS");
|
||||
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationFactAttribute : FactAttribute
|
||||
{
|
||||
public IntegrationFactAttribute()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public IntegrationTheoryAttribute()
|
||||
{
|
||||
if (!IntegrationTestSettings.IsEnabled)
|
||||
{
|
||||
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class ApkVersionComparerTests
|
||||
{
|
||||
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ComparisonCases))]
|
||||
public void Compare_ApkVersions_ReturnsExpectedOrder(string left, string right, int expected, string note)
|
||||
{
|
||||
var actual = Math.Sign(ApkVersionComparer.Instance.Compare(left, right));
|
||||
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ParsesApkVersionComponents()
|
||||
{
|
||||
var result = ApkVersionComparer.Instance.Compare(
|
||||
ApkVersion.Parse("3.1.4-r0"),
|
||||
ApkVersion.Parse("3.1.3-r2"));
|
||||
|
||||
Assert.True(result > 0);
|
||||
}
|
||||
|
||||
private static TheoryData<string, string, int, string> BuildComparisonCases()
|
||||
{
|
||||
var data = new TheoryData<string, string, int, string>();
|
||||
|
||||
// Suffix ordering.
|
||||
data.Add("1.0_alpha", "1.0_beta", -1, "suffix ordering: alpha < beta");
|
||||
data.Add("1.0_beta", "1.0_pre", -1, "suffix ordering: beta < pre");
|
||||
data.Add("1.0_pre", "1.0_rc", -1, "suffix ordering: pre < rc");
|
||||
data.Add("1.0_rc", "1.0", -1, "suffix ordering: rc < none");
|
||||
data.Add("1.0", "1.0_p", -1, "suffix ordering: none < p");
|
||||
data.Add("1.0_alpha", "1.0_p", -1, "suffix ordering: alpha < p");
|
||||
|
||||
// Suffix numeric ordering.
|
||||
data.Add("1.0_alpha1", "1.0_alpha2", -1, "suffix numeric ordering");
|
||||
data.Add("1.0_alpha2", "1.0_alpha10", -1, "suffix numeric ordering");
|
||||
data.Add("1.0_pre1", "1.0_pre2", -1, "suffix numeric ordering");
|
||||
data.Add("1.0_rc1", "1.0_rc2", -1, "suffix numeric ordering");
|
||||
data.Add("1.0_beta1", "1.0_beta01", 0, "suffix numeric leading zeros ignored");
|
||||
|
||||
// Numeric ordering in version.
|
||||
data.Add("1.2.3", "1.2.10", -1, "numeric segment ordering");
|
||||
data.Add("1.10.0", "1.2.9", 1, "numeric segment ordering");
|
||||
data.Add("2.0", "1.9", 1, "major version ordering");
|
||||
data.Add("1.02", "1.2", 0, "leading zeros ignored");
|
||||
data.Add("1.2.03", "1.2.3", 0, "leading zeros ignored");
|
||||
|
||||
// Alpha segment ordering.
|
||||
data.Add("1.2.3a", "1.2.3b", -1, "alpha segment ordering");
|
||||
data.Add("1.2.3a", "1.2.3", 1, "alpha sorts after empty");
|
||||
data.Add("1.2.3", "1.2.3a", -1, "empty sorts before alpha");
|
||||
data.Add("1.2.3aa", "1.2.3b", -1, "alpha lexical ordering");
|
||||
|
||||
// Package release ordering.
|
||||
data.Add("1.2.3-r0", "1.2.3-r1", -1, "pkgrel ordering");
|
||||
data.Add("1.2.3-r1", "1.2.3-r2", -1, "pkgrel ordering");
|
||||
data.Add("1.2.3-r2", "1.2.3-r10", -1, "pkgrel numeric ordering");
|
||||
data.Add("1.2.3-r10", "1.2.3-r2", 1, "pkgrel numeric ordering");
|
||||
data.Add("1.2.3", "1.2.3-r0", -1, "implicit release sorts before explicit r0");
|
||||
|
||||
// Combined ordering.
|
||||
data.Add("1.2.3_p1-r0", "1.2.3_p1-r1", -1, "pkgrel ordering after suffix");
|
||||
data.Add("1.2.3_rc1-r1", "1.2.3-r0", -1, "rc sorts before release even with higher pkgrel");
|
||||
data.Add("1.2.3_p1-r0", "1.2.3-r9", 1, "patch suffix sorts after release");
|
||||
data.Add("1.2.3_pre2-r3", "1.2.3_pre10-r1", -1, "suffix numeric ordering beats pkgrel");
|
||||
data.Add("1.2.3", "1.2.3", 0, "exact match");
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -81,4 +81,70 @@ public sealed class DebianEvrComparerTests
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ComparisonCases))]
|
||||
public void Compare_DebianEvr_ReturnsExpectedOrder(string left, string right, int expected, string note)
|
||||
{
|
||||
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
|
||||
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
private static TheoryData<string, string, int, string> BuildComparisonCases()
|
||||
{
|
||||
var data = new TheoryData<string, string, int, string>();
|
||||
|
||||
// Epoch precedence.
|
||||
data.Add("0:1.0-1", "1:1.0-1", -1, "epoch precedence: 0 < 1");
|
||||
data.Add("1:1.0-1", "0:9.9-9", 1, "epoch precedence: 1 > 0");
|
||||
data.Add("2:0.1-1", "1:9.9-9", 1, "epoch precedence: 2 > 1");
|
||||
data.Add("3:1.0-1", "4:0.1-1", -1, "epoch precedence: 3 < 4");
|
||||
data.Add("5:2.0-1", "4:9.9-9", 1, "epoch precedence: 5 > 4");
|
||||
data.Add("1:2.0-1", "2:1.0-1", -1, "epoch precedence: 1 < 2");
|
||||
|
||||
// Numeric ordering in upstream version.
|
||||
for (var i = 1; i <= 12; i++)
|
||||
{
|
||||
data.Add($"0:1.{i}-1", $"0:1.{i + 1}-1", -1, "numeric segment ordering");
|
||||
}
|
||||
|
||||
data.Add("0:1.09-1", "0:1.9-1", 0, "leading zeros ignored");
|
||||
data.Add("0:2.001-1", "0:2.1-1", 0, "leading zeros ignored");
|
||||
|
||||
// Tilde pre-releases.
|
||||
data.Add("0:1.0~alpha1-1", "0:1.0~alpha2-1", -1, "tilde pre-release ordering");
|
||||
data.Add("0:1.0~rc1-1", "0:1.0-1", -1, "tilde sorts before release");
|
||||
data.Add("0:1.0~~-1", "0:1.0~-1", -1, "double tilde sorts earlier");
|
||||
data.Add("0:2.0~beta-1", "0:2.0~rc-1", -1, "tilde alpha ordering");
|
||||
data.Add("0:1.0~rc1-1", "0:1.0~rc2-1", -1, "tilde rc ordering");
|
||||
data.Add("0:1.0~rc-1", "0:1.0~rc-2", -1, "revision breaks tilde ties");
|
||||
|
||||
// Debian revision ordering.
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
data.Add($"0:1.0-{i}", $"0:1.0-{i + 1}", -1, "revision numeric ordering");
|
||||
}
|
||||
|
||||
data.Add("0:1.0-1", "0:1.0-1ubuntu0.1", -1, "ubuntu security backport");
|
||||
data.Add("0:1.0-1ubuntu0.1", "0:1.0-1ubuntu0.2", -1, "ubuntu incremental backport");
|
||||
data.Add("0:1.0-1ubuntu1", "0:1.0-1ubuntu2", -1, "ubuntu delta update");
|
||||
data.Add("0:1.0-1build1", "0:1.0-1build2", -1, "ubuntu rebuild");
|
||||
data.Add("0:1.0-1+deb12u1", "0:1.0-1+deb12u2", -1, "debian stable update");
|
||||
data.Add("0:1.0-1ubuntu0.2", "0:1.0-1ubuntu1", -1, "ubuntu ordering baseline");
|
||||
data.Add("0:1.0-1ubuntu1", "0:1.0-1ubuntu1.1", -1, "ubuntu dotted revision");
|
||||
data.Add("0:1.0-1ubuntu1.1", "0:1.0-1ubuntu1.2", -1, "ubuntu dotted revision ordering");
|
||||
data.Add("0:1.0-1+deb12u1", "0:1.0-1ubuntu1", -1, "debian update before ubuntu delta");
|
||||
data.Add("0:1.0-1ubuntu2", "0:1.0-1ubuntu10", -1, "ubuntu numeric ordering");
|
||||
|
||||
// Native package handling.
|
||||
data.Add("0:1.0", "0:1.0-1", -1, "native package sorts before revisioned");
|
||||
data.Add("0:1.0", "0:1.0+deb12u1", -1, "native package sorts before debian update");
|
||||
data.Add("1:1.0", "1:1.0-1", -1, "native package sorts before revisioned");
|
||||
data.Add("0:2.0", "0:2.0-0", -1, "native package sorts before zero revision");
|
||||
data.Add("0:2.0-0", "0:2.0-1", -1, "zero revision sorts before higher revision");
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
param(
|
||||
[string]$Configuration = "Debug"
|
||||
)
|
||||
|
||||
$root = Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..\\..")
|
||||
$mergePath = Join-Path $root "__Libraries\\StellaOps.Concelier.Merge\\bin\\$Configuration\\net10.0\\StellaOps.Concelier.Merge.dll"
|
||||
$normPath = Join-Path $root "__Libraries\\StellaOps.Concelier.Normalization\\bin\\$Configuration\\net10.0\\StellaOps.Concelier.Normalization.dll"
|
||||
|
||||
if (-not (Test-Path $mergePath)) {
|
||||
throw "Build StellaOps.Concelier.Merge first. Missing: $mergePath"
|
||||
}
|
||||
|
||||
[System.Reflection.Assembly]::LoadFrom($mergePath) | Out-Null
|
||||
if (Test-Path $normPath) {
|
||||
[System.Reflection.Assembly]::LoadFrom($normPath) | Out-Null
|
||||
}
|
||||
|
||||
$nevraComparer = [StellaOps.Concelier.Merge.Comparers.NevraComparer]::Instance
|
||||
$debComparer = [StellaOps.Concelier.Merge.Comparers.DebianEvrComparer]::Instance
|
||||
$apkComparer = [StellaOps.Concelier.Merge.Comparers.ApkVersionComparer]::Instance
|
||||
|
||||
$rpmVersions = @(
|
||||
'kernel-0:4.18.0-80.el8.x86_64',
|
||||
'kernel-1:4.18.0-80.el8.x86_64',
|
||||
'kernel-0:4.18.11-80.el8.x86_64',
|
||||
'pkg-0:1.0-1.el9.noarch',
|
||||
'pkg-0:1.0-1.el9.x86_64',
|
||||
'pkg-0:1.0-2.el9.x86_64',
|
||||
'pkg-0:1.0-10.el9.x86_64',
|
||||
'pkg-0:1.0~rc1-1.el9.x86_64',
|
||||
'pkg-0:1.0-1.fc35.x86_64',
|
||||
'pkg-0:1.0-1.fc36.x86_64',
|
||||
'openssl-1:1.1.1k-7.el8.x86_64',
|
||||
'openssl-3:1.1.1k-7.el8.x86_64',
|
||||
'podman-1:4.5.0-1.el9.x86_64',
|
||||
'podman-2:4.4.0-1.el9.x86_64',
|
||||
'glibc-4:2.36-9.el9.x86_64',
|
||||
'glibc-5:2.36-8.el9.x86_64'
|
||||
)
|
||||
|
||||
$debVersions = @(
|
||||
'0:1.0-1',
|
||||
'1.0-1',
|
||||
'1.0-1ubuntu0.1',
|
||||
'1.0-1ubuntu0.2',
|
||||
'1.0-1ubuntu1',
|
||||
'1.0-1ubuntu2',
|
||||
'1.0-1+deb12u1',
|
||||
'1.0-1+deb12u2',
|
||||
'1:1.1.1n-0+deb11u2',
|
||||
'1:1.1.1n-0+deb11u5',
|
||||
'2.0~beta1-1',
|
||||
'2.0~rc1-1',
|
||||
'2.0-1',
|
||||
'1.2.3-1',
|
||||
'1.2.10-1',
|
||||
'3.0.0-1'
|
||||
)
|
||||
|
||||
$apkVersions = @(
|
||||
'1.0_alpha1-r0',
|
||||
'1.0_beta1-r0',
|
||||
'1.0_pre1-r0',
|
||||
'1.0_rc1-r0',
|
||||
'1.0-r0',
|
||||
'1.0_p1-r0',
|
||||
'1.2.3-r0',
|
||||
'1.2.10-r0',
|
||||
'2.0-r0',
|
||||
'1.2.3a-r0',
|
||||
'1.2.3b-r0',
|
||||
'1.2.3-r1',
|
||||
'1.2.3-r2',
|
||||
'1.2.3_p1-r1',
|
||||
'3.9.1-r0',
|
||||
'3.1.1-r0'
|
||||
)
|
||||
|
||||
function New-Cases {
|
||||
param(
|
||||
[string[]]$Versions,
|
||||
[string]$Distro,
|
||||
$Comparer
|
||||
)
|
||||
|
||||
$cases = New-Object System.Collections.Generic.List[object]
|
||||
for ($i = 0; $i -lt $Versions.Count; $i++) {
|
||||
for ($j = $i + 1; $j -lt $Versions.Count; $j++) {
|
||||
$left = $Versions[$i]
|
||||
$right = $Versions[$j]
|
||||
$expected = [Math]::Sign($Comparer.Compare($left, $right))
|
||||
$cases.Add([ordered]@{
|
||||
left = $left
|
||||
right = $right
|
||||
expected = $expected
|
||||
distro = $Distro
|
||||
note = "pairwise"
|
||||
})
|
||||
}
|
||||
}
|
||||
return $cases
|
||||
}
|
||||
|
||||
function Write-GoldenFile {
|
||||
param(
|
||||
[string]$Path,
|
||||
$Cases
|
||||
)
|
||||
$lines = foreach ($case in $Cases) {
|
||||
$case | ConvertTo-Json -Compress
|
||||
}
|
||||
Set-Content -Path $Path -Value ($lines -join "`n") -Encoding ascii
|
||||
}
|
||||
|
||||
$rpmCases = New-Cases -Versions $rpmVersions -Distro "rpm" -Comparer $nevraComparer
|
||||
$debCases = New-Cases -Versions $debVersions -Distro "deb" -Comparer $debComparer
|
||||
$apkCases = New-Cases -Versions $apkVersions -Distro "apk" -Comparer $apkComparer
|
||||
|
||||
Write-GoldenFile -Path (Join-Path $PSScriptRoot "rpm_version_comparison.golden.ndjson") -Cases $rpmCases
|
||||
Write-GoldenFile -Path (Join-Path $PSScriptRoot "deb_version_comparison.golden.ndjson") -Cases $debCases
|
||||
Write-GoldenFile -Path (Join-Path $PSScriptRoot "apk_version_comparison.golden.ndjson") -Cases $apkCases
|
||||
@@ -0,0 +1,28 @@
|
||||
# Distro Version Comparison Goldens
|
||||
|
||||
Golden files store pairwise version comparison results in NDJSON to guard
|
||||
regressions in distro-specific comparers (RPM, Debian, Alpine APK).
|
||||
|
||||
## Format
|
||||
Each line is a single JSON object:
|
||||
|
||||
```
|
||||
{"left":"0:1.0-1.el8","right":"1:0.1-1.el8","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- left/right: version strings as understood by the target comparer.
|
||||
- expected: comparison result (-1, 0, 1) after Math.Sign.
|
||||
- distro: rpm | deb | apk.
|
||||
- note: optional human note or generation hint.
|
||||
|
||||
## Updating goldens
|
||||
1) Build the comparers:
|
||||
`dotnet build ..\..\..\..\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj`
|
||||
2) Regenerate:
|
||||
`pwsh .\GenerateGoldenComparisons.ps1`
|
||||
|
||||
Files:
|
||||
- rpm_version_comparison.golden.ndjson
|
||||
- deb_version_comparison.golden.ndjson
|
||||
- apk_version_comparison.golden.ndjson
|
||||
@@ -0,0 +1,120 @@
|
||||
{"left":"1.0_alpha1-r0","right":"1.0_beta1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.0_pre1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_alpha1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.0_pre1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_beta1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_pre1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_rc1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.0_p1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"1.2.3a-r0","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"1.2.3b-r0","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"1.2.3_p1-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.10-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"1.2.3a-r0","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"1.2.3b-r0","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"1.2.3_p1-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"2.0-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3a-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3b-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3b-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3b-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3b-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3b-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r1","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r1","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r1","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r1","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r2","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r2","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3-r2","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3_p1-r1","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"1.2.3_p1-r1","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
|
||||
{"left":"3.9.1-r0","right":"3.1.1-r0","expected":1,"distro":"apk","note":"pairwise"}
|
||||
@@ -0,0 +1,120 @@
|
||||
{"left":"0:1.0-1","right":"1.0-1","expected":0,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1ubuntu0.1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1\u002Bdeb12u1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"0:1.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1ubuntu0.1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1\u002Bdeb12u1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu0.2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1ubuntu2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.0-1\u002Bdeb12u2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0~beta1-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0~rc1-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"3.0.0-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0~beta1-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0~rc1-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"3.0.0-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~beta1-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~beta1-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~beta1-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~beta1-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~beta1-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~rc1-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~rc1-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~rc1-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0~rc1-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"2.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.2.3-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.2.3-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
{"left":"1.2.10-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
|
||||
@@ -0,0 +1,120 @@
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"kernel-1:4.18.0-80.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"kernel-0:4.18.11-80.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"kernel-0:4.18.11-80.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.noarch","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-2.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-10.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"podman-2:4.4.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"podman-2:4.4.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
|
||||
{"left":"glibc-4:2.36-9.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Tests;
|
||||
|
||||
public sealed class GoldenVersionComparisonTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("rpm_version_comparison.golden.ndjson", "rpm")]
|
||||
[InlineData("deb_version_comparison.golden.ndjson", "deb")]
|
||||
[InlineData("apk_version_comparison.golden.ndjson", "apk")]
|
||||
public void GoldenFiles_MatchComparers(string fileName, string distro)
|
||||
{
|
||||
if (ShouldUpdateGoldens())
|
||||
{
|
||||
WriteGoldens(fileName, distro);
|
||||
return;
|
||||
}
|
||||
|
||||
var cases = LoadCases(fileName);
|
||||
Assert.True(cases.Count >= 100, $"Expected at least 100 cases in {fileName}.");
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var testCase in cases)
|
||||
{
|
||||
var actual = distro switch
|
||||
{
|
||||
"rpm" => Math.Sign(NevraComparer.Instance.Compare(testCase.Left, testCase.Right)),
|
||||
"deb" => Math.Sign(DebianEvrComparer.Instance.Compare(testCase.Left, testCase.Right)),
|
||||
"apk" => Math.Sign(ApkVersionComparer.Instance.Compare(testCase.Left, testCase.Right)),
|
||||
_ => throw new InvalidOperationException($"Unsupported distro: {distro}")
|
||||
};
|
||||
|
||||
if (actual != testCase.Expected)
|
||||
{
|
||||
failures.Add($"FAIL {distro}: {testCase.Left} vs {testCase.Right} expected {testCase.Expected} got {actual} ({testCase.Note})");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(failures);
|
||||
}
|
||||
|
||||
private static List<GoldenComparisonCase> LoadCases(string fileName)
|
||||
{
|
||||
var path = ResolveGoldenPath(fileName);
|
||||
var lines = File.ReadAllLines(path);
|
||||
var cases = new List<GoldenComparisonCase>(lines.Length);
|
||||
|
||||
foreach (var line in lines.Where(static l => !string.IsNullOrWhiteSpace(l)))
|
||||
{
|
||||
var testCase = JsonSerializer.Deserialize<GoldenComparisonCase>(line, JsonOptions);
|
||||
if (testCase is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cases.Add(testCase);
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
private static string ResolveGoldenPath(string fileName)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Golden", fileName),
|
||||
Path.Combine(GetProjectRoot(), "Fixtures", "Golden", fileName)
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Golden file '{fileName}' not found.", fileName);
|
||||
}
|
||||
|
||||
private static string GetProjectRoot()
|
||||
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
|
||||
|
||||
private static void WriteGoldens(string fileName, string distro)
|
||||
{
|
||||
var cases = BuildCases(distro);
|
||||
var path = Path.Combine(GetProjectRoot(), "Fixtures", "Golden", fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var lines = cases.Select(testCase => JsonSerializer.Serialize(testCase, JsonOptions));
|
||||
File.WriteAllText(path, string.Join('\n', lines), Encoding.ASCII);
|
||||
}
|
||||
|
||||
private static List<GoldenComparisonCase> BuildCases(string distro)
|
||||
{
|
||||
string[] versions;
|
||||
Func<string, string, int> comparer;
|
||||
|
||||
switch (distro)
|
||||
{
|
||||
case "rpm":
|
||||
versions =
|
||||
[
|
||||
"kernel-0:4.18.0-80.el8.x86_64",
|
||||
"kernel-1:4.18.0-80.el8.x86_64",
|
||||
"kernel-0:4.18.11-80.el8.x86_64",
|
||||
"pkg-0:1.0-1.el9.noarch",
|
||||
"pkg-0:1.0-1.el9.x86_64",
|
||||
"pkg-0:1.0-2.el9.x86_64",
|
||||
"pkg-0:1.0-10.el9.x86_64",
|
||||
"pkg-0:1.0~rc1-1.el9.x86_64",
|
||||
"pkg-0:1.0-1.fc35.x86_64",
|
||||
"pkg-0:1.0-1.fc36.x86_64",
|
||||
"openssl-1:1.1.1k-7.el8.x86_64",
|
||||
"openssl-3:1.1.1k-7.el8.x86_64",
|
||||
"podman-1:4.5.0-1.el9.x86_64",
|
||||
"podman-2:4.4.0-1.el9.x86_64",
|
||||
"glibc-4:2.36-9.el9.x86_64",
|
||||
"glibc-5:2.36-8.el9.x86_64"
|
||||
];
|
||||
comparer = (left, right) => Math.Sign(NevraComparer.Instance.Compare(left, right));
|
||||
break;
|
||||
case "deb":
|
||||
versions =
|
||||
[
|
||||
"0:1.0-1",
|
||||
"1.0-1",
|
||||
"1.0-1ubuntu0.1",
|
||||
"1.0-1ubuntu0.2",
|
||||
"1.0-1ubuntu1",
|
||||
"1.0-1ubuntu2",
|
||||
"1.0-1+deb12u1",
|
||||
"1.0-1+deb12u2",
|
||||
"1:1.1.1n-0+deb11u2",
|
||||
"1:1.1.1n-0+deb11u5",
|
||||
"2.0~beta1-1",
|
||||
"2.0~rc1-1",
|
||||
"2.0-1",
|
||||
"1.2.3-1",
|
||||
"1.2.10-1",
|
||||
"3.0.0-1"
|
||||
];
|
||||
comparer = (left, right) => Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
|
||||
break;
|
||||
case "apk":
|
||||
versions =
|
||||
[
|
||||
"1.0_alpha1-r0",
|
||||
"1.0_beta1-r0",
|
||||
"1.0_pre1-r0",
|
||||
"1.0_rc1-r0",
|
||||
"1.0-r0",
|
||||
"1.0_p1-r0",
|
||||
"1.2.3-r0",
|
||||
"1.2.10-r0",
|
||||
"2.0-r0",
|
||||
"1.2.3a-r0",
|
||||
"1.2.3b-r0",
|
||||
"1.2.3-r1",
|
||||
"1.2.3-r2",
|
||||
"1.2.3_p1-r1",
|
||||
"3.9.1-r0",
|
||||
"3.1.1-r0"
|
||||
];
|
||||
comparer = (left, right) => Math.Sign(ApkVersionComparer.Instance.Compare(left, right));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported distro: {distro}");
|
||||
}
|
||||
|
||||
var cases = new List<GoldenComparisonCase>(versions.Length * versions.Length);
|
||||
for (var i = 0; i < versions.Length; i++)
|
||||
{
|
||||
for (var j = i + 1; j < versions.Length; j++)
|
||||
{
|
||||
var left = versions[i];
|
||||
var right = versions[j];
|
||||
cases.Add(new GoldenComparisonCase(left, right, comparer(left, right), distro, "pairwise"));
|
||||
}
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
private static bool ShouldUpdateGoldens()
|
||||
=> IsTruthy(Environment.GetEnvironmentVariable("UPDATE_GOLDENS"))
|
||||
|| IsTruthy(Environment.GetEnvironmentVariable("DOTNET_TEST_UPDATE_GOLDENS"));
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private sealed record GoldenComparisonCase(
|
||||
string Left,
|
||||
string Right,
|
||||
int Expected,
|
||||
string? Distro,
|
||||
string? Note);
|
||||
}
|
||||
@@ -105,4 +105,81 @@ public sealed class NevraComparerTests
|
||||
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ComparisonCases))]
|
||||
public void Compare_NevraVersions_ReturnsExpectedOrder(string left, string right, int expected, string note)
|
||||
{
|
||||
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
|
||||
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
private static TheoryData<string, string, int, string> BuildComparisonCases()
|
||||
{
|
||||
var data = new TheoryData<string, string, int, string>();
|
||||
|
||||
// Epoch precedence.
|
||||
data.Add("kernel-0:4.18.0-80.el8.x86_64", "kernel-1:4.18.0-80.el8.x86_64", -1, "epoch precedence: 0 < 1");
|
||||
data.Add("kernel-2:4.18.0-80.el8.x86_64", "kernel-1:9.9.9-1.el8.x86_64", 1, "epoch precedence: 2 > 1");
|
||||
data.Add("openssl-1:1.1.1k-7.el8.x86_64", "openssl-3:1.1.1k-7.el8.x86_64", -1, "epoch precedence: 1 < 3");
|
||||
data.Add("bash-10:5.1.0-1.el9.x86_64", "bash-9:9.9.9-1.el9.x86_64", 1, "epoch precedence: 10 > 9");
|
||||
data.Add("podman-1:4.5.0-1.el9.x86_64", "podman-2:4.4.0-1.el9.x86_64", -1, "epoch precedence: 1 < 2");
|
||||
data.Add("glibc-5:2.36-8.el9.x86_64", "glibc-4:2.36-9.el9.x86_64", 1, "epoch precedence: 5 > 4");
|
||||
|
||||
// Numeric ordering.
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
data.Add($"pkg-0:1.{i}-1.el9.x86_64", $"pkg-0:1.{i + 1}-1.el9.x86_64", -1, "numeric segment ordering");
|
||||
}
|
||||
|
||||
data.Add("pkg-0:1.9-1.el9.x86_64", "pkg-0:1.10-1.el9.x86_64", -1, "numeric length ordering");
|
||||
data.Add("pkg-0:1.02-1.el9.x86_64", "pkg-0:1.2-1.el9.x86_64", 0, "leading zeros ignored");
|
||||
data.Add("pkg-0:1.002-1.el9.x86_64", "pkg-0:1.2-1.el9.x86_64", 0, "leading zeros ignored");
|
||||
|
||||
// Alpha ordering.
|
||||
data.Add("pkg-0:1.0a-1.el9.x86_64", "pkg-0:1.0b-1.el9.x86_64", -1, "alpha segment ordering");
|
||||
data.Add("pkg-0:1.0aa-1.el9.x86_64", "pkg-0:1.0b-1.el9.x86_64", -1, "alpha length ordering");
|
||||
data.Add("pkg-0:1.0b-1.el9.x86_64", "pkg-0:1.0aa-1.el9.x86_64", 1, "alpha segment ordering");
|
||||
data.Add("pkg-0:1.0a-1.el9.x86_64", "pkg-0:1.0-1.el9.x86_64", 1, "alpha sorts after empty");
|
||||
data.Add("pkg-0:1.0-1.el9.x86_64", "pkg-0:1.0a-1.el9.x86_64", -1, "empty sorts before alpha");
|
||||
data.Add("pkg-0:1.0z-1.el9.x86_64", "pkg-0:1.0aa-1.el9.x86_64", 1, "alpha lexical ordering");
|
||||
|
||||
// Tilde pre-releases.
|
||||
data.Add("pkg-0:1.0~rc1-1.el9.x86_64", "pkg-0:1.0-1.el9.x86_64", -1, "tilde sorts before release");
|
||||
data.Add("pkg-0:1.0~rc1-1.el9.x86_64", "pkg-0:1.0~rc2-1.el9.x86_64", -1, "tilde rc ordering");
|
||||
data.Add("pkg-0:1.0~~-1.el9.x86_64", "pkg-0:1.0~-1.el9.x86_64", -1, "double tilde sorts earlier");
|
||||
data.Add("pkg-0:1.0~beta-1.el9.x86_64", "pkg-0:1.0~rc-1.el9.x86_64", -1, "tilde alpha segment ordering");
|
||||
data.Add("pkg-0:1.0~rc-1.el9.x86_64", "pkg-0:1.0~rc-1.el9.x86_64", 0, "tilde equivalence");
|
||||
data.Add("pkg-0:1.0~rc-1.el9.x86_64", "pkg-0:1.0~rc-2.el9.x86_64", -1, "release breaks tilde ties");
|
||||
|
||||
// Release qualifiers and backports.
|
||||
data.Add("pkg-0:1.0-1.el8.x86_64", "pkg-0:1.0-1.el9.x86_64", -1, "release qualifier el8 < el9");
|
||||
data.Add("pkg-0:1.0-1.el8.x86_64", "pkg-0:1.0-1.el8_5.x86_64", -1, "backport suffix ordering");
|
||||
data.Add("pkg-0:1.0-1.el8_5.x86_64", "pkg-0:1.0-1.el8_5.1.x86_64", -1, "incremental backport");
|
||||
data.Add("pkg-0:1.0-1.el8_5.1.x86_64", "pkg-0:1.0-2.el8.x86_64", -1, "release increments beat base");
|
||||
data.Add("pkg-0:1.0-2.el8.x86_64", "pkg-0:1.0-10.el8.x86_64", -1, "release numeric ordering");
|
||||
data.Add("pkg-0:1.0-10.el8.x86_64", "pkg-0:1.0-2.el8.x86_64", 1, "release numeric ordering");
|
||||
data.Add("pkg-0:1.0-1.fc35.x86_64", "pkg-0:1.0-1.fc36.x86_64", -1, "release qualifier fc35 < fc36");
|
||||
data.Add("pkg-0:1.0-1.el8_5.x86_64", "pkg-0:1.0-1.el8_5.0.x86_64", -1, "zero suffix still sorts later");
|
||||
data.Add("pkg-0:1.0-1.el8_5.0.x86_64", "pkg-0:1.0-1.el8_5.x86_64", 1, "zero suffix still sorts later");
|
||||
data.Add("pkg-0:1.0-1.el8_5.1.x86_64", "pkg-0:1.0-1.el8_5.2.x86_64", -1, "backport numeric ordering");
|
||||
|
||||
// Architecture ordering.
|
||||
data.Add("pkg-0:1.0-1.el9.noarch", "pkg-0:1.0-1.el9.x86_64", -1, "architecture lexical ordering");
|
||||
data.Add("pkg-0:1.0-1.el9.aarch64", "pkg-0:1.0-1.el9.x86_64", -1, "architecture lexical ordering");
|
||||
data.Add("pkg-0:1.0-1.el9.ppc64le", "pkg-0:1.0-1.el9.ppc64", 1, "architecture lexical ordering");
|
||||
data.Add("pkg-0:1.0-1.el9.s390x", "pkg-0:1.0-1.el9.s390", 1, "architecture lexical ordering");
|
||||
data.Add("pkg-0:1.0-1.el9.arm64", "pkg-0:1.0-1.el9.aarch64", 1, "architecture lexical ordering");
|
||||
|
||||
// Package name ordering.
|
||||
data.Add("aaa-0:1.0-1.el9.x86_64", "bbb-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
|
||||
data.Add("openssl-0:1.0-1.el9.x86_64", "openssl-libs-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
|
||||
data.Add("zlib-0:1.0-1.el9.x86_64", "bzip2-0:1.0-1.el9.x86_64", 1, "name lexical ordering");
|
||||
data.Add("kernel-0:1.0-1.el9.x86_64", "kernel-core-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
|
||||
data.Add("glibc-0:1.0-1.el9.x86_64", "glibc-devel-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Concelier Merge Tests
|
||||
|
||||
This project verifies distro version comparison logic and merge rules.
|
||||
|
||||
## Layout
|
||||
- Comparer unit tests: `*.Tests.cs` in this project (RPM, Debian, APK).
|
||||
- Golden fixtures: `Fixtures/Golden/*.golden.ndjson`.
|
||||
- Integration cross-checks: `src/Concelier/__Tests/StellaOps.Concelier.Integration.Tests`.
|
||||
|
||||
## Golden files
|
||||
Golden files capture pairwise comparison results in NDJSON.
|
||||
See `Fixtures/Golden/README.md` for format and regeneration steps.
|
||||
|
||||
## Integration tests
|
||||
Cross-check tests compare container-installed versions against fixed
|
||||
versions using the same comparers. They require Docker/Testcontainers.
|
||||
|
||||
Enable with:
|
||||
`$env:STELLAOPS_INTEGRATION_TESTS = "true"`
|
||||
|
||||
Run (from repo root):
|
||||
`dotnet test src/Concelier/__Tests/StellaOps.Concelier.Integration.Tests/StellaOps.Concelier.Integration.Tests.csproj`
|
||||
@@ -10,4 +10,9 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\Golden\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Concelier Merge Comparator Test Tasks
|
||||
|
||||
Local status mirror for `docs/implplan/SPRINT_2000_0003_0002_distro_version_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| T1 | DONE | NEVRA comparison corpus expanded. |
|
||||
| T2 | DONE | Debian EVR comparison corpus expanded. |
|
||||
| T3 | DOING | Golden NDJSON fixtures + regression runner. |
|
||||
| T4 | TODO | Testcontainers real-image cross-checks. |
|
||||
| T5 | TODO | Test corpus README. |
|
||||
|
||||
Last synced: 2025-12-22 (UTC).
|
||||
@@ -71,4 +71,26 @@ public sealed class AffectedVersionRangeExtensionsTests
|
||||
|
||||
Assert.Null(rule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNormalizedVersionRule_FallsBackForApkRange()
|
||||
{
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "apk",
|
||||
introducedVersion: null,
|
||||
fixedVersion: "3.1.4-r0",
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "fixed:3.1.4-r0",
|
||||
provenance: AdvisoryProvenance.Empty,
|
||||
primitives: null);
|
||||
|
||||
var rule = range.ToNormalizedVersionRule("alpine:v3.20/main");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.Equal(NormalizedVersionSchemes.Apk, rule!.Scheme);
|
||||
Assert.Equal(NormalizedVersionRuleTypes.LessThan, rule.Type);
|
||||
Assert.Equal("3.1.4-r0", rule.Max);
|
||||
Assert.False(rule.MaxInclusive);
|
||||
Assert.Equal("alpine:v3.20/main", rule.Notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
namespace StellaOps.Concelier.Normalization.Tests;
|
||||
|
||||
public sealed class ApkVersionParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToCanonicalString_RoundTripsExplicitPkgRel()
|
||||
{
|
||||
var parsed = ApkVersion.Parse(" 3.1.4-r0 ");
|
||||
|
||||
Assert.Equal("3.1.4-r0", parsed.Original);
|
||||
Assert.Equal("3.1.4-r0", parsed.ToCanonicalString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalString_SuppressesImplicitPkgRel()
|
||||
{
|
||||
var parsed = ApkVersion.Parse("1.2.3_alpha");
|
||||
|
||||
Assert.Equal("1.2.3_alpha", parsed.ToCanonicalString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TracksExplicitRelease()
|
||||
{
|
||||
var success = ApkVersion.TryParse("2.0.1-r5", out var parsed);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(parsed!.HasExplicitPkgRel);
|
||||
Assert.Equal(5, parsed.PkgRel);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user