src/anchor_gate.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file anchor_gate.cpp | ||
| 3 | * @brief This TU owns the anchor quality summary, the launch gate verdict, and the report string tables. | ||
| 4 | * | ||
| 5 | * It consumes only the public ResolvedAnchor vocabulary. The resolution engine stays in anchor.cpp and the drift | ||
| 6 | * fingerprints in anchor_evidence.cpp. | ||
| 7 | */ | ||
| 8 | |||
| 9 | #include "DetourModKit/anchor.hpp" | ||
| 10 | |||
| 11 | #include <cmath> | ||
| 12 | #include <cstddef> | ||
| 13 | #include <span> | ||
| 14 | #include <string_view> | ||
| 15 | |||
| 16 | namespace DetourModKit | ||
| 17 | { | ||
| 18 | namespace anchor | ||
| 19 | { | ||
| 20 | namespace | ||
| 21 | { | ||
| 22 | 15 | [[nodiscard]] double clamped_gate_ratio(double ratio) noexcept | |
| 23 | { | ||
| 24 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 14 times.
|
15 | if (std::isnan(ratio)) |
| 25 | { | ||
| 26 | 1 | return 1.0; | |
| 27 | } | ||
| 28 |
2/2✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 13 times.
|
14 | if (ratio < 0.0) |
| 29 | { | ||
| 30 | 1 | return 0.0; | |
| 31 | } | ||
| 32 |
2/2✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 12 times.
|
13 | if (ratio > 1.0) |
| 33 | { | ||
| 34 | 1 | return 1.0; | |
| 35 | } | ||
| 36 | 12 | return ratio; | |
| 37 | } | ||
| 38 | } // anonymous namespace | ||
| 39 | |||
| 40 | 219 | AnchorQuality assess_quality(std::span<const ResolvedAnchor> report) noexcept | |
| 41 | { | ||
| 42 | 219 | AnchorQuality quality{}; | |
| 43 | 219 | quality.total = report.size(); | |
| 44 |
2/2✓ Branch 28 → 5 taken 118 times.
✓ Branch 28 → 29 taken 219 times.
|
556 | for (const ResolvedAnchor &entry : report) |
| 45 | { | ||
| 46 |
6/7✓ Branch 7 → 8 taken 88 times.
✓ Branch 7 → 9 taken 16 times.
✓ Branch 7 → 10 taken 4 times.
✓ Branch 7 → 11 taken 2 times.
✓ Branch 7 → 12 taken 1 time.
✓ Branch 7 → 13 taken 7 times.
✗ Branch 7 → 14 not taken.
|
118 | switch (entry.status) |
| 47 | { | ||
| 48 | 88 | case AnchorStatus::Resolved: | |
| 49 | 88 | ++quality.resolved; | |
| 50 | 88 | break; | |
| 51 | 16 | case AnchorStatus::Failed: | |
| 52 | 16 | ++quality.failed; | |
| 53 | 16 | break; | |
| 54 | 4 | case AnchorStatus::Unsupported: | |
| 55 | 4 | ++quality.unsupported; | |
| 56 | 4 | break; | |
| 57 | 2 | case AnchorStatus::QuorumNotIndependent: | |
| 58 | 2 | ++quality.not_independent; | |
| 59 | 2 | break; | |
| 60 | 1 | case AnchorStatus::QuorumAmbiguous: | |
| 61 | // This status commits no trusted value, so it is a failure alongside a backend miss. | ||
| 62 | 1 | ++quality.failed; | |
| 63 | 1 | break; | |
| 64 | 7 | case AnchorStatus::Unresolved: | |
| 65 | 7 | break; | |
| 66 | } | ||
| 67 | // A pinned literal is at-risk regardless of status: it "resolves" but cannot self-heal across a patch. | ||
| 68 |
2/2✓ Branch 14 → 15 taken 28 times.
✓ Branch 14 → 16 taken 90 times.
|
118 | if (entry.kind == AnchorKind::Manual) |
| 69 | { | ||
| 70 | 28 | ++quality.manual_at_risk; | |
| 71 | } | ||
| 72 | // A corroborated quorum is the strongest evidence: N independent signals had to agree. | ||
| 73 |
4/4✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 19 taken 113 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 17 → 19 taken 3 times.
|
118 | if (entry.kind == AnchorKind::Quorum && entry.status == AnchorStatus::Resolved) |
| 74 | { | ||
| 75 | 2 | ++quality.corroborated; | |
| 76 | } | ||
| 77 | } | ||
| 78 | 219 | return quality; | |
| 79 | } | ||
| 80 | |||
| 81 | 24 | GateVerdict evaluate_gate(const AnchorQuality &quality, const GatePolicy &policy) noexcept | |
| 82 | { | ||
| 83 | // The span overload always feeds a self-consistent summary, but the direct AnchorQuality overload is | ||
| 84 | // public. If a caller supplies impossible counts, fail closed so an inflated resolved count cannot create | ||
| 85 | // a healthy verdict. | ||
| 86 | 24 | std::size_t accounted = 0; | |
| 87 | 117 | const auto count_fits = [&accounted, total = quality.total](std::size_t count) noexcept -> bool | |
| 88 | { | ||
| 89 |
2/2✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 92 times.
|
93 | if (count > total - accounted) |
| 90 | { | ||
| 91 | 1 | return false; | |
| 92 | } | ||
| 93 | 92 | accounted += count; | |
| 94 | 92 | return true; | |
| 95 | 24 | }; | |
| 96 |
6/8✓ Branch 3 → 4 taken 23 times.
✓ Branch 3 → 10 taken 1 time.
✓ Branch 5 → 6 taken 23 times.
✗ Branch 5 → 10 not taken.
✓ Branch 7 → 8 taken 23 times.
✗ Branch 7 → 10 not taken.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 23 times.
|
47 | if (!count_fits(quality.resolved) || !count_fits(quality.failed) || !count_fits(quality.unsupported) || |
| 97 |
1/2✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 23 times.
|
23 | !count_fits(quality.not_independent)) |
| 98 | { | ||
| 99 | 1 | return GateVerdict::Fail; | |
| 100 | } | ||
| 101 | |||
| 102 | // QuorumNotIndependent counts as a failure alongside Failed for the cap: both mean a declared anchor | ||
| 103 | // yielded no verified value. Check the hard cap before the ratio. A failure-heavy manifest then fails even | ||
| 104 | // when its few resolved entries clear the ratio. | ||
| 105 |
2/2✓ Branch 14 → 15 taken 5 times.
✓ Branch 14 → 16 taken 18 times.
|
23 | if (quality.failed > policy.max_failed) |
| 106 | { | ||
| 107 | 5 | return GateVerdict::Fail; | |
| 108 | } | ||
| 109 | 18 | const std::size_t remaining_failure_budget = policy.max_failed - quality.failed; | |
| 110 |
2/2✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 17 times.
|
18 | if (quality.not_independent > remaining_failure_budget) |
| 111 | { | ||
| 112 | 1 | return GateVerdict::Fail; | |
| 113 | } | ||
| 114 | |||
| 115 | // Resolvable excludes the Unsupported kind, which has no backend and can never heal. Every other unresolved | ||
| 116 | // kind stays in the denominator. A partial result therefore reduces the ratio and fails closed. | ||
| 117 | 17 | const std::size_t resolvable = quality.total - quality.unsupported; | |
| 118 |
2/2✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 20 taken 15 times.
|
17 | if (resolvable == 0) |
| 119 | { | ||
| 120 | // No assessable entry proves runtime health. An empty report or all-unsupported table must | ||
| 121 | // not become a healthy Pass merely because no resolvable anchor contradicts it. | ||
| 122 | 2 | return GateVerdict::Degraded; | |
| 123 | } | ||
| 124 | |||
| 125 | // Clamp the ratio to [0, 1]. NaN becomes the strict default. This prevents an out-of-range value from | ||
| 126 | // inversion of the comparison. Avoid division so the full-ratio case retains exact equality. | ||
| 127 | 15 | const double ratio = clamped_gate_ratio(policy.min_resolved_ratio); | |
| 128 |
2/2✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 12 times.
|
15 | if (static_cast<double>(quality.resolved) < ratio * static_cast<double>(resolvable)) |
| 129 | { | ||
| 130 | 3 | return GateVerdict::Fail; | |
| 131 | } | ||
| 132 | |||
| 133 | // A Manual literal cannot self-heal in any status (AnchorQuality::manual_at_risk counts every Manual | ||
| 134 | // entry). If policy requests it, surface this soft risk so the caller can report silent manual offset | ||
| 135 | // drift after a patch. | ||
| 136 |
4/4✓ Branch 23 → 24 taken 10 times.
✓ Branch 23 → 26 taken 2 times.
✓ Branch 24 → 25 taken 4 times.
✓ Branch 24 → 26 taken 6 times.
|
12 | if (policy.manual_at_risk_degrades && quality.manual_at_risk > 0) |
| 137 | { | ||
| 138 | 4 | return GateVerdict::Degraded; | |
| 139 | } | ||
| 140 | 8 | return GateVerdict::Pass; | |
| 141 | } | ||
| 142 | |||
| 143 | 19 | GateVerdict evaluate_gate(std::span<const ResolvedAnchor> report, const GatePolicy &policy) noexcept | |
| 144 | { | ||
| 145 | 19 | return evaluate_gate(assess_quality(report), policy); | |
| 146 | } | ||
| 147 | |||
| 148 | 6 | std::string_view anchor_status_to_string(AnchorStatus status) noexcept | |
| 149 | { | ||
| 150 |
6/7✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 1 time.
✓ Branch 2 → 8 taken 1 time.
✗ Branch 2 → 9 not taken.
|
6 | switch (status) |
| 151 | { | ||
| 152 | 1 | case AnchorStatus::Unresolved: | |
| 153 | 1 | return "Unresolved"; | |
| 154 | 1 | case AnchorStatus::Resolved: | |
| 155 | 1 | return "Resolved"; | |
| 156 | 1 | case AnchorStatus::Failed: | |
| 157 | 1 | return "Failed"; | |
| 158 | 1 | case AnchorStatus::Unsupported: | |
| 159 | 1 | return "Unsupported"; | |
| 160 | 1 | case AnchorStatus::QuorumNotIndependent: | |
| 161 | 1 | return "QuorumNotIndependent"; | |
| 162 | 1 | case AnchorStatus::QuorumAmbiguous: | |
| 163 | 1 | return "QuorumAmbiguous"; | |
| 164 | } | ||
| 165 | ✗ | return "Unknown"; | |
| 166 | } | ||
| 167 | |||
| 168 | 5 | std::string_view result_domain_to_string(ResultDomain domain) noexcept | |
| 169 | { | ||
| 170 |
5/6✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 1 time.
✗ Branch 2 → 8 not taken.
|
5 | switch (domain) |
| 171 | { | ||
| 172 | 1 | case ResultDomain::Unknown: | |
| 173 | 1 | return "Unknown"; | |
| 174 | 1 | case ResultDomain::CodeSite: | |
| 175 | 1 | return "CodeSite"; | |
| 176 | 1 | case ResultDomain::DataAddress: | |
| 177 | 1 | return "DataAddress"; | |
| 178 | 1 | case ResultDomain::VtableAddress: | |
| 179 | 1 | return "VtableAddress"; | |
| 180 | 1 | case ResultDomain::Scalar: | |
| 181 | 1 | return "Scalar"; | |
| 182 | } | ||
| 183 | ✗ | return "Unknown"; | |
| 184 | } | ||
| 185 | |||
| 186 | 9 | std::string_view physical_source_to_string(PhysicalSource source) noexcept | |
| 187 | { | ||
| 188 |
9/9✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 1 time.
✓ Branch 2 → 8 taken 1 time.
✓ Branch 2 → 9 taken 1 time.
✓ Branch 2 → 10 taken 1 time.
✓ Branch 2 → 11 taken 1 time.
|
9 | switch (source) |
| 189 | { | ||
| 190 | 1 | case PhysicalSource::None: | |
| 191 | 1 | return "None"; | |
| 192 | 1 | case PhysicalSource::ByteSignature: | |
| 193 | 1 | return "ByteSignature"; | |
| 194 | 1 | case PhysicalSource::StringLiteral: | |
| 195 | 1 | return "StringLiteral"; | |
| 196 | 1 | case PhysicalSource::TypeIdentity: | |
| 197 | 1 | return "TypeIdentity"; | |
| 198 | 1 | case PhysicalSource::ExportTable: | |
| 199 | 1 | return "ExportTable"; | |
| 200 | 1 | case PhysicalSource::CodeOperand: | |
| 201 | 1 | return "CodeOperand"; | |
| 202 | 1 | case PhysicalSource::ManualPin: | |
| 203 | 1 | return "ManualPin"; | |
| 204 | 1 | case PhysicalSource::Corroborated: | |
| 205 | 1 | return "Corroborated"; | |
| 206 | } | ||
| 207 | 1 | return "Unknown"; | |
| 208 | } | ||
| 209 | |||
| 210 | 3 | std::string_view gate_verdict_to_string(GateVerdict verdict) noexcept | |
| 211 | { | ||
| 212 |
3/4✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✗ Branch 2 → 6 not taken.
|
3 | switch (verdict) |
| 213 | { | ||
| 214 | 1 | case GateVerdict::Pass: | |
| 215 | 1 | return "Pass"; | |
| 216 | 1 | case GateVerdict::Degraded: | |
| 217 | 1 | return "Degraded"; | |
| 218 | 1 | case GateVerdict::Fail: | |
| 219 | 1 | return "Fail"; | |
| 220 | } | ||
| 221 | ✗ | return "Unknown"; | |
| 222 | } | ||
| 223 | } // namespace anchor | ||
| 224 | } // namespace DetourModKit | ||
| 225 |