| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Presentation for the support-collection manifest: one header sentence a human |
| 9 |
* reads first, then the record as a single JSON line so the section stays JSONL |
| 10 |
* like every other section around it. |
| 11 |
* |
| 12 |
* Separate from ABJ_404_Solution_DiagnosticCollectionManifest, which OBSERVES: |
| 13 |
* it stats the candidate files, reconciles the browser's attempts, and decides |
| 14 |
* what is present. This class decides nothing about the site and reads nothing |
| 15 |
* from disk. It answers only "how does this fit in the bytes available", which |
| 16 |
* is the same layer split the project applies everywhere else -- and it is a |
| 17 |
* real one here, because the shedding order below is a presentation policy that |
| 18 |
* changes independently of what the collector looks for. |
| 19 |
* |
| 20 |
* The shedding order is the whole point: over-budget input drops detail in |
| 21 |
* decreasing order of value (per-file stats, then the id lists) rather than |
| 22 |
* being cut at a byte offset, and the last fallback is a record small enough to |
| 23 |
* fit unconditionally. A truncated JSON line would be worse than a smaller |
| 24 |
* complete one, because the manifest exists to be read when everything else in |
| 25 |
* the payload came back empty. |
| 26 |
*/ |
| 27 |
final class ABJ_404_Solution_DiagnosticCollectionManifestRenderer { |
| 28 |
|
| 29 |
/** |
| 30 |
* The block: a human-readable header sentence, then the record as one JSON |
| 31 |
* line so the section stays JSONL like every other section around it. |
| 32 |
* |
| 33 |
* Over-budget input sheds detail in decreasing order of value (per-file |
| 34 |
* stats, then the id lists) rather than being cut at a byte offset, and the |
| 35 |
* last fallback is a record small enough to fit unconditionally. The counts |
| 36 |
* in the header are the same either way, so a shed manifest still says how |
| 37 |
* much was checked and how much was found. |
| 38 |
* |
| 39 |
* @param array<string, mixed> $manifest |
| 40 |
*/ |
| 41 |
public static function render(array $manifest, int $budgetBytes): string { |
| 42 |
$header = self::headerLine($manifest); |
| 43 |
$anythingEncoded = false; |
| 44 |
foreach (array($manifest, self::withoutIdLists($manifest), self::minimal($manifest)) as $candidate) { |
| 45 |
$line = self::encodeOrEmpty(array(ABJ_404_Solution_DiagnosticCollectionManifest::RECORD_KEY => $candidate)); |
| 46 |
if ($line === '') { |
| 47 |
continue; |
| 48 |
} |
| 49 |
$anythingEncoded = true; |
| 50 |
if (strlen($header) + strlen($line) <= $budgetBytes) { |
| 51 |
return $header . $line; |
| 52 |
} |
| 53 |
} |
| 54 |
// Two different reasons reach here and they are not the same finding. |
| 55 |
// Everything encoded but nothing fit means the budget was too small -- |
| 56 |
// the site is fine and the payload was squeezed. Nothing encoding at |
| 57 |
// all means the manifest itself held bytes json_encode() rejected, |
| 58 |
// which is a defect worth chasing. Reporting both as `encoding_failed` |
| 59 |
// sent a reader after the wrong one, in the very record whose job is |
| 60 |
// to explain why the rest of the payload is thin. |
| 61 |
return $header . self::encodeOrEmpty(array(ABJ_404_Solution_DiagnosticCollectionManifest::RECORD_KEY => array( |
| 62 |
'reduced' => $anythingEncoded ? 'all_forms_over_budget' : 'encoding_failed', |
| 63 |
'outcome' => self::stringOf($manifest['outcome'] ?? ABJ_404_Solution_DiagnosticCollectionManifest::OUTCOME_EMPTY), |
| 64 |
))); |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* The scannable one-line version, so the first thing a reader sees is what |
| 69 |
* was looked for and how much of it was there. |
| 70 |
* |
| 71 |
* @param array<string, mixed> $manifest |
| 72 |
*/ |
| 73 |
private static function headerLine(array $manifest): string { |
| 74 |
$channels = self::arrayOf($manifest['channels'] ?? null); |
| 75 |
$checked = 0; |
| 76 |
$found = 0; |
| 77 |
$lines = 0; |
| 78 |
foreach ($channels as $channel) { |
| 79 |
$described = self::arrayOf($channel); |
| 80 |
$checked += self::intOf($described['candidates_checked'] ?? 0); |
| 81 |
$found += self::intOf($described['candidates_found'] ?? 0); |
| 82 |
$lines += self::intOf($described['collected_lines'] ?? 0); |
| 83 |
} |
| 84 |
return 'Diagnostic collection manifest -- ' . count($channels) . ' channel(s), ' |
| 85 |
. $found . ' of ' . $checked . ' candidate files present, ' . $lines . ' lines collected; ' |
| 86 |
. self::attemptClause(self::arrayOf($manifest['client_expected_attempts'] ?? null)) |
| 87 |
. " (JSONL):\n"; |
| 88 |
} |
| 89 |
|
| 90 |
/** @param array<array-key, mixed> $expected */ |
| 91 |
private static function attemptClause(array $expected): string { |
| 92 |
$status = self::stringOf($expected['status'] ?? 'absent'); |
| 93 |
if ($status === 'parsed') { |
| 94 |
return 'browser expected ' . self::intOf($expected['expected'] ?? 0) . ' attempt id(s), ' |
| 95 |
. self::intOf($expected['found'] ?? 0) . ' present'; |
| 96 |
} |
| 97 |
if ($status === 'unparseable') { |
| 98 |
return 'the browser attempt buffer could not be parsed'; |
| 99 |
} |
| 100 |
return 'no browser attempt ids were sent'; |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* @param array<string, mixed> $manifest |
| 105 |
* @return array<string, mixed> |
| 106 |
*/ |
| 107 |
private static function withoutIdLists(array $manifest): array { |
| 108 |
$manifest['reduced'] = 'attempt_id_lists_and_file_stats_dropped_for_budget'; |
| 109 |
$expected = self::arrayOf($manifest['client_expected_attempts'] ?? null); |
| 110 |
$expected['found_ids'] = array(); |
| 111 |
$expected['missing_ids'] = array(); |
| 112 |
$manifest['client_expected_attempts'] = $expected; |
| 113 |
$channels = array(); |
| 114 |
foreach (self::arrayOf($manifest['channels'] ?? null) as $channel) { |
| 115 |
$described = self::arrayOf($channel); |
| 116 |
unset($described['files']); |
| 117 |
$selection = self::arrayOf($described['file_selection'] ?? null); |
| 118 |
$selection['dropped_file_names'] = array(); |
| 119 |
$selection['dropped_request_ids'] = array(); |
| 120 |
$described['file_selection'] = $selection; |
| 121 |
$channels[] = $described; |
| 122 |
} |
| 123 |
$manifest['channels'] = $channels; |
| 124 |
return $manifest; |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* The smallest manifest that is still worth having: who collected, how many |
| 129 |
* files each channel checked and found, and the outcome. |
| 130 |
* |
| 131 |
* @param array<string, mixed> $manifest |
| 132 |
* @return array<string, mixed> |
| 133 |
*/ |
| 134 |
private static function minimal(array $manifest): array { |
| 135 |
$channels = array(); |
| 136 |
foreach (self::arrayOf($manifest['channels'] ?? null) as $channel) { |
| 137 |
$described = self::arrayOf($channel); |
| 138 |
$selection = self::arrayOf($described['file_selection'] ?? null); |
| 139 |
$minimalSelection = array( |
| 140 |
'policy' => self::stringOf($selection['policy'] ?? ''), |
| 141 |
); |
| 142 |
foreach (array( |
| 143 |
'existing_files', 'selected_files', 'known_failure_files', |
| 144 |
'server_failure_files', 'classification_issue_files', 'pinned_files', |
| 145 |
'classification_issues_omitted', 'dropped_files', |
| 146 |
'dropped_file_names_omitted', 'dropped_request_ids_omitted', |
| 147 |
) as $field) { |
| 148 |
$minimalSelection[$field] = self::intOf($selection[$field] ?? 0); |
| 149 |
} |
| 150 |
$channels[] = array( |
| 151 |
'channel' => self::stringOf($described['channel'] ?? 'unknown'), |
| 152 |
'directory_usable' => !empty($described['directory_usable']), |
| 153 |
'candidates_checked' => self::intOf($described['candidates_checked'] ?? 0), |
| 154 |
'candidates_found' => self::intOf($described['candidates_found'] ?? 0), |
| 155 |
'collected_bytes' => self::intOf($described['collected_bytes'] ?? 0), |
| 156 |
'file_selection' => $minimalSelection, |
| 157 |
); |
| 158 |
} |
| 159 |
return array( |
| 160 |
'reduced' => 'channel_detail_dropped_for_budget', |
| 161 |
'collector' => self::arrayOf($manifest['collector'] ?? null), |
| 162 |
'channels' => $channels, |
| 163 |
'required_evidence_records' => |
| 164 |
self::arrayOf($manifest['required_evidence_records'] ?? null), |
| 165 |
'outcome' => self::stringOf($manifest['outcome'] ?? ABJ_404_Solution_DiagnosticCollectionManifest::OUTCOME_EMPTY), |
| 166 |
); |
| 167 |
} |
| 168 |
|
| 169 |
/** @param array<string, mixed> $value */ |
| 170 |
public static function encodeOrEmpty(array $value): string { |
| 171 |
$json = json_encode($value, JSON_UNESCAPED_SLASHES); |
| 172 |
return is_string($json) ? $json : ''; |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Typed reads of the assembled record. The manifest is deliberately a |
| 177 |
* loose map (its shape is the wire format, not a PHP contract), so the |
| 178 |
* places that summarize it read through these rather than casting mixed. |
| 179 |
* |
| 180 |
* @param mixed $value |
| 181 |
*/ |
| 182 |
private static function intOf($value): int { |
| 183 |
return is_numeric($value) ? (int)$value : 0; |
| 184 |
} |
| 185 |
|
| 186 |
/** @param mixed $value */ |
| 187 |
private static function stringOf($value): string { |
| 188 |
return is_scalar($value) ? (string)$value : ''; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* @param mixed $value |
| 193 |
* @return array<array-key, mixed> |
| 194 |
*/ |
| 195 |
private static function arrayOf($value): array { |
| 196 |
return is_array($value) ? $value : array(); |
| 197 |
} |
| 198 |
} |
| 199 |
|