Skip to main content

pixellint_core/
manifest.rs

1//! Declarative rulepack manifests.
2//!
3//! A manifest describes a vendor endpoint family as data: which hosts and paths
4//! it covers, which parameters it contracts, and what each violation should say.
5//! [`ManifestRulePack`] interprets a compiled manifest and implements the same
6//! [`ValidatorPlugin`] trait as the hand-written `core` pack, so first-party
7//! vendor packs and user-supplied packs run through one code path.
8
9use std::borrow::Cow;
10use std::collections::{BTreeMap, BTreeSet};
11use std::error::Error;
12use std::fmt;
13use std::fs;
14use std::path::Path;
15
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18
19use crate::TimestampUnit;
20use crate::json::{self, JsonDocument, JsonField, JsonValueKind};
21use crate::prepare::{self, ArtifactUrl, PreparedArtifact};
22use crate::{
23    ArtifactKind, PluginRouting, RulePackMetadata, RuleSource, RuleSourceLevel, Severity,
24    ValidationReport, ValidationRequest, ValidatorPlugin, Violation, ViolationTarget,
25    ViolationTargetComponent, detect_macro_spans,
26};
27
28/// How a pack's parameters are carried on the wire.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ParamStyle {
32    /// Standard `?name=value&name=value` query parameters.
33    #[default]
34    Query,
35    /// Query parameters that split on both `&` and `;`, the shape Adform
36    /// impression and click tags use: `?bn=123;C=1` next to `?bn=123&v=3`.
37    QuerySemicolon,
38    /// Semicolon-delimited `name=value` pairs inside the path, as used by
39    /// Floodlight activity tags.
40    Matrix,
41    /// Slash-delimited `key:value` path segments, as used by Partnerize
42    /// conversion URLs. Basket containers wrap a group in `[...]`; the
43    /// brackets are stripped so `category` and `quantity` still match.
44    ColonPath,
45}
46
47impl ParamStyle {
48    pub(crate) fn cache_index(self) -> usize {
49        match self {
50            Self::Query => 0,
51            Self::QuerySemicolon => 1,
52            Self::Matrix => 2,
53            Self::ColonPath => 3,
54        }
55    }
56}
57
58/// Whether a contracted parameter has to be present, must be absent, or is on
59/// its way out.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Requirement {
63    /// Absence is a violation at error severity by default.
64    Required,
65    /// Absence is a violation at warning severity by default.
66    Recommended,
67    /// Presence and absence are both fine; only the value format is checked.
68    #[default]
69    Optional,
70    /// Presence is a violation at error severity by default.
71    Forbidden,
72    /// Presence is a violation at warning severity by default.
73    Deprecated,
74}
75
76/// Value-level contract applied to a parameter when it is present and fully
77/// expanded.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
80pub enum ValueFormat {
81    /// Any non-empty value.
82    NonEmpty,
83    /// A JSON numeric value inside at least one inclusive interval.
84    NumericRanges { ranges: Vec<NumericRange> },
85    /// Decimal digits only, with optional length bounds.
86    Integer {
87        #[serde(default)]
88        min_digits: Option<usize>,
89        #[serde(default)]
90        max_digits: Option<usize>,
91    },
92    /// One of a fixed set of values.
93    Enum {
94        values: Vec<String>,
95        #[serde(default)]
96        case_insensitive: bool,
97    },
98    /// Matches a regular expression.
99    Regex { pattern: String },
100    /// Parses as an absolute URL, optionally HTTPS-only. The value is
101    /// percent-decoded before parsing.
102    Url {
103        #[serde(default)]
104        require_https: bool,
105    },
106    /// Lowercase hexadecimal of an exact length, for hashed identifiers.
107    Hex { length: usize },
108    /// An IP address literal, optionally restricted to one address family.
109    Ip {
110        #[serde(default)]
111        version: Option<IpVersion>,
112        /// Vendor-specific address ranges that are not accepted.
113        #[serde(default)]
114        exclude_ranges: Vec<String>,
115    },
116    /// Comma-separated literal IP addresses, with unresolved entries preserved.
117    IpChain,
118    /// Calendar-valid date/time representations selected by a destination.
119    #[serde(rename = "datetime_formats")]
120    DateTimeFormats {
121        formats: Vec<DateTimeRepresentation>,
122    },
123    /// A calendar-valid RFC 3339 timestamp, optionally allowing local time.
124    #[serde(rename = "datetime")]
125    DateTime {
126        #[serde(default)]
127        require_timezone: bool,
128        #[serde(default)]
129        allow_date_only: bool,
130        #[serde(default)]
131        allow_basic: bool,
132        #[serde(default)]
133        allow_space_separator: bool,
134        #[serde(default)]
135        allow_javascript_date: bool,
136        #[serde(default)]
137        allow_unpadded_date: bool,
138    },
139    /// A calendar-valid ISO 8601 date in YYYY-MM-DD form.
140    Date,
141    /// JSON syntax inside a string, accepting any JSON value.
142    Json,
143    /// Adobe AppMeasurement semicolon product rows and merchandising cells.
144    AdobeProducts,
145    /// AppMeasurement event lists with source-defined names and assignments.
146    AdobeEvents,
147    /// Google's versioned Additional Consent provider lists.
148    AdditionalConsent,
149    /// Braze's explicit $time custom attribute date representations.
150    BrazeTime,
151    /// A currency assigned by the dated ISO 4217 registry.
152    Currency {
153        #[serde(default)]
154        allow_historical: bool,
155        #[serde(default)]
156        case_insensitive: bool,
157    },
158    /// TCF v2 structural encoding, version, and mandatory-prefix checks.
159    Tcf,
160    /// GPP structural encoding and header type/version checks.
161    Gpp,
162    /// Published four-character US Privacy encoding.
163    UsPrivacy,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum IpVersion {
169    V4,
170    V6,
171}
172
173/// Inclusive numeric endpoints, compared without floating-point rounding.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct NumericRange {
177    pub minimum: serde_json::Number,
178    pub maximum: serde_json::Number,
179}
180
181/// Supported second-resolution date/time representations. Named zones remain
182/// opaque text: validating their date does not resolve the zone or an instant.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum DateTimeRepresentation {
186    Us12Hour,
187    Us24Hour,
188    IsoLocal,
189    IsoOffset,
190    IsoNamedTimezone,
191}
192
193/// Which artifacts a pack claims.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct MatchSpec {
197    /// Self-hosted vendor endpoints, constrained by an explicit path selector.
198    #[serde(default)]
199    pub any_host: bool,
200    /// Artifact kinds the pack applies to. Empty means all URL-like kinds.
201    #[serde(default)]
202    pub artifact_kinds: Vec<ArtifactKind>,
203    /// Exact host matches, compared case-insensitively.
204    #[serde(default)]
205    pub hosts: Vec<String>,
206    /// Domain suffix matches. `example.com` matches `a.example.com` and
207    /// `example.com`, but not `notexample.com`.
208    #[serde(default)]
209    pub host_suffixes: Vec<String>,
210    /// Exact path matches.
211    #[serde(default)]
212    pub paths: Vec<String>,
213    /// Path prefix matches.
214    #[serde(default)]
215    pub path_prefixes: Vec<String>,
216    /// Path substring matches.
217    #[serde(default)]
218    pub path_contains: Vec<String>,
219    /// Full-path regular expression alternatives on a host-bound endpoint.
220    /// These select endpoint scope; `path_pattern` separately extracts fields.
221    #[serde(default)]
222    pub path_patterns: Vec<String>,
223    /// At least one named query key must be submitted, including an empty value.
224    #[serde(default)]
225    pub query_params_any: Vec<String>,
226    /// None of these query keys may be submitted.
227    #[serde(default)]
228    pub query_params_none: Vec<String>,
229    /// Every named query marker must occur, and all occurrences must be allowed.
230    #[serde(default)]
231    pub query_params_values: Vec<QueryParamValuesMatch>,
232    /// JSON body shapes the pack claims. A body artifact belongs to this pack
233    /// when every entry here holds, so the shape of the payload stands in for
234    /// the host that a bare body does not carry.
235    #[serde(default)]
236    pub json_paths: Vec<ShapeMatch>,
237}
238
239/// An exact, decoded query marker selecting a source-specific endpoint profile.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct QueryParamValuesMatch {
243    pub name: String,
244    pub values: Vec<String>,
245}
246
247/// One condition on the shape of a JSON body.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(untagged, deny_unknown_fields)]
250pub enum ShapeMatch {
251    /// The path resolves to a field that is present.
252    Present(String),
253    /// The path carries no value that belongs to a different vendor.
254    ///
255    /// Conversion APIs have converged on the same `{"data": [...]}` envelope,
256    /// so presence alone cannot tell a Meta payload from a Snap one. The values
257    /// can: Meta writes `action_source: "website"` where Snap writes `"WEB"`.
258    ///
259    /// This is written as an exclusion rather than as "my values match" on
260    /// purpose. The discriminating field is usually one the pack also contracts,
261    /// and a payload with a typo in it is the one that most needs validating, so
262    /// an unfamiliar value must leave the payload claimable. Only a value that
263    /// positively belongs to someone else rules the pack out.
264    Excludes { path: String, excludes: String },
265    /// At least one present value matches the supplied regular expression.
266    Matches { path: String, pattern: String },
267    /// A recognizable field with an actual JSON type.
268    Type { path: String, json_type: JsonTypes },
269    /// At least one of the nested conditions holds. Endpoints that accept both
270    /// a single event and a batch envelope need it: the two shapes have no path
271    /// in common, but either one identifies the payload.
272    Any { any_of: Vec<ShapeMatch> },
273}
274
275impl ShapeMatch {
276    /// Every path this condition names, so they can all be checked at load.
277    fn paths(&self) -> Vec<&str> {
278        match self {
279            Self::Present(path) => vec![path],
280            Self::Excludes { path, .. } => vec![path],
281            Self::Matches { path, .. } => vec![path],
282            Self::Type { path, .. } => vec![path],
283            Self::Any { any_of } => any_of.iter().flat_map(Self::paths).collect(),
284        }
285    }
286}
287
288/// The JSON type required by a body field, independently of its scalar format.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum JsonType {
292    String,
293    Number,
294    Integer,
295    Boolean,
296    Object,
297    Array,
298    Null,
299}
300
301/// A single JSON type or an explicit union of allowed types.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(untagged)]
304pub enum JsonTypes {
305    One(JsonType),
306    Any(Vec<JsonType>),
307}
308
309impl JsonTypes {
310    fn types(&self) -> &[JsonType] {
311        match self {
312            Self::One(value) => std::slice::from_ref(value),
313            Self::Any(values) => values,
314        }
315    }
316}
317
318/// A contracted parameter.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct ParamContract {
322    pub name: String,
323    /// Read this document-root JSON path into the scope-local logical name.
324    #[serde(default)]
325    pub root_path: Option<String>,
326    /// Read a path relative to an ancestor of the selected body scope.
327    #[serde(default)]
328    pub ancestor_path: Option<AncestorPath>,
329    /// When set, this contract applies to every present parameter whose name
330    /// matches, instead of to `name` alone. Findings use the matched name.
331    /// Required and recommended are rejected at load: absence of a family is
332    /// not a missing parameter.
333    #[serde(default)]
334    pub name_pattern: Option<String>,
335    /// On a body, match immediate member names under this relative object path.
336    /// The empty path names the current scope.
337    #[serde(default)]
338    pub name_pattern_parent: Option<String>,
339    /// Alternate spellings that satisfy the same contract.
340    #[serde(default)]
341    pub aliases: Vec<String>,
342    /// A scope-local guard for this field's presence and value checks.
343    #[serde(default)]
344    pub condition: Option<RuleCondition>,
345    /// Vendor-documented normalization applied before string value checks.
346    #[serde(default)]
347    pub normalization: Option<StringNormalization>,
348    #[serde(default)]
349    pub requirement: Requirement,
350    #[serde(default)]
351    pub format: Option<ValueFormat>,
352    /// Actual JSON type, rather than the text representation of the value.
353    /// Only body contracts can declare this field.
354    #[serde(default)]
355    pub json_type: Option<JsonTypes>,
356    #[serde(default)]
357    pub min_items: Option<usize>,
358    #[serde(default)]
359    pub max_items: Option<usize>,
360    #[serde(default)]
361    pub min_properties: Option<usize>,
362    #[serde(default)]
363    pub max_properties: Option<usize>,
364    /// Object keys excluded from the property count, for reserved vendor fields.
365    #[serde(default)]
366    pub property_exclusions: Vec<String>,
367    /// Nested object/array depth, counting this container as level one.
368    #[serde(default)]
369    pub max_depth: Option<usize>,
370    /// Total scalar values and array elements across immediate object members.
371    #[serde(default)]
372    pub max_member_values: Option<usize>,
373    /// Limit a string array after joining its decoded elements.
374    #[serde(default)]
375    pub max_joined_length: Option<usize>,
376    #[serde(default)]
377    pub join_separator: Option<String>,
378    /// Compact UTF-8 JSON bytes of this selected value, excluding whitespace.
379    #[serde(default)]
380    pub max_compact_bytes: Option<usize>,
381    /// Constraints on the decoded object member name at the selected location.
382    #[serde(default)]
383    pub max_key_length: Option<usize>,
384    #[serde(default)]
385    pub key_pattern: Option<String>,
386    /// String length in Unicode scalar values after JSON or URL decoding.
387    #[serde(default)]
388    pub min_length: Option<usize>,
389    #[serde(default)]
390    pub max_length: Option<usize>,
391    /// Decoded UTF-16 code units, matching JavaScript String.length.
392    #[serde(default)]
393    pub max_utf16_length: Option<usize>,
394    /// String length in decoded UTF-8 bytes, distinct from character count.
395    #[serde(default)]
396    pub min_byte_length: Option<usize>,
397    #[serde(default)]
398    pub max_byte_length: Option<usize>,
399    /// Inclusive numeric bounds. URL values are parsed as JSON numbers.
400    #[serde(default)]
401    pub minimum: Option<serde_json::Number>,
402    #[serde(default)]
403    pub maximum: Option<serde_json::Number>,
404    /// The numeric value must be an exact multiple of this positive number.
405    #[serde(default)]
406    pub multiple_of: Option<serde_json::Number>,
407    /// Apply numeric bounds to string members of a number/string type union.
408    #[serde(default)]
409    pub numeric_strings: bool,
410    /// Protobuf JSON treats null fields as unset, without changing array members.
411    #[serde(default)]
412    pub null_as_missing: bool,
413    /// Overrides the severity derived from `requirement`.
414    #[serde(default)]
415    pub severity: Option<Severity>,
416    /// Overrides the severity of value-format violations only. Lets a pack say
417    /// "this parameter is mandatory, but an unrecognized value is only worth a
418    /// warning", which is the common shape for event-name parameters that also
419    /// accept custom values.
420    #[serde(default)]
421    pub format_severity: Option<Severity>,
422    /// Human-readable explanation appended to generated messages.
423    #[serde(default)]
424    pub description: Option<String>,
425    #[serde(default)]
426    pub fix_hint: Option<String>,
427    /// Official documentation URL for this parameter. Falls back to the pack's
428    /// `docs` value.
429    #[serde(default)]
430    pub doc: Option<String>,
431    /// Overrides the pack-level evidence level for this parameter.
432    #[serde(default)]
433    pub source_level: Option<RuleSourceLevel>,
434    /// When true, a blank value is treated as an unfilled template slot and is
435    /// not reported. Format checks still run on a populated value. Floodlight
436    /// `npa` and `tfua` ship this way on exported tags.
437    #[serde(default)]
438    pub allow_empty: bool,
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum StringNormalization {
444    Trim,
445    TrimLowercase,
446}
447
448fn normalize_string(value: &str, normalization: Option<StringNormalization>) -> Cow<'_, str> {
449    match normalization {
450        None => Cow::Borrowed(value),
451        Some(StringNormalization::Trim) => Cow::Borrowed(value.trim()),
452        Some(StringNormalization::TrimLowercase) => Cow::Owned(value.trim().to_lowercase()),
453    }
454}
455
456/// The assertion a pack-level rule makes.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(tag = "kind", rename_all = "snake_case")]
459pub enum Assertion {
460    /// At least one of `params` must be present.
461    RequireOneOf { params: Vec<String> },
462    /// At most one of `params` may be present.
463    MutuallyExclusive { params: Vec<String> },
464    /// When `when` is present, every parameter in `requires` must be present.
465    RequiredWith { when: String, requires: Vec<String> },
466    /// When `when` carries one of `equals`, every parameter in `requires` must
467    /// be present. This is the shape consent signals take: a flag value decides
468    /// whether the rest of the signal is mandatory.
469    RequiredWhenValue {
470        when: String,
471        equals: Vec<String>,
472        requires: Vec<String>,
473    },
474    /// No parameter value may match `pattern`. Empty `params` means every
475    /// parameter is checked.
476    ForbidValuePattern {
477        pattern: String,
478        #[serde(default)]
479        params: Vec<String>,
480    },
481    /// When `when` carries one of `equals` and `param` is present, `param` must
482    /// equal `value`. Missing `param` is left to a presence contract. Macro
483    /// values never trigger it.
484    ValueWhen {
485        when: String,
486        equals: Vec<String>,
487        param: String,
488        value: String,
489    },
490    /// When `when` carries one of `equals`, none of `params` may be present.
491    ForbiddenWhenValue {
492        when: String,
493        equals: Vec<String>,
494        params: Vec<String>,
495    },
496    /// At least one complete group must be present.
497    RequireAnyOf {
498        groups: Vec<Vec<String>>,
499        /// Count present empty values when the vendor requires keys alone.
500        #[serde(default)]
501        allow_empty: bool,
502    },
503    /// Apply a scalar format only for the specified discriminator values.
504    FormatWhen {
505        when: String,
506        equals: Vec<String>,
507        param: String,
508        format: ValueFormat,
509        #[serde(default)]
510        pair_occurrences: bool,
511    },
512    /// Apply a scalar format independently of a discriminator.
513    Format { param: String, format: ValueFormat },
514    /// This URL parameter must be the final parameter carried on the wire.
515    LastParam { param: String },
516    /// Limit the raw query text after the question mark, before any fragment.
517    MaxQueryLength { max_length: usize },
518    /// Require a literal HTTPS scheme on this URL endpoint.
519    RequireHttps,
520    /// Limit the entire trimmed URL's Unicode characters.
521    MaxUrlLength { max_length: usize },
522    /// Limit the complete JSON request's UTF-8 bytes, including whitespace.
523    MaxBodyBytes { max_bytes: usize },
524    /// When a parameter is present, another present parameter must equal a value.
525    ValueWith {
526        when: String,
527        param: String,
528        value: String,
529    },
530    /// The minimum string length is supplied by another field, or a default.
531    MinLengthFrom {
532        params: Vec<String>,
533        length_param: String,
534        default_length: usize,
535    },
536    /// Both fields must be numeric, and the left cannot exceed the right.
537    LessEqual {
538        left: String,
539        right: String,
540        #[serde(default)]
541        strict: bool,
542        #[serde(default)]
543        unit: Option<TimestampUnit>,
544    },
545    /// Both populated literal scalar fields must carry the same value.
546    EqualValues { left: String, right: String },
547    /// Pinned d9core indexed btoa text and unindexed seed-31 MurmurHash parity.
548    FtrackFingerprint { indexed: String, hash: String },
549    /// Limit a URL field's path, excluding its origin, query and fragment.
550    UrlPathLength { param: String, max_length: usize },
551    /// Present delimited lists must describe the same number of records.
552    EqualSplitLengths {
553        params: Vec<String>,
554        separator: String,
555    },
556    /// Numbered field families must form complete item groups beginning at one.
557    IndexedParamGroups {
558        params: Vec<String>,
559        #[serde(default)]
560        optional_params: Vec<String>,
561        max_index: usize,
562        #[serde(default)]
563        contiguous: bool,
564    },
565    /// Exact sum of amounts in a delimited key/value list.
566    DelimitedSum {
567        param: String,
568        total: String,
569        separator: String,
570        value_separator: String,
571    },
572    /// Published Awin product index or commission-group relationships.
573    AwinBasket {
574        parts: String,
575        check: AwinBasketCheck,
576    },
577    /// Applicable GPP IDs must occur in the encoded header's section list.
578    GppSections { param: String, sections: String },
579    /// Destination-specific positive TCF consent for a vendor and purposes.
580    TcfConsent {
581        param: String,
582        vendor_id: u16,
583        purpose_ids: Vec<u8>,
584    },
585    /// Destination-specific choices in applicable, published US GPP fields.
586    GppFieldValues {
587        param: String,
588        section_param: String,
589        field: String,
590        values: Vec<u8>,
591    },
592    /// Present native JSON arrays must describe the same number of records.
593    EqualArrayLengths { params: Vec<String> },
594    /// Bound the combined item count of present native JSON arrays.
595    MaxTotalArrayItems {
596        params: Vec<String>,
597        max_items: usize,
598    },
599    /// Literal scalar values of this object member must be unique in an array.
600    UniqueArrayBy { param: String, field: String },
601    /// Repeated parameter families must have the same occurrence count.
602    EqualOccurrences { params: Vec<String> },
603    /// Limit submitted occurrences, including empty and unresolved values.
604    MaxOccurrences {
605        param: String,
606        max_occurrences: usize,
607        /// Run only on URL scopes without a complete HTTP capture.
608        #[serde(default)]
609        bare_url_only: bool,
610    },
611    /// A timestamp must fall within the permitted window around validation time.
612    TimeWindow {
613        param: String,
614        /// Use this field only when the primary field is absent in the scope.
615        #[serde(default)]
616        fallback_param: Option<String>,
617        unit: TimestampUnit,
618        #[serde(default)]
619        max_age_seconds: Option<u64>,
620        #[serde(default)]
621        min_age_seconds: Option<u64>,
622        #[serde(default)]
623        max_future_seconds: Option<u64>,
624    },
625}
626
627/// An optional guard for a cross-field assertion, evaluated within its scope.
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
630pub enum RuleCondition {
631    Exists {
632        param: String,
633    },
634    Present {
635        param: String,
636    },
637    ValueIn {
638        param: String,
639        values: Vec<String>,
640    },
641    /// At least one scalar is submitted and every occurrence is allowed.
642    AllValuesIn {
643        params: Vec<String>,
644        values: Vec<String>,
645    },
646    ValuePattern {
647        param: String,
648        pattern: String,
649    },
650    JsonType {
651        param: String,
652        json_type: JsonTypes,
653    },
654    All {
655        conditions: Vec<RuleCondition>,
656    },
657    Any {
658        conditions: Vec<RuleCondition>,
659    },
660    Not {
661        condition: Box<RuleCondition>,
662    },
663}
664
665#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
666#[serde(rename_all = "snake_case")]
667pub enum AwinBasketCheck {
668    IndexSequence,
669    GroupMembership,
670}
671
672impl RuleCondition {
673    fn params(&self) -> Vec<&String> {
674        match self {
675            Self::Exists { param }
676            | Self::Present { param }
677            | Self::ValueIn { param, .. }
678            | Self::ValuePattern { param, .. }
679            | Self::JsonType { param, .. } => vec![param],
680            Self::AllValuesIn { params, .. } => params.iter().collect(),
681            Self::All { conditions } | Self::Any { conditions } => {
682                conditions.iter().flat_map(Self::params).collect()
683            }
684            Self::Not { condition } => condition.params(),
685        }
686    }
687
688    // A macro has an unknown value. Negation must not turn it into a positive
689    // match and accidentally apply a vendor requirement to a template.
690    fn evaluate(&self, params: &[RawParam<'_>]) -> Option<bool> {
691        match self {
692            Self::Exists { param } => Some(params.iter().any(|field| {
693                (!field.missing || field.json_kind.is_some()) && field.name.as_ref() == param
694            })),
695            Self::ValuePattern { param, pattern } => {
696                let regex = Regex::new(pattern).expect("condition regex validated at compilation");
697                let mut unknown = false;
698                for field in params
699                    .iter()
700                    .filter(|field| !field.missing && field.name.as_ref() == param)
701                {
702                    if contains_macro(field.value.as_ref()) {
703                        unknown = true;
704                    } else if !field.container && regex.is_match(field.value.as_ref()) {
705                        return Some(true);
706                    }
707                }
708                if unknown { None } else { Some(false) }
709            }
710            Self::JsonType { param, json_type } => {
711                let mut unknown = false;
712                for field in params
713                    .iter()
714                    .filter(|field| !field.missing && field.name.as_ref() == param)
715                {
716                    if !field.container && contains_macro(field.value.as_ref()) {
717                        unknown = true;
718                        continue;
719                    }
720                    let matched = json_type.types().iter().any(|kind| match kind {
721                        JsonType::String => field.json_kind == Some(JsonValueKind::String),
722                        JsonType::Number => field.json_kind == Some(JsonValueKind::Number),
723                        JsonType::Integer => {
724                            field.json_kind == Some(JsonValueKind::Number)
725                                && decimal_parts(field.value.as_ref())
726                                    .is_some_and(|(_, _, exponent)| exponent >= 0)
727                        }
728                        JsonType::Boolean => field.json_kind == Some(JsonValueKind::Bool),
729                        JsonType::Object => field.json_kind == Some(JsonValueKind::Object),
730                        JsonType::Array => field.json_kind == Some(JsonValueKind::Array),
731                        JsonType::Null => field.json_kind == Some(JsonValueKind::Null),
732                    });
733                    if matched {
734                        return Some(true);
735                    }
736                }
737                if unknown { None } else { Some(false) }
738            }
739            Self::Present { param } => Some(params.iter().any(|field| {
740                !field.missing
741                    && field.name.as_ref() == param
742                    && (field.container || !field.value.is_empty())
743            })),
744            Self::ValueIn { param, values } => {
745                let mut unknown = false;
746                for field in params
747                    .iter()
748                    .filter(|field| !field.missing && field.name.as_ref() == param)
749                {
750                    if contains_macro(field.value.as_ref()) {
751                        unknown = true;
752                    } else if values.iter().any(|value| {
753                        value == field.value.as_ref()
754                            || field.json_kind == Some(JsonValueKind::Number)
755                                && compare_numeric_text(value, field.value.as_ref())
756                                    == Some(std::cmp::Ordering::Equal)
757                    }) {
758                        return Some(true);
759                    }
760                }
761                if unknown { None } else { Some(false) }
762            }
763            Self::AllValuesIn {
764                params: names,
765                values,
766            } => {
767                let mut submitted = false;
768                let mut unknown = false;
769                for field in params.iter().filter(|field| {
770                    !field.missing
771                        && !field.container
772                        && names.iter().any(|name| name == field.name.as_ref())
773                }) {
774                    submitted = true;
775                    if contains_macro(field.value.as_ref()) {
776                        unknown = true;
777                    } else if !values.iter().any(|value| {
778                        value == field.value.as_ref()
779                            || field.json_kind == Some(JsonValueKind::Number)
780                                && compare_numeric_text(value, field.value.as_ref())
781                                    == Some(std::cmp::Ordering::Equal)
782                    }) {
783                        return Some(false);
784                    }
785                }
786                if unknown { None } else { Some(submitted) }
787            }
788            Self::All { conditions } => {
789                let values: Vec<_> = conditions
790                    .iter()
791                    .map(|condition| condition.evaluate(params))
792                    .collect();
793                if values.contains(&Some(false)) {
794                    Some(false)
795                } else if values.contains(&None) {
796                    None
797                } else {
798                    Some(true)
799                }
800            }
801            Self::Any { conditions } => {
802                let values: Vec<_> = conditions
803                    .iter()
804                    .map(|condition| condition.evaluate(params))
805                    .collect();
806                if values.contains(&Some(true)) {
807                    Some(true)
808                } else if values.contains(&None) {
809                    None
810                } else {
811                    Some(false)
812                }
813            }
814            Self::Not { condition } => condition.evaluate(params).map(|value| !value),
815        }
816    }
817}
818
819/// An ancestor-relative field, retaining its actual document location.
820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821#[serde(deny_unknown_fields)]
822pub struct AncestorPath {
823    pub levels: usize,
824    pub path: String,
825}
826
827fn validate_condition(
828    pack_id: &str,
829    name: &str,
830    condition: &RuleCondition,
831) -> Result<(), ManifestError> {
832    let invalid = |reason: &str| ManifestError::InvalidConstraint {
833        pack_id: pack_id.to_string(),
834        name: name.to_string(),
835        reason: reason.to_string(),
836    };
837    match condition {
838        RuleCondition::ValuePattern { pattern, .. } => {
839            compile_regex(pack_id, pattern)?;
840        }
841        RuleCondition::JsonType { json_type, .. } if json_type.types().is_empty() => {
842            return Err(invalid("json_type condition needs at least one type"));
843        }
844        RuleCondition::AllValuesIn { params, values } if params.is_empty() || values.is_empty() => {
845            return Err(invalid("all_values_in needs nonempty params and values"));
846        }
847        RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
848            if conditions.is_empty() {
849                return Err(invalid("condition group must not be empty"));
850            }
851            for condition in conditions {
852                validate_condition(pack_id, name, condition)?;
853            }
854        }
855        RuleCondition::Not { condition } => validate_condition(pack_id, name, condition)?,
856        _ => {}
857    }
858    Ok(())
859}
860
861fn validate_format(pack_id: &str, name: &str, format: &ValueFormat) -> Result<(), ManifestError> {
862    if let ValueFormat::NumericRanges { ranges } = format {
863        if ranges.is_empty() {
864            return Err(ManifestError::EmptyFormatValues {
865                pack_id: pack_id.to_string(),
866                name: name.to_string(),
867            });
868        }
869        for range in ranges {
870            if !matches!(
871                compare_numeric_text(&range.minimum.to_string(), &range.maximum.to_string()),
872                Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
873            ) {
874                return Err(ManifestError::InvalidConstraint {
875                    pack_id: pack_id.to_string(),
876                    name: name.to_string(),
877                    reason: "numeric interval endpoints must be ordered and comparable".into(),
878                });
879            }
880        }
881    }
882    if let ValueFormat::DateTimeFormats { formats } = format
883        && formats.is_empty()
884    {
885        return Err(ManifestError::EmptyFormatValues {
886            pack_id: pack_id.to_string(),
887            name: name.to_string(),
888        });
889    }
890    if let ValueFormat::Enum { values, .. } = format
891        && values.is_empty()
892    {
893        return Err(ManifestError::EmptyFormatValues {
894            pack_id: pack_id.to_string(),
895            name: name.to_string(),
896        });
897    }
898    if let ValueFormat::Ip { exclude_ranges, .. } = format {
899        for range in exclude_ranges {
900            if parse_cidr(range).is_none() {
901                return Err(ManifestError::InvalidConstraint {
902                    pack_id: pack_id.to_string(),
903                    name: name.to_string(),
904                    reason: format!("invalid excluded CIDR range `{range}`"),
905                });
906            }
907        }
908    }
909    Ok(())
910}
911
912/// A pack-level rule that spans more than one parameter.
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
914pub struct PackRule {
915    /// Stable rule id. Must start with the pack's code prefix.
916    pub code: String,
917    #[serde(flatten)]
918    pub assertion: Assertion,
919    #[serde(default)]
920    pub condition: Option<RuleCondition>,
921    pub severity: Severity,
922    pub message: String,
923    #[serde(default)]
924    pub fix_hint: Option<String>,
925    #[serde(default)]
926    pub doc: Option<String>,
927    #[serde(default)]
928    pub source_level: Option<RuleSourceLevel>,
929}
930
931/// A rulepack expressed as data.
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933#[serde(deny_unknown_fields)]
934pub struct RulePackManifest {
935    /// Destination-documented alternate values meaning GDPR does not apply.
936    #[serde(default)]
937    pub gdpr_non_applicable_values: Vec<String>,
938    /// Destination-documented alternative query names carrying a TC String.
939    #[serde(default)]
940    pub gdpr_consent_aliases: Vec<String>,
941    /// URL presence checked by declared HTTP alternatives on complete captures.
942    #[serde(default)]
943    pub http_url_presence_overrides: Vec<String>,
944    /// Pack id such as `vendor/meta`. Becomes the code prefix `vendor.meta`.
945    pub id: String,
946    pub display_name: String,
947    pub description: String,
948    /// Pack version. Defaults to the crate version when omitted.
949    #[serde(default)]
950    pub version: Option<String>,
951    /// Vendor slug reported as `detected_vendor` when the pack matches.
952    #[serde(default)]
953    pub vendor: Option<String>,
954    #[serde(default = "default_source_level")]
955    pub source_level: RuleSourceLevel,
956    /// Pack-wide documentation URL, used when a rule omits its own.
957    #[serde(default)]
958    pub docs: Option<String>,
959    #[serde(default)]
960    pub param_style: ParamStyle,
961    /// Regular expression with named capture groups, run against the artifact's
962    /// path. Each named group becomes a parameter, which is how endpoints that
963    /// carry an identifier in the path rather than the query get contracted.
964    #[serde(default)]
965    pub path_pattern: Option<String>,
966    /// Fragment keys read by a documented browser loader. This declaration
967    /// changes only core's server-delivery warning, not value contracts.
968    #[serde(default)]
969    pub client_fragment_params: Vec<String>,
970    #[serde(rename = "match")]
971    pub matcher: MatchSpec,
972    #[serde(default)]
973    pub params: Vec<ParamContract>,
974    #[serde(default)]
975    pub rules: Vec<PackRule>,
976    /// Contracts evaluated separately in raw semicolon-separated query groups.
977    #[serde(default)]
978    pub query_scopes: Vec<QuerySpec>,
979    /// Contracts on the JSON request body, for endpoints that carry their
980    /// payload there rather than in the query string. A list declares more than
981    /// one, which is how a pack contracts both the envelope and the events
982    /// inside it.
983    #[serde(default)]
984    pub body: Option<BodySpecs>,
985    /// Complete-request contracts. The namespace includes method, headers,
986    /// URL query fields, and a decoded body. Bare artifacts do not run these.
987    #[serde(default)]
988    pub http: Option<BodySpecs>,
989    /// Explicit source-backed form decoding when Content-Type is observed absent.
990    #[serde(default)]
991    pub http_headerless_form: bool,
992    /// Query-only event strings inside a captured bulk request. Each item uses
993    /// this pack's existing URL contracts, bound to the captured endpoint.
994    #[serde(default)]
995    pub http_queries: Vec<RequestQuerySpec>,
996}
997
998#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
999#[serde(deny_unknown_fields)]
1000pub struct RequestQuerySpec {
1001    pub source_field: String,
1002    #[serde(default)]
1003    pub encoding: RequestQueryEncoding,
1004    #[serde(default)]
1005    pub inherited_params: std::collections::BTreeMap<String, String>,
1006    #[serde(default)]
1007    pub inherited_overrides: bool,
1008    /// Opt-in native map reader with explicitly sourced server semantics.
1009    #[serde(default)]
1010    pub native_map: Option<RequestQueryMapCoercion>,
1011    /// Advisory oldest-first order for explicit Matomo cdt values.
1012    #[serde(default)]
1013    pub check_chronological_order: bool,
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1017#[serde(rename_all = "snake_case")]
1018pub enum RequestQueryMapCoercion {
1019    MatomoPhp8,
1020}
1021
1022#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1023#[serde(rename_all = "snake_case")]
1024pub enum RequestQueryEncoding {
1025    #[default]
1026    Query,
1027    UrlQuery,
1028}
1029
1030/// A namespace evaluated independently for each selected query or path group.
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032#[serde(deny_unknown_fields)]
1033pub struct QuerySpec {
1034    /// Query semicolon groups by default, path items, or opt-in indexed keys.
1035    #[serde(default)]
1036    pub source: QuerySource,
1037    /// Literal collection prefix for opt-in prefix[index][field] URL keys.
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub key_prefix: Option<String>,
1040    /// Read these declared logical parameters from outside all path items.
1041    #[serde(default)]
1042    pub global_params: Vec<String>,
1043    /// Skip initial global or targeting groups without renumbering later groups.
1044    #[serde(default)]
1045    pub first_index: usize,
1046    /// Include groups through this absolute index. Omitted means no upper bound.
1047    #[serde(default)]
1048    pub last_index: Option<usize>,
1049    /// Select groups using values within that group only.
1050    #[serde(default)]
1051    pub condition: Option<RuleCondition>,
1052    #[serde(default)]
1053    pub params: Vec<ParamContract>,
1054    #[serde(default)]
1055    pub rules: Vec<PackRule>,
1056    /// These fields must have distinct literal values across selected groups.
1057    /// The field contract supplies the finding's citation and severity.
1058    #[serde(default)]
1059    pub unique_by: Vec<String>,
1060}
1061
1062#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1063#[serde(rename_all = "snake_case")]
1064pub enum QuerySource {
1065    #[default]
1066    QuerySemicolon,
1067    PathItems,
1068    IndexedQuery,
1069}
1070
1071/// One body contract, or several at different levels of the same payload.
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1073#[serde(untagged)]
1074pub enum BodySpecs {
1075    One(Box<BodySpec>),
1076    Many(Vec<BodySpec>),
1077}
1078
1079impl<'de> Deserialize<'de> for BodySpecs {
1080    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1081        let value = serde_json::Value::deserialize(deserializer)?;
1082        if value.is_array() {
1083            serde_json::from_value(value)
1084                .map(Self::Many)
1085                .map_err(serde::de::Error::custom)
1086        } else {
1087            serde_json::from_value(value)
1088                .map(Self::One)
1089                .map_err(serde::de::Error::custom)
1090        }
1091    }
1092}
1093
1094impl BodySpecs {
1095    fn specs(&self) -> &[BodySpec] {
1096        match self {
1097            Self::One(spec) => std::slice::from_ref(spec.as_ref()),
1098            Self::Many(specs) => specs,
1099        }
1100    }
1101}
1102
1103/// Which part of a body the contracts are written against.
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(untagged)]
1106pub enum ScopeSpec {
1107    /// One batch array, such as `data[]`.
1108    One(String),
1109    /// Alternative envelopes, tried in order, first one that is present wins. An
1110    /// empty string means the document itself. LinkedIn takes either a single
1111    /// event at the root or a batch under `elements`, so its pack declares
1112    /// `["elements[]", ""]`.
1113    Any(Vec<String>),
1114}
1115
1116impl ScopeSpec {
1117    fn patterns(&self) -> Vec<&str> {
1118        match self {
1119            Self::One(pattern) => vec![pattern],
1120            Self::Any(patterns) => patterns.iter().map(String::as_str).collect(),
1121        }
1122    }
1123}
1124
1125/// Contracts applied to a JSON request body.
1126///
1127/// Conversion APIs batch events into an array, and every element of that array
1128/// has to satisfy the same contract. `scope` names that array, and the packs
1129/// underneath it are written relative to one element, so a manifest reads
1130/// `event_name` rather than repeating `data[].event_name` on every line. Each
1131/// element is then evaluated on its own: three events with no `event_name`
1132/// produce three findings, each pointing at its own bytes.
1133///
1134/// Omitting `scope` evaluates the document once as a single scope, which is the
1135/// shape of an API that posts one event per request.
1136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1137#[serde(deny_unknown_fields)]
1138pub struct BodySpec {
1139    /// Decode this URL parameter and validate its JSON value as the body.
1140    #[serde(default)]
1141    pub source_param: Option<String>,
1142    /// Decode a JSON string field in the request body, or inside source_param.
1143    #[serde(default)]
1144    pub source_field: Option<String>,
1145    /// Decode this second string field inside the first decoded field.
1146    #[serde(default)]
1147    pub decoded_source_field: Option<String>,
1148    /// Select the nested representation using the first decoded document.
1149    #[serde(default)]
1150    pub decoded_source_condition: Option<ShapeMatch>,
1151    /// Maximum Unicode characters in the original source_field string.
1152    #[serde(default)]
1153    pub source_max_length: Option<usize>,
1154    /// Apply source_max_length when this first-decoded-document shape holds.
1155    #[serde(default)]
1156    pub source_max_length_when: Option<ShapeMatch>,
1157    /// A URL-scope guard for a body decoded from a query parameter.
1158    #[serde(default)]
1159    pub condition: Option<RuleCondition>,
1160    #[serde(default)]
1161    pub encoding: ParamEncoding,
1162    /// Severity of encoded-source syntax failures. Omission preserves errors.
1163    #[serde(default)]
1164    pub encoding_severity: Option<Severity>,
1165    /// Inner field encoding when source_param and source_field are combined.
1166    #[serde(default)]
1167    pub field_encoding: ParamEncoding,
1168    #[serde(default)]
1169    pub scope: Option<ScopeSpec>,
1170    /// Absolute JSON paths excluded along with their descendants from this scope.
1171    #[serde(default)]
1172    pub scope_exclusions: Vec<String>,
1173    #[serde(default)]
1174    pub params: Vec<ParamContract>,
1175    #[serde(default)]
1176    pub rules: Vec<PackRule>,
1177}
1178
1179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1180#[serde(rename_all = "snake_case")]
1181pub enum ParamEncoding {
1182    #[default]
1183    Json,
1184    Base64Json,
1185    /// Browser btoa JSON, with every decoded byte mapped to its Latin-1 code point.
1186    Base64Latin1Json,
1187    /// Pipe-separated tuple components, preserved as JSON strings.
1188    PipeDelimitedJson,
1189    /// One strict encodeURIComponent layer over UTF-8 JSON, preserving plus.
1190    PercentEncodedJson,
1191    /// Form-style query fields represented as JSON strings or repeated arrays.
1192    QueryParamsJson,
1193    /// Nonempty JSON lines represented as an array, preserving original byte limits.
1194    Ndjson,
1195}
1196
1197impl ParamEncoding {
1198    fn decoded_byte_length(self, source: &str, text: &str) -> usize {
1199        match self {
1200            Self::Base64Latin1Json => text.chars().count(),
1201            Self::PipeDelimitedJson | Self::QueryParamsJson | Self::Ndjson => source.len(),
1202            Self::Json | Self::Base64Json | Self::PercentEncodedJson => text.len(),
1203        }
1204    }
1205
1206    fn decode(self, source: &str) -> Result<String, String> {
1207        match self {
1208            Self::Json => Ok(source.to_string()),
1209            Self::Base64Json => decode_base64_json(source),
1210            Self::Base64Latin1Json => decode_base64_latin1_json(source),
1211            Self::PercentEncodedJson => decode_percent_encoded_json(source),
1212            Self::QueryParamsJson => decode_query_params_json(source),
1213            Self::Ndjson => {
1214                let values = source
1215                    .lines()
1216                    .filter(|line| !line.trim().is_empty())
1217                    .map(serde_json::from_str::<serde_json::Value>)
1218                    .collect::<Result<Vec<_>, _>>()
1219                    .map_err(|error| error.to_string())?;
1220                serde_json::to_string(&values).map_err(|error| error.to_string())
1221            }
1222            Self::PipeDelimitedJson => {
1223                serde_json::to_string(&source.split('|').collect::<Vec<_>>())
1224                    .map_err(|error| error.to_string())
1225            }
1226        }
1227    }
1228}
1229
1230fn default_source_level() -> RuleSourceLevel {
1231    RuleSourceLevel::OfficialVendor
1232}
1233
1234/// Why a manifest could not be loaded.
1235#[derive(Debug, Clone, PartialEq, Eq)]
1236pub enum ManifestError {
1237    Parse(String),
1238    Io(String),
1239    InvalidId(String),
1240    EmptyField {
1241        pack_id: String,
1242        field: &'static str,
1243    },
1244    MatcherTooBroad(String),
1245    DuplicateParam {
1246        pack_id: String,
1247        name: String,
1248    },
1249    InvalidRegex {
1250        pack_id: String,
1251        pattern: String,
1252        error: String,
1253    },
1254    MissingCitation {
1255        pack_id: String,
1256        rule: String,
1257    },
1258    CodePrefix {
1259        pack_id: String,
1260        code: String,
1261        expected_prefix: String,
1262    },
1263    UnknownParam {
1264        pack_id: String,
1265        code: String,
1266        name: String,
1267    },
1268    EmptyFormatValues {
1269        pack_id: String,
1270        name: String,
1271    },
1272    PathPatternWithoutCaptures {
1273        pack_id: String,
1274        pattern: String,
1275    },
1276    BodyWithoutShape(String),
1277    ShapeWithoutBody(String),
1278    InvalidJsonPath {
1279        pack_id: String,
1280        path: String,
1281    },
1282    NamePatternNotOptional {
1283        pack_id: String,
1284        name: String,
1285    },
1286    NamePatternWithAliases {
1287        pack_id: String,
1288        name: String,
1289    },
1290    NamePatternOnBody {
1291        pack_id: String,
1292        name: String,
1293    },
1294    InvalidConstraint {
1295        pack_id: String,
1296        name: String,
1297        reason: String,
1298    },
1299}
1300
1301impl fmt::Display for ManifestError {
1302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1303        match self {
1304            Self::Parse(error) => write!(f, "invalid rulepack manifest JSON: {error}"),
1305            Self::Io(error) => write!(f, "could not read rulepack manifest: {error}"),
1306            Self::InvalidId(id) => write!(
1307                f,
1308                "invalid rulepack id `{id}`: use lowercase segments such as `vendor/meta`"
1309            ),
1310            Self::EmptyField { pack_id, field } => {
1311                write!(f, "rulepack `{pack_id}` has an empty `{field}`")
1312            }
1313            Self::MatcherTooBroad(pack_id) => write!(
1314                f,
1315                "rulepack `{pack_id}` must declare at least one host or host suffix so it only runs on its own endpoints"
1316            ),
1317            Self::DuplicateParam { pack_id, name } => write!(
1318                f,
1319                "rulepack `{pack_id}` contracts the parameter `{name}` more than once"
1320            ),
1321            Self::InvalidRegex {
1322                pack_id,
1323                pattern,
1324                error,
1325            } => write!(
1326                f,
1327                "rulepack `{pack_id}` has an invalid regex `{pattern}`: {error}"
1328            ),
1329            Self::MissingCitation { pack_id, rule } => write!(
1330                f,
1331                "rulepack `{pack_id}` rule `{rule}` claims vendor-documented evidence but cites no documentation URL"
1332            ),
1333            Self::CodePrefix {
1334                pack_id,
1335                code,
1336                expected_prefix,
1337            } => write!(
1338                f,
1339                "rulepack `{pack_id}` rule code `{code}` must start with `{expected_prefix}`"
1340            ),
1341            Self::UnknownParam {
1342                pack_id,
1343                code,
1344                name,
1345            } => write!(
1346                f,
1347                "rulepack `{pack_id}` rule `{code}` references the uncontracted parameter `{name}`"
1348            ),
1349            Self::EmptyFormatValues { pack_id, name } => write!(
1350                f,
1351                "rulepack `{pack_id}` parameter `{name}` declares an enum format with no values"
1352            ),
1353            Self::PathPatternWithoutCaptures { pack_id, pattern } => write!(
1354                f,
1355                "rulepack `{pack_id}` path pattern `{pattern}` has no named capture groups, so it contributes no parameters"
1356            ),
1357            Self::BodyWithoutShape(pack_id) => write!(
1358                f,
1359                "rulepack `{pack_id}` contracts a JSON body but declares no `match.json_paths`, so it would claim every payload it is shown"
1360            ),
1361            Self::ShapeWithoutBody(pack_id) => write!(
1362                f,
1363                "rulepack `{pack_id}` declares `match.json_paths` but contracts no body, so the shape match would check nothing"
1364            ),
1365            Self::InvalidJsonPath { pack_id, path } => write!(
1366                f,
1367                "rulepack `{pack_id}` has the malformed JSON path `{path}`: use dotted keys with `[]` for arrays, as in `data[].user_data.em[]`"
1368            ),
1369            Self::NamePatternNotOptional { pack_id, name } => write!(
1370                f,
1371                "rulepack `{pack_id}` parameter `{name}` uses `name_pattern`, which can only check values that are already present, so it cannot be required or recommended"
1372            ),
1373            Self::NamePatternWithAliases { pack_id, name } => write!(
1374                f,
1375                "rulepack `{pack_id}` parameter `{name}` uses `name_pattern` and cannot also declare aliases"
1376            ),
1377            Self::NamePatternOnBody { pack_id, name } => write!(
1378                f,
1379                "rulepack `{pack_id}` parameter `{name}` uses `name_pattern` on a JSON body, which only matches exact field paths"
1380            ),
1381            Self::InvalidConstraint {
1382                pack_id,
1383                name,
1384                reason,
1385            } => write!(
1386                f,
1387                "rulepack `{pack_id}` parameter `{name}` has invalid constraints: {reason}"
1388            ),
1389        }
1390    }
1391}
1392
1393impl Error for ManifestError {}
1394
1395#[derive(Debug)]
1396struct CompiledParam {
1397    contract: ParamContract,
1398    names: Vec<String>,
1399    regex: Option<Regex>,
1400    name_regex: Option<Regex>,
1401    key_regex: Option<Regex>,
1402    doc: Option<String>,
1403    source_level: RuleSourceLevel,
1404}
1405
1406#[derive(Debug)]
1407struct CompiledRule {
1408    rule: PackRule,
1409    regex: Option<Regex>,
1410    doc: Option<String>,
1411    source_level: RuleSourceLevel,
1412}
1413
1414/// Where a set of contracts is being evaluated, so one checker can serve both
1415/// the query string and a JSON body without either borrowing the other's
1416/// vocabulary.
1417struct Scope<'a> {
1418    /// Segment used in violation codes: `param` for the URL, `body` for a
1419    /// payload. Keeping them apart matters because an endpoint may accept the
1420    /// same field in both places under different rules.
1421    code_segment: &'static str,
1422    /// The artifact being checked. Fallback targets borrow spans from it and
1423    /// are built only when a contract fires.
1424    artifact: &'a str,
1425    raw_artifact_len: usize,
1426    reference_time_unix_seconds: i64,
1427    complete_http_request: bool,
1428    /// The payload being read, when this scope is a body rather than a URL.
1429    body: Option<BodyScope<'a>>,
1430    query: Option<QuerySpan<'a>>,
1431}
1432
1433#[derive(Clone, Copy)]
1434struct QuerySpan<'a> {
1435    index: usize,
1436    start: usize,
1437    end: usize,
1438    physical_row: Option<&'a str>,
1439    anchor_name: Option<&'a str>,
1440}
1441
1442struct ScopedQueryGroup<'a> {
1443    index: usize,
1444    start: usize,
1445    end: usize,
1446    physical_row: Option<String>,
1447    anchor_name: Option<String>,
1448    params: Vec<RawParam<'a>>,
1449}
1450
1451impl<'a> From<crate::query_segments::QuerySegment<'a>> for ScopedQueryGroup<'a> {
1452    fn from(group: crate::query_segments::QuerySegment<'a>) -> Self {
1453        Self {
1454            index: group.index,
1455            start: group.start,
1456            end: group.end,
1457            physical_row: None,
1458            anchor_name: None,
1459            params: group.params,
1460        }
1461    }
1462}
1463
1464/// The document and the element a body scope is evaluating.
1465struct BodyScope<'a> {
1466    document: &'a JsonDocument<'a>,
1467    path: &'a str,
1468}
1469
1470impl Scope<'_> {
1471    fn field(&self, name: &str) -> String {
1472        if let Some(query) = self.query {
1473            return match query.physical_row {
1474                Some(row) => format!("{}.{row}[{name}]", self.code_segment),
1475                None => format!("{}[{}].{name}", self.code_segment, query.index),
1476            };
1477        }
1478        match &self.body {
1479            None => format!("param.{name}"),
1480            Some(body) if body.path.is_empty() => format!("{}.{name}", self.code_segment),
1481            Some(body) => format!("{}.{}.{name}", self.code_segment, body.path),
1482        }
1483    }
1484
1485    fn fallback(&self) -> ViolationTarget {
1486        if let Some(query) = self.query {
1487            return ViolationTarget {
1488                component: if self.code_segment == "path_items" {
1489                    ViolationTargetComponent::Path
1490                } else {
1491                    ViolationTargetComponent::QueryParam
1492                },
1493                name: Some(query.anchor_name.map_or_else(
1494                    || format!("{}[{}]", self.code_segment, query.index),
1495                    str::to_string,
1496                )),
1497                value: None,
1498                start: query.start,
1499                end: query.end,
1500            };
1501        }
1502        match &self.body {
1503            Some(body) => body_target(body.document, body.path, self.artifact),
1504            None => whole_url_target(self.artifact),
1505        }
1506    }
1507
1508    /// Where to point for a finding about a field that carries no value of its
1509    /// own. A missing `user_data.em` is most useful pointed at the `user_data`
1510    /// that does exist, rather than at the whole event.
1511    fn fallback_for(&self, name: &str) -> ViolationTarget {
1512        let Some(body) = &self.body else {
1513            return self.fallback();
1514        };
1515
1516        let path = match body.path.is_empty() {
1517            true => name.to_string(),
1518            false => format!("{}.{name}", body.path),
1519        };
1520
1521        match body.document.nearest_present_ancestor(&path) {
1522            Some((ancestor, field)) => ViolationTarget {
1523                component: ViolationTargetComponent::BodyField,
1524                name: Some(ancestor.to_string()),
1525                value: None,
1526                start: field.start,
1527                end: field.end,
1528            },
1529            None => self.fallback(),
1530        }
1531    }
1532}
1533
1534/// A shape condition with its patterns compiled.
1535#[derive(Debug)]
1536enum CompiledShape {
1537    Present(String),
1538    Excludes { path: String, regex: Regex },
1539    Matches { path: String, regex: Regex },
1540    Type { path: String, json_type: JsonTypes },
1541    Any(Vec<CompiledShape>),
1542}
1543
1544impl CompiledShape {
1545    fn holds(&self, document: &JsonDocument<'_>) -> bool {
1546        match self {
1547            Self::Present(path) => document.matches_pattern(path),
1548            Self::Excludes { path, regex } => !document
1549                .expand(path)
1550                .iter()
1551                .filter_map(|concrete| document.get(concrete))
1552                .any(|field| regex.is_match(&field.text)),
1553            Self::Matches { path, regex } => document
1554                .expand(path)
1555                .iter()
1556                .filter_map(|path| document.get(path))
1557                .any(|field| regex.is_match(&field.text)),
1558            Self::Type { path, json_type } => document
1559                .expand(path)
1560                .iter()
1561                .filter_map(|path| document.get(path))
1562                .any(|field| {
1563                    json_type
1564                        .types()
1565                        .iter()
1566                        .any(|kind| matches_json_type(*kind, field))
1567                }),
1568            Self::Any(alternatives) => alternatives.iter().any(|shape| shape.holds(document)),
1569        }
1570    }
1571}
1572
1573#[derive(Debug)]
1574struct CompiledBody {
1575    code_segment: &'static str,
1576    source_param: Option<String>,
1577    source_field: Option<String>,
1578    decoded_source_field: Option<String>,
1579    decoded_source_condition: Option<CompiledShape>,
1580    source_max_length: Option<usize>,
1581    source_max_length_when: Option<CompiledShape>,
1582    condition: Option<RuleCondition>,
1583    encoding: ParamEncoding,
1584    encoding_severity: Severity,
1585    field_encoding: ParamEncoding,
1586    scope: Option<ScopeSpec>,
1587    scope_exclusions: Vec<String>,
1588    params: Vec<CompiledParam>,
1589    rules: Vec<CompiledRule>,
1590    exact_names: BTreeSet<String>,
1591}
1592
1593#[derive(Debug)]
1594struct CompiledQuery {
1595    spec: QuerySpec,
1596    params: Vec<CompiledParam>,
1597    rules: Vec<CompiledRule>,
1598    exact_names: BTreeSet<String>,
1599}
1600
1601fn strongest_encoding_severity<'a>(bodies: impl Iterator<Item = &'a CompiledBody>) -> Severity {
1602    bodies
1603        .map(|body| body.encoding_severity)
1604        .max_by_key(|severity| match severity {
1605            Severity::Info => 0,
1606            Severity::Warning => 1,
1607            Severity::Error => 2,
1608        })
1609        .unwrap_or(Severity::Error)
1610}
1611
1612/// A rulepack compiled from a [`RulePackManifest`].
1613#[derive(Debug)]
1614pub struct ManifestRulePack {
1615    metadata: RulePackMetadata,
1616    gdpr_non_applicable_values: Vec<String>,
1617    gdpr_consent_aliases: Vec<String>,
1618    http_url_presence_overrides: BTreeSet<String>,
1619    code_prefix: String,
1620    vendor: Option<String>,
1621    docs: Option<String>,
1622    param_style: ParamStyle,
1623    path_pattern: Option<Regex>,
1624    matching_path_patterns: Vec<Regex>,
1625    client_fragment_params: BTreeSet<String>,
1626    matcher: MatchSpec,
1627    params: Vec<CompiledParam>,
1628    exact_param_names: BTreeSet<String>,
1629    rules: Vec<CompiledRule>,
1630    bodies: Vec<CompiledBody>,
1631    queries: Vec<CompiledQuery>,
1632    shapes: Vec<CompiledShape>,
1633    http: Option<Box<ManifestRulePack>>,
1634    http_headerless_form: bool,
1635    http_queries: Vec<RequestQuerySpec>,
1636}
1637
1638impl RulePackManifest {
1639    /// Parses a manifest from JSON without compiling it.
1640    pub fn from_json(json: &str) -> Result<Self, ManifestError> {
1641        serde_json::from_str(json).map_err(|error| ManifestError::Parse(error.to_string()))
1642    }
1643}
1644
1645/// Compiles the shape conditions a pack claims its payloads by.
1646fn compile_shapes(
1647    pack_id: &str,
1648    shapes: &[ShapeMatch],
1649) -> Result<Vec<CompiledShape>, ManifestError> {
1650    shapes
1651        .iter()
1652        .map(|shape| compile_shape(pack_id, shape))
1653        .collect()
1654}
1655
1656fn compile_shape(pack_id: &str, shape: &ShapeMatch) -> Result<CompiledShape, ManifestError> {
1657    Ok(match shape {
1658        ShapeMatch::Present(path) => CompiledShape::Present(path.clone()),
1659        ShapeMatch::Excludes { path, excludes } => CompiledShape::Excludes {
1660            path: path.clone(),
1661            regex: compile_regex(pack_id, excludes)?,
1662        },
1663        ShapeMatch::Matches { path, pattern } => CompiledShape::Matches {
1664            path: path.clone(),
1665            regex: compile_regex(pack_id, pattern)?,
1666        },
1667        ShapeMatch::Type { path, json_type } => {
1668            if json_type.types().is_empty() {
1669                return Err(ManifestError::InvalidConstraint {
1670                    pack_id: pack_id.to_string(),
1671                    name: path.clone(),
1672                    reason: "shape json_type must not be empty".to_string(),
1673                });
1674            }
1675            CompiledShape::Type {
1676                path: path.clone(),
1677                json_type: json_type.clone(),
1678            }
1679        }
1680        ShapeMatch::Any { any_of } => CompiledShape::Any(compile_shapes(pack_id, any_of)?),
1681    })
1682}
1683
1684/// Compiles one list of parameter contracts, returning the names it defines so
1685/// rules can be checked against them.
1686fn validate_constraints(
1687    pack_id: &str,
1688    contract: &ParamContract,
1689    is_body: bool,
1690) -> Result<(), ManifestError> {
1691    let reject = |reason: &str| ManifestError::InvalidConstraint {
1692        pack_id: pack_id.to_string(),
1693        name: contract.name.clone(),
1694        reason: reason.to_string(),
1695    };
1696    if contract.name_pattern_parent.is_some() && (!is_body || contract.name_pattern.is_none()) {
1697        return Err(reject("name_pattern_parent requires a body name_pattern"));
1698    }
1699    if let Some(path) = &contract.root_path {
1700        if !is_body
1701            || contract.name_pattern.is_some()
1702            || !contract.aliases.is_empty()
1703            || contract.ancestor_path.is_some()
1704        {
1705            return Err(reject(
1706                "root_path requires an exact body contract without aliases",
1707            ));
1708        }
1709        if !path.is_empty() && !json::is_valid_pattern(path) {
1710            return Err(reject("root_path must be a JSON path"));
1711        }
1712    }
1713    if let Some(ancestor) = &contract.ancestor_path {
1714        if !is_body || contract.name_pattern.is_some() || !contract.aliases.is_empty() {
1715            return Err(reject(
1716                "ancestor_path requires an exact body contract without aliases",
1717            ));
1718        }
1719        if ancestor.levels == 0
1720            || (!ancestor.path.is_empty() && !json::is_valid_pattern(&ancestor.path))
1721        {
1722            return Err(reject(
1723                "ancestor_path needs positive levels and a valid JSON path",
1724            ));
1725        }
1726    }
1727    if let Some(parent) = &contract.name_pattern_parent
1728        && !parent.is_empty()
1729        && !json::is_valid_pattern(parent)
1730    {
1731        return Err(reject("name_pattern_parent must be a JSON object path"));
1732    }
1733    if !is_body
1734        && (contract.null_as_missing
1735            || contract.json_type.is_some()
1736            || contract.min_items.is_some()
1737            || contract.max_items.is_some()
1738            || contract.min_properties.is_some()
1739            || contract.max_properties.is_some()
1740            || !contract.property_exclusions.is_empty()
1741            || contract.max_depth.is_some()
1742            || contract.max_member_values.is_some()
1743            || contract.max_joined_length.is_some()
1744            || contract.join_separator.is_some()
1745            || contract.max_compact_bytes.is_some()
1746            || contract.max_key_length.is_some()
1747            || contract.key_pattern.is_some()
1748            || contract.name.is_empty())
1749    {
1750        return Err(reject(
1751            "JSON types, container limits, and the root path are body-only",
1752        ));
1753    }
1754    if contract.max_joined_length.is_some() != contract.join_separator.is_some() {
1755        return Err(reject(
1756            "max_joined_length and join_separator must be declared together",
1757        ));
1758    }
1759    if !contract.property_exclusions.is_empty()
1760        && contract.min_properties.is_none()
1761        && contract.max_properties.is_none()
1762    {
1763        return Err(reject(
1764            "property_exclusions requires an object property limit",
1765        ));
1766    }
1767    if contract
1768        .json_type
1769        .as_ref()
1770        .is_some_and(|types| types.types().is_empty())
1771    {
1772        return Err(reject("json_type cannot be an empty union"));
1773    }
1774    for (min, max, label) in [
1775        (contract.min_items, contract.max_items, "array size"),
1776        (
1777            contract.min_properties,
1778            contract.max_properties,
1779            "object size",
1780        ),
1781        (contract.min_length, contract.max_length, "string length"),
1782        (
1783            contract.min_byte_length,
1784            contract.max_byte_length,
1785            "string byte length",
1786        ),
1787    ] {
1788        if let (Some(min), Some(max)) = (min, max)
1789            && min > max
1790        {
1791            return Err(reject(&format!("minimum {label} exceeds maximum")));
1792        }
1793    }
1794    if let (Some(min), Some(max)) = (&contract.minimum, &contract.maximum)
1795        && compare_numbers(min, max) == Some(std::cmp::Ordering::Greater)
1796    {
1797        return Err(reject("minimum exceeds maximum"));
1798    }
1799    for bound in [&contract.minimum, &contract.maximum, &contract.multiple_of]
1800        .into_iter()
1801        .flatten()
1802    {
1803        if compare_numbers(bound, bound).is_none() {
1804            return Err(reject("numeric bound exponent exceeds the supported range"));
1805        }
1806    }
1807    if let Some(step) = &contract.multiple_of
1808        && compare_numbers(step, &serde_json::Number::from(0)) != Some(std::cmp::Ordering::Greater)
1809    {
1810        return Err(reject("multiple_of must be positive"));
1811    }
1812    if let Some(step) = &contract.multiple_of
1813        && decimal_parts(&step.to_string())
1814            .is_none_or(|(_, digits, _)| digits.parse::<u128>().is_err())
1815    {
1816        return Err(reject(
1817            "multiple_of significand exceeds the supported 128-bit range",
1818        ));
1819    }
1820    if let Some(types) = &contract.json_type {
1821        for (present, allowed, label) in [
1822            (
1823                contract.min_items.is_some() || contract.max_items.is_some(),
1824                vec![JsonType::Array],
1825                "array limits",
1826            ),
1827            (
1828                contract.min_properties.is_some()
1829                    || contract.max_properties.is_some()
1830                    || contract.max_member_values.is_some(),
1831                vec![JsonType::Object],
1832                "object limits",
1833            ),
1834            (
1835                contract.max_depth.is_some(),
1836                vec![JsonType::Object, JsonType::Array],
1837                "nesting depth",
1838            ),
1839            (
1840                contract.min_length.is_some()
1841                    || contract.max_length.is_some()
1842                    || contract.max_utf16_length.is_some()
1843                    || contract.min_byte_length.is_some()
1844                    || contract.max_byte_length.is_some(),
1845                vec![JsonType::String],
1846                "string limits",
1847            ),
1848            (
1849                contract.minimum.is_some()
1850                    || contract.maximum.is_some()
1851                    || contract.multiple_of.is_some(),
1852                vec![JsonType::Number, JsonType::Integer, JsonType::String],
1853                "numeric limits",
1854            ),
1855        ] {
1856            if present && !types.types().iter().any(|kind| allowed.contains(kind)) {
1857                return Err(reject(&format!("{label} do not apply to json_type")));
1858            }
1859        }
1860    }
1861    Ok(())
1862}
1863
1864fn compile_params(
1865    pack_id: &str,
1866    manifest: &RulePackManifest,
1867    contracts: &[ParamContract],
1868    is_body: bool,
1869) -> Result<(Vec<CompiledParam>, BTreeSet<String>), ManifestError> {
1870    let mut seen_names = BTreeSet::new();
1871    let mut params = Vec::with_capacity(contracts.len());
1872
1873    for contract in contracts {
1874        validate_constraints(pack_id, contract, is_body)?;
1875        if let Some(condition) = &contract.condition {
1876            validate_condition(pack_id, &contract.name, condition)?;
1877        }
1878        if let Some(format) = &contract.format {
1879            validate_format(pack_id, &contract.name, format)?;
1880        }
1881        if contract.name_pattern.is_some() {
1882            if !contract.aliases.is_empty() {
1883                return Err(ManifestError::NamePatternWithAliases {
1884                    pack_id: pack_id.to_string(),
1885                    name: contract.name.clone(),
1886                });
1887            }
1888            match contract.requirement {
1889                Requirement::Required | Requirement::Recommended => {
1890                    return Err(ManifestError::NamePatternNotOptional {
1891                        pack_id: pack_id.to_string(),
1892                        name: contract.name.clone(),
1893                    });
1894                }
1895                Requirement::Optional | Requirement::Forbidden | Requirement::Deprecated => {}
1896            }
1897        }
1898
1899        let mut names = vec![contract.name.clone()];
1900        names.extend(contract.aliases.iter().cloned());
1901
1902        for name in &names {
1903            if !seen_names.insert(name.clone()) {
1904                return Err(ManifestError::DuplicateParam {
1905                    pack_id: pack_id.to_string(),
1906                    name: name.clone(),
1907                });
1908            }
1909        }
1910
1911        let source_level = contract.source_level.unwrap_or(manifest.source_level);
1912        let doc = contract.doc.clone().or_else(|| manifest.docs.clone());
1913        require_citation(pack_id, &contract.name, source_level, doc.as_deref())?;
1914
1915        let regex = match &contract.format {
1916            Some(ValueFormat::Regex { pattern }) => Some(compile_regex(pack_id, pattern)?),
1917            Some(ValueFormat::Enum { values, .. }) if values.is_empty() => {
1918                return Err(ManifestError::EmptyFormatValues {
1919                    pack_id: pack_id.to_string(),
1920                    name: contract.name.clone(),
1921                });
1922            }
1923            _ => None,
1924        };
1925
1926        let name_regex = match &contract.name_pattern {
1927            Some(pattern) => Some(compile_regex(pack_id, pattern)?),
1928            None => None,
1929        };
1930        let key_regex = contract
1931            .key_pattern
1932            .as_ref()
1933            .map(|pattern| compile_regex(pack_id, pattern))
1934            .transpose()?;
1935
1936        let mut normalized_contract = contract.clone();
1937        if let Some(condition) = &mut normalized_contract.condition {
1938            normalize_condition_aliases(condition, contracts);
1939        }
1940        params.push(CompiledParam {
1941            contract: normalized_contract,
1942            names,
1943            regex,
1944            name_regex,
1945            key_regex,
1946            doc,
1947            source_level,
1948        });
1949    }
1950
1951    for contract in contracts {
1952        for name in contract.condition.iter().flat_map(RuleCondition::params) {
1953            if !seen_names.contains(name) {
1954                return Err(ManifestError::UnknownParam {
1955                    pack_id: pack_id.to_string(),
1956                    code: format!("{}.condition", contract.name),
1957                    name: name.clone(),
1958                });
1959            }
1960        }
1961    }
1962    Ok((params, seen_names))
1963}
1964
1965/// Compiles one list of cross-parameter rules against the names available to
1966/// them, so a rule can never reference a parameter the pack does not contract.
1967fn compile_rules(
1968    pack_id: &str,
1969    code_prefix: &str,
1970    manifest: &RulePackManifest,
1971    pack_rules: &[PackRule],
1972    available: &BTreeSet<String>,
1973    contracts: &[ParamContract],
1974) -> Result<Vec<CompiledRule>, ManifestError> {
1975    let mut rules = Vec::with_capacity(pack_rules.len());
1976
1977    for original_rule in pack_rules {
1978        let mut normalized_rule = original_rule.clone();
1979        for param in assertion_params_mut(&mut normalized_rule.assertion) {
1980            normalize_alias(param, contracts);
1981        }
1982        if let Some(condition) = &mut normalized_rule.condition {
1983            normalize_condition_aliases(condition, contracts);
1984        }
1985        let rule = &normalized_rule;
1986        if let Assertion::UniqueArrayBy { field, .. } = &rule.assertion
1987            && field.is_empty()
1988        {
1989            return Err(ManifestError::InvalidConstraint {
1990                pack_id: pack_id.to_string(),
1991                name: rule.code.clone(),
1992                reason: "unique_array_by requires a nonempty literal object member name".into(),
1993            });
1994        }
1995        if let Assertion::MaxTotalArrayItems { params, .. } = &rule.assertion
1996            && (params.is_empty() || params.iter().collect::<BTreeSet<_>>().len() != params.len())
1997        {
1998            return Err(ManifestError::InvalidConstraint {
1999                pack_id: pack_id.to_string(),
2000                name: rule.code.clone(),
2001                reason: "max_total_array_items requires nonempty, distinct field names".into(),
2002            });
2003        }
2004        if let Assertion::GppFieldValues { field, values, .. } = &rule.assertion
2005            && (!crate::gpp_structure::us_field_supported(field) || values.is_empty())
2006        {
2007            return Err(ManifestError::InvalidConstraint {
2008                pack_id: pack_id.to_string(),
2009                name: rule.code.clone(),
2010                reason: "GPP field choice checks need a field name and permitted values".into(),
2011            });
2012        }
2013        if let Assertion::TcfConsent {
2014            vendor_id,
2015            purpose_ids,
2016            ..
2017        } = &rule.assertion
2018            && (*vendor_id == 0 || purpose_ids.iter().any(|id| !(1..=24).contains(id)))
2019        {
2020            return Err(ManifestError::InvalidConstraint {
2021                pack_id: pack_id.to_string(),
2022                name: rule.code.clone(),
2023                reason: "TCF vendor ID must be positive and purpose IDs must be 1 through 24"
2024                    .into(),
2025            });
2026        }
2027        if let Some(condition) = &rule.condition {
2028            validate_condition(pack_id, &rule.code, condition)?;
2029        }
2030        if let Assertion::FormatWhen { format, .. } | Assertion::Format { format, .. } =
2031            &rule.assertion
2032        {
2033            validate_format(pack_id, &rule.code, format)?;
2034        }
2035        if let Assertion::EqualOccurrences { params } | Assertion::EqualArrayLengths { params } =
2036            &rule.assertion
2037            && params.len() < 2
2038        {
2039            return Err(ManifestError::InvalidConstraint {
2040                pack_id: pack_id.to_string(),
2041                name: rule.code.clone(),
2042                reason: "array length and occurrence equality require at least two fields"
2043                    .to_string(),
2044            });
2045        }
2046        if let Assertion::TimeWindow {
2047            max_age_seconds,
2048            min_age_seconds,
2049            max_future_seconds,
2050            ..
2051        } = &rule.assertion
2052            && (max_age_seconds.is_none()
2053                && min_age_seconds.is_none()
2054                && max_future_seconds.is_none())
2055        {
2056            return Err(ManifestError::InvalidConstraint {
2057                pack_id: pack_id.to_string(),
2058                name: rule.code.clone(),
2059                reason: "time_window requires an age or future bound".to_string(),
2060            });
2061        }
2062        if let Assertion::TimeWindow {
2063            min_age_seconds: Some(minimum),
2064            max_age_seconds: Some(maximum),
2065            ..
2066        } = &rule.assertion
2067            && minimum > maximum
2068        {
2069            return Err(ManifestError::InvalidConstraint {
2070                pack_id: pack_id.to_string(),
2071                name: rule.code.clone(),
2072                reason: "minimum age exceeds maximum age".to_string(),
2073            });
2074        }
2075        if let Assertion::EqualSplitLengths { params, separator } = &rule.assertion
2076            && (params.len() < 2 || separator.is_empty())
2077        {
2078            return Err(ManifestError::InvalidConstraint {
2079                pack_id: pack_id.to_string(),
2080                name: rule.code.clone(),
2081                reason: "equal_split_lengths requires two fields and a nonempty separator"
2082                    .to_string(),
2083            });
2084        }
2085        if let Assertion::IndexedParamGroups {
2086            params,
2087            optional_params,
2088            max_index,
2089            ..
2090        } = &rule.assertion
2091        {
2092            let all: Vec<_> = params.iter().chain(optional_params).collect();
2093            if params.len() < 2
2094                || *max_index == 0
2095                || all.iter().any(|p| {
2096                    p.is_empty() || p.as_bytes().last().is_some_and(|b| b.is_ascii_digit())
2097                })
2098                || all.iter().collect::<BTreeSet<_>>().len() != all.len()
2099            {
2100                return Err(ManifestError::InvalidConstraint { pack_id: pack_id.into(), name: rule.code.clone(), reason: "indexed_param_groups needs distinct nonempty prefixes ending before the numeric index, two required families and a positive maximum index".into() });
2101            }
2102        }
2103        if let Assertion::DelimitedSum {
2104            separator,
2105            value_separator,
2106            ..
2107        } = &rule.assertion
2108            && (separator.is_empty() || value_separator.is_empty())
2109        {
2110            return Err(ManifestError::InvalidConstraint {
2111                pack_id: pack_id.to_string(),
2112                name: rule.code.clone(),
2113                reason: "delimited_sum requires nonempty separators".to_string(),
2114            });
2115        }
2116        if let Assertion::RequireAnyOf { groups, .. } = &rule.assertion
2117            && (groups.is_empty() || groups.iter().any(Vec::is_empty))
2118        {
2119            return Err(ManifestError::InvalidConstraint {
2120                pack_id: pack_id.to_string(),
2121                name: rule.code.clone(),
2122                reason: "require_any_of needs nonempty groups".to_string(),
2123            });
2124        }
2125        if !rule.code.starts_with(&format!("{code_prefix}.")) {
2126            return Err(ManifestError::CodePrefix {
2127                pack_id: pack_id.to_string(),
2128                code: rule.code.clone(),
2129                expected_prefix: format!("{code_prefix}."),
2130            });
2131        }
2132
2133        for name in assertion_params(&rule.assertion)
2134            .into_iter()
2135            .chain(rule.condition.iter().flat_map(RuleCondition::params))
2136        {
2137            if !available.contains(name) {
2138                return Err(ManifestError::UnknownParam {
2139                    pack_id: pack_id.to_string(),
2140                    code: rule.code.clone(),
2141                    name: name.clone(),
2142                });
2143            }
2144        }
2145
2146        let source_level = rule.source_level.unwrap_or(manifest.source_level);
2147        let doc = rule.doc.clone().or_else(|| manifest.docs.clone());
2148        require_citation(pack_id, &rule.code, source_level, doc.as_deref())?;
2149
2150        let regex = match &rule.assertion {
2151            Assertion::ForbidValuePattern { pattern, .. } => Some(compile_regex(pack_id, pattern)?),
2152            Assertion::FormatWhen {
2153                format: ValueFormat::Regex { pattern },
2154                ..
2155            }
2156            | Assertion::Format {
2157                format: ValueFormat::Regex { pattern },
2158                ..
2159            } => Some(compile_regex(pack_id, pattern)?),
2160            _ => None,
2161        };
2162
2163        rules.push(CompiledRule {
2164            rule: rule.clone(),
2165            regex,
2166            doc,
2167            source_level,
2168        });
2169    }
2170
2171    Ok(rules)
2172}
2173
2174impl ManifestRulePack {
2175    /// Compiles a manifest, validating everything that can be checked without
2176    /// an artifact: ids, citations, regexes, and cross-references.
2177    pub fn compile(mut manifest: RulePackManifest) -> Result<Self, ManifestError> {
2178        let http = if let Some(specs) = manifest.http.take() {
2179            let mut transport = manifest.clone();
2180            transport.params.clear();
2181            transport.rules.clear();
2182            transport.query_scopes.clear();
2183            transport.http_queries.clear();
2184            transport.path_pattern = None;
2185            transport.client_fragment_params.clear();
2186            transport.gdpr_non_applicable_values.clear();
2187            transport.gdpr_consent_aliases.clear();
2188            transport.http_url_presence_overrides.clear();
2189            transport.matcher.json_paths = vec![ShapeMatch::Present("method".into())];
2190            let mut bodies = specs.specs().to_vec();
2191            for spec in &mut bodies {
2192                if spec.source_param.is_some()
2193                    || spec.source_field.is_some()
2194                    || spec.decoded_source_field.is_some()
2195                    || spec.source_max_length.is_some()
2196                    || spec
2197                        .rules
2198                        .iter()
2199                        .any(|rule| matches!(rule.assertion, Assertion::MaxBodyBytes { .. }))
2200                {
2201                    return Err(ManifestError::Parse("http contracts use normalized request fields; decoding and raw body byte limits belong in body contracts".into()));
2202                }
2203                spec.condition = None;
2204            }
2205            transport.body = Some(BodySpecs::Many(bodies));
2206            let mut compiled = Self::compile(transport)?;
2207            for (body, original) in compiled.bodies.iter_mut().zip(specs.specs()) {
2208                body.code_segment = "http";
2209                if let Some(condition) = &original.condition {
2210                    validate_condition(&manifest.id, "http.condition", condition)?;
2211                    for name in condition.params() {
2212                        if !body.params.iter().any(|p| p.names.contains(name)) {
2213                            return Err(ManifestError::UnknownParam {
2214                                pack_id: manifest.id.clone(),
2215                                code: "http.condition".into(),
2216                                name: name.clone(),
2217                            });
2218                        }
2219                    }
2220                    let mut guard = condition.clone();
2221                    normalize_condition_aliases(&mut guard, &original.params);
2222                    body.condition = Some(guard);
2223                }
2224            }
2225            Some(Box::new(compiled))
2226        } else {
2227            None
2228        };
2229        let pack_id = manifest.id.clone();
2230        validate_pack_id(&pack_id)?;
2231        for spec in &manifest.http_queries {
2232            if spec.check_chronological_order
2233                && spec.native_map != Some(RequestQueryMapCoercion::MatomoPhp8)
2234            {
2235                return Err(ManifestError::InvalidConstraint {
2236                    pack_id: pack_id.clone(),
2237                    name: "http_queries.check_chronological_order".into(),
2238                    reason: "chronological order uses the explicit matomo_php8 cdt source profile"
2239                        .into(),
2240                });
2241            }
2242            if !spec.source_field.starts_with("body.")
2243                || !spec.source_field.ends_with("[]")
2244                || !json::is_valid_pattern(&spec.source_field)
2245                || spec
2246                    .inherited_params
2247                    .values()
2248                    .any(|path| !path.starts_with("body.") || !json::is_valid_pattern(path))
2249            {
2250                return Err(ManifestError::InvalidConstraint {
2251                    pack_id: pack_id.clone(),
2252                    name: "http_queries".into(),
2253                    reason:
2254                        "HTTP query sources need a body array path and valid body fallback paths"
2255                            .into(),
2256                });
2257            }
2258            for name in spec.inherited_params.keys() {
2259                if !manifest
2260                    .params
2261                    .iter()
2262                    .any(|param| &param.name == name || param.aliases.contains(name))
2263                {
2264                    return Err(ManifestError::UnknownParam {
2265                        pack_id: pack_id.clone(),
2266                        code: "http_queries.inherited_params".into(),
2267                        name: name.clone(),
2268                    });
2269                }
2270            }
2271        }
2272
2273        for (field, value) in [
2274            ("display_name", &manifest.display_name),
2275            ("description", &manifest.description),
2276        ] {
2277            if value.trim().is_empty() {
2278                return Err(ManifestError::EmptyField { pack_id, field });
2279            }
2280        }
2281        if manifest
2282            .matcher
2283            .query_params_any
2284            .iter()
2285            .chain(&manifest.matcher.query_params_none)
2286            .any(|name| name.is_empty())
2287            || manifest
2288                .matcher
2289                .query_params_any
2290                .iter()
2291                .any(|name| manifest.matcher.query_params_none.contains(name))
2292        {
2293            return Err(ManifestError::InvalidConstraint {
2294                pack_id,
2295                name: "match.query_params".into(),
2296                reason: "query selectors need nonempty, nonconflicting key names".into(),
2297            });
2298        }
2299
2300        let mut marker_names = BTreeSet::new();
2301        for marker in &manifest.matcher.query_params_values {
2302            let values: BTreeSet<_> = marker.values.iter().collect();
2303            if marker.name.is_empty()
2304                || !marker_names.insert(marker.name.as_str())
2305                || marker.values.is_empty()
2306                || values.len() != marker.values.len()
2307                || manifest.matcher.query_params_none.contains(&marker.name)
2308            {
2309                return Err(ManifestError::InvalidConstraint {
2310                    pack_id,
2311                    name: "match.query_params_values".into(),
2312                    reason: "query value selectors need distinct nonempty names, nonempty distinct value lists, and no forbidden-key conflict".into(),
2313                });
2314            }
2315        }
2316
2317        if manifest.matcher.any_host && !manifest.matcher.path_contains.is_empty() {
2318            return Err(ManifestError::InvalidConstraint {
2319                pack_id,
2320                name: "match.any_host".to_string(),
2321                reason: "any_host cannot combine exact paths with path_contains alternatives"
2322                    .to_string(),
2323            });
2324        }
2325        if manifest.matcher.any_host && !manifest.matcher.path_patterns.is_empty() {
2326            return Err(ManifestError::InvalidConstraint {
2327                pack_id,
2328                name: "match.path_patterns".to_string(),
2329                reason: "path expression selectors require an explicit host or host suffix"
2330                    .to_string(),
2331            });
2332        }
2333        if manifest.matcher.any_host
2334            && ((manifest.matcher.paths.is_empty() && manifest.matcher.path_prefixes.is_empty())
2335                || manifest
2336                    .matcher
2337                    .paths
2338                    .iter()
2339                    .chain(&manifest.matcher.path_prefixes)
2340                    .any(|path| path == "/" || !path.starts_with('/')))
2341        {
2342            return Err(ManifestError::InvalidConstraint {
2343                pack_id,
2344                name: "match.any_host".to_string(),
2345                reason: "any_host requires a specific non-root path or prefix".to_string(),
2346            });
2347        }
2348        if !manifest.matcher.any_host
2349            && manifest.matcher.hosts.is_empty()
2350            && manifest.matcher.host_suffixes.is_empty()
2351        {
2352            return Err(ManifestError::MatcherTooBroad(pack_id));
2353        }
2354        if manifest
2355            .client_fragment_params
2356            .iter()
2357            .any(|name| name.is_empty())
2358        {
2359            return Err(ManifestError::EmptyField {
2360                pack_id,
2361                field: "client_fragment_params",
2362            });
2363        }
2364        if !manifest.client_fragment_params.is_empty() && manifest.path_pattern.is_none() {
2365            return Err(ManifestError::InvalidConstraint {
2366                pack_id,
2367                name: "client_fragment_params".to_string(),
2368                reason: "browser fragment declarations require path_pattern".to_string(),
2369            });
2370        }
2371
2372        let code_prefix = pack_id.replace('/', ".");
2373
2374        let matching_path_patterns = manifest
2375            .matcher
2376            .path_patterns
2377            .iter()
2378            .map(|pattern| {
2379                if pattern.is_empty() {
2380                    return Err(ManifestError::EmptyField {
2381                        pack_id: pack_id.clone(),
2382                        field: "match.path_patterns",
2383                    });
2384                }
2385                // Validate the standalone expression before adding scope anchors.
2386                // An unmatched group must not escape the enclosing expression.
2387                compile_regex(&pack_id, pattern)?;
2388                compile_regex(&pack_id, &format!(r"\A(?:{pattern})\z"))
2389            })
2390            .collect::<Result<Vec<_>, _>>()?;
2391
2392        let path_pattern = match &manifest.path_pattern {
2393            Some(pattern) => {
2394                let regex = compile_regex(&pack_id, pattern)?;
2395
2396                if regex.capture_names().flatten().count() == 0 {
2397                    return Err(ManifestError::PathPatternWithoutCaptures {
2398                        pack_id,
2399                        pattern: pattern.clone(),
2400                    });
2401                }
2402
2403                Some(regex)
2404            }
2405            None => None,
2406        };
2407
2408        let mut shapes = Vec::new();
2409        let (params, url_names) = compile_params(&pack_id, &manifest, &manifest.params, false)?;
2410        let rules = compile_rules(
2411            &pack_id,
2412            &code_prefix,
2413            &manifest,
2414            &manifest.rules,
2415            &url_names,
2416            &manifest.params,
2417        )?;
2418
2419        let mut queries = Vec::new();
2420        for spec in &manifest.query_scopes {
2421            if let Some(condition) = &spec.condition {
2422                validate_condition(&pack_id, "query_scopes.condition", condition)?;
2423            }
2424            if spec.last_index.is_some_and(|last| last < spec.first_index) {
2425                return Err(ManifestError::InvalidConstraint {
2426                    pack_id,
2427                    name: "query_scopes".to_string(),
2428                    reason: "last_index must not precede first_index".to_string(),
2429                });
2430            }
2431            if spec.source == QuerySource::IndexedQuery {
2432                if spec
2433                    .key_prefix
2434                    .as_ref()
2435                    .is_none_or(|prefix| prefix.is_empty() || prefix.contains(['[', ']']))
2436                {
2437                    return Err(ManifestError::InvalidConstraint {
2438                        pack_id,
2439                        name: "key_prefix".to_string(),
2440                        reason:
2441                            "indexed_query requires a nonempty literal key_prefix without brackets"
2442                                .to_string(),
2443                    });
2444                }
2445                if spec.first_index != 0
2446                    || spec.last_index.is_some()
2447                    || !spec.global_params.is_empty()
2448                {
2449                    return Err(ManifestError::InvalidConstraint {
2450                        pack_id,
2451                        name: "query_scopes".to_string(),
2452                        reason: "indexed_query does not accept first_index, last_index or global_params filters".to_string(),
2453                    });
2454                }
2455            } else if spec.key_prefix.is_some() {
2456                return Err(ManifestError::InvalidConstraint {
2457                    pack_id,
2458                    name: "key_prefix".to_string(),
2459                    reason: "key_prefix requires indexed_query source".to_string(),
2460                });
2461            }
2462            let expected_style = match spec.source {
2463                QuerySource::QuerySemicolon => ParamStyle::QuerySemicolon,
2464                QuerySource::PathItems => ParamStyle::ColonPath,
2465                QuerySource::IndexedQuery => ParamStyle::Query,
2466            };
2467            if manifest.param_style != expected_style {
2468                return Err(ManifestError::InvalidConstraint {
2469                    pack_id,
2470                    name: "query_scopes".to_string(),
2471                    reason: "query_scopes source must agree with the parameter style".to_string(),
2472                });
2473            }
2474            let (params, names) = compile_params(&pack_id, &manifest, &spec.params, false)?;
2475            let rules = compile_rules(
2476                &pack_id,
2477                &code_prefix,
2478                &manifest,
2479                &spec.rules,
2480                &names,
2481                &spec.params,
2482            )?;
2483            let exact_names = exact_param_names(&params);
2484            if !spec.global_params.is_empty() && spec.source != QuerySource::PathItems {
2485                return Err(ManifestError::InvalidConstraint {
2486                    pack_id,
2487                    name: "global_params".to_string(),
2488                    reason: "global_params requires path_items source".to_string(),
2489                });
2490            }
2491            let mut global_names = BTreeSet::new();
2492            for name in &spec.global_params {
2493                if !exact_names.contains(name) || !global_names.insert(name) {
2494                    return Err(ManifestError::InvalidConstraint {
2495                        pack_id,
2496                        name: name.clone(),
2497                        reason: "global_params requires distinct exact declared parameter names"
2498                            .to_string(),
2499                    });
2500                }
2501            }
2502            for name in spec.condition.iter().flat_map(RuleCondition::params) {
2503                if !names.contains(name) {
2504                    return Err(ManifestError::UnknownParam {
2505                        pack_id,
2506                        code: "query_scopes.condition".to_string(),
2507                        name: name.clone(),
2508                    });
2509                }
2510            }
2511            let mut unique_names = BTreeSet::new();
2512            for name in &spec.unique_by {
2513                if !exact_names.contains(name) || !unique_names.insert(name) {
2514                    return Err(ManifestError::InvalidConstraint {
2515                        pack_id,
2516                        name: name.clone(),
2517                        reason: "unique_by requires distinct exact parameter names".to_string(),
2518                    });
2519                }
2520            }
2521            let mut normalized_spec = spec.clone();
2522            if let Some(condition) = &mut normalized_spec.condition {
2523                normalize_condition_aliases(condition, &spec.params);
2524            }
2525            for name in &mut normalized_spec.unique_by {
2526                normalize_alias(name, &spec.params);
2527            }
2528            for name in &mut normalized_spec.global_params {
2529                normalize_alias(name, &spec.params);
2530            }
2531            queries.push(CompiledQuery {
2532                spec: normalized_spec,
2533                params,
2534                rules,
2535                exact_names,
2536            });
2537        }
2538
2539        // Body contracts get their own name space, because an endpoint is free
2540        // to accept the same field in the query string and in the payload, and
2541        // the two are different contracts with different findings.
2542        let mut bodies = Vec::new();
2543
2544        match &manifest.body {
2545            Some(specs) => {
2546                let has_plain_body = specs.specs().iter().any(|spec| spec.source_param.is_none());
2547                if has_plain_body && manifest.matcher.json_paths.is_empty() {
2548                    return Err(ManifestError::BodyWithoutShape(pack_id));
2549                }
2550                if !has_plain_body && !manifest.matcher.json_paths.is_empty() {
2551                    return Err(ManifestError::ShapeWithoutBody(pack_id));
2552                }
2553
2554                for spec in specs.specs() {
2555                    if spec.encoding_severity.is_some()
2556                        && spec.source_param.is_none()
2557                        && spec.source_field.is_none()
2558                    {
2559                        return Err(ManifestError::Parse(
2560                            "body encoding_severity requires source_param or source_field".into(),
2561                        ));
2562                    }
2563                    if spec.rules.iter().any(|rule| {
2564                        matches!(
2565                            rule.assertion,
2566                            Assertion::RequireHttps | Assertion::MaxUrlLength { .. }
2567                        )
2568                    }) {
2569                        return Err(ManifestError::Parse("HTTPS and complete URL length assertions belong in URL rules, not body rules".into()));
2570                    }
2571                    if (spec.decoded_source_field.is_some() || spec.source_max_length.is_some())
2572                        && spec.source_field.is_none()
2573                        || spec.decoded_source_condition.is_some()
2574                            && spec.decoded_source_field.is_none()
2575                        || spec.source_max_length_when.is_some() && spec.source_max_length.is_none()
2576                    {
2577                        return Err(ManifestError::Parse("nested decoding and source length guards require their source fields and limits".to_string()));
2578                    }
2579                    if spec.field_encoding != ParamEncoding::Json
2580                        && (spec.source_param.is_none() || spec.source_field.is_none())
2581                    {
2582                        return Err(ManifestError::Parse(
2583                            "body field_encoding requires both source_param and source_field"
2584                                .to_string(),
2585                        ));
2586                    }
2587                    if let Some(path) = &spec.source_field
2588                        && (path.is_empty() || !json::is_valid_pattern(path))
2589                    {
2590                        return Err(ManifestError::InvalidJsonPath {
2591                            pack_id,
2592                            path: path.clone(),
2593                        });
2594                    }
2595                    if let Some(path) = &spec.decoded_source_field
2596                        && (path.is_empty() || !json::is_valid_pattern(path))
2597                    {
2598                        return Err(ManifestError::InvalidJsonPath {
2599                            pack_id,
2600                            path: path.clone(),
2601                        });
2602                    }
2603                    for shape in spec
2604                        .decoded_source_condition
2605                        .iter()
2606                        .chain(spec.source_max_length_when.iter())
2607                    {
2608                        for path in shape.paths() {
2609                            if !path.is_empty() && !json::is_valid_pattern(path) {
2610                                return Err(ManifestError::InvalidJsonPath {
2611                                    pack_id,
2612                                    path: path.to_string(),
2613                                });
2614                            }
2615                        }
2616                    }
2617                    if let Some(condition) = &spec.condition {
2618                        validate_condition(&pack_id, "body.condition", condition)?;
2619                        if spec.source_param.is_none() {
2620                            return Err(ManifestError::Parse(
2621                                "body condition requires source_param".to_string(),
2622                            ));
2623                        }
2624                        for name in condition.params() {
2625                            if !url_names.contains(name) {
2626                                return Err(ManifestError::UnknownParam {
2627                                    pack_id,
2628                                    code: "body.condition".to_string(),
2629                                    name: name.clone(),
2630                                });
2631                            }
2632                        }
2633                    }
2634                    if let Some(source_param) = &spec.source_param
2635                        && !url_names.contains(source_param)
2636                    {
2637                        return Err(ManifestError::UnknownParam {
2638                            pack_id,
2639                            code: "body.source_param".to_string(),
2640                            name: source_param.clone(),
2641                        });
2642                    }
2643                    let (params, names) = compile_params(&pack_id, &manifest, &spec.params, true)?;
2644                    if let Some(compiled) = params.iter().find(|param| {
2645                        param.name_regex.is_some() && param.contract.name_pattern_parent.is_none()
2646                    }) {
2647                        return Err(ManifestError::NamePatternOnBody {
2648                            pack_id,
2649                            name: compiled.contract.name.clone(),
2650                        });
2651                    }
2652                    let rules = compile_rules(
2653                        &pack_id,
2654                        &code_prefix,
2655                        &manifest,
2656                        &spec.rules,
2657                        &names,
2658                        &spec.params,
2659                    )?;
2660
2661                    let paths = manifest
2662                        .matcher
2663                        .json_paths
2664                        .iter()
2665                        .flat_map(ShapeMatch::paths)
2666                        .map(str::to_string)
2667                        .chain(
2668                            spec.scope
2669                                .iter()
2670                                .flat_map(ScopeSpec::patterns)
2671                                // The empty pattern names the document itself.
2672                                .filter(|pattern| !pattern.is_empty())
2673                                .map(str::to_string),
2674                        )
2675                        .chain(names.iter().cloned())
2676                        .chain(spec.scope_exclusions.iter().cloned())
2677                        .collect::<Vec<_>>();
2678
2679                    for path in &paths {
2680                        if !path.is_empty() && !json::is_valid_pattern(path) {
2681                            return Err(ManifestError::InvalidJsonPath {
2682                                pack_id,
2683                                path: path.clone(),
2684                            });
2685                        }
2686                    }
2687
2688                    let exact_names = exact_param_names(&params);
2689                    let mut source_param = spec.source_param.clone();
2690                    if let Some(name) = &mut source_param {
2691                        normalize_alias(name, &manifest.params);
2692                    }
2693                    let mut condition = spec.condition.clone();
2694                    if let Some(guard) = &mut condition {
2695                        normalize_condition_aliases(guard, &manifest.params);
2696                    }
2697                    bodies.push(CompiledBody {
2698                        code_segment: "body",
2699                        source_param,
2700                        source_field: spec.source_field.clone(),
2701                        decoded_source_field: spec.decoded_source_field.clone(),
2702                        decoded_source_condition: spec
2703                            .decoded_source_condition
2704                            .as_ref()
2705                            .map(|shape| compile_shape(&pack_id, shape))
2706                            .transpose()?,
2707                        source_max_length: spec.source_max_length,
2708                        source_max_length_when: spec
2709                            .source_max_length_when
2710                            .as_ref()
2711                            .map(|shape| compile_shape(&pack_id, shape))
2712                            .transpose()?,
2713                        condition,
2714                        encoding: spec.encoding,
2715                        encoding_severity: spec.encoding_severity.unwrap_or(Severity::Error),
2716                        field_encoding: spec.field_encoding,
2717                        scope: spec.scope.clone(),
2718                        scope_exclusions: spec.scope_exclusions.clone(),
2719                        params,
2720                        rules,
2721                        exact_names,
2722                    });
2723                }
2724
2725                shapes = compile_shapes(&pack_id, &manifest.matcher.json_paths)?;
2726            }
2727            None => {
2728                if !manifest.matcher.json_paths.is_empty() {
2729                    return Err(ManifestError::ShapeWithoutBody(pack_id));
2730                }
2731            }
2732        }
2733
2734        let mut matcher = manifest.matcher;
2735        for host in &mut matcher.hosts {
2736            host.make_ascii_lowercase();
2737        }
2738        for suffix in &mut matcher.host_suffixes {
2739            suffix.make_ascii_lowercase();
2740        }
2741        let exact_param_names = exact_param_names(&params);
2742        if manifest.http_headerless_form
2743            && (matcher.any_host
2744                || (matcher.paths.is_empty() && matcher.path_prefixes.is_empty())
2745                || manifest.docs.is_none())
2746        {
2747            return Err(ManifestError::InvalidConstraint {
2748                pack_id,
2749                name: "http_headerless_form".into(),
2750                reason: "headerless form decoding needs a cited host-bound endpoint path".into(),
2751            });
2752        }
2753        for value in &manifest.gdpr_non_applicable_values {
2754            if value.is_empty() || value == "1" || matcher.any_host ||
2755                !manifest.params.iter().any(|contract| contract.name == "gdpr" && matches!(&contract.format, Some(ValueFormat::Enum { values, .. }) if values.contains(value))) {
2756                return Err(ManifestError::InvalidConstraint { pack_id, name: "gdpr_non_applicable_values".to_string(), reason: "alternate GDPR values require a host-bound destination and a documented gdpr enum containing each non-applicable value".to_string() });
2757            }
2758        }
2759        for alias in &manifest.gdpr_consent_aliases {
2760            if alias.is_empty()
2761                || alias == "gdpr_consent"
2762                || matcher.any_host
2763                || (matcher.hosts.is_empty() && matcher.host_suffixes.is_empty())
2764                || !manifest.params.iter().any(|contract| {
2765                    contract.name == "gdpr_consent" && contract.aliases.contains(alias)
2766                })
2767            {
2768                return Err(ManifestError::InvalidConstraint {
2769                    pack_id,
2770                    name: "gdpr_consent_aliases".to_string(),
2771                    reason: "consent aliases require a host-bound destination and a declared alias of gdpr_consent".to_string(),
2772                });
2773            }
2774        }
2775
2776        let mut http_url_presence_overrides = BTreeSet::new();
2777        for name in &manifest.http_url_presence_overrides {
2778            if !http_url_presence_overrides.insert(name.clone())
2779                || matcher.any_host
2780                || (matcher.hosts.is_empty() && matcher.host_suffixes.is_empty())
2781                || http
2782                    .as_ref()
2783                    .is_none_or(|transport| transport.bodies.is_empty())
2784                || !manifest.params.iter().any(|contract| {
2785                    contract.name == *name
2786                        && matches!(
2787                            contract.requirement,
2788                            Requirement::Required | Requirement::Recommended
2789                        )
2790                })
2791            {
2792                return Err(ManifestError::InvalidConstraint {
2793                    pack_id,
2794                    name: "http_url_presence_overrides".to_string(),
2795                    reason: "HTTP presence overrides require unique declared required or recommended URL fields, a host-bound endpoint, and complete HTTP contract scopes".to_string(),
2796                });
2797            }
2798        }
2799
2800        Ok(Self {
2801            gdpr_non_applicable_values: manifest.gdpr_non_applicable_values,
2802            gdpr_consent_aliases: manifest.gdpr_consent_aliases,
2803            http_url_presence_overrides,
2804            metadata: RulePackMetadata {
2805                id: manifest.id.clone(),
2806                display_name: manifest.display_name.clone(),
2807                version: manifest
2808                    .version
2809                    .clone()
2810                    .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()),
2811                description: manifest.description.clone(),
2812                source_level: manifest.source_level,
2813                vendor: manifest.vendor.clone(),
2814            },
2815            code_prefix,
2816            vendor: manifest.vendor.clone(),
2817            docs: manifest.docs.clone(),
2818            param_style: manifest.param_style,
2819            path_pattern,
2820            matching_path_patterns,
2821            client_fragment_params: manifest.client_fragment_params.into_iter().collect(),
2822            matcher,
2823            params,
2824            exact_param_names,
2825            rules,
2826            bodies,
2827            queries,
2828            shapes,
2829            http,
2830            http_headerless_form: manifest.http_headerless_form,
2831            http_queries: manifest.http_queries,
2832        })
2833    }
2834
2835    /// Compiles a manifest straight from JSON.
2836    pub fn from_json(json: &str) -> Result<Self, ManifestError> {
2837        Self::compile(RulePackManifest::from_json(json)?)
2838    }
2839
2840    /// Compiles a manifest from a JSON file on disk.
2841    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, ManifestError> {
2842        let path = path.as_ref();
2843        let json = fs::read_to_string(path)
2844            .map_err(|error| ManifestError::Io(format!("{}: {error}", path.display())))?;
2845        Self::from_json(&json)
2846    }
2847
2848    /// The vendor slug this pack reports when it matches.
2849    pub fn vendor(&self) -> Option<&str> {
2850        self.vendor.as_deref()
2851    }
2852
2853    /// The pack-wide documentation URL, if any.
2854    pub fn docs(&self) -> Option<&str> {
2855        self.docs.as_deref()
2856    }
2857
2858    fn matches_artifact_kind(&self, kind: ArtifactKind) -> bool {
2859        if self.matcher.artifact_kinds.is_empty() {
2860            return matches!(
2861                kind,
2862                ArtifactKind::Url
2863                    | ArtifactKind::VastTracker
2864                    | ArtifactKind::ServerPostback
2865                    | ArtifactKind::NetworkRequest
2866                    | ArtifactKind::Unknown
2867            ) || (kind == ArtifactKind::JsonPayload && !self.bodies.is_empty());
2868        }
2869
2870        self.matcher.artifact_kinds.contains(&kind)
2871    }
2872
2873    /// Whether this artifact should be read as a JSON body rather than a URL.
2874    /// An explicit `json` kind says so outright; an unstated kind is treated as
2875    /// a body when it opens like one and the pack has body contracts to apply.
2876    fn reads_as_body(&self, kind: ArtifactKind, artifact: &str) -> bool {
2877        if !self.bodies.iter().any(|body| body.source_param.is_none()) {
2878            return false;
2879        }
2880
2881        kind == ArtifactKind::JsonPayload
2882            || (kind == ArtifactKind::Unknown && JsonDocument::looks_like_json(artifact))
2883    }
2884
2885    /// Whether the payload has the shape this pack claims. A bare body carries
2886    /// no host, so its shape is the only thing that can identify it.
2887    fn matches_shape(&self, document: &JsonDocument<'_>) -> bool {
2888        !self.shapes.is_empty() && self.shapes.iter().all(|shape| shape.holds(document))
2889    }
2890
2891    fn matches_parsed_url(
2892        &self,
2893        parsed: Option<&ArtifactUrl>,
2894        params: &[RawParam<'_>],
2895        artifact: &str,
2896    ) -> bool {
2897        if !self.matches_parsed_endpoint(parsed) {
2898            return false;
2899        }
2900        let marker_pairs = if self.matcher.query_params_values.is_empty() {
2901            None
2902        } else {
2903            let query = artifact
2904                .split('#')
2905                .next()
2906                .unwrap_or(artifact)
2907                .split_once('?')
2908                .map_or("", |(_, query)| query);
2909            // Read bare keys too, so a conflicting key without '=' cannot disappear.
2910            // Reuse the normal one-pass query decoders and macro handling.
2911            let pairs: Vec<_> = query
2912                .split('&')
2913                .filter(|pair| !pair.is_empty())
2914                .map(|pair| {
2915                    let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
2916                    (percent_decode(name), decode_param_value(value))
2917                })
2918                .collect();
2919            Some(pairs)
2920        };
2921        if !self.matcher.query_params_any.is_empty() || !self.matcher.query_params_none.is_empty() {
2922            let query_names: BTreeSet<&str> = if let Some(pairs) = &marker_pairs {
2923                pairs.iter().map(|(name, _)| name.as_ref()).collect()
2924            } else {
2925                params
2926                    .iter()
2927                    .filter(|param| param.component == ViolationTargetComponent::QueryParam)
2928                    .map(|param| param.name.as_ref())
2929                    .collect()
2930            };
2931            if !self.matcher.query_params_any.is_empty()
2932                && !self
2933                    .matcher
2934                    .query_params_any
2935                    .iter()
2936                    .any(|name| query_names.contains(name.as_str()))
2937                || self
2938                    .matcher
2939                    .query_params_none
2940                    .iter()
2941                    .any(|name| query_names.contains(name.as_str()))
2942            {
2943                return false;
2944            }
2945        }
2946
2947        if let Some(pairs) = marker_pairs {
2948            for marker in &self.matcher.query_params_values {
2949                let mut present = false;
2950                for (_, value) in pairs
2951                    .iter()
2952                    .filter(|(name, _)| name.as_ref() == marker.name)
2953                {
2954                    present = true;
2955                    if contains_macro(value.as_ref())
2956                        || !marker
2957                            .values
2958                            .iter()
2959                            .any(|accepted| accepted == value.as_ref())
2960                    {
2961                        return false;
2962                    }
2963                }
2964                if !present {
2965                    return false;
2966                }
2967            }
2968        }
2969
2970        true
2971    }
2972
2973    fn matches_parsed_endpoint(&self, parsed: Option<&ArtifactUrl>) -> bool {
2974        let Some(parsed) = parsed else {
2975            return false;
2976        };
2977        let Some(host) = parsed.host.as_deref() else {
2978            return false;
2979        };
2980
2981        let host_matches = self.matcher.any_host
2982            || self.matcher.hosts.iter().any(|candidate| candidate == host)
2983            || self
2984                .matcher
2985                .host_suffixes
2986                .iter()
2987                .any(|suffix| prepare::host_matches_suffix(host, suffix));
2988
2989        if !host_matches {
2990            return false;
2991        }
2992
2993        let path = parsed.path.as_ref();
2994        let path_constrained = !self.matcher.paths.is_empty()
2995            || !self.matcher.path_prefixes.is_empty()
2996            || !self.matcher.path_contains.is_empty()
2997            || !self.matching_path_patterns.is_empty();
2998
2999        if !path_constrained {
3000            return true;
3001        }
3002
3003        self.matcher.paths.iter().any(|candidate| candidate == path)
3004            || self
3005                .matcher
3006                .path_prefixes
3007                .iter()
3008                .any(|prefix| path.starts_with(prefix))
3009            || self
3010                .matcher
3011                .path_contains
3012                .iter()
3013                .any(|needle| path.contains(needle))
3014            || self
3015                .matching_path_patterns
3016                .iter()
3017                .any(|pattern| pattern.is_match(path))
3018    }
3019}
3020
3021fn matomo_explicit_timestamp(value: &str) -> Option<String> {
3022    static NUMERIC: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
3023    if NUMERIC
3024        .get_or_init(|| Regex::new(r"^-?[0-9]+(?:\.[0-9]+)?$").expect("static Matomo cdt grammar"))
3025        .is_match(value)
3026    {
3027        return value
3028            .parse::<f64>()
3029            .ok()
3030            .filter(|v| v.is_finite())
3031            .map(|_| value.to_string());
3032    }
3033    if value.len() != 19 || !value.contains(' ') {
3034        return None;
3035    }
3036    let millis = crate::timestamp::timestamp_millis(value, TimestampUnit::DatetimeUtc)?;
3037    Some((millis / 1000).to_string())
3038}
3039
3040impl ValidatorPlugin for ManifestRulePack {
3041    fn uses_headerless_form_body(&self, prepared: &PreparedArtifact<'_>) -> bool {
3042        self.http_headerless_form && self.supports_http(prepared)
3043    }
3044
3045    fn supports_http(&self, prepared: &PreparedArtifact<'_>) -> bool {
3046        self.matches_parsed_url(
3047            prepared.url(),
3048            prepared.params(ParamStyle::Query),
3049            prepared.trimmed(),
3050        )
3051    }
3052
3053    fn validate_http(
3054        &self,
3055        prepared: &PreparedArtifact<'_>,
3056        body: Option<&PreparedArtifact<'_>>,
3057        raw_body_len: usize,
3058        normalized: &str,
3059        run_core_url_rules: bool,
3060    ) -> ValidationReport {
3061        let matches_endpoint = self.supports_http(prepared);
3062        let document = JsonDocument::parse_http(normalized).ok();
3063        let bulk = matches_endpoint
3064            && document.as_ref().is_some_and(|document| {
3065                self.http_queries.iter().any(|spec| {
3066                    document.contains(
3067                        spec.source_field
3068                            .strip_suffix("[]")
3069                            .unwrap_or(&spec.source_field),
3070                    )
3071                })
3072            });
3073        let query_body_unavailable = !self.http_queries.is_empty()
3074            && document.as_ref().is_some_and(|document| {
3075                document
3076                    .get("body_encoding")
3077                    .is_some_and(|field| field.text == "unsupported")
3078            });
3079        let mut report = if bulk {
3080            ValidationReport {
3081                plugin_id: self.metadata.id.clone(),
3082                detected_vendor: self.vendor.clone(),
3083                violations: Vec::new(),
3084            }
3085        } else if query_body_unavailable {
3086            // An unavailable entity may contain bulk events. Missing URL
3087            // event fields are speculative, but supplied URL values remain
3088            // observable and keep their syntax and value checks.
3089            let mut url_only = PreparedArtifact::from_request_at(
3090                prepared.request(),
3091                prepared.reference_time_unix_seconds(),
3092            );
3093            url_only.mark_complete_http_request();
3094            url_only.mark_unavailable_form_body();
3095            self.prepare_vendor_context(&mut url_only);
3096            self.validate_prepared(&url_only)
3097        } else {
3098            self.validate_prepared(prepared)
3099        };
3100        if !matches_endpoint {
3101            return report;
3102        }
3103        report.detected_vendor = self.vendor.clone();
3104        if bulk && let Some(document) = &document {
3105            let endpoint = prepared
3106                .trimmed()
3107                .split(['?', '#'])
3108                .next()
3109                .unwrap_or_default();
3110            for spec in &self.http_queries {
3111                let mut previous_timestamp: Option<String> = None;
3112                let source_parent = spec
3113                    .source_field
3114                    .strip_suffix("[]")
3115                    .unwrap_or(&spec.source_field);
3116                let paths = if spec.native_map == Some(RequestQueryMapCoercion::MatomoPhp8)
3117                    && document
3118                        .get(source_parent)
3119                        .is_some_and(|source| source.kind == JsonValueKind::Object)
3120                {
3121                    document
3122                        .members(source_parent)
3123                        .into_iter()
3124                        .map(|(_, path)| path)
3125                        .collect()
3126                } else {
3127                    document.expand(&spec.source_field)
3128                };
3129                for path in paths {
3130                    let Some(field) = document.get(&path) else {
3131                        continue;
3132                    };
3133                    let uncertain_outer_token = spec.native_map
3134                        == Some(RequestQueryMapCoercion::MatomoPhp8)
3135                        && spec
3136                            .inherited_params
3137                            .get("token_auth")
3138                            .and_then(|source| document.get(source))
3139                            .is_some_and(|token| {
3140                                let value: serde_json::Value =
3141                                    serde_json::from_str(&normalized[token.start..token.end])
3142                                        .expect("validated normalized JSON field");
3143                                crate::php_query::matomo_php8_auth_string(&value).is_err()
3144                            });
3145                    if uncertain_outer_token {
3146                        report.violations.push(Violation {
3147                            code: format!("{}.http.query_source.unvalidated", self.code_prefix),
3148                            message: "The native outer token needs PHP architecture or precision configuration. This item's authentication-dependent event checks remain unvalidated.".into(),
3149                            severity: Severity::Info,
3150                            field: Some(format!("http.{path}")),
3151                            fix_hint: None,
3152                            source: self.source_for(RuleSourceLevel::OfficialTemplate, Some(crate::php_query::MATOMO_PHP8_SOURCE)),
3153                            targets: Vec::new(),
3154                        });
3155                        continue;
3156                    }
3157                    let query = match spec.encoding {
3158                        RequestQueryEncoding::Query => {
3159                            field.text.strip_prefix('?').unwrap_or(&field.text)
3160                        }
3161                        RequestQueryEncoding::UrlQuery => field
3162                            .text
3163                            .split('#')
3164                            .next()
3165                            .unwrap_or_default()
3166                            .split_once('?')
3167                            .map(|(_, query)| query)
3168                            .unwrap_or_default(),
3169                    };
3170                    let native_pairs = if spec.native_map
3171                        == Some(RequestQueryMapCoercion::MatomoPhp8)
3172                    {
3173                        let native: serde_json::Value =
3174                            serde_json::from_str(&normalized[field.start..field.end])
3175                                .expect("validated normalized JSON field");
3176                        let mapped = if field.kind == JsonValueKind::String {
3177                            crate::php_query::matomo_php8_query(query)
3178                        } else {
3179                            crate::php_query::matomo_php8_map(&native)
3180                        };
3181                        match mapped {
3182                            crate::php_query::MatomoMap::Ignored => continue,
3183                            crate::php_query::MatomoMap::Ready(pairs) => Some(pairs),
3184                            crate::php_query::MatomoMap::Unsupported(names) => {
3185                                report.violations.push(Violation {
3186                                    code: format!("{}.http.query_source.unvalidated", self.code_prefix),
3187                                    message: format!("This item's PHP query representation or native field reader needs additional runtime context for: {}. Its event checks remain unvalidated.", names.join(", ")),
3188                                    severity: Severity::Info,
3189                                    field: Some(format!("http.{path}")),
3190                                    fix_hint: None,
3191                                    source: self.source_for(RuleSourceLevel::OfficialTemplate, Some(crate::php_query::MATOMO_PHP8_SOURCE)),
3192                                    targets: Vec::new(),
3193                                });
3194                                continue;
3195                            }
3196                        }
3197                    } else {
3198                        if field.kind == JsonValueKind::Object {
3199                            report.violations.push(Violation {
3200                                code:format!("{}.http.query_source.unvalidated", self.code_prefix),
3201                                message:"Native parameter maps need destination-specific coercion contracts; this item's event fields were not validated.".into(),
3202                                severity:Severity::Info, field:Some(format!("http.{path}")), fix_hint:None,
3203                                source:self.source_for(RuleSourceLevel::OfficialVendor,self.docs.as_deref()), targets:Vec::new(),
3204                            });
3205                            continue;
3206                        }
3207                        if field.kind != JsonValueKind::String {
3208                            continue;
3209                        }
3210                        None
3211                    };
3212                    if query.is_empty() && native_pairs.is_none() {
3213                        continue;
3214                    }
3215                    let native = native_pairs.is_some();
3216                    let mut pairs: Vec<(String, String)> = native_pairs.unwrap_or_else(|| {
3217                        url::form_urlencoded::parse(query.as_bytes())
3218                            .map(|(name, value)| (name.into_owned(), value.into_owned()))
3219                            .collect()
3220                    });
3221                    let matomo = spec.native_map == Some(RequestQueryMapCoercion::MatomoPhp8);
3222                    if matomo {
3223                        pairs.retain(|(name, value)| {
3224                            name != "token_auth" || (!value.is_empty() && value != "0")
3225                        });
3226                    }
3227                    let mut inherited = Vec::new();
3228                    for (name, fallback_path) in &spec.inherited_params {
3229                        let contract = self.params.iter().find(|param| param.names.contains(name));
3230                        let supplied = pairs.iter().any(|(submitted, _)| {
3231                            contract.is_some_and(|param| param.names.contains(submitted))
3232                        });
3233                        let fallback = document.get(fallback_path).and_then(|fallback| {
3234                            if matomo && name == "token_auth" {
3235                                let value: serde_json::Value =
3236                                    serde_json::from_str(&normalized[fallback.start..fallback.end])
3237                                        .expect("validated normalized JSON field");
3238                                crate::php_query::matomo_php8_auth_string(&value)
3239                                    .ok()
3240                                    .flatten()
3241                            } else {
3242                                (fallback.kind == JsonValueKind::String
3243                                    && !fallback.text.is_empty())
3244                                .then(|| fallback.text.to_string())
3245                            }
3246                        });
3247                        if (spec.inherited_overrides || !supplied)
3248                            && let Some(fallback) = fallback
3249                        {
3250                            if spec.inherited_overrides {
3251                                pairs.retain(|(submitted, _)| {
3252                                    !contract.is_some_and(|param| param.names.contains(submitted))
3253                                });
3254                            }
3255                            inherited.push((name.clone(), fallback));
3256                        }
3257                    }
3258                    let mut wire_query = if native {
3259                        url::form_urlencoded::Serializer::new(String::new())
3260                            .extend_pairs(pairs.iter().map(|(name, value)| (name, value)))
3261                            .finish()
3262                    } else if (spec.inherited_overrides && !inherited.is_empty()) || matomo {
3263                        // Only remove overridden keys. Preserve every other
3264                        // original field's encoding, order and macro spans.
3265                        query
3266                            .split('&')
3267                            .filter(|part| {
3268                                let name = url::form_urlencoded::parse(part.as_bytes())
3269                                    .next()
3270                                    .map(|(name, _)| name.into_owned());
3271                                let empty_matomo_token = matomo
3272                                    && url::form_urlencoded::parse(part.as_bytes())
3273                                        .next()
3274                                        .is_some_and(|(name, value)| {
3275                                            name == "token_auth"
3276                                                && (value.is_empty() || value == "0")
3277                                        });
3278                                !empty_matomo_token
3279                                    && !inherited.iter().any(|(inherited_name, _)| {
3280                                        self.params
3281                                            .iter()
3282                                            .find(|param| param.names.contains(inherited_name))
3283                                            .is_some_and(|param| {
3284                                                name.as_ref()
3285                                                    .is_some_and(|name| param.names.contains(name))
3286                                            })
3287                                    })
3288                            })
3289                            .collect::<Vec<_>>()
3290                            .join("&")
3291                    } else {
3292                        query.to_string()
3293                    };
3294                    if !inherited.is_empty() {
3295                        if !wire_query.is_empty() {
3296                            wire_query.push('&');
3297                        }
3298                        wire_query.push_str(
3299                            &url::form_urlencoded::Serializer::new(String::new())
3300                                .extend_pairs(inherited.iter().map(|(name, value)| (name, value)))
3301                                .finish(),
3302                        );
3303                    }
3304                    if spec.check_chronological_order {
3305                        let clocks: Vec<_> =
3306                            pairs.iter().filter(|(name, _)| name == "cdt").collect();
3307                        if clocks.len() == 1
3308                            && let Some(timestamp) = matomo_explicit_timestamp(&clocks[0].1)
3309                        {
3310                            if previous_timestamp.as_deref().is_some_and(|previous| {
3311                                compare_numeric_text(previous, &timestamp)
3312                                    == Some(std::cmp::Ordering::Greater)
3313                            }) {
3314                                report.violations.push(Violation {
3315                                    code: format!("{}.http.chronological_order", self.code_prefix),
3316                                    message: "Matomo recommends bulk events in chronological order, oldest first. This explicit cdt is earlier than the previous explicit event time.".into(),
3317                                    severity: Severity::Warning,
3318                                    field: Some(format!("http.{path}.cdt")),
3319                                    fix_hint: Some("Order events with explicit timestamps from oldest to newest.".into()),
3320                                    source: self.source_for(RuleSourceLevel::OfficialVendor, self.docs.as_deref()),
3321                                    targets: Vec::new(),
3322                                });
3323                            }
3324                            previous_timestamp = Some(timestamp);
3325                        }
3326                    }
3327                    let inner_request = ValidationRequest {
3328                        artifact_kind: ArtifactKind::Url,
3329                        artifact: format!("{endpoint}?{wire_query}"),
3330                        claimed_vendor: prepared.request().claimed_vendor.clone(),
3331                        expansion_state: prepared.request().expansion_state,
3332                    };
3333                    let mut inner = PreparedArtifact::from_request_at(
3334                        &inner_request,
3335                        prepared.reference_time_unix_seconds(),
3336                    );
3337                    self.prepare_vendor_context(&mut inner);
3338                    let mut violations = self.validate_prepared(&inner).violations;
3339                    if run_core_url_rules {
3340                        violations.extend(
3341                            crate::CoreRulePack::default()
3342                                .validate_prepared(&inner)
3343                                .violations,
3344                        );
3345                    }
3346                    for mut violation in violations {
3347                        violation.field = Some(match violation.field {
3348                            Some(field) => format!("http.{path}.{field}"),
3349                            None => format!("http.{path}"),
3350                        });
3351                        report.violations.push(violation);
3352                    }
3353                }
3354            }
3355        }
3356        if let Some(body) = body
3357            && let Some(Ok(document)) = body.json()
3358        {
3359            for spec in self
3360                .bodies
3361                .iter()
3362                .filter(|spec| spec.source_param.is_none() && spec.source_field.is_none())
3363            {
3364                self.check_body_spec(
3365                    spec,
3366                    document,
3367                    body.trimmed(),
3368                    raw_body_len,
3369                    body.reference_time_unix_seconds(),
3370                    &mut report.violations,
3371                );
3372            }
3373            self.check_embedded_bodies(
3374                document,
3375                None,
3376                &[],
3377                body.reference_time_unix_seconds(),
3378                &mut report.violations,
3379            );
3380        }
3381        if let Some(http) = &self.http
3382            && let Some(document) = &document
3383        {
3384            for spec in &http.bodies {
3385                http.check_body_spec(
3386                    spec,
3387                    document,
3388                    normalized,
3389                    normalized.len(),
3390                    prepared.reference_time_unix_seconds(),
3391                    &mut report.violations,
3392                );
3393            }
3394        }
3395        report
3396    }
3397    fn prepare_vendor_context(&self, prepared: &mut PreparedArtifact<'_>) {
3398        if self.accepts_client_fragment(prepared) {
3399            prepared.mark_client_fragment_configuration();
3400        }
3401        if self.matches_parsed_url(
3402            prepared.url(),
3403            prepared.params(ParamStyle::Query),
3404            prepared.trimmed(),
3405        ) {
3406            for value in &self.gdpr_non_applicable_values {
3407                prepared.allow_non_applicable_gdpr(value);
3408            }
3409            for alias in &self.gdpr_consent_aliases {
3410                prepared.allow_gdpr_consent_alias(alias);
3411            }
3412        }
3413    }
3414    fn metadata(&self) -> &RulePackMetadata {
3415        &self.metadata
3416    }
3417
3418    fn routing(&self) -> PluginRouting {
3419        if self.matcher.any_host {
3420            return PluginRouting::Always;
3421        }
3422        PluginRouting::Indexed {
3423            hosts: self.matcher.hosts.clone(),
3424            suffixes: self.matcher.host_suffixes.clone(),
3425            json: self.bodies.iter().any(|body| body.source_param.is_none()),
3426        }
3427    }
3428
3429    fn supports(&self, request: &ValidationRequest) -> bool {
3430        self.supports_prepared(&PreparedArtifact::from_request(request))
3431    }
3432
3433    fn supports_prepared(&self, prepared: &PreparedArtifact<'_>) -> bool {
3434        let artifact = prepared.trimmed();
3435
3436        if !self.matches_artifact_kind(prepared.request().artifact_kind) {
3437            return false;
3438        }
3439
3440        if self.reads_as_body(prepared.request().artifact_kind, artifact) {
3441            return match prepared.json() {
3442                Some(Ok(document)) => self.matches_shape(document),
3443                _ => false,
3444            };
3445        }
3446
3447        self.matches_parsed_url(
3448            prepared.url(),
3449            prepared.params(ParamStyle::Query),
3450            prepared.trimmed(),
3451        )
3452    }
3453
3454    fn validate(&self, request: &ValidationRequest) -> ValidationReport {
3455        self.validate_prepared(&PreparedArtifact::from_request(request))
3456    }
3457
3458    fn validate_prepared(&self, prepared: &PreparedArtifact<'_>) -> ValidationReport {
3459        let request = prepared.request();
3460        let artifact = prepared.trimmed();
3461        let mut violations = Vec::new();
3462
3463        if self.reads_as_body(request.artifact_kind, artifact) {
3464            return self.validate_body(
3465                request,
3466                artifact,
3467                prepared.json(),
3468                false,
3469                prepared.reference_time_unix_seconds(),
3470            );
3471        }
3472
3473        let matches_endpoint = self.matches_parsed_url(
3474            prepared.url(),
3475            prepared.params(ParamStyle::Query),
3476            prepared.trimmed(),
3477        );
3478        if !matches_endpoint {
3479            violations.push(Violation {
3480                code: format!("{}.endpoint_mismatch", self.code_prefix),
3481                message: format!(
3482                    "{} was requested explicitly, but this artifact does not target one of its endpoints.",
3483                    self.metadata.display_name
3484                ),
3485                severity: Severity::Info,
3486                field: None,
3487                fix_hint: Some(
3488                    "Drop the rulepack selection to let Pixellint pick packs by endpoint.".to_string(),
3489                ),
3490                source: RuleSource {
3491                    level: RuleSourceLevel::Heuristic,
3492                    name: format!("{} endpoint matcher", self.metadata.display_name),
3493                    reference: self.docs.clone(),
3494                },
3495                targets: Vec::new(),
3496            });
3497
3498            if !self.matches_parsed_endpoint(prepared.url()) {
3499                return ValidationReport {
3500                    plugin_id: self.metadata.id.clone(),
3501                    detected_vendor: None,
3502                    violations,
3503                };
3504            }
3505        }
3506
3507        let extracted = prepared.params(self.param_style);
3508        let basket_ranges: Vec<_> = if self
3509            .queries
3510            .iter()
3511            .any(|query| query.spec.source == QuerySource::PathItems)
3512        {
3513            crate::path_items::path_items(artifact, 0)
3514                .unwrap_or_default()
3515                .into_iter()
3516                .map(|group| (group.start, group.end))
3517                .collect()
3518        } else {
3519            Vec::new()
3520        };
3521        let path_params = self.extract_path_params(artifact);
3522        let owned;
3523        let params: &[RawParam] = if !prepared.has_form_body()
3524            && self.path_pattern.is_none()
3525            && path_params.is_empty()
3526            && basket_ranges.is_empty()
3527            && !extracted.iter().any(|param| {
3528                self.params.iter().any(|contract| {
3529                    contract
3530                        .contract
3531                        .aliases
3532                        .iter()
3533                        .any(|alias| alias == param.name.as_ref())
3534                })
3535            }) {
3536            extracted
3537        } else {
3538            owned = {
3539                // A query key cannot override an identifier or discriminator
3540                // physically carried in the endpoint path.
3541                let mut merged: Vec<_> = extracted
3542                    .iter()
3543                    .filter(|param| {
3544                        let supplies_path_capture =
3545                            self.path_pattern.as_ref().is_some_and(|pattern| {
3546                                pattern.capture_names().flatten().any(|capture| {
3547                                    param.name.as_ref() == capture
3548                                        || self.params.iter().any(|contract| {
3549                                            contract.names.iter().any(|name| name == capture)
3550                                                && contract
3551                                                    .names
3552                                                    .iter()
3553                                                    .any(|name| name == param.name.as_ref())
3554                                        })
3555                                })
3556                            });
3557                        if supplies_path_capture {
3558                            return false;
3559                        }
3560                        param.component != ViolationTargetComponent::BodyField
3561                            || (!self.path_pattern.as_ref().is_some_and(|pattern| {
3562                                pattern
3563                                    .capture_names()
3564                                    .flatten()
3565                                    .any(|name| name == param.name)
3566                            }) && !self.params.iter().any(|contract| {
3567                                contract
3568                                    .names
3569                                    .iter()
3570                                    .any(|name| name == param.name.as_ref())
3571                                    && extracted.iter().any(|url_field| {
3572                                        url_field.component == ViolationTargetComponent::QueryParam
3573                                            && contract
3574                                                .names
3575                                                .iter()
3576                                                .any(|name| name == url_field.name.as_ref())
3577                                    })
3578                            }))
3579                    })
3580                    .filter(|param| !path_params.iter().any(|path| path.name == param.name))
3581                    .filter(|param| {
3582                        !basket_ranges
3583                            .iter()
3584                            .any(|(start, end)| param.start >= *start && param.end <= *end)
3585                    })
3586                    .cloned()
3587                    .collect();
3588                normalize_param_aliases(&mut merged, &self.params);
3589                merged.retain(|param| !path_params.iter().any(|path| path.name == param.name));
3590                merged.extend(path_params);
3591                merged
3592            };
3593            &owned
3594        };
3595
3596        let scope = Scope {
3597            code_segment: "param",
3598            artifact,
3599            raw_artifact_len: request.artifact.len(),
3600            reference_time_unix_seconds: prepared.reference_time_unix_seconds(),
3601            complete_http_request: prepared.is_complete_http_request(),
3602            body: None,
3603            query: None,
3604        };
3605
3606        let exact_names = &self.exact_param_names;
3607        for compiled in &self.params {
3608            if matches_endpoint
3609                && prepared.is_complete_http_request()
3610                && self
3611                    .http_url_presence_overrides
3612                    .contains(&compiled.contract.name)
3613                && matches!(
3614                    compiled.contract.requirement,
3615                    Requirement::Required | Requirement::Recommended
3616                )
3617                && !params.iter().any(|field| {
3618                    !field.missing
3619                        && compiled
3620                            .names
3621                            .iter()
3622                            .any(|name| name == field.name.as_ref())
3623                })
3624            {
3625                continue;
3626            }
3627            // A multipart/compressed form may contain these query-style keys.
3628            // Path captures remain observable and cannot be supplied by a form.
3629            if prepared.has_unavailable_form_body()
3630                && !self.path_pattern.as_ref().is_some_and(|pattern| {
3631                    pattern
3632                        .capture_names()
3633                        .flatten()
3634                        .any(|name| compiled.names.iter().any(|candidate| candidate == name))
3635                })
3636                && !params.iter().any(|field| {
3637                    !field.missing
3638                        && compiled
3639                            .names
3640                            .iter()
3641                            .any(|name| name == field.name.as_ref())
3642                })
3643            {
3644                continue;
3645            }
3646            self.check_param(&scope, compiled, params, exact_names, &mut violations);
3647        }
3648
3649        for compiled in &self.rules {
3650            if prepared.has_unavailable_form_body()
3651                && assertion_requires_presence(&compiled.rule.assertion)
3652            {
3653                continue;
3654            }
3655            self.check_rule(&scope, compiled, params, &mut violations);
3656        }
3657        self.check_query_scopes(prepared, &mut violations);
3658        self.check_encoded_bodies(
3659            params,
3660            prepared.reference_time_unix_seconds(),
3661            &mut violations,
3662        );
3663
3664        if matches_endpoint
3665            && let (Some(claimed), Some(vendor)) =
3666                (request.claimed_vendor.as_deref(), self.vendor())
3667            && !claimed.eq_ignore_ascii_case(vendor)
3668        {
3669            violations.push(Violation {
3670                code: format!("{}.claimed_vendor_mismatch", self.code_prefix),
3671                message: format!(
3672                    "Artifact was submitted as `{claimed}` but targets a {vendor} endpoint."
3673                ),
3674                severity: Severity::Info,
3675                field: None,
3676                fix_hint: Some(format!(
3677                    "Set claimed_vendor to `{vendor}` or check that the endpoint is the intended one."
3678                )),
3679                source: RuleSource {
3680                    level: RuleSourceLevel::Heuristic,
3681                    name: format!("{} endpoint matcher", self.metadata.display_name),
3682                    reference: self.docs.clone(),
3683                },
3684                targets: Vec::new(),
3685            });
3686        }
3687
3688        ValidationReport {
3689            plugin_id: self.metadata.id.clone(),
3690            detected_vendor: matches_endpoint.then(|| self.vendor.clone()).flatten(),
3691            violations,
3692        }
3693    }
3694    fn validate_selected_prepared(&self, prepared: &PreparedArtifact<'_>) -> ValidationReport {
3695        let artifact = prepared.trimmed();
3696        if self.reads_as_body(prepared.request().artifact_kind, artifact) {
3697            self.validate_body(
3698                prepared.request(),
3699                artifact,
3700                prepared.json(),
3701                true,
3702                prepared.reference_time_unix_seconds(),
3703            )
3704        } else {
3705            self.validate_prepared(prepared)
3706        }
3707    }
3708
3709    fn accepts_client_fragment(&self, prepared: &PreparedArtifact<'_>) -> bool {
3710        if self.client_fragment_params.is_empty()
3711            || !matches!(
3712                prepared.request().artifact_kind,
3713                ArtifactKind::Url | ArtifactKind::Unknown
3714            )
3715            || !self.matches_parsed_url(
3716                prepared.url(),
3717                prepared.params(ParamStyle::Query),
3718                prepared.trimmed(),
3719            )
3720        {
3721            return false;
3722        }
3723        let Some(parsed) = prepared.url() else {
3724            return false;
3725        };
3726        if !self
3727            .path_pattern
3728            .as_ref()
3729            .is_some_and(|pattern| pattern.is_match(&parsed.path))
3730        {
3731            return false;
3732        }
3733        let Some((_, fragment)) = prepared.trimmed().split_once('#') else {
3734            return false;
3735        };
3736        let mut keys = url::form_urlencoded::parse(fragment.as_bytes()).map(|(key, _)| key);
3737        let Some(first) = keys.next() else {
3738            return false;
3739        };
3740        self.client_fragment_params.contains(first.as_ref())
3741            && keys.all(|key| self.client_fragment_params.contains(key.as_ref()))
3742    }
3743}
3744
3745impl ManifestRulePack {
3746    fn check_query_scopes(&self, prepared: &PreparedArtifact<'_>, violations: &mut Vec<Violation>) {
3747        for query in &self.queries {
3748            let mut seen: BTreeMap<(&str, &str), ViolationTarget> = BTreeMap::new();
3749            let code_segment = match query.spec.source {
3750                QuerySource::QuerySemicolon => "query",
3751                QuerySource::PathItems => "path_items",
3752                QuerySource::IndexedQuery => "indexed_query",
3753            };
3754            let mut segments: Vec<ScopedQueryGroup<'_>> = match query.spec.source {
3755                QuerySource::QuerySemicolon => crate::query_segments::query_segments(
3756                    prepared.trimmed(),
3757                    query.spec.first_index,
3758                )
3759                .into_iter()
3760                .map(ScopedQueryGroup::from)
3761                .collect(),
3762                QuerySource::IndexedQuery => indexed_query_groups(
3763                    prepared.trimmed(),
3764                    query
3765                        .spec
3766                        .key_prefix
3767                        .as_deref()
3768                        .expect("validated key_prefix"),
3769                ),
3770                QuerySource::PathItems => {
3771                    match crate::path_items::path_items(prepared.trimmed(), query.spec.first_index)
3772                    {
3773                        Ok(groups) => groups.into_iter().map(ScopedQueryGroup::from).collect(),
3774                        Err((start, end)) => {
3775                            violations.push(Violation {
3776                                code: format!("{}.path_items.malformed", self.code_prefix),
3777                                message: "Bracketed path items must close at a path boundary."
3778                                    .to_string(),
3779                                severity: Severity::Error,
3780                                field: Some("path_items".to_string()),
3781                                fix_hint: Some(
3782                                    "Close each basket with ] before the next path segment."
3783                                        .to_string(),
3784                                ),
3785                                source: self
3786                                    .source_for(self.metadata.source_level, self.docs.as_deref()),
3787                                targets: vec![ViolationTarget {
3788                                    component: ViolationTargetComponent::Path,
3789                                    name: Some("path_items".to_string()),
3790                                    value: None,
3791                                    start,
3792                                    end,
3793                                }],
3794                            });
3795                            continue;
3796                        }
3797                    }
3798                }
3799            };
3800            let global_params = if query.spec.global_params.is_empty() {
3801                Vec::new()
3802            } else {
3803                let mut params = prepared.params(self.param_style).to_vec();
3804                normalize_param_aliases(&mut params, &query.params);
3805                let all_items =
3806                    crate::path_items::path_items(prepared.trimmed(), 0).unwrap_or_default();
3807                params.retain(|param| {
3808                    query
3809                        .spec
3810                        .global_params
3811                        .iter()
3812                        .any(|name| name == param.name.as_ref())
3813                        && !all_items
3814                            .iter()
3815                            .any(|segment| param.start >= segment.start && param.end <= segment.end)
3816                });
3817                params
3818            };
3819            for segment in &mut segments {
3820                normalize_param_aliases(&mut segment.params, &query.params);
3821                segment.params.retain(|param| {
3822                    !query
3823                        .spec
3824                        .global_params
3825                        .iter()
3826                        .any(|name| name == param.name.as_ref())
3827                });
3828                segment.params.extend(global_params.iter().cloned());
3829                if query
3830                    .spec
3831                    .last_index
3832                    .is_some_and(|last| segment.index > last)
3833                {
3834                    break;
3835                }
3836                if query
3837                    .spec
3838                    .condition
3839                    .as_ref()
3840                    .is_some_and(|guard| guard.evaluate(&segment.params) != Some(true))
3841                {
3842                    continue;
3843                }
3844                let scope = Scope {
3845                    code_segment,
3846                    artifact: prepared.trimmed(),
3847                    raw_artifact_len: prepared.request().artifact.len(),
3848                    reference_time_unix_seconds: prepared.reference_time_unix_seconds(),
3849                    complete_http_request: prepared.is_complete_http_request(),
3850                    body: None,
3851                    query: Some(QuerySpan {
3852                        index: segment.index,
3853                        start: segment.start,
3854                        end: segment.end,
3855                        physical_row: segment.physical_row.as_deref(),
3856                        anchor_name: segment.anchor_name.as_deref(),
3857                    }),
3858                };
3859                for compiled in &query.params {
3860                    self.check_param(
3861                        &scope,
3862                        compiled,
3863                        &segment.params,
3864                        &query.exact_names,
3865                        violations,
3866                    );
3867                }
3868                for compiled in &query.rules {
3869                    self.check_rule(&scope, compiled, &segment.params, violations);
3870                }
3871                for name in &query.spec.unique_by {
3872                    let compiled = query
3873                        .params
3874                        .iter()
3875                        .find(|param| param.names.contains(name))
3876                        .expect("validated exact parameter");
3877                    for param in segment.params.iter().filter(|param| {
3878                        param.name.as_ref() == name
3879                            && !param.value.is_empty()
3880                            && !contains_macro(param.value.as_ref())
3881                    }) {
3882                        let key = (name.as_str(), param.value.as_ref());
3883                        if let Some(previous) = seen.get(&key) {
3884                            violations.push(Violation {
3885                                code: format!("{}.{code_segment}.{name}.duplicate", self.code_prefix),
3886                                message: format!("`{name}` must have a distinct value in each selected query group."),
3887                                severity: compiled.contract.severity.unwrap_or(Severity::Error),
3888                                field: Some(scope.field(name)),
3889                                fix_hint: compiled.contract.fix_hint.clone(),
3890                                source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
3891                                targets: vec![previous.clone(), param.target()],
3892                            });
3893                        } else {
3894                            seen.insert(key, param.target());
3895                        }
3896                    }
3897                }
3898            }
3899        }
3900    }
3901
3902    /// Validates a JSON request body.
3903    ///
3904    /// The contracts are written against one element of the batch, so the body
3905    /// is evaluated once per element: an array of three events that all omit
3906    /// `event_name` reports three findings, each pointing at its own event. A
3907    /// payload the pack does not recognize is reported the same way a URL that
3908    /// misses the endpoint is, since both mean the caller aimed the pack at the
3909    /// wrong artifact.
3910    fn validate_body(
3911        &self,
3912        request: &ValidationRequest,
3913        artifact: &str,
3914        parsed: Option<&Result<JsonDocument<'_>, json::JsonError>>,
3915        selected: bool,
3916        reference_time_unix_seconds: i64,
3917    ) -> ValidationReport {
3918        // A body that does not parse is the core pack's finding to report, and
3919        // it has nothing this pack can contract.
3920        let owned;
3921        let document = match parsed {
3922            Some(Ok(document)) => document,
3923            Some(Err(_)) => {
3924                return ValidationReport {
3925                    plugin_id: self.metadata.id.clone(),
3926                    detected_vendor: None,
3927                    violations: Vec::new(),
3928                };
3929            }
3930            None => match JsonDocument::parse(artifact) {
3931                Ok(document) => {
3932                    owned = document;
3933                    &owned
3934                }
3935                Err(_) => {
3936                    return ValidationReport {
3937                        plugin_id: self.metadata.id.clone(),
3938                        detected_vendor: None,
3939                        violations: Vec::new(),
3940                    };
3941                }
3942            },
3943        };
3944
3945        let mut violations = Vec::new();
3946
3947        let matches_shape = self.matches_shape(document);
3948        if !matches_shape {
3949            violations.push(Violation {
3950                code: format!("{}.payload_mismatch", self.code_prefix),
3951                message: format!(
3952                    "{} was requested explicitly, but this payload does not have the shape its endpoint accepts.",
3953                    self.metadata.display_name
3954                ),
3955                severity: Severity::Info,
3956                field: None,
3957                fix_hint: Some(
3958                    "Drop the rulepack selection to let Pixellint pick packs by payload shape."
3959                        .to_string(),
3960                ),
3961                source: RuleSource {
3962                    level: RuleSourceLevel::Heuristic,
3963                    name: format!("{} payload matcher", self.metadata.display_name),
3964                    reference: self.docs.clone(),
3965                },
3966                targets: Vec::new(),
3967            });
3968
3969            if !selected {
3970                return ValidationReport {
3971                    plugin_id: self.metadata.id.clone(),
3972                    detected_vendor: None,
3973                    violations,
3974                };
3975            }
3976        }
3977
3978        for body in self
3979            .bodies
3980            .iter()
3981            .filter(|body| body.source_param.is_none() && body.source_field.is_none())
3982        {
3983            self.check_body_spec(
3984                body,
3985                document,
3986                artifact,
3987                request.artifact.len(),
3988                reference_time_unix_seconds,
3989                &mut violations,
3990            );
3991        }
3992        self.check_embedded_bodies(
3993            document,
3994            None,
3995            &[],
3996            reference_time_unix_seconds,
3997            &mut violations,
3998        );
3999
4000        if matches_shape
4001            && let (Some(claimed), Some(vendor)) =
4002                (request.claimed_vendor.as_deref(), self.vendor())
4003            && !claimed.eq_ignore_ascii_case(vendor)
4004        {
4005            violations.push(Violation {
4006                code: format!("{}.claimed_vendor_mismatch", self.code_prefix),
4007                message: format!(
4008                    "Artifact was submitted as `{claimed}` but is a {vendor} payload."
4009                ),
4010                severity: Severity::Info,
4011                field: None,
4012                fix_hint: Some(format!(
4013                    "Set claimed_vendor to `{vendor}` or check that the payload is the intended one."
4014                )),
4015                source: RuleSource {
4016                    level: RuleSourceLevel::Heuristic,
4017                    name: format!("{} payload matcher", self.metadata.display_name),
4018                    reference: self.docs.clone(),
4019                },
4020                targets: Vec::new(),
4021            });
4022        }
4023
4024        ValidationReport {
4025            plugin_id: self.metadata.id.clone(),
4026            detected_vendor: matches_shape.then(|| self.vendor.clone()).flatten(),
4027            violations,
4028        }
4029    }
4030
4031    /// Pulls named captures out of the path so an identifier carried there can
4032    /// be contracted like any query parameter.
4033    fn extract_path_params<'a>(&self, artifact: &'a str) -> Vec<RawParam<'a>> {
4034        let Some(pattern) = &self.path_pattern else {
4035            return Vec::new();
4036        };
4037
4038        let (path_start, path_end) = path_span(artifact);
4039        let path = &artifact[path_start..path_end];
4040        let Some(captures) = pattern.captures(path) else {
4041            return Vec::new();
4042        };
4043
4044        pattern
4045            .capture_names()
4046            .flatten()
4047            .filter_map(|name| {
4048                let capture = captures.name(name)?;
4049
4050                Some(RawParam::query(
4051                    Cow::Owned(name.to_string()),
4052                    decode_path_param_value(capture.as_str()),
4053                    path_start + capture.start(),
4054                    path_start + capture.end(),
4055                ))
4056            })
4057            .collect()
4058    }
4059
4060    fn source_for(&self, level: RuleSourceLevel, doc: Option<&str>) -> RuleSource {
4061        RuleSource {
4062            level,
4063            name: self.metadata.display_name.clone(),
4064            reference: doc.map(str::to_string),
4065        }
4066    }
4067
4068    fn check_body_spec(
4069        &self,
4070        body: &CompiledBody,
4071        document: &JsonDocument<'_>,
4072        artifact: &str,
4073        raw_artifact_len: usize,
4074        reference_time_unix_seconds: i64,
4075        violations: &mut Vec<Violation>,
4076    ) {
4077        for scope_path in body_scopes(document, body.scope.as_ref()) {
4078            let unavailable_body = body.code_segment == "http"
4079                && document
4080                    .get("body_encoding")
4081                    .is_some_and(|field| field.text == "unsupported");
4082            if unavailable_body
4083                && (scope_path == "body"
4084                    || scope_path.starts_with("body.")
4085                    || scope_path.starts_with("body["))
4086            {
4087                continue;
4088            }
4089            let unavailable_content_type = body.code_segment == "http"
4090                && document
4091                    .get("content_type_unavailable")
4092                    .is_some_and(|field| field.text == "true");
4093            let unavailable_capture =
4094                body.code_segment == "http" && document.contains("capture_unavailable_paths[0]");
4095            if unavailable_capture
4096                && scope_path != "headers"
4097                && http_capture_path_unavailable(document, &scope_path)
4098            {
4099                continue;
4100            }
4101            let param_unavailable = |param: &CompiledParam| {
4102                (unavailable_body && http_param_reads_body(param))
4103                    || (unavailable_content_type
4104                        && (param.contract.root_path.as_deref() == Some("content_type")
4105                            || param.names.iter().any(|name| name == "content_type")))
4106                    || (unavailable_capture
4107                        && http_capture_param_unavailable(document, &scope_path, param))
4108            };
4109            let mut params = collect_body_params(document, &scope_path, &body.params);
4110            if unavailable_capture {
4111                params.retain(|param| {
4112                    param
4113                        .location
4114                        .as_deref()
4115                        .is_none_or(|path| !http_capture_path_unavailable(document, path))
4116                });
4117            }
4118            let dependency_unavailable = |param: &CompiledParam| {
4119                param_unavailable(param)
4120                    || (unavailable_capture
4121                        && http_capture_param_has_unknown(document, &scope_path, param))
4122            };
4123            let condition_reads_unavailable_body = |condition: &RuleCondition| {
4124                if unavailable_capture {
4125                    http_capture_condition(condition, &params, &|name| {
4126                        body.params.iter().any(|param| {
4127                            param
4128                                .names
4129                                .iter()
4130                                .any(|candidate| candidate.as_str() == name)
4131                                && dependency_unavailable(param)
4132                        })
4133                    })
4134                    .is_none()
4135                } else {
4136                    (unavailable_body || unavailable_content_type)
4137                        && condition.params().iter().any(|name| {
4138                            body.params
4139                                .iter()
4140                                .any(|param| param.names.contains(name) && param_unavailable(param))
4141                        })
4142                }
4143            };
4144            if body
4145                .condition
4146                .as_ref()
4147                .is_some_and(condition_reads_unavailable_body)
4148            {
4149                continue;
4150            }
4151            if document.get(&scope_path).is_some_and(|selected| {
4152                body.scope_exclusions
4153                    .iter()
4154                    .flat_map(|pattern| document.expand(pattern))
4155                    .filter_map(|path| document.get(&path))
4156                    .any(|excluded| {
4157                        selected.start >= excluded.start && selected.end <= excluded.end
4158                    })
4159            }) {
4160                continue;
4161            }
4162            if body.code_segment == "http"
4163                && body
4164                    .condition
4165                    .as_ref()
4166                    .is_some_and(|guard| guard.evaluate(&params) != Some(true))
4167            {
4168                continue;
4169            }
4170            let scope = Scope {
4171                code_segment: body.code_segment,
4172                artifact,
4173                raw_artifact_len,
4174                reference_time_unix_seconds,
4175                complete_http_request: false,
4176                body: Some(BodyScope {
4177                    document,
4178                    path: &scope_path,
4179                }),
4180                query: None,
4181            };
4182            for compiled in &body.params {
4183                if (unavailable_body || unavailable_content_type || unavailable_capture)
4184                    && (param_unavailable(compiled)
4185                        || compiled
4186                            .contract
4187                            .condition
4188                            .as_ref()
4189                            .is_some_and(condition_reads_unavailable_body))
4190                {
4191                    continue;
4192                }
4193                self.check_param(&scope, compiled, &params, &body.exact_names, violations);
4194            }
4195            for compiled in &body.rules {
4196                if (unavailable_body || unavailable_content_type || unavailable_capture)
4197                    && (assertion_params(&compiled.rule.assertion)
4198                        .iter()
4199                        .any(|name| {
4200                            body.params.iter().any(|param| {
4201                                param.names.contains(name)
4202                                    && if matches!(
4203                                        compiled.rule.assertion,
4204                                        Assertion::Format { .. }
4205                                            | Assertion::ForbidValuePattern { .. }
4206                                    ) {
4207                                        param_unavailable(param)
4208                                    } else {
4209                                        dependency_unavailable(param)
4210                                    }
4211                            })
4212                        })
4213                        || compiled
4214                            .rule
4215                            .condition
4216                            .as_ref()
4217                            .is_some_and(condition_reads_unavailable_body))
4218                {
4219                    continue;
4220                }
4221                self.check_rule(&scope, compiled, &params, violations);
4222            }
4223        }
4224    }
4225
4226    fn check_encoded_bodies(
4227        &self,
4228        params: &[RawParam<'_>],
4229        reference_time_unix_seconds: i64,
4230        violations: &mut Vec<Violation>,
4231    ) {
4232        let sources: BTreeSet<_> = self
4233            .bodies
4234            .iter()
4235            .filter_map(|body| body.source_param.as_ref().map(|name| (name, body.encoding)))
4236            .collect();
4237        for (source, encoding) in sources {
4238            let active: Vec<_> = self
4239                .bodies
4240                .iter()
4241                .filter(|body| {
4242                    body.source_param.as_ref() == Some(source)
4243                        && body.encoding == encoding
4244                        && body
4245                            .condition
4246                            .as_ref()
4247                            .is_none_or(|guard| guard.evaluate(params) == Some(true))
4248                })
4249                .collect();
4250            if active.is_empty() {
4251                continue;
4252            }
4253            let encoding_severity = strongest_encoding_severity(active.iter().copied());
4254            for param in params.iter().filter(|param| {
4255                param.name.as_ref() == source && !param.missing && !param.value.is_empty()
4256            }) {
4257                let decoded = encoding.decode(param.value.as_ref());
4258                let decoded = match decoded {
4259                    Ok(decoded) => decoded,
4260                    Err(reason) => {
4261                        if encoding == ParamEncoding::PercentEncodedJson
4262                            || !contains_macro(param.value.as_ref())
4263                        {
4264                            self.encoded_body_error(
4265                                source,
4266                                param,
4267                                &reason,
4268                                encoding_severity,
4269                                violations,
4270                            );
4271                        }
4272                        continue;
4273                    }
4274                };
4275                let document = match JsonDocument::parse(&decoded) {
4276                    Ok(document) => document,
4277                    Err(error) => {
4278                        if !contains_macro(param.value.as_ref())
4279                            && !(encoding == ParamEncoding::PercentEncodedJson
4280                                && contains_macro(&decoded))
4281                        {
4282                            self.encoded_body_error(
4283                                source,
4284                                param,
4285                                &error.to_string(),
4286                                encoding_severity,
4287                                violations,
4288                            );
4289                        }
4290                        continue;
4291                    }
4292                };
4293                let mut decoded_violations = Vec::new();
4294                for body in self.bodies.iter().filter(|body| {
4295                    body.source_param.as_ref() == Some(source)
4296                        && body.source_field.is_none()
4297                        && body.encoding == encoding
4298                        && body
4299                            .condition
4300                            .as_ref()
4301                            .is_none_or(|guard| guard.evaluate(params) == Some(true))
4302                }) {
4303                    self.check_body_spec(
4304                        body,
4305                        &document,
4306                        &decoded,
4307                        encoding.decoded_byte_length(param.value.as_ref(), &decoded),
4308                        reference_time_unix_seconds,
4309                        &mut decoded_violations,
4310                    );
4311                }
4312                self.check_embedded_bodies(
4313                    &document,
4314                    Some((source, encoding)),
4315                    params,
4316                    reference_time_unix_seconds,
4317                    &mut decoded_violations,
4318                );
4319                // Decoding changes byte offsets. Point at the original parameter
4320                // rather than presenting offsets in decoded text as URL offsets.
4321                for mut violation in decoded_violations {
4322                    if let Some(field) = &mut violation.field {
4323                        *field = format!(
4324                            "param.{source}.{}",
4325                            field.strip_prefix("body.").unwrap_or(field)
4326                        );
4327                    }
4328                    violation.targets = vec![param.target()];
4329                    violations.push(violation);
4330                }
4331            }
4332        }
4333    }
4334
4335    fn check_embedded_bodies(
4336        &self,
4337        outer: &JsonDocument<'_>,
4338        parent_source: Option<(&str, ParamEncoding)>,
4339        params: &[RawParam<'_>],
4340        reference_time_unix_seconds: i64,
4341        violations: &mut Vec<Violation>,
4342    ) {
4343        let sources: BTreeSet<_> = self
4344            .bodies
4345            .iter()
4346            .filter(|body| match parent_source {
4347                None => body.source_param.is_none(),
4348                Some((name, encoding)) => {
4349                    body.source_param.as_deref() == Some(name) && body.encoding == encoding
4350                }
4351            })
4352            .filter(|body| {
4353                body.condition
4354                    .as_ref()
4355                    .is_none_or(|guard| guard.evaluate(params) == Some(true))
4356            })
4357            .filter_map(|body| {
4358                body.source_field.as_ref().map(|name| {
4359                    (
4360                        name,
4361                        if parent_source.is_some() {
4362                            body.field_encoding
4363                        } else {
4364                            body.encoding
4365                        },
4366                    )
4367                })
4368            })
4369            .collect();
4370        for (source, encoding) in sources {
4371            let active: Vec<_> = self
4372                .bodies
4373                .iter()
4374                .filter(|body| {
4375                    body.source_field.as_ref() == Some(source)
4376                        && match parent_source {
4377                            None => body.source_param.is_none() && body.encoding == encoding,
4378                            Some((name, outer_encoding)) => {
4379                                body.source_param.as_deref() == Some(name)
4380                                    && body.encoding == outer_encoding
4381                                    && body.field_encoding == encoding
4382                            }
4383                        }
4384                        && body
4385                            .condition
4386                            .as_ref()
4387                            .is_none_or(|guard| guard.evaluate(params) == Some(true))
4388                })
4389                .collect();
4390            let encoding_severity = strongest_encoding_severity(active.iter().copied());
4391            for path in outer.expand(source) {
4392                let Some(field) = outer.get(&path) else {
4393                    continue;
4394                };
4395                // The outer field contract owns presence and native type checks.
4396                if field.kind != JsonValueKind::String || field.text.is_empty() {
4397                    continue;
4398                }
4399                let param = RawParam::query(
4400                    Cow::Borrowed(path.as_str()),
4401                    field.text.clone(),
4402                    field.start,
4403                    field.end,
4404                );
4405                let decoded = encoding.decode(field.text.as_ref());
4406                let decoded = match decoded {
4407                    Ok(decoded) => decoded,
4408                    Err(reason) => {
4409                        if encoding == ParamEncoding::PercentEncodedJson
4410                            || !contains_macro(field.text.as_ref())
4411                        {
4412                            self.embedded_body_error(
4413                                source,
4414                                &path,
4415                                &param,
4416                                &reason,
4417                                encoding_severity,
4418                                violations,
4419                            );
4420                        }
4421                        continue;
4422                    }
4423                };
4424                let document = match JsonDocument::parse(&decoded) {
4425                    Ok(document) => document,
4426                    Err(error) => {
4427                        if !contains_macro(field.text.as_ref())
4428                            && !(encoding == ParamEncoding::PercentEncodedJson
4429                                && contains_macro(&decoded))
4430                        {
4431                            self.embedded_body_error(
4432                                source,
4433                                &path,
4434                                &param,
4435                                &error.to_string(),
4436                                encoding_severity,
4437                                violations,
4438                            );
4439                        }
4440                        continue;
4441                    }
4442                };
4443                let mut decoded_violations = Vec::new();
4444                let mut nested_errors = BTreeSet::new();
4445                for body in self.bodies.iter().filter(|body| {
4446                    body.source_field.as_ref() == Some(source)
4447                        && match parent_source {
4448                            None => body.source_param.is_none() && body.encoding == encoding,
4449                            Some((name, outer_encoding)) => {
4450                                body.source_param.as_deref() == Some(name)
4451                                    && body.encoding == outer_encoding
4452                                    && body.field_encoding == encoding
4453                            }
4454                        }
4455                        && body
4456                            .condition
4457                            .as_ref()
4458                            .is_none_or(|guard| guard.evaluate(params) == Some(true))
4459                }) {
4460                    if let Some(maximum) = body.source_max_length
4461                        && body
4462                            .source_max_length_when
4463                            .as_ref()
4464                            .is_none_or(|guard| guard.holds(&document))
4465                        && field.text.chars().count() > maximum
4466                        && !contains_macro(field.text.as_ref())
4467                    {
4468                        violations.push(Violation {
4469                            code: format!("{}.body.{source}.source_length", self.code_prefix),
4470                            message: format!("`{source}` exceeds {maximum} Unicode characters for the selected decoded representation."),
4471                            severity: Severity::Error, field: Some(format!("body.{path}")),
4472                            fix_hint: Some("Shorten the original JSON string for this representation.".into()),
4473                            source: self.source_for(self.metadata.source_level, self.docs.as_deref()),
4474                            targets: vec![ViolationTarget { component: ViolationTargetComponent::BodyField, name: Some(path.clone()), value: Some(field.text.to_string()), start: field.start, end: field.end }],
4475                        });
4476                    }
4477                    if let Some(nested_source) = &body.decoded_source_field {
4478                        if body
4479                            .decoded_source_condition
4480                            .as_ref()
4481                            .is_some_and(|guard| !guard.holds(&document))
4482                        {
4483                            continue;
4484                        }
4485                        for nested_path in document.expand(nested_source) {
4486                            let Some(nested) = document.get(&nested_path) else {
4487                                continue;
4488                            };
4489                            if nested.kind != JsonValueKind::String
4490                                || nested.text.is_empty()
4491                                || contains_macro(nested.text.as_ref())
4492                            {
4493                                continue;
4494                            }
4495                            match JsonDocument::parse(nested.text.as_ref()) {
4496                                Ok(nested_doc) => {
4497                                    let mut findings = Vec::new();
4498                                    self.check_body_spec(
4499                                        body,
4500                                        &nested_doc,
4501                                        nested.text.as_ref(),
4502                                        nested.text.len(),
4503                                        reference_time_unix_seconds,
4504                                        &mut findings,
4505                                    );
4506                                    for mut finding in findings {
4507                                        if let Some(field) = &mut finding.field {
4508                                            let suffix =
4509                                                field.strip_prefix("body.").unwrap_or(field);
4510                                            *field = format!(
4511                                                "body.{nested_path}{}{suffix}",
4512                                                if suffix.starts_with('[') || suffix.is_empty() {
4513                                                    ""
4514                                                } else {
4515                                                    "."
4516                                                }
4517                                            );
4518                                        }
4519                                        decoded_violations.push(finding);
4520                                    }
4521                                }
4522                                Err(error) => {
4523                                    if nested_errors.insert(nested_path.clone()) {
4524                                        let severity = strongest_encoding_severity(
4525                                            active.iter().copied().filter(|candidate| {
4526                                                candidate.decoded_source_field.as_ref().is_some_and(
4527                                                    |pattern| {
4528                                                        document
4529                                                            .expand(pattern)
4530                                                            .contains(&nested_path)
4531                                                    },
4532                                                ) && candidate
4533                                                    .decoded_source_condition
4534                                                    .as_ref()
4535                                                    .is_none_or(|guard| guard.holds(&document))
4536                                            }),
4537                                        );
4538                                        self.embedded_body_error(
4539                                            nested_source,
4540                                            &nested_path,
4541                                            &param,
4542                                            &error.to_string(),
4543                                            severity,
4544                                            &mut decoded_violations,
4545                                        );
4546                                    }
4547                                }
4548                            }
4549                        }
4550                    } else {
4551                        self.check_body_spec(
4552                            body,
4553                            &document,
4554                            &decoded,
4555                            encoding.decoded_byte_length(field.text.as_ref(), &decoded),
4556                            reference_time_unix_seconds,
4557                            &mut decoded_violations,
4558                        );
4559                    }
4560                }
4561                for mut violation in decoded_violations {
4562                    if let Some(field) = &mut violation.field {
4563                        *field = format!(
4564                            "body.{path}.{}",
4565                            field.strip_prefix("body.").unwrap_or(field)
4566                        );
4567                    }
4568                    violation.targets = vec![ViolationTarget {
4569                        component: ViolationTargetComponent::BodyField,
4570                        start: param.start,
4571                        end: param.end,
4572                        name: Some(path.clone()),
4573                        value: Some(field.text.to_string()),
4574                    }];
4575                    violations.push(violation);
4576                }
4577            }
4578        }
4579    }
4580
4581    fn embedded_body_error(
4582        &self,
4583        source: &str,
4584        path: &str,
4585        param: &RawParam<'_>,
4586        reason: &str,
4587        severity: Severity,
4588        violations: &mut Vec<Violation>,
4589    ) {
4590        self.encoded_body_error(source, param, reason, severity, violations);
4591        let violation = violations
4592            .last_mut()
4593            .expect("encoded_body_error adds a finding");
4594        violation.field = Some(format!("body.{path}"));
4595        violation.targets[0].component = ViolationTargetComponent::BodyField;
4596        violation.targets[0].name = Some(path.to_string());
4597    }
4598
4599    fn encoded_body_error(
4600        &self,
4601        source: &str,
4602        param: &RawParam<'_>,
4603        reason: &str,
4604        severity: Severity,
4605        violations: &mut Vec<Violation>,
4606    ) {
4607        violations.push(Violation {
4608            code: format!("{}.body.{source}.invalid", self.code_prefix),
4609            message: format!("`{source}` must encode valid JSON: {reason}."),
4610            severity,
4611            field: Some(format!("param.{source}")),
4612            fix_hint: Some("Encode a JSON value in the documented parameter format.".to_string()),
4613            source: self.source_for(self.metadata.source_level, self.docs.as_deref()),
4614            targets: vec![param.target()],
4615        });
4616    }
4617
4618    fn check_param(
4619        &self,
4620        scope: &Scope,
4621        compiled: &CompiledParam,
4622        params: &[RawParam<'_>],
4623        exact_names: &BTreeSet<String>,
4624        violations: &mut Vec<Violation>,
4625    ) {
4626        let contract = &compiled.contract;
4627        if contract
4628            .condition
4629            .as_ref()
4630            .is_some_and(|guard| guard.evaluate(params) != Some(true))
4631        {
4632            return;
4633        }
4634        let addressed: Vec<&RawParam> = match &compiled.name_regex {
4635            Some(name_regex) => params
4636                .iter()
4637                .filter(|param| {
4638                    if exact_names.contains(param.name.as_ref()) {
4639                        return false;
4640                    }
4641                    let name = match (&contract.name_pattern_parent, &scope.body) {
4642                        (Some(parent), Some(_)) => match (&param.family_parent, &param.member_name)
4643                        {
4644                            (Some(actual), Some(name)) if actual == parent => name.as_str(),
4645                            _ => return false,
4646                        },
4647                        _ => param.name.as_ref(),
4648                    };
4649                    name_regex.is_match(name)
4650                })
4651                .collect(),
4652            None => params
4653                .iter()
4654                .filter(|param| {
4655                    compiled
4656                        .names
4657                        .iter()
4658                        .any(|name| name.as_str() == param.name.as_ref())
4659                })
4660                .collect(),
4661        };
4662        match contract.requirement {
4663            Requirement::Required | Requirement::Recommended => {
4664                let severity =
4665                    contract
4666                        .severity
4667                        .unwrap_or(if contract.requirement == Requirement::Required {
4668                            Severity::Error
4669                        } else {
4670                            Severity::Warning
4671                        });
4672
4673                let report_missing =
4674                    |targets: Vec<ViolationTarget>, violations: &mut Vec<Violation>| {
4675                        if targets.is_empty() {
4676                            return;
4677                        }
4678                        let field = scope.field(&contract.name);
4679                        let source =
4680                            self.source_for(compiled.source_level, compiled.doc.as_deref());
4681                        for target in targets {
4682                            violations.push(Violation {
4683                                code: format!(
4684                                    "{}.{}.{}.missing",
4685                                    self.code_prefix, scope.code_segment, contract.name
4686                                ),
4687                                message: describe(
4688                                    format!(
4689                                        "`{}` is {} on {} requests but is not present.",
4690                                        contract.name,
4691                                        if contract.requirement == Requirement::Required {
4692                                            "required"
4693                                        } else {
4694                                            "expected"
4695                                        },
4696                                        self.metadata.display_name
4697                                    ),
4698                                    contract.description.as_deref(),
4699                                ),
4700                                severity,
4701                                field: Some(field.clone()),
4702                                fix_hint: contract.fix_hint.clone().or_else(|| {
4703                                    Some(format!("Add the `{}` parameter.", contract.name))
4704                                }),
4705                                source: source.clone(),
4706                                targets: vec![target],
4707                            });
4708                        }
4709                    };
4710
4711                if scope.body.is_some() {
4712                    // Nothing addressed the contract at all: something above it
4713                    // is missing and has already been reported.
4714                    if addressed.is_empty() {
4715                        return;
4716                    }
4717
4718                    // Every place the value belongs is checked on its own. One
4719                    // identifier carrying `idType` does not excuse the next one
4720                    // for leaving it out.
4721                    let missing: Vec<_> = addressed
4722                        .iter()
4723                        .filter(|slot| slot.missing)
4724                        .map(|slot| slot.target())
4725                        .collect();
4726                    // Exact aliases identify one logical field in this scope.
4727                    // A present alternate spelling satisfies its presence.
4728                    let exact_aliases = !contract.aliases.is_empty()
4729                        && compiled
4730                            .names
4731                            .iter()
4732                            .all(|name| !name.contains("[]") && !name.contains('*'));
4733                    if !exact_aliases || addressed.iter().all(|slot| slot.missing) {
4734                        report_missing(
4735                            if exact_aliases {
4736                                missing.into_iter().take(1).collect()
4737                            } else {
4738                                missing
4739                            },
4740                            violations,
4741                        );
4742                    }
4743                } else if addressed.iter().all(|param| param.missing) {
4744                    report_missing(vec![scope.fallback_for(&contract.name)], violations);
4745                    return;
4746                }
4747            }
4748            Requirement::Forbidden => {
4749                for param in addressed.iter().filter(|param| !param.missing) {
4750                    let code_name = finding_name(compiled, param);
4751                    violations.push(Violation {
4752                        code: format!(
4753                            "{}.{}.{}.forbidden",
4754                            self.code_prefix, scope.code_segment, code_name
4755                        ),
4756                        message: describe(
4757                            format!(
4758                                "`{}` must not be sent to {}.",
4759                                param.name, self.metadata.display_name
4760                            ),
4761                            contract.description.as_deref(),
4762                        ),
4763                        severity: contract.severity.unwrap_or(Severity::Error),
4764                        field: Some(scope.field(code_name)),
4765                        fix_hint: contract
4766                            .fix_hint
4767                            .clone()
4768                            .or_else(|| Some(format!("Remove the `{}` parameter.", param.name))),
4769                        source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
4770                        targets: vec![param.target()],
4771                    });
4772                }
4773                return;
4774            }
4775            Requirement::Deprecated => {
4776                for param in addressed.iter().filter(|param| !param.missing) {
4777                    let code_name = finding_name(compiled, param);
4778                    violations.push(Violation {
4779                        code: format!(
4780                            "{}.{}.{}.deprecated",
4781                            self.code_prefix, scope.code_segment, code_name
4782                        ),
4783                        message: describe(
4784                            format!(
4785                                "`{}` is deprecated by {}.",
4786                                param.name, self.metadata.display_name
4787                            ),
4788                            contract.description.as_deref(),
4789                        ),
4790                        severity: contract.severity.unwrap_or(Severity::Warning),
4791                        field: Some(scope.field(code_name)),
4792                        fix_hint: contract.fix_hint.clone(),
4793                        source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
4794                        targets: vec![param.target()],
4795                    });
4796                }
4797            }
4798            Requirement::Optional => {}
4799        }
4800
4801        for param in addressed.iter().filter(|param| !param.missing) {
4802            let code_name = finding_name(compiled, param);
4803            let json_field = scope.body.as_ref().and_then(|body| {
4804                param
4805                    .location
4806                    .as_deref()
4807                    .and_then(|path| body.document.get(path))
4808            });
4809            let normalized_value = normalize_string(param.value.as_ref(), contract.normalization);
4810
4811            // Structured constraints run before emptiness and scalar formats.
4812            // Otherwise a malformed object could satisfy a numeric contract,
4813            // and an empty array could hide a required batch's size violation.
4814            if let Some(reason) = constraint_violation(
4815                contract,
4816                param,
4817                json_field,
4818                scope.body.as_ref().map(|body| body.document),
4819                compiled.key_regex.as_ref(),
4820            ) {
4821                violations.push(Violation {
4822                    code: format!(
4823                        "{}.{}.{}.invalid",
4824                        self.code_prefix, scope.code_segment, code_name
4825                    ),
4826                    message: describe(
4827                        format!("`{}` {reason}", param.name),
4828                        contract.description.as_deref(),
4829                    ),
4830                    severity: contract
4831                        .format_severity
4832                        .or(contract.severity)
4833                        .unwrap_or(Severity::Error),
4834                    field: Some(scope.field(code_name)),
4835                    fix_hint: contract.fix_hint.clone(),
4836                    source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
4837                    targets: vec![param.target()],
4838                });
4839                continue;
4840            }
4841            // Typed containers can be empty unless a minimum size is declared.
4842            // Explicit nullable contracts accept null without inventing a value.
4843            if let Some(field) = json_field
4844                && contract.json_type.is_some()
4845                && matches!(
4846                    field.kind,
4847                    JsonValueKind::Array | JsonValueKind::Object | JsonValueKind::Null
4848                )
4849                && !(field.kind != JsonValueKind::Null
4850                    && field.is_blank()
4851                    && matches!(contract.format, Some(ValueFormat::NonEmpty)))
4852            {
4853                continue;
4854            }
4855            if normalized_value.is_empty() {
4856                if contract.allow_empty {
4857                    continue;
4858                }
4859                violations.push(Violation {
4860                    code: format!(
4861                        "{}.{}.{}.empty",
4862                        self.code_prefix, scope.code_segment, code_name
4863                    ),
4864                    message: format!("`{}` is present but has an empty value.", param.name),
4865                    severity: contract.severity.unwrap_or(match contract.requirement {
4866                        Requirement::Required => Severity::Error,
4867                        _ => Severity::Warning,
4868                    }),
4869                    field: Some(scope.field(code_name)),
4870                    fix_hint: contract
4871                        .fix_hint
4872                        .clone()
4873                        .or_else(|| Some(format!("Populate `{}` before firing.", param.name))),
4874                    source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
4875                    targets: vec![param.target()],
4876                });
4877                continue;
4878            }
4879
4880            // Unexpanded macros are the core pack's business. Checking the
4881            // literal macro text against a value format would double-report the
4882            // same defect with a worse message.
4883            if contains_macro(param.value.as_ref())
4884                && !matches!(contract.format, Some(ValueFormat::IpChain))
4885            {
4886                continue;
4887            }
4888
4889            // A value format describes a scalar. When the same field is also
4890            // accepted as a list, the list itself is checked element by element
4891            // by the contract written for it.
4892            if param.container {
4893                continue;
4894            }
4895
4896            let Some(format) = &contract.format else {
4897                continue;
4898            };
4899
4900            if let Some(reason) =
4901                format_violation(format, compiled.regex.as_ref(), normalized_value.as_ref())
4902            {
4903                violations.push(Violation {
4904                    code: format!(
4905                        "{}.{}.{}.invalid",
4906                        self.code_prefix, scope.code_segment, code_name
4907                    ),
4908                    message: describe(
4909                        format!("`{}` {reason}", param.name),
4910                        contract.description.as_deref(),
4911                    ),
4912                    severity: contract
4913                        .format_severity
4914                        .or(contract.severity)
4915                        .unwrap_or(Severity::Error),
4916                    field: Some(scope.field(code_name)),
4917                    fix_hint: contract.fix_hint.clone(),
4918                    source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
4919                    targets: vec![param.target()],
4920                });
4921            } else {
4922                self.check_format_warnings(
4923                    &format!("{}.{}.{}", self.code_prefix, scope.code_segment, code_name),
4924                    format,
4925                    param,
4926                    &scope.field(code_name),
4927                    violations,
4928                );
4929            }
4930        }
4931    }
4932
4933    fn check_format_warnings(
4934        &self,
4935        code: &str,
4936        format: &ValueFormat,
4937        param: &RawParam<'_>,
4938        field: &str,
4939        violations: &mut Vec<Violation>,
4940    ) {
4941        let kind = match format {
4942            ValueFormat::Tcf => "tcf",
4943            ValueFormat::Gpp => "gpp",
4944            ValueFormat::AdditionalConsent => "additional_consent",
4945            _ => return,
4946        };
4947        for message in crate::privacy::literal_format_warnings(kind, param.value.as_ref()) {
4948            violations.push(Violation {
4949                code: format!("{code}.policy_warning"),
4950                message: format!("`{}`: {message}", param.name),
4951                severity: Severity::Warning,
4952                field: Some(field.to_string()),
4953                fix_hint: Some(
4954                    "Check the CMP output against the cited consent specification.".to_string(),
4955                ),
4956                source: RuleSource {
4957                    level: RuleSourceLevel::Normative,
4958                    name: if kind == "additional_consent" {
4959                        "Google Additional Consent specification".to_string()
4960                    } else {
4961                        format!("IAB {kind} consent specification")
4962                    },
4963                    reference: Some(crate::privacy::consent_spec(kind).to_string()),
4964                },
4965                targets: vec![param.target()],
4966            });
4967        }
4968    }
4969
4970    fn check_rule(
4971        &self,
4972        scope: &Scope,
4973        compiled: &CompiledRule,
4974        params: &[RawParam<'_>],
4975        violations: &mut Vec<Violation>,
4976    ) {
4977        let rule = &compiled.rule;
4978        if rule
4979            .condition
4980            .as_ref()
4981            .is_some_and(|condition| condition.evaluate(params) != Some(true))
4982        {
4983            return;
4984        }
4985        // An empty slot is not a value, so a rule must not read it as one.
4986        let live = |param: &RawParam| !param.missing;
4987        let present = |name: &str| {
4988            params
4989                .iter()
4990                .any(|param| live(param) && param.name.as_ref() == name)
4991        };
4992        let named = |name: &str| {
4993            params
4994                .iter()
4995                .find(|param| live(param) && param.name.as_ref() == name)
4996        };
4997        let hits_named = |names: &[String]| {
4998            params
4999                .iter()
5000                .filter(|param| {
5001                    live(param)
5002                        && names
5003                            .iter()
5004                            .any(|name| name.as_str() == param.name.as_ref())
5005                })
5006                .collect::<Vec<_>>()
5007        };
5008        let value_in = |values: &[String], param: &RawParam| {
5009            values
5010                .iter()
5011                .any(|value| value.as_str() == param.value.as_ref())
5012        };
5013
5014        let (triggered, targets) = match &rule.assertion {
5015            Assertion::RequireOneOf { params: names } => {
5016                if names.iter().any(|name| present(name)) {
5017                    (false, Vec::new())
5018                } else {
5019                    (true, vec![scope.fallback()])
5020                }
5021            }
5022            Assertion::MutuallyExclusive { params: names } => {
5023                let hits = hits_named(names);
5024                if hits.len() > 1 {
5025                    (true, hits.iter().map(|param| param.target()).collect())
5026                } else {
5027                    (false, Vec::new())
5028                }
5029            }
5030            Assertion::RequiredWith { when, requires } => {
5031                if present(when) && !requires.iter().all(|name| present(name)) {
5032                    let target = named(when)
5033                        .map(RawParam::target)
5034                        .unwrap_or_else(|| scope.fallback_for(when));
5035                    (true, vec![target])
5036                } else {
5037                    (false, Vec::new())
5038                }
5039            }
5040            Assertion::RequiredWhenValue {
5041                when,
5042                equals,
5043                requires,
5044            } => {
5045                let triggered = params
5046                    .iter()
5047                    .filter(|param| live(param) && param.name.as_ref() == when)
5048                    .filter(|param| !contains_macro(param.value.as_ref()))
5049                    .any(|param| value_in(equals, param));
5050
5051                if triggered && !requires.iter().all(|name| present(name)) {
5052                    let target = named(when)
5053                        .map(RawParam::target)
5054                        .unwrap_or_else(|| scope.fallback_for(when));
5055                    (true, vec![target])
5056                } else {
5057                    (false, Vec::new())
5058                }
5059            }
5060            Assertion::ForbidValuePattern {
5061                params: names,
5062                pattern: _,
5063            } => {
5064                let Some(regex) = compiled.regex.as_ref() else {
5065                    return;
5066                };
5067
5068                // Bracketed reserved names such as Amplitude's `[Amplitude]
5069                // Start Session` look like ad-tech macros. The forbid still
5070                // has to read the literal; a format check can wait for the
5071                // template to expand, this rule cannot.
5072                let hits: Vec<&RawParam> = params
5073                    .iter()
5074                    .filter(|param| {
5075                        live(param)
5076                            && (names.is_empty()
5077                                || names
5078                                    .iter()
5079                                    .any(|name| name.as_str() == param.name.as_ref()))
5080                    })
5081                    .filter(|param| regex.is_match(param.value.as_ref()))
5082                    .collect();
5083
5084                if hits.is_empty() {
5085                    (false, Vec::new())
5086                } else {
5087                    (true, hits.iter().map(|param| param.target()).collect())
5088                }
5089            }
5090            Assertion::ValueWhen {
5091                when,
5092                equals,
5093                param,
5094                value,
5095            } => {
5096                let triggered = params
5097                    .iter()
5098                    .filter(|candidate| live(candidate) && candidate.name.as_ref() == when)
5099                    .filter(|candidate| !contains_macro(candidate.value.as_ref()))
5100                    .any(|candidate| value_in(equals, candidate));
5101
5102                if !triggered {
5103                    (false, Vec::new())
5104                } else {
5105                    match named(param) {
5106                        Some(field)
5107                            if !contains_macro(field.value.as_ref())
5108                                && field.value.as_ref() != value =>
5109                        {
5110                            (true, vec![field.target()])
5111                        }
5112                        _ => (false, Vec::new()),
5113                    }
5114                }
5115            }
5116            Assertion::ForbiddenWhenValue {
5117                when,
5118                equals,
5119                params: names,
5120            } => {
5121                let triggered = params
5122                    .iter()
5123                    .filter(|candidate| live(candidate) && candidate.name.as_ref() == when)
5124                    .filter(|candidate| !contains_macro(candidate.value.as_ref()))
5125                    .any(|candidate| value_in(equals, candidate));
5126
5127                if !triggered {
5128                    (false, Vec::new())
5129                } else {
5130                    let hits = hits_named(names);
5131                    if hits.is_empty() {
5132                        (false, Vec::new())
5133                    } else {
5134                        (true, hits.iter().map(|param| param.target()).collect())
5135                    }
5136                }
5137            }
5138            Assertion::RequireAnyOf {
5139                groups,
5140                allow_empty,
5141            } => {
5142                let meaningful = |name: &str| {
5143                    params.iter().any(|param| {
5144                        live(param)
5145                            && param.name.as_ref() == name
5146                            && (*allow_empty || param.container || !param.value.is_empty())
5147                    })
5148                };
5149                let satisfied = groups
5150                    .iter()
5151                    .any(|group| group.iter().all(|name| meaningful(name)));
5152                (
5153                    !satisfied,
5154                    if satisfied {
5155                        Vec::new()
5156                    } else {
5157                        vec![scope.fallback()]
5158                    },
5159                )
5160            }
5161            Assertion::FormatWhen {
5162                when,
5163                equals,
5164                param,
5165                format,
5166                pair_occurrences,
5167            } => {
5168                let applies = params.iter().any(|candidate| {
5169                    live(candidate)
5170                        && candidate.name.as_ref() == when
5171                        && !contains_macro(candidate.value.as_ref())
5172                        && value_in(equals, candidate)
5173                });
5174                let discriminators: Vec<_> = params
5175                    .iter()
5176                    .filter(|candidate| !candidate.missing && candidate.name.as_ref() == when)
5177                    .collect();
5178                for (index, candidate) in params
5179                    .iter()
5180                    .filter(|field| !field.missing && field.name.as_ref() == param)
5181                    .enumerate()
5182                {
5183                    let paired_applies = !*pair_occurrences
5184                        || discriminators.get(index).is_some_and(|field| {
5185                            !contains_macro(field.value.as_ref()) && value_in(equals, field)
5186                        });
5187                    if applies
5188                        && paired_applies
5189                        && !candidate.container
5190                        && !format_has_unresolved_macro(format, candidate.value.as_ref())
5191                    {
5192                        self.check_format_warnings(
5193                            &rule.code,
5194                            format,
5195                            candidate,
5196                            &scope.field(param),
5197                            violations,
5198                        );
5199                    }
5200                }
5201                let hits: Vec<_> = params
5202                    .iter()
5203                    .filter(|candidate| !candidate.missing && candidate.name.as_ref() == param)
5204                    .enumerate()
5205                    .filter(|(index, candidate)| {
5206                        let paired_applies = !*pair_occurrences
5207                            || discriminators.get(*index).is_some_and(|field| {
5208                                !contains_macro(field.value.as_ref()) && value_in(equals, field)
5209                            });
5210                        applies
5211                            && paired_applies
5212                            && live(candidate)
5213                            && candidate.name.as_ref() == param
5214                            && !candidate.container
5215                            && !format_has_unresolved_macro(format, candidate.value.as_ref())
5216                            && format_violation(
5217                                format,
5218                                compiled.regex.as_ref(),
5219                                candidate.value.as_ref(),
5220                            )
5221                            .is_some()
5222                    })
5223                    .map(|(_, candidate)| candidate)
5224                    .collect();
5225                (
5226                    !hits.is_empty(),
5227                    hits.iter().map(|param| param.target()).collect(),
5228                )
5229            }
5230            Assertion::LastParam { param } => {
5231                let last = params
5232                    .iter()
5233                    .filter(|candidate| candidate.component == ViolationTargetComponent::QueryParam)
5234                    .map(|candidate| candidate.start)
5235                    .max();
5236                let hits: Vec<_> = params
5237                    .iter()
5238                    .filter(|candidate| {
5239                        candidate.name.as_ref() == param
5240                            && candidate.component == ViolationTargetComponent::QueryParam
5241                            && Some(candidate.start) != last
5242                    })
5243                    .collect();
5244                (
5245                    !hits.is_empty(),
5246                    hits.iter().map(|param| param.target()).collect(),
5247                )
5248            }
5249            Assertion::MaxQueryLength { max_length } => {
5250                let query = scope
5251                    .artifact
5252                    .split_once('?')
5253                    .map(|(_, query)| query.split('#').next().unwrap_or(query));
5254                let triggered = query.is_some_and(|query| query.chars().count() > *max_length);
5255                (
5256                    triggered,
5257                    if triggered {
5258                        vec![scope.fallback()]
5259                    } else {
5260                        Vec::new()
5261                    },
5262                )
5263            }
5264            Assertion::RequireHttps => {
5265                let triggered = scope.body.is_none()
5266                    && scope.artifact.split_once(':').is_some_and(|(scheme, _)| {
5267                        !contains_macro(scheme) && !scheme.eq_ignore_ascii_case("https")
5268                    });
5269                (
5270                    triggered,
5271                    if triggered {
5272                        vec![scope.fallback()]
5273                    } else {
5274                        Vec::new()
5275                    },
5276                )
5277            }
5278            Assertion::MaxUrlLength { max_length } => {
5279                let triggered = scope.body.is_none()
5280                    && !contains_macro(scope.artifact)
5281                    && scope.artifact.chars().count() > *max_length;
5282                (
5283                    triggered,
5284                    if triggered {
5285                        vec![scope.fallback()]
5286                    } else {
5287                        Vec::new()
5288                    },
5289                )
5290            }
5291            Assertion::MaxBodyBytes { max_bytes } => {
5292                let triggered = scope.body.is_some() && scope.raw_artifact_len > *max_bytes;
5293                (
5294                    triggered,
5295                    if triggered {
5296                        vec![scope.fallback()]
5297                    } else {
5298                        Vec::new()
5299                    },
5300                )
5301            }
5302            Assertion::EqualValues { left, right } => match (named(left), named(right)) {
5303                (Some(left), Some(right))
5304                    if !left.container
5305                        && !right.container
5306                        && !left.value.is_empty()
5307                        && !right.value.is_empty()
5308                        && !contains_macro(&left.value)
5309                        && !contains_macro(&right.value)
5310                        && left.value != right.value =>
5311                {
5312                    (true, vec![left.target(), right.target()])
5313                }
5314                _ => (false, Vec::new()),
5315            },
5316            Assertion::UrlPathLength { param, max_length } => {
5317                let hits: Vec<_> = params
5318                    .iter()
5319                    .filter(|field| {
5320                        live(field)
5321                            && field.name.as_ref() == param
5322                            && !field.container
5323                            && !contains_macro(&field.value)
5324                            && url_path_character_count(&field.value) > *max_length
5325                    })
5326                    .collect();
5327                (
5328                    !hits.is_empty(),
5329                    hits.iter().map(|field| field.target()).collect(),
5330                )
5331            }
5332            Assertion::ValueWith { when, param, value } => match named(param) {
5333                Some(field)
5334                    if present(when)
5335                        && !contains_macro(field.value.as_ref())
5336                        && field.value.as_ref() != value =>
5337                {
5338                    (true, vec![field.target()])
5339                }
5340                _ => (false, Vec::new()),
5341            },
5342            Assertion::Format { param, format } => {
5343                for candidate in params.iter().filter(|field| {
5344                    live(field)
5345                        && field.name.as_ref() == param
5346                        && !field.container
5347                        && !format_has_unresolved_macro(format, field.value.as_ref())
5348                }) {
5349                    self.check_format_warnings(
5350                        &rule.code,
5351                        format,
5352                        candidate,
5353                        &scope.field(param),
5354                        violations,
5355                    );
5356                }
5357                let hits: Vec<_> = params
5358                    .iter()
5359                    .filter(|field| {
5360                        live(field)
5361                            && field.name.as_ref() == param
5362                            && !field.container
5363                            && !format_has_unresolved_macro(format, field.value.as_ref())
5364                            && format_violation(
5365                                format,
5366                                compiled.regex.as_ref(),
5367                                field.value.as_ref(),
5368                            )
5369                            .is_some()
5370                    })
5371                    .collect();
5372                (
5373                    !hits.is_empty(),
5374                    hits.iter().map(|field| field.target()).collect(),
5375                )
5376            }
5377            Assertion::LessEqual {
5378                left,
5379                right,
5380                strict,
5381                unit,
5382            } => match (named(left), named(right)) {
5383                (Some(left), Some(right))
5384                    if !contains_macro(left.value.as_ref())
5385                        && !contains_macro(right.value.as_ref())
5386                        && compare_ordered_values(
5387                            left.value.as_ref(),
5388                            right.value.as_ref(),
5389                            *unit,
5390                        )
5391                        .is_some_and(|order| {
5392                            order == std::cmp::Ordering::Greater
5393                                || (*strict && order == std::cmp::Ordering::Equal)
5394                        }) =>
5395                {
5396                    (true, vec![left.target(), right.target()])
5397                }
5398                _ => (false, Vec::new()),
5399            },
5400            Assertion::IndexedParamGroups {
5401                params: required,
5402                optional_params,
5403                max_index,
5404                contiguous,
5405            } => {
5406                let hits: Vec<_> = params.iter().filter(|param| live(param)).collect();
5407                let triggered = !crate::indexed_params::valid(
5408                    hits.iter().map(|p| p.name.as_ref()),
5409                    required,
5410                    optional_params,
5411                    *max_index,
5412                    *contiguous,
5413                );
5414                let prefixes: Vec<_> = required.iter().chain(optional_params).cloned().collect();
5415                let targets = if triggered {
5416                    hits.iter()
5417                        .filter(|p| {
5418                            crate::indexed_params::matching_prefix(p.name.as_ref(), &prefixes)
5419                                .is_some()
5420                        })
5421                        .map(|p| p.target())
5422                        .collect()
5423                } else {
5424                    Vec::new()
5425                };
5426                (triggered, targets)
5427            }
5428            Assertion::EqualSplitLengths {
5429                params: names,
5430                separator,
5431            } => {
5432                let hits: Vec<_> = hits_named(names)
5433                    .into_iter()
5434                    .filter(|param| {
5435                        !param.container
5436                            && !param.value.is_empty()
5437                            && !contains_macro(param.value.as_ref())
5438                    })
5439                    .collect();
5440                let first = hits
5441                    .first()
5442                    .map(|param| param.value.split(separator).count());
5443                let triggered = first.is_some_and(|length| {
5444                    hits.iter()
5445                        .any(|param| param.value.split(separator).count() != length)
5446                });
5447                (
5448                    triggered,
5449                    if triggered {
5450                        hits.iter().map(|param| param.target()).collect()
5451                    } else {
5452                        Vec::new()
5453                    },
5454                )
5455            }
5456            Assertion::EqualOccurrences { params: names } => {
5457                let counts: Vec<_> = names
5458                    .iter()
5459                    .map(|name| {
5460                        params
5461                            .iter()
5462                            .filter(|field| !field.missing && field.name.as_ref() == name)
5463                            .count()
5464                    })
5465                    .collect();
5466                let triggered = counts.windows(2).any(|counts| counts[0] != counts[1]);
5467                let targets = if triggered {
5468                    let hits: Vec<_> = params
5469                        .iter()
5470                        .filter(|field| {
5471                            !field.missing && names.iter().any(|name| field.name.as_ref() == name)
5472                        })
5473                        .map(RawParam::target)
5474                        .collect();
5475                    if hits.is_empty() {
5476                        vec![scope.fallback()]
5477                    } else {
5478                        hits
5479                    }
5480                } else {
5481                    Vec::new()
5482                };
5483                (triggered, targets)
5484            }
5485            Assertion::MaxOccurrences {
5486                param,
5487                max_occurrences,
5488                bare_url_only,
5489            } => {
5490                if *bare_url_only && (scope.complete_http_request || scope.body.is_some()) {
5491                    return;
5492                }
5493                let mut targets: Vec<_> = params
5494                    .iter()
5495                    .filter(|field| !field.missing && field.name.as_ref() == param)
5496                    .map(RawParam::target)
5497                    .collect();
5498                // URL parameter contracts normally read key=value pairs. A
5499                // cardinality bound also counts a submitted key without '='.
5500                if scope.body.is_none()
5501                    && scope.query.is_none()
5502                    && matches!(
5503                        self.param_style,
5504                        ParamStyle::Query | ParamStyle::QuerySemicolon
5505                    )
5506                {
5507                    let names = self
5508                        .params
5509                        .iter()
5510                        .find(|compiled| compiled.contract.name == *param)
5511                        .map(|compiled| compiled.names.as_slice());
5512                    targets.extend(bare_query_targets(
5513                        scope.artifact,
5514                        self.param_style,
5515                        param,
5516                        names,
5517                    ));
5518                }
5519                let triggered = targets.len() > *max_occurrences;
5520                (triggered, if triggered { targets } else { Vec::new() })
5521            }
5522            Assertion::FtrackFingerprint { indexed, hash } => {
5523                let (Some(indexed_field), Some(hash_field)) = (named(indexed), named(hash)) else {
5524                    return;
5525                };
5526                if indexed_field.json_kind != Some(JsonValueKind::String)
5527                    || hash_field.json_kind != Some(JsonValueKind::Number)
5528                    || contains_macro(indexed_field.value.as_ref())
5529                {
5530                    return;
5531                }
5532                let Some(hash_value) = hash_field.value.parse::<f64>().ok().filter(|value| {
5533                    value.is_finite()
5534                        && *value >= 0.0
5535                        && *value <= f64::from(u32::MAX)
5536                        && value.fract() == 0.0
5537                }) else {
5538                    return;
5539                };
5540                match crate::ftrack_fingerprint::check(
5541                    indexed_field.value.as_ref(),
5542                    hash_value as u32,
5543                ) {
5544                    Some(crate::ftrack_fingerprint::FingerprintCheck::Unvalidated) => {
5545                        violations.push(Violation {
5546                            code: format!("{}.unvalidated", rule.code),
5547                            message: "The indexed fingerprint is outside the pinned d9core profile, has ambiguous delimiter boundaries, or exceeds the local decoder resource bound. Hash parity remains unvalidated; no receiver rejection is inferred.".into(),
5548                            severity: Severity::Info,
5549                            field: Some(scope.field(indexed)),
5550                            fix_hint: None,
5551                            source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
5552                            targets: vec![indexed_field.target()],
5553                        });
5554                        return;
5555                    }
5556                    Some(crate::ftrack_fingerprint::FingerprintCheck::Mismatch) => {
5557                        (true, vec![indexed_field.target(), hash_field.target()])
5558                    }
5559                    _ => (false, Vec::new()),
5560                }
5561            }
5562            Assertion::DelimitedSum {
5563                param,
5564                total,
5565                separator,
5566                value_separator,
5567            } => {
5568                let mut targets = Vec::new();
5569                for parts in hits_named(std::slice::from_ref(param)) {
5570                    for amount in hits_named(std::slice::from_ref(total)) {
5571                        if !parts.container
5572                            && !amount.container
5573                            && !contains_macro(parts.value.as_ref())
5574                            && !contains_macro(amount.value.as_ref())
5575                            && crate::decimal_sum::sum_matches(
5576                                parts.value.as_ref(),
5577                                amount.value.as_ref(),
5578                                separator,
5579                                value_separator,
5580                            ) == Some(false)
5581                        {
5582                            targets.extend([parts.target(), amount.target()]);
5583                        }
5584                    }
5585                }
5586                (!targets.is_empty(), targets)
5587            }
5588            Assertion::AwinBasket { parts, check } => {
5589                use crate::awin_basket::{BasketIssueKind, BasketParam};
5590                let pairs: Vec<_> = params
5591                    .iter()
5592                    .filter(|param| live(param))
5593                    .map(|param| BasketParam {
5594                        name: param.name.as_ref(),
5595                        value: param.value.as_ref(),
5596                        start: param.start,
5597                        end: param.end,
5598                    })
5599                    .collect();
5600                let group_parts = named(parts);
5601                let issues = crate::awin_basket::inspect_basket(
5602                    &pairs,
5603                    group_parts.map(|param| param.value.as_ref()),
5604                );
5605                let mut targets: Vec<_> = issues
5606                    .into_iter()
5607                    .filter(|issue| match check {
5608                        AwinBasketCheck::IndexSequence => matches!(
5609                            issue.kind,
5610                            BasketIssueKind::IndexGap | BasketIssueKind::DuplicateIndex
5611                        ),
5612                        AwinBasketCheck::GroupMembership => {
5613                            issue.kind == BasketIssueKind::GroupMissing
5614                        }
5615                    })
5616                    .map(|issue| ViolationTarget {
5617                        component: ViolationTargetComponent::QueryParam,
5618                        name: Some(issue.name),
5619                        value: Some(issue.value),
5620                        start: issue.start,
5621                        end: issue.end,
5622                    })
5623                    .collect();
5624                if !targets.is_empty()
5625                    && *check == AwinBasketCheck::GroupMembership
5626                    && let Some(parts) = group_parts
5627                {
5628                    targets.push(parts.target());
5629                }
5630                (!targets.is_empty(), targets)
5631            }
5632            Assertion::GppSections { param, sections } => {
5633                let mut ids = Vec::new();
5634                let mut id_targets = Vec::new();
5635                for field in hits_named(std::slice::from_ref(sections)) {
5636                    if field.container {
5637                        if let (Some(body), Some(path)) = (&scope.body, &field.location) {
5638                            for path in body.document.expand(&format!("{path}[]")) {
5639                                if let Some(id) = body.document.get(&path)
5640                                    && id.kind == JsonValueKind::Number
5641                                    && let Some(id) = canonical_gpp_section_id(id.text.as_ref())
5642                                {
5643                                    ids.push(id);
5644                                }
5645                            }
5646                        }
5647                    } else if !field.value.is_empty() && !contains_macro(field.value.as_ref()) {
5648                        ids.extend(field.value.split(',').map(str::to_string));
5649                    }
5650                    id_targets.push(field.target());
5651                }
5652                let mut targets = Vec::new();
5653                if !ids.is_empty() {
5654                    let ids = ids.join(",");
5655                    for field in hits_named(std::slice::from_ref(param)) {
5656                        if field.container
5657                            || contains_macro(field.value.as_ref())
5658                            || field.value.eq_ignore_ascii_case("REDACTED")
5659                        {
5660                            continue;
5661                        }
5662                        if let Ok(structure) =
5663                            crate::gpp_structure::validate_gpp_structure(field.value.as_ref())
5664                            && crate::gpp_structure::gpp_sid_mismatch(&ids, &structure).is_some()
5665                        {
5666                            targets.push(field.target());
5667                            targets.extend(id_targets.iter().cloned());
5668                        }
5669                    }
5670                }
5671                (!targets.is_empty(), targets)
5672            }
5673            Assertion::TcfConsent {
5674                param,
5675                vendor_id,
5676                purpose_ids,
5677            } => {
5678                let targets: Vec<_> = hits_named(std::slice::from_ref(param))
5679                    .into_iter()
5680                    .filter(|field| {
5681                        !field.container
5682                            && !contains_macro(field.value.as_ref())
5683                            && crate::tcf_sections::tcf_consent_requirements(
5684                                field.value.as_ref(),
5685                                *vendor_id,
5686                                purpose_ids,
5687                            ) == Ok(false)
5688                    })
5689                    .map(RawParam::target)
5690                    .collect();
5691                (!targets.is_empty(), targets)
5692            }
5693            Assertion::GppFieldValues {
5694                param,
5695                section_param,
5696                field,
5697                values,
5698            } => {
5699                let ids: Vec<_> = hits_named(std::slice::from_ref(section_param))
5700                    .into_iter()
5701                    .filter(|field| !contains_macro(field.value.as_ref()))
5702                    .flat_map(|field| {
5703                        if field.container {
5704                            scope
5705                                .body
5706                                .as_ref()
5707                                .zip(field.location.as_ref())
5708                                .map(|(body, path)| {
5709                                    body.document
5710                                        .expand(&format!("{path}[]"))
5711                                        .iter()
5712                                        .filter_map(|path| body.document.get(path))
5713                                        .filter_map(|id| {
5714                                            canonical_gpp_section_id(id.text.as_ref())
5715                                                .and_then(|value| value.parse::<u32>().ok())
5716                                        })
5717                                        .collect::<Vec<_>>()
5718                                })
5719                                .unwrap_or_default()
5720                        } else {
5721                            field
5722                                .value
5723                                .split(',')
5724                                .filter_map(|id| id.parse::<u32>().ok())
5725                                .collect()
5726                        }
5727                    })
5728                    .collect();
5729                let targets: Vec<_> = hits_named(std::slice::from_ref(param))
5730                    .into_iter()
5731                    .filter(|signal| {
5732                        !signal.container
5733                            && !contains_macro(signal.value.as_ref())
5734                            && crate::gpp_structure::decoded_us_sections(signal.value.as_ref())
5735                                .iter()
5736                                .any(|section| {
5737                                    ids.contains(&section.section_id)
5738                                        && section.fields.get(field).is_some_and(|choices| {
5739                                            choices.iter().any(|choice| !values.contains(choice))
5740                                        })
5741                                })
5742                    })
5743                    .map(RawParam::target)
5744                    .collect();
5745                (!targets.is_empty(), targets)
5746            }
5747            Assertion::UniqueArrayBy { param, field } => {
5748                let mut duplicates = Vec::new();
5749                if let Some(body) = &scope.body {
5750                    for array in hits_named(std::slice::from_ref(param)) {
5751                        let Some(path) = &array.location else {
5752                            continue;
5753                        };
5754                        let mut groups: BTreeMap<(u8, String), Vec<ViolationTarget>> =
5755                            BTreeMap::new();
5756                        for item in body.document.expand(&format!("{path}[]")) {
5757                            let member = json::member_path(&item, field);
5758                            let Some(value) = body.document.get(&member) else {
5759                                continue;
5760                            };
5761                            if contains_macro(value.text.as_ref()) {
5762                                continue;
5763                            }
5764                            let key = match value.kind {
5765                                JsonValueKind::String => (0, value.text.to_string()),
5766                                JsonValueKind::Bool => (1, value.text.to_string()),
5767                                JsonValueKind::Number => {
5768                                    decimal_parts(value.text.as_ref()).map_or_else(
5769                                        // Exponents beyond the exact normalizer's range still
5770                                        // identify repeated identical JSON number spellings.
5771                                        || (3, value.text.to_string()),
5772                                        |(negative, digits, exponent)| {
5773                                            (2, format!("{negative}:{digits}:{exponent}"))
5774                                        },
5775                                    )
5776                                }
5777                                _ => continue,
5778                            };
5779                            groups.entry(key).or_default().push(body_target(
5780                                body.document,
5781                                &member,
5782                                scope.artifact,
5783                            ));
5784                        }
5785                        duplicates
5786                            .extend(groups.into_values().filter(|hits| hits.len() > 1).flatten());
5787                    }
5788                }
5789                (!duplicates.is_empty(), duplicates)
5790            }
5791            Assertion::MaxTotalArrayItems {
5792                params: names,
5793                max_items,
5794            } => {
5795                let arrays: Vec<_> = hits_named(names)
5796                    .into_iter()
5797                    .filter_map(|field| {
5798                        let body = scope.body.as_ref()?;
5799                        let path = field.location.as_ref()?;
5800                        let value = body.document.get(path)?;
5801                        (value.kind == JsonValueKind::Array).then_some((field, value.len?))
5802                    })
5803                    .collect();
5804                let total = arrays
5805                    .iter()
5806                    .try_fold(0usize, |total, (_, count)| total.checked_add(*count));
5807                let triggered = total.is_none_or(|total| total > *max_items);
5808                (
5809                    triggered,
5810                    if triggered {
5811                        arrays
5812                            .into_iter()
5813                            .map(|(field, _)| field.target())
5814                            .collect()
5815                    } else {
5816                        Vec::new()
5817                    },
5818                )
5819            }
5820            Assertion::EqualArrayLengths { params: names } => {
5821                let arrays: Vec<_> = hits_named(names)
5822                    .into_iter()
5823                    .filter_map(|field| {
5824                        let body = scope.body.as_ref()?;
5825                        let path = field.location.as_ref()?;
5826                        let value = body.document.get(path)?;
5827                        (value.kind == JsonValueKind::Array).then_some((field, value.len?))
5828                    })
5829                    .collect();
5830                let triggered = arrays
5831                    .first()
5832                    .is_some_and(|(_, count)| arrays.iter().any(|(_, other)| count != other));
5833                (
5834                    triggered,
5835                    if triggered {
5836                        arrays
5837                            .into_iter()
5838                            .map(|(field, _)| field.target())
5839                            .collect()
5840                    } else {
5841                        Vec::new()
5842                    },
5843                )
5844            }
5845            Assertion::TimeWindow {
5846                param,
5847                fallback_param,
5848                unit,
5849                max_age_seconds,
5850                min_age_seconds,
5851                max_future_seconds,
5852            } => {
5853                let effective_param = if params
5854                    .iter()
5855                    .any(|candidate| !candidate.missing && candidate.name.as_ref() == param)
5856                {
5857                    param.as_str()
5858                } else {
5859                    fallback_param.as_deref().unwrap_or(param)
5860                };
5861                let hits: Vec<_> = params
5862                    .iter()
5863                    .filter(|candidate| {
5864                        if candidate.name.as_ref() != effective_param
5865                            || !live(candidate)
5866                            || candidate.container
5867                            || contains_macro(candidate.value.as_ref())
5868                        {
5869                            return false;
5870                        }
5871                        if *unit == TimestampUnit::Microseconds {
5872                            if candidate.value.parse::<serde_json::Number>().is_err() {
5873                                return false;
5874                            }
5875                            let reference = i128::from(scope.reference_time_unix_seconds);
5876                            let too_old = max_age_seconds.is_some_and(|age| {
5877                                let bound = ((reference - i128::from(age)) * 1_000_000).to_string();
5878                                compare_numeric_text(candidate.value.as_ref(), &bound)
5879                                    .is_none_or(|order| order == std::cmp::Ordering::Less)
5880                            });
5881                            let too_new = max_future_seconds.is_some_and(|future| {
5882                                let bound =
5883                                    ((reference + i128::from(future)) * 1_000_000).to_string();
5884                                compare_numeric_text(candidate.value.as_ref(), &bound)
5885                                    .is_none_or(|order| order == std::cmp::Ordering::Greater)
5886                            });
5887                            let too_recent = min_age_seconds.is_some_and(|age| {
5888                                let bound = ((reference - i128::from(age)) * 1_000_000).to_string();
5889                                compare_numeric_text(candidate.value.as_ref(), &bound)
5890                                    .is_none_or(|order| order == std::cmp::Ordering::Greater)
5891                            });
5892                            return too_old || too_new || too_recent;
5893                        }
5894                        let Some(time) =
5895                            crate::timestamp::timestamp_millis(candidate.value.as_ref(), *unit)
5896                        else {
5897                            // A numeric timestamp that cannot be represented must
5898                            // not bypass the window. Non-numeric syntax belongs to
5899                            // the field's native type and scalar format contracts.
5900                            return !matches!(
5901                                unit,
5902                                TimestampUnit::Datetime | TimestampUnit::DatetimeUtc
5903                            ) && candidate.value.parse::<serde_json::Number>().is_ok();
5904                        };
5905                        let delta =
5906                            i128::from(time) - i128::from(scope.reference_time_unix_seconds) * 1000;
5907                        min_age_seconds.is_some_and(|age| delta > -(i128::from(age) * 1000))
5908                            || max_age_seconds.is_some_and(|age| delta < -(i128::from(age) * 1000))
5909                            || max_future_seconds
5910                                .is_some_and(|future| delta > i128::from(future) * 1000)
5911                    })
5912                    .collect();
5913                (
5914                    !hits.is_empty(),
5915                    hits.iter().map(|param| param.target()).collect(),
5916                )
5917            }
5918            Assertion::MinLengthFrom {
5919                params: names,
5920                length_param,
5921                default_length,
5922            } => {
5923                let min = match named(length_param) {
5924                    Some(field) if contains_macro(field.value.as_ref()) => return,
5925                    Some(field) => match decimal_usize(field.value.as_ref()) {
5926                        Some(length) => length,
5927                        None => return,
5928                    },
5929                    None => *default_length,
5930                };
5931                let hits: Vec<_> = hits_named(names)
5932                    .into_iter()
5933                    .filter(|field| {
5934                        !field.container
5935                            && !contains_macro(field.value.as_ref())
5936                            && field.value.chars().count() < min
5937                    })
5938                    .collect();
5939                (
5940                    !hits.is_empty(),
5941                    hits.iter().map(|field| field.target()).collect(),
5942                )
5943            }
5944        };
5945
5946        if triggered {
5947            violations.push(Violation {
5948                code: rule.code.clone(),
5949                message: rule.message.clone(),
5950                severity: rule.severity,
5951                field: None,
5952                fix_hint: rule.fix_hint.clone(),
5953                source: self.source_for(compiled.source_level, compiled.doc.as_deref()),
5954                targets,
5955            });
5956        }
5957    }
5958}
5959
5960fn normalize_alias(name: &mut String, contracts: &[ParamContract]) {
5961    if let Some(contract) = contracts
5962        .iter()
5963        .find(|contract| contract.aliases.contains(name))
5964    {
5965        *name = contract.name.clone();
5966    }
5967}
5968
5969fn normalize_condition_aliases(condition: &mut RuleCondition, contracts: &[ParamContract]) {
5970    match condition {
5971        RuleCondition::Exists { param }
5972        | RuleCondition::Present { param }
5973        | RuleCondition::ValueIn { param, .. }
5974        | RuleCondition::ValuePattern { param, .. }
5975        | RuleCondition::JsonType { param, .. } => normalize_alias(param, contracts),
5976        RuleCondition::AllValuesIn { params, .. } => {
5977            for param in params {
5978                normalize_alias(param, contracts);
5979            }
5980        }
5981        RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
5982            for condition in conditions {
5983                normalize_condition_aliases(condition, contracts);
5984            }
5985        }
5986        RuleCondition::Not { condition } => normalize_condition_aliases(condition, contracts),
5987    }
5988}
5989
5990fn normalize_param_aliases(params: &mut [RawParam<'_>], contracts: &[CompiledParam]) {
5991    for param in params {
5992        if let Some(contract) = contracts.iter().find(|contract| {
5993            contract
5994                .contract
5995                .aliases
5996                .iter()
5997                .any(|alias| alias == param.name.as_ref())
5998        }) {
5999            if param.location.is_none() {
6000                param.location = Some(param.name.to_string());
6001            }
6002            param.name = Cow::Owned(contract.contract.name.clone());
6003        }
6004    }
6005}
6006
6007fn describe(message: String, description: Option<&str>) -> String {
6008    match description {
6009        Some(description) if !description.trim().is_empty() => {
6010            format!("{message} {description}")
6011        }
6012        _ => message,
6013    }
6014}
6015
6016fn http_capture_path_unavailable(document: &JsonDocument<'_>, path: &str) -> bool {
6017    document
6018        .expand("capture_unavailable_paths[]")
6019        .iter()
6020        .filter_map(|entry| document.get(entry))
6021        .any(|entry| {
6022            let missing = entry.text.as_ref();
6023            let reads_missing = path == missing
6024                || path
6025                    .strip_prefix(missing)
6026                    .is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['));
6027            // A partially observed header list still establishes the values it has.
6028            reads_missing
6029                && !(missing == "headers"
6030                    && path != "headers"
6031                    && document
6032                        .expand(path)
6033                        .iter()
6034                        .any(|concrete| document.contains(concrete)))
6035        })
6036}
6037
6038fn http_capture_param_paths(scope: &str, param: &CompiledParam) -> Vec<String> {
6039    if let Some(root) = &param.contract.root_path {
6040        return vec![root.clone()];
6041    }
6042    param
6043        .names
6044        .iter()
6045        .map(|name| {
6046            if scope.is_empty() {
6047                name.clone()
6048            } else {
6049                format!("{scope}.{name}")
6050            }
6051        })
6052        .collect()
6053}
6054
6055fn http_capture_param_has_unknown(
6056    document: &JsonDocument<'_>,
6057    scope: &str,
6058    param: &CompiledParam,
6059) -> bool {
6060    http_capture_param_paths(scope, param)
6061        .iter()
6062        .any(|path| http_capture_path_unavailable(document, path))
6063}
6064
6065fn http_capture_param_unavailable(
6066    document: &JsonDocument<'_>,
6067    scope: &str,
6068    param: &CompiledParam,
6069) -> bool {
6070    let paths = http_capture_param_paths(scope, param);
6071    // An omitted alias cannot suppress a format check on an observed value.
6072    let observed = paths.iter().any(|path| {
6073        !http_capture_path_unavailable(document, path)
6074            && document
6075                .expand(path)
6076                .iter()
6077                .any(|concrete| document.contains(concrete))
6078    });
6079    !observed
6080        && paths
6081            .iter()
6082            .any(|path| http_capture_path_unavailable(document, path))
6083}
6084
6085fn http_capture_condition(
6086    condition: &RuleCondition,
6087    params: &[RawParam<'_>],
6088    unknown: &impl Fn(&str) -> bool,
6089) -> Option<bool> {
6090    match condition {
6091        RuleCondition::All { conditions } => {
6092            let values: Vec<_> = conditions
6093                .iter()
6094                .map(|condition| http_capture_condition(condition, params, unknown))
6095                .collect();
6096            if values.contains(&Some(false)) {
6097                Some(false)
6098            } else if values.contains(&None) {
6099                None
6100            } else {
6101                Some(true)
6102            }
6103        }
6104        RuleCondition::Any { conditions } => {
6105            let values: Vec<_> = conditions
6106                .iter()
6107                .map(|condition| http_capture_condition(condition, params, unknown))
6108                .collect();
6109            if values.contains(&Some(true)) {
6110                Some(true)
6111            } else if values.contains(&None) {
6112                None
6113            } else {
6114                Some(false)
6115            }
6116        }
6117        RuleCondition::Not { condition } => {
6118            http_capture_condition(condition, params, unknown).map(|value| !value)
6119        }
6120        RuleCondition::AllValuesIn { params: names, .. } => {
6121            let value = condition.evaluate(params);
6122            let submitted = params.iter().any(|field| {
6123                !field.missing
6124                    && !field.container
6125                    && names.iter().any(|name| name == field.name.as_ref())
6126            });
6127            if value == Some(false) && submitted {
6128                Some(false)
6129            } else if names.iter().any(|name| unknown(name)) {
6130                None
6131            } else {
6132                value
6133            }
6134        }
6135        _ => {
6136            let value = condition.evaluate(params);
6137            if value == Some(true) {
6138                value
6139            } else if condition.params().iter().any(|name| unknown(name)) {
6140                None
6141            } else {
6142                value
6143            }
6144        }
6145    }
6146}
6147
6148fn http_param_reads_body(param: &CompiledParam) -> bool {
6149    param.contract.root_path.as_ref().is_some_and(|path| {
6150        path == "body" || path.starts_with("body.") || path.starts_with("body[")
6151    }) || param
6152        .names
6153        .iter()
6154        .any(|name| name == "body" || name.starts_with("body.") || name.starts_with("body["))
6155}
6156
6157fn assertion_requires_presence(assertion: &Assertion) -> bool {
6158    matches!(
6159        assertion,
6160        Assertion::RequireOneOf { .. }
6161            | Assertion::RequireAnyOf { .. }
6162            | Assertion::RequiredWith { .. }
6163            | Assertion::RequiredWhenValue { .. }
6164    )
6165}
6166
6167fn assertion_params(assertion: &Assertion) -> Vec<&String> {
6168    match assertion {
6169        Assertion::RequireOneOf { params } | Assertion::MutuallyExclusive { params } => {
6170            params.iter().collect()
6171        }
6172        Assertion::RequiredWith { when, requires }
6173        | Assertion::RequiredWhenValue { when, requires, .. } => {
6174            let mut names = vec![when];
6175            names.extend(requires.iter());
6176            names
6177        }
6178        Assertion::ForbidValuePattern { params, .. } => params.iter().collect(),
6179        Assertion::ValueWhen { when, param, .. } => vec![when, param],
6180        Assertion::FormatWhen { when, param, .. } => vec![when, param],
6181        Assertion::Format { param, .. } => vec![param],
6182        Assertion::RequireAnyOf { groups, .. } => groups.iter().flatten().collect(),
6183        Assertion::LastParam { param } => vec![param],
6184        Assertion::MaxQueryLength { .. }
6185        | Assertion::MaxBodyBytes { .. }
6186        | Assertion::RequireHttps
6187        | Assertion::MaxUrlLength { .. } => Vec::new(),
6188        Assertion::ValueWith { when, param, .. } => vec![when, param],
6189        Assertion::LessEqual { left, right, .. } | Assertion::EqualValues { left, right } => {
6190            vec![left, right]
6191        }
6192        Assertion::UrlPathLength { param, .. } => vec![param],
6193        Assertion::IndexedParamGroups {
6194            params,
6195            optional_params,
6196            ..
6197        } => params.iter().chain(optional_params).collect(),
6198        Assertion::EqualSplitLengths { params, .. } => params.iter().collect(),
6199        Assertion::DelimitedSum { param, total, .. } => vec![param, total],
6200        Assertion::FtrackFingerprint { indexed, hash } => vec![indexed, hash],
6201        Assertion::AwinBasket { parts, .. } => vec![parts],
6202        Assertion::GppSections { param, sections } => vec![param, sections],
6203        Assertion::TcfConsent { param, .. } => vec![param],
6204        Assertion::GppFieldValues {
6205            param,
6206            section_param,
6207            ..
6208        } => vec![param, section_param],
6209        Assertion::EqualArrayLengths { params } | Assertion::MaxTotalArrayItems { params, .. } => {
6210            params.iter().collect()
6211        }
6212        Assertion::UniqueArrayBy { param, .. } => vec![param],
6213        Assertion::EqualOccurrences { params } => params.iter().collect(),
6214        Assertion::MaxOccurrences { param, .. } => vec![param],
6215        Assertion::TimeWindow {
6216            param,
6217            fallback_param,
6218            ..
6219        } => std::iter::once(param)
6220            .chain(fallback_param.iter())
6221            .collect(),
6222        Assertion::MinLengthFrom {
6223            params,
6224            length_param,
6225            ..
6226        } => params.iter().chain(std::iter::once(length_param)).collect(),
6227        Assertion::ForbiddenWhenValue { when, params, .. } => {
6228            let mut names = vec![when];
6229            names.extend(params.iter());
6230            names
6231        }
6232    }
6233}
6234
6235fn assertion_params_mut(assertion: &mut Assertion) -> Vec<&mut String> {
6236    match assertion {
6237        Assertion::RequireOneOf { params } | Assertion::MutuallyExclusive { params } => {
6238            params.iter_mut().collect()
6239        }
6240        Assertion::RequiredWith { when, requires }
6241        | Assertion::RequiredWhenValue { when, requires, .. } => {
6242            let mut names = vec![when];
6243            names.extend(requires.iter_mut());
6244            names
6245        }
6246        Assertion::ForbidValuePattern { params, .. } => params.iter_mut().collect(),
6247        Assertion::ValueWhen { when, param, .. } => vec![when, param],
6248        Assertion::FormatWhen { when, param, .. } => vec![when, param],
6249        Assertion::Format { param, .. } => vec![param],
6250        Assertion::RequireAnyOf { groups, .. } => groups.iter_mut().flatten().collect(),
6251        Assertion::LastParam { param } => vec![param],
6252        Assertion::MaxQueryLength { .. }
6253        | Assertion::MaxBodyBytes { .. }
6254        | Assertion::RequireHttps
6255        | Assertion::MaxUrlLength { .. } => Vec::new(),
6256        Assertion::ValueWith { when, param, .. } => vec![when, param],
6257        Assertion::LessEqual { left, right, .. } | Assertion::EqualValues { left, right } => {
6258            vec![left, right]
6259        }
6260        Assertion::UrlPathLength { param, .. } => vec![param],
6261        Assertion::IndexedParamGroups {
6262            params,
6263            optional_params,
6264            ..
6265        } => params.iter_mut().chain(optional_params).collect(),
6266        Assertion::EqualSplitLengths { params, .. } => params.iter_mut().collect(),
6267        Assertion::DelimitedSum { param, total, .. } => vec![param, total],
6268        Assertion::FtrackFingerprint { indexed, hash } => vec![indexed, hash],
6269        Assertion::AwinBasket { parts, .. } => vec![parts],
6270        Assertion::GppSections { param, sections } => vec![param, sections],
6271        Assertion::TcfConsent { param, .. } => vec![param],
6272        Assertion::GppFieldValues {
6273            param,
6274            section_param,
6275            ..
6276        } => vec![param, section_param],
6277        Assertion::EqualArrayLengths { params } | Assertion::MaxTotalArrayItems { params, .. } => {
6278            params.iter_mut().collect()
6279        }
6280        Assertion::UniqueArrayBy { param, .. } => vec![param],
6281        Assertion::EqualOccurrences { params } => params.iter_mut().collect(),
6282        Assertion::MaxOccurrences { param, .. } => vec![param],
6283        Assertion::TimeWindow {
6284            param,
6285            fallback_param,
6286            ..
6287        } => std::iter::once(param)
6288            .chain(fallback_param.iter_mut())
6289            .collect(),
6290        Assertion::MinLengthFrom {
6291            params,
6292            length_param,
6293            ..
6294        } => params
6295            .iter_mut()
6296            .chain(std::iter::once(length_param))
6297            .collect(),
6298        Assertion::ForbiddenWhenValue { when, params, .. } => {
6299            let mut names = vec![when];
6300            names.extend(params.iter_mut());
6301            names
6302        }
6303    }
6304}
6305
6306fn require_citation(
6307    pack_id: &str,
6308    rule: &str,
6309    level: RuleSourceLevel,
6310    doc: Option<&str>,
6311) -> Result<(), ManifestError> {
6312    let cited = doc.is_some_and(|doc| doc.starts_with("http://") || doc.starts_with("https://"));
6313
6314    if level == RuleSourceLevel::OfficialVendor && !cited {
6315        return Err(ManifestError::MissingCitation {
6316            pack_id: pack_id.to_string(),
6317            rule: rule.to_string(),
6318        });
6319    }
6320
6321    Ok(())
6322}
6323
6324fn compile_regex(pack_id: &str, pattern: &str) -> Result<Regex, ManifestError> {
6325    Regex::new(pattern).map_err(|error| ManifestError::InvalidRegex {
6326        pack_id: pack_id.to_string(),
6327        pattern: pattern.to_string(),
6328        error: error.to_string(),
6329    })
6330}
6331
6332fn exact_param_names(params: &[CompiledParam]) -> BTreeSet<String> {
6333    params
6334        .iter()
6335        .filter(|compiled| compiled.name_regex.is_none())
6336        .flat_map(|compiled| compiled.names.iter().cloned())
6337        .collect()
6338}
6339
6340fn finding_name<'a>(compiled: &'a CompiledParam, param: &'a RawParam<'_>) -> &'a str {
6341    if compiled.contract.name.is_empty() {
6342        return "root";
6343    }
6344    if compiled.name_regex.is_some() {
6345        param.name.as_ref()
6346    } else {
6347        compiled.contract.name.as_str()
6348    }
6349}
6350
6351fn validate_pack_id(id: &str) -> Result<(), ManifestError> {
6352    let valid = !id.is_empty()
6353        && id.split('/').all(|segment| {
6354            !segment.is_empty()
6355                && segment.chars().all(|character| {
6356                    character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
6357                })
6358        });
6359
6360    if valid {
6361        Ok(())
6362    } else {
6363        Err(ManifestError::InvalidId(id.to_string()))
6364    }
6365}
6366
6367/// Compare integer bounds without rounding them through f64 first.
6368fn compare_numbers(
6369    left: &serde_json::Number,
6370    right: &serde_json::Number,
6371) -> Option<std::cmp::Ordering> {
6372    compare_numeric_text(&left.to_string(), &right.to_string())
6373}
6374
6375// Normalize decimal text without rounding a JSON number through a float.
6376fn decimal_parts(value: &str) -> Option<(bool, String, i64)> {
6377    let _number = value.parse::<serde_json::Number>().ok()?;
6378    let negative = value.starts_with('-');
6379    let unsigned = value.strip_prefix('-').unwrap_or(value);
6380    let (significand, exponent) = match unsigned.split_once(['e', 'E']) {
6381        Some((significand, exponent)) => (significand, exponent.parse::<i64>().ok()?),
6382        None => (unsigned, 0),
6383    };
6384    let fraction_len = significand
6385        .split_once('.')
6386        .map_or(0, |(_, fraction)| fraction.len());
6387    let digits = significand.replace('.', "");
6388    let digits = digits.trim_start_matches('0');
6389    if digits.is_empty() {
6390        return Some((false, "0".to_string(), 0));
6391    }
6392    let stripped = digits.trim_end_matches('0');
6393    let exponent = exponent
6394        .checked_sub(i64::try_from(fraction_len).ok()?)?
6395        .checked_add(i64::try_from(digits.len() - stripped.len()).ok()?)?;
6396    Some((negative, stripped.to_string(), exponent))
6397}
6398
6399fn compare_numeric_text(left: &str, right: &str) -> Option<std::cmp::Ordering> {
6400    let (left_negative, mut left_digits, left_exp) = decimal_parts(left)?;
6401    let (right_negative, mut right_digits, right_exp) = decimal_parts(right)?;
6402    if left_negative != right_negative {
6403        return Some(if left_negative {
6404            std::cmp::Ordering::Less
6405        } else {
6406            std::cmp::Ordering::Greater
6407        });
6408    }
6409    let order = match (left_digits == "0", right_digits == "0") {
6410        (true, true) => std::cmp::Ordering::Equal,
6411        (true, false) => std::cmp::Ordering::Less,
6412        (false, true) => std::cmp::Ordering::Greater,
6413        (false, false) => {
6414            let left_places = i64::try_from(left_digits.len())
6415                .ok()?
6416                .checked_add(left_exp)?;
6417            let right_places = i64::try_from(right_digits.len())
6418                .ok()?
6419                .checked_add(right_exp)?;
6420            let magnitude = left_places.cmp(&right_places);
6421            if magnitude == std::cmp::Ordering::Equal {
6422                let width = left_digits.len().max(right_digits.len());
6423                left_digits.extend(std::iter::repeat_n('0', width - left_digits.len()));
6424                right_digits.extend(std::iter::repeat_n('0', width - right_digits.len()));
6425                left_digits.cmp(&right_digits)
6426            } else {
6427                magnitude
6428            }
6429        }
6430    };
6431    Some(if left_negative {
6432        order.reverse()
6433    } else {
6434        order
6435    })
6436}
6437
6438fn compare_ordered_values(
6439    left: &str,
6440    right: &str,
6441    unit: Option<TimestampUnit>,
6442) -> Option<std::cmp::Ordering> {
6443    match unit {
6444        Some(unit @ (TimestampUnit::Datetime | TimestampUnit::DatetimeUtc)) => {
6445            let left_seconds = crate::timestamp::timestamp_millis(left, unit)?.div_euclid(1000);
6446            let right_seconds = crate::timestamp::timestamp_millis(right, unit)?.div_euclid(1000);
6447            let order = left_seconds.cmp(&right_seconds);
6448            if order != std::cmp::Ordering::Equal {
6449                return Some(order);
6450            }
6451            let fraction = |value: &str| {
6452                value
6453                    .split_once('.')
6454                    .map(|(_, fraction)| {
6455                        let count = fraction.bytes().take_while(u8::is_ascii_digit).count();
6456                        format!("0.{}", &fraction[..count])
6457                    })
6458                    .unwrap_or_else(|| "0".to_string())
6459            };
6460            compare_numeric_text(&fraction(left), &fraction(right))
6461        }
6462        Some(TimestampUnit::SecondsOrMilliseconds) => Some(
6463            crate::timestamp::timestamp_millis(left, TimestampUnit::SecondsOrMilliseconds)?.cmp(
6464                &crate::timestamp::timestamp_millis(right, TimestampUnit::SecondsOrMilliseconds)?,
6465            ),
6466        ),
6467        _ => compare_numeric_text(left, right),
6468    }
6469}
6470
6471fn url_path_character_count(value: &str) -> usize {
6472    // Count the submitted path rather than URL::path(), which percent-encodes
6473    // Unicode and can change the number of characters on the wire.
6474    let without_suffix = value.split(['?', '#']).next().unwrap_or(value);
6475    let path = if let Some((_, authority)) = without_suffix.split_once("://") {
6476        authority.find('/').map_or("/", |start| &authority[start..])
6477    } else if let Some(authority) = without_suffix.strip_prefix("//") {
6478        authority.find('/').map_or("/", |start| &authority[start..])
6479    } else {
6480        without_suffix
6481    };
6482    path.chars().count()
6483}
6484
6485pub(crate) fn decode_base64_json(value: &str) -> Result<String, String> {
6486    String::from_utf8(decode_base64_bytes(value)?)
6487        .map_err(|_| "decoded base64 is not UTF-8 JSON".to_string())
6488}
6489
6490fn decode_base64_latin1_json(value: &str) -> Result<String, String> {
6491    Ok(decode_base64_bytes(value)?
6492        .into_iter()
6493        .map(char::from)
6494        .collect())
6495}
6496
6497fn decode_percent_encoded_json(value: &str) -> Result<String, String> {
6498    let mut output = Vec::with_capacity(value.len());
6499    let mut bytes = value.bytes();
6500    while let Some(byte) = bytes.next() {
6501        if byte == b'%' {
6502            let high = bytes
6503                .next()
6504                .and_then(|digit| char::from(digit).to_digit(16));
6505            let low = bytes
6506                .next()
6507                .and_then(|digit| char::from(digit).to_digit(16));
6508            let (Some(high), Some(low)) = (high, low) else {
6509                return Err("invalid percent escape in encoded JSON".into());
6510            };
6511            output.push((high * 16 + low) as u8);
6512        } else {
6513            output.push(byte);
6514        }
6515    }
6516    String::from_utf8(output).map_err(|_| "percent-decoded JSON is not UTF-8".into())
6517}
6518
6519fn decode_query_params_json(value: &str) -> Result<String, String> {
6520    let mut fields = serde_json::Map::new();
6521    for field in value.split('&').filter(|field| !field.is_empty()) {
6522        let (name, value) = field.split_once('=').unwrap_or((field, ""));
6523        let name = decode_percent_encoded_json(&name.replace('+', " "))?;
6524        let value =
6525            serde_json::Value::String(decode_percent_encoded_json(&value.replace('+', " "))?);
6526        match fields.entry(name) {
6527            serde_json::map::Entry::Vacant(entry) => {
6528                entry.insert(value);
6529            }
6530            serde_json::map::Entry::Occupied(mut entry) => {
6531                if let serde_json::Value::Array(values) = entry.get_mut() {
6532                    values.push(value);
6533                } else {
6534                    let first = entry.get().clone();
6535                    entry.insert(serde_json::Value::Array(vec![first, value]));
6536                }
6537            }
6538        }
6539    }
6540    serde_json::to_string(&fields).map_err(|error| error.to_string())
6541}
6542
6543pub(crate) fn decode_base64_bytes(value: &str) -> Result<Vec<u8>, String> {
6544    let data = value.trim_end_matches('=');
6545    let padding = value.len() - data.len();
6546    if padding > 2
6547        || data.len() % 4 == 1
6548        || (padding > 0 && (!value.len().is_multiple_of(4) || padding != (4 - data.len() % 4) % 4))
6549    {
6550        return Err("invalid base64 length or padding".to_string());
6551    }
6552    let mut output = Vec::with_capacity(data.len() * 3 / 4);
6553    let mut buffer = 0_u32;
6554    let mut bits = 0;
6555    for byte in data.bytes() {
6556        let value = match byte {
6557            b'A'..=b'Z' => byte - b'A',
6558            b'a'..=b'z' => byte - b'a' + 26,
6559            b'0'..=b'9' => byte - b'0' + 52,
6560            b'+' | b'-' => 62,
6561            b'/' | b'_' => 63,
6562            _ => return Err("invalid base64 character".to_string()),
6563        };
6564        buffer = (buffer << 6) | u32::from(value);
6565        bits += 6;
6566        if bits >= 8 {
6567            bits -= 8;
6568            output.push((buffer >> bits) as u8);
6569            buffer &= (1 << bits) - 1;
6570        }
6571    }
6572    if buffer != 0 {
6573        return Err("nonzero base64 padding bits".to_string());
6574    }
6575    Ok(output)
6576}
6577
6578fn exact_multiple(value: &str, step: &serde_json::Number) -> Option<bool> {
6579    let (_, digits, exponent) = decimal_parts(value)?;
6580    let (_, divisor, divisor_exponent) = decimal_parts(&step.to_string())?;
6581    if digits == "0" {
6582        return Some(true);
6583    }
6584    let powers = exponent.checked_sub(divisor_exponent)?;
6585    // Normalized digits have no trailing zero. An extra factor of ten in the
6586    // divisor therefore cannot divide the numerator exactly.
6587    if powers < 0 {
6588        return Some(false);
6589    }
6590    let divisor: u128 = divisor.parse().ok()?;
6591    let mut remainder = 0_u128;
6592    for digit in digits.bytes() {
6593        remainder = add_mod(
6594            multiply_mod(remainder, 10 % divisor, divisor),
6595            u128::from(digit - b'0') % divisor,
6596            divisor,
6597        );
6598    }
6599    let mut powers = u64::try_from(powers).ok()?;
6600    let mut factor = 10_u128 % divisor;
6601    while powers > 0 {
6602        if powers % 2 == 1 {
6603            remainder = multiply_mod(remainder, factor, divisor);
6604        }
6605        factor = multiply_mod(factor, factor, divisor);
6606        powers /= 2;
6607    }
6608    Some(remainder == 0)
6609}
6610
6611fn add_mod(left: u128, right: u128, modulus: u128) -> u128 {
6612    if left >= modulus - right {
6613        left - (modulus - right)
6614    } else {
6615        left + right
6616    }
6617}
6618
6619fn multiply_mod(mut left: u128, mut right: u128, modulus: u128) -> u128 {
6620    if let Some(product) = left.checked_mul(right) {
6621        return product % modulus;
6622    }
6623    let mut result = 0;
6624    while right > 0 {
6625        if right % 2 == 1 {
6626            result = add_mod(result, left, modulus);
6627        }
6628        left = add_mod(left, left, modulus);
6629        right /= 2;
6630    }
6631    result
6632}
6633
6634fn decimal_usize(value: &str) -> Option<usize> {
6635    let (negative, mut digits, exponent) = decimal_parts(value)?;
6636    if negative || !(0..=20).contains(&exponent) {
6637        return None;
6638    }
6639    digits.extend(std::iter::repeat_n('0', usize::try_from(exponent).ok()?));
6640    digits.parse().ok()
6641}
6642
6643// JSON integers can use decimal or exponent notation. Preserve every integral
6644// ID for membership checking without allocating the expanded exponent.
6645fn canonical_gpp_section_id(value: &str) -> Option<String> {
6646    let (negative, mut digits, exponent) = decimal_parts(value)?;
6647    if exponent < 0 {
6648        return None;
6649    }
6650    if negative {
6651        return Some(
6652            if digits == "1" && exponent == 0 {
6653                "-1"
6654            } else {
6655                "-2"
6656            }
6657            .into(),
6658        );
6659    }
6660    if digits.len() > 10 || exponent > 10 || digits.len() + exponent as usize > 10 {
6661        return Some("4294967296".into());
6662    }
6663    digits.extend(std::iter::repeat_n('0', exponent as usize));
6664    Some(digits)
6665}
6666
6667fn matches_json_type(kind: JsonType, field: &JsonField<'_>) -> bool {
6668    match kind {
6669        JsonType::String => field.kind == JsonValueKind::String,
6670        JsonType::Number => field.kind == JsonValueKind::Number,
6671        JsonType::Integer => {
6672            field.kind == JsonValueKind::Number
6673                && decimal_parts(field.text.as_ref()).is_some_and(|(_, _, exponent)| exponent >= 0)
6674        }
6675        JsonType::Boolean => field.kind == JsonValueKind::Bool,
6676        JsonType::Object => field.kind == JsonValueKind::Object,
6677        JsonType::Array => field.kind == JsonValueKind::Array,
6678        JsonType::Null => field.kind == JsonValueKind::Null,
6679    }
6680}
6681
6682fn json_contains_macro(value: &serde_json::Value) -> bool {
6683    match value {
6684        serde_json::Value::String(value) => contains_macro(value),
6685        serde_json::Value::Array(values) => values.iter().any(json_contains_macro),
6686        serde_json::Value::Object(values) => values
6687            .iter()
6688            .any(|(key, value)| contains_macro(key) || json_contains_macro(value)),
6689        _ => false,
6690    }
6691}
6692
6693fn constraint_violation(
6694    contract: &ParamContract,
6695    param: &RawParam<'_>,
6696    field: Option<&JsonField<'_>>,
6697    document: Option<&JsonDocument<'_>>,
6698    key_regex: Option<&Regex>,
6699) -> Option<String> {
6700    let unresolved_scalar = !param.container && contains_macro(param.value.as_ref());
6701    if let (Some(types), Some(field)) = (&contract.json_type, field)
6702        && !types
6703            .types()
6704            .iter()
6705            .any(|kind| matches_json_type(*kind, field))
6706        && !(unresolved_scalar
6707            && types
6708                .types()
6709                .iter()
6710                .any(|kind| !matches!(kind, JsonType::Object | JsonType::Array)))
6711    {
6712        return Some(format!(
6713            "must have JSON type {}, but has type {}.",
6714            types
6715                .types()
6716                .iter()
6717                .map(|kind| format!("{kind:?}").to_lowercase())
6718                .collect::<Vec<_>>()
6719                .join(" or "),
6720            field.kind.label()
6721        ));
6722    }
6723    if let (Some(document), Some(path)) = (document, param.location.as_deref()) {
6724        if let Some(name) = document.member_name(path) {
6725            if !contains_macro(name)
6726                && contract
6727                    .max_key_length
6728                    .is_some_and(|max| name.chars().count() > max)
6729            {
6730                return Some(format!(
6731                    "has an object member name longer than {} characters.",
6732                    contract.max_key_length.unwrap()
6733                ));
6734            }
6735            if !contains_macro(name) && key_regex.is_some_and(|regex| !regex.is_match(name)) {
6736                return Some(
6737                    "has an object member name outside the documented grammar.".to_string(),
6738                );
6739            }
6740        }
6741        if let (Some(max), Some(field)) = (contract.max_compact_bytes, field) {
6742            let bytes = serde_json::from_str::<serde_json::Value>(
6743                &document.artifact()[field.start..field.end],
6744            )
6745            .ok()
6746            .filter(|value| !json_contains_macro(value))
6747            .and_then(|value| serde_json::to_vec(&value).ok())
6748            .map(|bytes| bytes.len());
6749            if bytes.is_some_and(|bytes| bytes > max) {
6750                return Some(format!(
6751                    "exceeds the compact UTF-8 JSON size threshold of {max} bytes."
6752                ));
6753            }
6754        }
6755    }
6756    for (min, max, kind, label) in [
6757        (
6758            contract.min_items,
6759            contract.max_items,
6760            JsonValueKind::Array,
6761            "array elements",
6762        ),
6763        (
6764            contract.min_properties,
6765            contract.max_properties,
6766            JsonValueKind::Object,
6767            "object properties",
6768        ),
6769    ] {
6770        if min.is_none() && max.is_none() {
6771            continue;
6772        }
6773        let Some(field) = field else {
6774            continue;
6775        };
6776        if field.kind != kind {
6777            if contract.json_type.is_some() {
6778                continue;
6779            }
6780            return Some(format!(
6781                "must be a JSON {} to check its size.",
6782                kind.label()
6783            ));
6784        }
6785        let len = if kind == JsonValueKind::Object && !contract.property_exclusions.is_empty() {
6786            field
6787                .member_names
6788                .iter()
6789                .filter(|name| !contract.property_exclusions.contains(name))
6790                .count()
6791        } else {
6792            field.len.unwrap_or(0)
6793        };
6794        if let Some(min) = min
6795            && len < min
6796        {
6797            return Some(format!(
6798                "must contain at least {min} {label}, but contains {len}."
6799            ));
6800        }
6801        if let Some(max) = max
6802            && len > max
6803        {
6804            return Some(format!(
6805                "must contain at most {max} {label}, but contains {len}."
6806            ));
6807        }
6808    }
6809    if !param.container && contains_macro(param.value.as_ref()) {
6810        return None;
6811    }
6812    if let Some(maximum) = contract.max_utf16_length {
6813        let applies = field.is_none_or(|field| field.kind == JsonValueKind::String);
6814        if !applies && contract.json_type.is_none() {
6815            return Some("must be a string to check its length.".to_string());
6816        }
6817        if applies {
6818            let length = normalize_string(param.value.as_ref(), contract.normalization)
6819                .encode_utf16()
6820                .count();
6821            if length > maximum {
6822                return Some(format!(
6823                    "must contain at most {maximum} UTF-16 code units, but contains {length}."
6824                ));
6825            }
6826        }
6827    }
6828    if let (Some(document), Some(path), Some(field)) = (document, param.location.as_deref(), field)
6829    {
6830        if let (Some(max), Some(separator)) = (contract.max_joined_length, &contract.join_separator)
6831            && field.kind == JsonValueKind::Array
6832        {
6833            let values: Vec<_> = document
6834                .expand(&format!("{path}[]"))
6835                .iter()
6836                .filter_map(|path| document.get(path))
6837                .collect();
6838            if values.iter().all(|value| {
6839                value.kind == JsonValueKind::String && !contains_macro(value.text.as_ref())
6840            }) {
6841                let length = values
6842                    .iter()
6843                    .fold(0usize, |sum, value| {
6844                        sum.saturating_add(value.text.chars().count())
6845                    })
6846                    .saturating_add(
6847                        values
6848                            .len()
6849                            .saturating_sub(1)
6850                            .saturating_mul(separator.chars().count()),
6851                    );
6852                if length > max {
6853                    return Some(format!(
6854                        "must join to at most {max} characters, but contains {length}."
6855                    ));
6856                }
6857            }
6858        }
6859        if let Some(max) = contract.max_depth {
6860            if matches!(field.kind, JsonValueKind::Object | JsonValueKind::Array) {
6861                if let Some(depth) = document.subtree_depth(path)
6862                    && depth > max
6863                {
6864                    return Some(format!(
6865                        "must have nesting depth at most {max}, but has {depth}."
6866                    ));
6867                }
6868            } else if contract.json_type.is_none() {
6869                return Some("must be a JSON container to check nesting depth.".to_string());
6870            }
6871        }
6872        if let Some(max) = contract.max_member_values {
6873            if let Some(count) = document.member_value_count(path) {
6874                if count > max {
6875                    return Some(format!(
6876                        "must contain at most {max} member values, but contains {count}."
6877                    ));
6878                }
6879            } else if contract.json_type.is_none() {
6880                return Some("must be a JSON object to count member values.".to_string());
6881            }
6882        }
6883    }
6884    for (min, max, use_bytes) in [
6885        (contract.min_length, contract.max_length, false),
6886        (contract.min_byte_length, contract.max_byte_length, true),
6887    ] {
6888        if min.is_none() && max.is_none() {
6889            continue;
6890        }
6891        let applies = field.is_none_or(|field| field.kind == JsonValueKind::String);
6892        if !applies && contract.json_type.is_none() {
6893            return Some("must be a string to check its length.".to_string());
6894        }
6895        if applies {
6896            let value = normalize_string(
6897                field.map_or(param.value.as_ref(), |field| field.text.as_ref()),
6898                contract.normalization,
6899            );
6900            let len = if use_bytes {
6901                value.len()
6902            } else {
6903                value.chars().count()
6904            };
6905            let units = if use_bytes {
6906                "UTF-8 bytes"
6907            } else {
6908                "characters"
6909            };
6910            if let Some(min) = min
6911                && len < min
6912            {
6913                return Some(format!(
6914                    "must contain at least {min} {units}, but contains {len}."
6915                ));
6916            }
6917            if let Some(max) = max
6918                && len > max
6919            {
6920                return Some(format!(
6921                    "must contain at most {max} {units}, but contains {len}."
6922                ));
6923            }
6924        }
6925    }
6926    if contract.minimum.is_some() || contract.maximum.is_some() || contract.multiple_of.is_some() {
6927        let applies = field.is_none_or(|field| {
6928            field.kind == JsonValueKind::Number
6929                || (field.kind == JsonValueKind::String
6930                    && (contract.numeric_strings
6931                        || contract.json_type.as_ref().is_none_or(|types| {
6932                            !types.types().contains(&JsonType::Number)
6933                                && !types.types().contains(&JsonType::Integer)
6934                        })))
6935        });
6936        if !applies && contract.json_type.is_some() {
6937            return None;
6938        }
6939        let value = normalize_string(
6940            field.map_or(param.value.as_ref(), |field| field.text.as_ref()),
6941            contract.normalization,
6942        );
6943        let Ok(number) = value.parse::<serde_json::Number>() else {
6944            return Some("must be a number to check its bounds.".to_string());
6945        };
6946        if decimal_parts(value.as_ref()).is_none() {
6947            return Some(
6948                "must have a representable decimal exponent to check its bounds.".to_string(),
6949            );
6950        }
6951        if let Some(min) = &contract.minimum
6952            && compare_numeric_text(value.as_ref(), &min.to_string())
6953                == Some(std::cmp::Ordering::Less)
6954        {
6955            return Some(format!("must be at least {min}, but is {number}."));
6956        }
6957        if let Some(max) = &contract.maximum
6958            && compare_numeric_text(value.as_ref(), &max.to_string())
6959                == Some(std::cmp::Ordering::Greater)
6960        {
6961            return Some(format!("must be at most {max}, but is {number}."));
6962        }
6963        if let Some(step) = &contract.multiple_of
6964            && exact_multiple(value.as_ref(), step) != Some(true)
6965        {
6966            return Some(format!("must be a multiple of {step}, but is {number}."));
6967        }
6968    }
6969    None
6970}
6971
6972fn parse_cidr(value: &str) -> Option<(std::net::IpAddr, u8)> {
6973    let (address, prefix) = value.split_once('/')?;
6974    let address: std::net::IpAddr = address.parse().ok()?;
6975    let prefix: u8 = prefix.parse().ok()?;
6976    (prefix <= if address.is_ipv4() { 32 } else { 128 }).then_some((address, prefix))
6977}
6978
6979fn in_cidr(address: std::net::IpAddr, network: std::net::IpAddr, prefix: u8) -> bool {
6980    match (address, network) {
6981        (std::net::IpAddr::V4(address), std::net::IpAddr::V4(network)) => {
6982            let mask = if prefix == 0 {
6983                0
6984            } else {
6985                u32::MAX << (32 - prefix)
6986            };
6987            (u32::from(address) & mask) == (u32::from(network) & mask)
6988        }
6989        (std::net::IpAddr::V6(address), std::net::IpAddr::V6(network)) => {
6990            let mask = if prefix == 0 {
6991                0
6992            } else {
6993                u128::MAX << (128 - prefix)
6994            };
6995            (u128::from(address) & mask) == (u128::from(network) & mask)
6996        }
6997        _ => false,
6998    }
6999}
7000
7001fn format_has_unresolved_macro(format: &ValueFormat, value: &str) -> bool {
7002    !matches!(format, ValueFormat::IpChain) && contains_macro(value)
7003}
7004
7005fn format_violation(format: &ValueFormat, regex: Option<&Regex>, value: &str) -> Option<String> {
7006    match format {
7007        ValueFormat::NonEmpty => None,
7008        ValueFormat::NumericRanges { ranges } => {
7009            let matched = ranges.iter().any(|range| {
7010                matches!(
7011                    compare_numeric_text(value, &range.minimum.to_string()),
7012                    Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
7013                ) && matches!(
7014                    compare_numeric_text(value, &range.maximum.to_string()),
7015                    Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
7016                )
7017            });
7018            (!matched).then(|| {
7019                let intervals = ranges.iter().map(|range| {
7020                    format!("[{}, {}]", range.minimum, range.maximum)
7021                }).collect::<Vec<_>>().join(" or ");
7022                format!("must be a number in {intervals}, but is `{value}`.")
7023            })
7024        }
7025        ValueFormat::Integer {
7026            min_digits,
7027            max_digits,
7028        } => {
7029            if !value.chars().all(|character| character.is_ascii_digit()) {
7030                return Some(format!("must be numeric, but is `{value}`."));
7031            }
7032            if let Some(min) = min_digits
7033                && value.len() < *min
7034            {
7035                return Some(format!(
7036                    "must be at least {min} digits, but `{value}` has {}.",
7037                    value.len()
7038                ));
7039            }
7040            if let Some(max) = max_digits
7041                && value.len() > *max
7042            {
7043                return Some(format!(
7044                    "must be at most {max} digits, but `{value}` has {}.",
7045                    value.len()
7046                ));
7047            }
7048            None
7049        }
7050        ValueFormat::Enum {
7051            values,
7052            case_insensitive,
7053        } => {
7054            let matched = values.iter().any(|candidate| {
7055                if *case_insensitive {
7056                    candidate.eq_ignore_ascii_case(value)
7057                } else {
7058                    candidate == value
7059                }
7060            });
7061
7062            if matched {
7063                None
7064            } else {
7065                Some(format!(
7066                    "is `{value}`, which is not one of the documented values: {}.",
7067                    values.join(", ")
7068                ))
7069            }
7070        }
7071        ValueFormat::Regex { pattern } => match regex {
7072            Some(regex) if regex.is_match(value) => None,
7073            Some(_) => Some(format!(
7074                "is `{value}`, which does not match the documented format `{pattern}`."
7075            )),
7076            None => None,
7077        },
7078        ValueFormat::Url { require_https } => {
7079            let decoded = percent_decode(value);
7080            match url::Url::parse(decoded.as_ref()) {
7081                Ok(parsed) => {
7082                    if *require_https && parsed.scheme() != "https" {
7083                        Some(format!("must be an https URL, but is `{decoded}`."))
7084                    } else {
7085                        None
7086                    }
7087                }
7088                Err(_) => Some(format!("must be an absolute URL, but is `{decoded}`.")),
7089            }
7090        }
7091        ValueFormat::Hex { length } => {
7092            let valid = value.len() == *length
7093                && value.chars().all(|character| {
7094                    character.is_ascii_hexdigit() && !character.is_ascii_uppercase()
7095                });
7096
7097            if valid {
7098                None
7099            } else {
7100                Some(format!(
7101                    "must be {length} lowercase hex characters, which usually means a hashed value, but is `{value}`."
7102                ))
7103            }
7104        }
7105        ValueFormat::Ip {
7106            version,
7107            exclude_ranges,
7108        } => {
7109            let valid = value
7110                .parse::<std::net::IpAddr>()
7111                .ok()
7112                .is_some_and(|address| {
7113                    (match version {
7114                        None => true,
7115                        Some(IpVersion::V4) => address.is_ipv4(),
7116                        Some(IpVersion::V6) => address.is_ipv6(),
7117                    }) && !exclude_ranges.iter().any(|range| {
7118                        let (network, prefix) =
7119                            parse_cidr(range).expect("CIDR validated at compilation");
7120                        in_cidr(address, network, prefix)
7121                    })
7122                });
7123            if valid {
7124                None
7125            } else {
7126                Some(format!(
7127                    "must be an IP address literal of the documented family outside excluded ranges, but is `{value}`."
7128                ))
7129            }
7130        }
7131        ValueFormat::IpChain => {
7132            let valid = value.split(',').all(|entry| {
7133                let entry = entry.trim_matches([' ', '\t']);
7134                entry.parse::<std::net::IpAddr>().is_ok() || {
7135                    let macros = detect_macro_spans(entry);
7136                    macros.len() == 1 && macros[0].start == 0 && macros[0].end == entry.len()
7137                }
7138            });
7139            (!valid).then(|| format!(
7140                "must contain a comma-separated chain of IPv4 or IPv6 literals; `{value}` contains an invalid entry."
7141            ))
7142        }
7143        ValueFormat::DateTimeFormats { formats } => {
7144            (!formats.iter().any(|representation|
7145                crate::datetime_formats::valid_representation(value, *representation)
7146            )).then(|| format!(
7147                "must use a selected second-resolution date/time representation with a valid calendar, clock and numeric offset; `{value}` is invalid. Named timezone text is not resolved."
7148            ))
7149        }
7150        ValueFormat::DateTime {
7151            require_timezone,
7152            allow_date_only,
7153            allow_basic,
7154            allow_space_separator,
7155            allow_javascript_date,
7156            allow_unpadded_date,
7157        } => {
7158            let normalized;
7159            let value = if *allow_space_separator && value.as_bytes().get(10) == Some(&b' ') {
7160                normalized = format!("{}T{}", &value[..10], &value[11..]);
7161                normalized.as_str()
7162            } else {
7163                value
7164            };
7165            if valid_datetime(value, *require_timezone, *allow_date_only, *allow_basic)
7166                || (*allow_javascript_date && crate::javascript_date::valid_javascript_date(value))
7167                || (*allow_date_only && *allow_unpadded_date && valid_unpadded_date(value))
7168            {
7169                None
7170            } else {
7171                Some(format!(
7172                    "must be a calendar-valid RFC 3339 date and time{}; `{value}` is invalid.",
7173                    if *require_timezone {
7174                        " with a timezone"
7175                    } else {
7176                        ""
7177                    }
7178                ))
7179            }
7180        }
7181        ValueFormat::Date => {
7182            if valid_datetime(&format!("{value}T00:00:00Z"), true, false, false) {
7183                None
7184            } else {
7185                Some(format!(
7186                    "must be a valid YYYY-MM-DD calendar date, but is `{value}`."
7187                ))
7188            }
7189        }
7190        ValueFormat::Json => serde_json::from_str::<serde_json::Value>(value)
7191            .err()
7192            .map(|error| format!("must contain valid JSON: {error}")),
7193        ValueFormat::AdobeProducts => crate::adobe_products::validate_products(value),
7194        ValueFormat::AdobeEvents => crate::adobe_events::validate_events(value),
7195        ValueFormat::AdditionalConsent => {
7196            crate::google_additional_consent::validate_additional_consent(value)
7197        }
7198        ValueFormat::BrazeTime => crate::braze_time::validate_braze_time(value).err(),
7199        ValueFormat::Currency {
7200            allow_historical,
7201            case_insensitive,
7202        } => {
7203            if crate::currency::known_currency(value, *allow_historical)
7204                && (*case_insensitive || value == value.to_ascii_uppercase())
7205            {
7206                None
7207            } else {
7208                Some(format!(
7209                    "must be a{} assigned ISO 4217 currency code, but is `{value}`.",
7210                    if *case_insensitive {
7211                        "n"
7212                    } else {
7213                        "n uppercase"
7214                    }
7215                ))
7216            }
7217        }
7218        ValueFormat::Tcf => crate::privacy::literal_format_error("tcf", value),
7219        ValueFormat::Gpp => crate::privacy::literal_format_error("gpp", value),
7220        ValueFormat::UsPrivacy => crate::privacy::literal_format_error("us_privacy", value),
7221    }
7222}
7223
7224pub(crate) fn valid_datetime(
7225    value: &str,
7226    require_timezone: bool,
7227    allow_date_only: bool,
7228    allow_basic: bool,
7229) -> bool {
7230    if !value.is_ascii() {
7231        return false;
7232    }
7233    let Some((date, time)) = value.split_once(['T', 't']) else {
7234        return allow_date_only
7235            && valid_datetime(&format!("{value}T00:00:00Z"), true, false, allow_basic);
7236    };
7237    let normalized_date;
7238    let date = if allow_basic && date.len() == 8 && date.bytes().all(|byte| byte.is_ascii_digit()) {
7239        normalized_date = format!("{}-{}-{}", &date[..4], &date[4..6], &date[6..]);
7240        normalized_date.as_str()
7241    } else {
7242        date
7243    };
7244    let normalized_time;
7245    let time =
7246        if allow_basic && time.len() >= 6 && time.as_bytes()[..6].iter().all(u8::is_ascii_digit) {
7247            normalized_time = format!(
7248                "{}:{}:{}{}",
7249                &time[..2],
7250                &time[2..4],
7251                &time[4..6],
7252                &time[6..]
7253            );
7254            normalized_time.as_str()
7255        } else {
7256            time
7257        };
7258    if date.len() != 10 || date.as_bytes()[4] != b'-' || date.as_bytes()[7] != b'-' {
7259        return false;
7260    }
7261    let digits = |text: &str| -> Option<u32> {
7262        if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
7263            return None;
7264        }
7265        text.parse().ok()
7266    };
7267    let (Some(year), Some(month), Some(day)) =
7268        (digits(&date[..4]), digits(&date[5..7]), digits(&date[8..]))
7269    else {
7270        return false;
7271    };
7272    let days = match month {
7273        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
7274        4 | 6 | 9 | 11 => 30,
7275        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
7276        2 => 28,
7277        _ => return false,
7278    };
7279    if day == 0
7280        || day > days
7281        || time.len() < 8
7282        || time.as_bytes()[2] != b':'
7283        || time.as_bytes()[5] != b':'
7284        || !time.as_bytes()[6..8].iter().all(u8::is_ascii_digit)
7285    {
7286        return false;
7287    }
7288    let (Some(hour), Some(minute), Some(second)) =
7289        (digits(&time[..2]), digits(&time[3..5]), digits(&time[6..8]))
7290    else {
7291        return false;
7292    };
7293    if hour > 23 || minute > 59 || second > 60 {
7294        return false;
7295    }
7296    let mut rest = &time[8..];
7297    if let Some(fraction) = rest.strip_prefix('.') {
7298        let count = fraction.bytes().take_while(u8::is_ascii_digit).count();
7299        if count == 0 {
7300            return false;
7301        }
7302        rest = &fraction[count..];
7303    }
7304    if rest.is_empty() {
7305        return !require_timezone;
7306    }
7307    if rest == "Z" || rest == "z" {
7308        return true;
7309    }
7310    if allow_basic && rest.len() == 5 && matches!(rest.as_bytes()[0], b'+' | b'-') {
7311        return matches!((digits(&rest[1..3]),digits(&rest[3..5])),(Some(hour),Some(minute)) if hour<=23 && minute<=59);
7312    }
7313    if rest.len() != 6 || !matches!(rest.as_bytes()[0], b'+' | b'-') || rest.as_bytes()[3] != b':' {
7314        return false;
7315    }
7316    matches!((digits(&rest[1..3]), digits(&rest[4..6])), (Some(hour),Some(minute)) if hour <= 23 && minute <= 59)
7317}
7318
7319fn valid_unpadded_date(value: &str) -> bool {
7320    let fields: Vec<_> = value.split('-').collect();
7321    if fields.len() != 3
7322        || fields[0].len() != 4
7323        || !(1..=2).contains(&fields[1].len())
7324        || !(1..=2).contains(&fields[2].len())
7325        || fields
7326            .iter()
7327            .any(|field| !field.bytes().all(|byte| byte.is_ascii_digit()))
7328    {
7329        return false;
7330    }
7331    let normalized = format!(
7332        "{}-{:0>2}-{:0>2}T00:00:00Z",
7333        fields[0], fields[1], fields[2]
7334    );
7335    valid_datetime(&normalized, true, false, false)
7336}
7337
7338pub(crate) fn contains_macro(value: &str) -> bool {
7339    if !value
7340        .as_bytes()
7341        .iter()
7342        .any(|&byte| matches!(byte, b'$' | b'[' | b'{' | b'%' | b'!'))
7343    {
7344        return false;
7345    }
7346    !detect_macro_spans(value).is_empty()
7347}
7348
7349/// A parameter as it appears in the raw artifact, with byte offsets preserved so
7350/// findings can point at the exact span.
7351#[derive(Debug, Clone, PartialEq, Eq)]
7352pub(crate) struct RawParam<'a> {
7353    pub(crate) name: Cow<'a, str>,
7354    pub(crate) value: Cow<'a, str>,
7355    pub(crate) start: usize,
7356    pub(crate) end: usize,
7357    /// Where the value was carried, so a finding about a body field is not
7358    /// reported as if it were a query parameter.
7359    pub(crate) component: ViolationTargetComponent,
7360    /// The concrete location for a body field, such as `data[1].event_name`.
7361    /// Aliased indexed-query fields retain their original decoded wire key here.
7362    pub(crate) location: Option<String>,
7363    /// Whether the value is an object or an array. Vendors accept several of
7364    /// these fields as either a scalar or a list of them, so a value contract
7365    /// written for the scalar form must not fire on the list.
7366    pub(crate) container: bool,
7367    /// A slot the contract addresses where no value was found.
7368    ///
7369    /// Body contracts need to tell two absences apart. A field missing from an
7370    /// event that exists is worth reporting, once per event. A field under a
7371    /// container that is itself missing is not: the container has already been
7372    /// reported, and the fields beneath it were never addressable.
7373    pub(crate) missing: bool,
7374    family_parent: Option<String>,
7375    member_name: Option<String>,
7376    json_kind: Option<JsonValueKind>,
7377    /// New private SDK captures discard targets after HTTP validation. Avoid
7378    /// copying the entire data envelope once per intermediate body finding.
7379    omit_target_value: bool,
7380}
7381
7382impl<'a> RawParam<'a> {
7383    /// A query parameter, the common case.
7384    pub(crate) fn query(
7385        name: impl Into<Cow<'a, str>>,
7386        value: impl Into<Cow<'a, str>>,
7387        start: usize,
7388        end: usize,
7389    ) -> Self {
7390        Self {
7391            name: name.into(),
7392            value: value.into(),
7393            start,
7394            end,
7395            component: ViolationTargetComponent::QueryParam,
7396            location: None,
7397            container: false,
7398            missing: false,
7399            family_parent: None,
7400            member_name: None,
7401            json_kind: None,
7402            omit_target_value: false,
7403        }
7404    }
7405
7406    pub(crate) fn omit_capture_target_value(&mut self) {
7407        self.omit_target_value = true;
7408    }
7409
7410    pub(crate) fn target(&self) -> ViolationTarget {
7411        ViolationTarget {
7412            component: self.component,
7413            name: Some(
7414                self.location
7415                    .as_deref()
7416                    .unwrap_or(self.name.as_ref())
7417                    .to_string(),
7418            ),
7419            value: (!self.omit_target_value).then(|| self.value.as_ref().to_string()),
7420            start: self.start,
7421            end: self.end,
7422        }
7423    }
7424}
7425
7426/// The concrete scopes a body spec evaluates over: one per element of the batch
7427/// array, or a single empty scope covering the whole document when the endpoint
7428/// takes one event per request.
7429fn body_scopes(document: &JsonDocument<'_>, scope: Option<&ScopeSpec>) -> Vec<String> {
7430    let Some(scope) = scope else {
7431        return vec![String::new()];
7432    };
7433
7434    let patterns = scope.patterns();
7435    for pattern in &patterns {
7436        if pattern.is_empty() {
7437            return vec![String::new()];
7438        }
7439
7440        // `expand` keeps a missing final key so a presence contract can report
7441        // it. Alternative envelopes must not treat that ghost path as a hit:
7442        // Adobe collect posts `events[]`, and `event` is the interact envelope.
7443        let named_array_present = pattern.strip_suffix("[]").is_some_and(|parent| {
7444            document
7445                .expand(parent)
7446                .iter()
7447                .filter_map(|path| document.get(path))
7448                .any(|field| {
7449                    // A root array alternative distinguishes [] from an object.
7450                    // A named envelope, even malformed, must not fall back to the
7451                    // single-event shape and report spurious missing event fields.
7452                    field.kind == JsonValueKind::Array
7453                        || (!parent.is_empty() && !patterns.contains(&parent))
7454                })
7455        });
7456        if document.matches_pattern(pattern) || named_array_present {
7457            return document
7458                .expand(pattern)
7459                .into_iter()
7460                .filter(|path| document.contains(path))
7461                .collect();
7462        }
7463    }
7464
7465    Vec::new()
7466}
7467
7468/// Reads one scope's contracted fields out of the document.
7469///
7470/// Each contract name is a path relative to the scope, so `user_data.em[]`
7471/// under `data[1]` reads `data[1].user_data.em[0]` and up. Every hit keeps the
7472/// contract's own name so the existing checkers match it, and carries its
7473/// concrete path along for the finding.
7474type BodyParamEntry<'a> = (Cow<'a, str>, String, Option<String>, Option<String>);
7475
7476fn collect_body_params<'a>(
7477    document: &'a JsonDocument<'_>,
7478    scope: &str,
7479    contracts: &'a [CompiledParam],
7480) -> Vec<RawParam<'a>> {
7481    let mut params = Vec::new();
7482
7483    for compiled in contracts {
7484        let entries: Vec<BodyParamEntry<'a>> = match &compiled.contract.name_pattern_parent {
7485            Some(parent) => {
7486                let parent_path = match (scope.is_empty(), parent.is_empty()) {
7487                    (true, _) => parent.clone(),
7488                    (false, true) => scope.to_string(),
7489                    (false, false) => format!("{scope}.{parent}"),
7490                };
7491                document
7492                    .expand(&parent_path)
7493                    .iter()
7494                    .flat_map(|path| document.members(path))
7495                    .map(|(key, path)| {
7496                        (
7497                            Cow::Owned(json::member_path(parent, &key)),
7498                            path,
7499                            Some(parent.clone()),
7500                            Some(key),
7501                        )
7502                    })
7503                    .collect()
7504            }
7505            None => compiled
7506                .names
7507                .iter()
7508                .flat_map(|name| {
7509                    let pattern = if let Some(root_path) = &compiled.contract.root_path {
7510                        root_path.clone()
7511                    } else if let Some(ancestor) = &compiled.contract.ancestor_path {
7512                        let mut prefix = Some(scope);
7513                        for _ in 0..ancestor.levels {
7514                            prefix = prefix.and_then(json::parent_path);
7515                        }
7516                        let Some(prefix) = prefix else {
7517                            return Vec::new();
7518                        };
7519                        match (prefix.is_empty(), ancestor.path.is_empty()) {
7520                            (true, _) => ancestor.path.clone(),
7521                            (false, true) => prefix.to_string(),
7522                            (false, false) => format!("{prefix}.{}", ancestor.path),
7523                        }
7524                    } else {
7525                        match scope.is_empty() {
7526                            true => name.to_string(),
7527                            false if name.is_empty() => scope.to_string(),
7528                            false => format!("{scope}.{name}"),
7529                        }
7530                    };
7531                    document
7532                        .expand(&pattern)
7533                        .into_iter()
7534                        .map(move |path| {
7535                            (
7536                                Cow::Borrowed(compiled.contract.name.as_str()),
7537                                path,
7538                                None,
7539                                None,
7540                            )
7541                        })
7542                        .collect::<Vec<_>>()
7543                })
7544                .collect(),
7545        };
7546        let alias_key = |path: &str| {
7547            let relative = if scope.is_empty() {
7548                path
7549            } else {
7550                let relative = path.strip_prefix(scope)?;
7551                relative.strip_prefix('.').unwrap_or(relative)
7552            };
7553            compiled
7554                .names
7555                .iter()
7556                .find_map(|name| json::alias_slot_key(name, relative))
7557        };
7558        let present_alias_slots: BTreeSet<_> = if compiled.contract.aliases.is_empty() {
7559            BTreeSet::new()
7560        } else {
7561            entries
7562                .iter()
7563                .filter_map(|(_, path, _, _)| {
7564                    let field = document.get(path)?;
7565                    if compiled.contract.null_as_missing && field.kind == JsonValueKind::Null {
7566                        return None;
7567                    }
7568                    alias_key(path)
7569                })
7570                .collect()
7571        };
7572        let mut missing_alias_slots = BTreeSet::new();
7573        for (name, path, family_parent, member_name) in entries {
7574            let missing = document.get(&path).is_none_or(|field| {
7575                compiled.contract.null_as_missing && field.kind == JsonValueKind::Null
7576            });
7577            if missing
7578                && !compiled.contract.aliases.is_empty()
7579                && let Some(key) = alias_key(&path)
7580                && (present_alias_slots.contains(&key) || !missing_alias_slots.insert(key))
7581            {
7582                continue;
7583            }
7584            let Some(field) = document.get(&path) else {
7585                // The slot is addressable but empty. Recording it lets the
7586                // contract report once per place the value belongs.
7587                let (start, end) = match document.nearest_present_ancestor(&path) {
7588                    Some((_, field)) => (field.start, field.end),
7589                    None => (0, 0),
7590                };
7591
7592                params.push(RawParam {
7593                    name: name.clone(),
7594                    value: Cow::Borrowed(""),
7595                    start,
7596                    end,
7597                    component: ViolationTargetComponent::BodyField,
7598                    location: Some(path),
7599                    container: false,
7600                    missing: true,
7601                    family_parent,
7602                    member_name,
7603                    json_kind: None,
7604                    omit_target_value: false,
7605                });
7606                continue;
7607            };
7608
7609            // Containers have no text of their own. Standing in the label
7610            // keeps a populated object from being reported as empty, while
7611            // an empty one still is.
7612            let value = match (field.kind, field.is_blank()) {
7613                (_, true) => Cow::Borrowed(""),
7614                (JsonValueKind::Object | JsonValueKind::Array, false) => {
7615                    Cow::Borrowed(field.kind.label())
7616                }
7617                _ => Cow::Borrowed(field.text.as_ref()),
7618            };
7619
7620            params.push(RawParam {
7621                name: name.clone(),
7622                value,
7623                start: field.start,
7624                end: field.end,
7625                component: ViolationTargetComponent::BodyField,
7626                location: Some(path),
7627                container: matches!(field.kind, JsonValueKind::Object | JsonValueKind::Array),
7628                missing: compiled.contract.null_as_missing && field.kind == JsonValueKind::Null,
7629                family_parent,
7630                member_name,
7631                json_kind: Some(field.kind),
7632                omit_target_value: false,
7633            });
7634        }
7635    }
7636
7637    let mut seen = BTreeSet::new();
7638    params.retain(|param| seen.insert((param.name.to_string(), param.location.clone())));
7639    params
7640}
7641
7642/// Where to point when a body finding has no field of its own to blame: the
7643/// enclosing event if there is one, otherwise the whole payload.
7644fn body_target(document: &JsonDocument<'_>, scope: &str, artifact: &str) -> ViolationTarget {
7645    match document.get(scope) {
7646        Some(field) => ViolationTarget {
7647            component: ViolationTargetComponent::BodyField,
7648            name: Some(scope.to_string()),
7649            value: None,
7650            start: field.start,
7651            end: field.end,
7652        },
7653        None => ViolationTarget {
7654            component: ViolationTargetComponent::WholeBody,
7655            name: None,
7656            value: None,
7657            start: 0,
7658            end: artifact.len(),
7659        },
7660    }
7661}
7662
7663fn bare_query_targets(
7664    artifact: &str,
7665    style: ParamStyle,
7666    param: &str,
7667    aliases: Option<&[String]>,
7668) -> Vec<ViolationTarget> {
7669    let fragment_start = artifact.find('#').unwrap_or(artifact.len());
7670    let Some(query_start) = artifact[..fragment_start].find('?') else {
7671        return Vec::new();
7672    };
7673    let mut cursor = query_start + 1;
7674    let mut targets = Vec::new();
7675    for segment in artifact[cursor..fragment_start]
7676        .split(|c| c == '&' || (style == ParamStyle::QuerySemicolon && c == ';'))
7677    {
7678        let name = percent_decode(segment);
7679        if !segment.is_empty()
7680            && !segment.contains('=')
7681            && (name == param
7682                || aliases
7683                    .is_some_and(|aliases| aliases.iter().any(|alias| alias == name.as_ref())))
7684        {
7685            targets.push(ViolationTarget {
7686                component: ViolationTargetComponent::QueryParam,
7687                name: Some(name.into_owned()),
7688                value: Some(String::new()),
7689                start: cursor,
7690                end: cursor + segment.len(),
7691            });
7692        }
7693        cursor += segment.len() + 1;
7694    }
7695    targets
7696}
7697
7698fn whole_url_target(artifact: &str) -> ViolationTarget {
7699    ViolationTarget {
7700        component: ViolationTargetComponent::WholeUrl,
7701        name: None,
7702        value: None,
7703        start: 0,
7704        end: artifact.len(),
7705    }
7706}
7707
7708/// Opt-in URL-only groups preserve every pair, including bare keys. Numeric
7709/// index spellings are lexical labels, never parsed, bounded or canonicalized.
7710fn indexed_query_groups<'a>(artifact: &'a str, prefix: &str) -> Vec<ScopedQueryGroup<'a>> {
7711    let end = artifact.find('#').unwrap_or(artifact.len());
7712    let Some(mut cursor) = artifact[..end].find('?').map(|offset| offset + 1) else {
7713        return Vec::new();
7714    };
7715    let mut positions = BTreeMap::<String, usize>::new();
7716    let mut groups: Vec<ScopedQueryGroup<'a>> = Vec::new();
7717    while cursor < end {
7718        let pair_end = artifact[cursor..end]
7719            .find('&')
7720            .map_or(end, |offset| cursor + offset);
7721        let pair = &artifact[cursor..pair_end];
7722        if !pair.is_empty() {
7723            let (raw_name, raw_value) = pair.split_once('=').unwrap_or((pair, ""));
7724            let physical_name = percent_decode(raw_name);
7725            if let Some(rest) = physical_name
7726                .strip_prefix(prefix)
7727                .and_then(|rest| rest.strip_prefix('['))
7728                && let Some((index, leaf)) = rest.split_once("][")
7729                && !index.is_empty()
7730                && index.bytes().all(|byte| byte.is_ascii_digit())
7731                && let Some(field) = leaf.strip_suffix(']')
7732                && !field.is_empty()
7733                && !field.contains(['[', ']'])
7734            {
7735                let field = field.to_string();
7736                let physical_row = format!("{prefix}[{index}]");
7737                let position = if let Some(position) = positions.get(index) {
7738                    *position
7739                } else {
7740                    let position = groups.len();
7741                    positions.insert(index.to_string(), position);
7742                    groups.push(ScopedQueryGroup {
7743                        index: position,
7744                        start: cursor,
7745                        end: pair_end,
7746                        physical_row: Some(physical_row),
7747                        anchor_name: Some(physical_name.to_string()),
7748                        params: Vec::new(),
7749                    });
7750                    position
7751                };
7752                let mut param =
7753                    RawParam::query(field, decode_param_value(raw_value), cursor, pair_end);
7754                param.location = Some(physical_name.into_owned());
7755                groups[position].params.push(param);
7756            }
7757        }
7758        cursor = pair_end + 1;
7759    }
7760    groups
7761}
7762
7763/// Splits the raw artifact into parameters, keeping byte offsets into the
7764/// original string. Query style reads `?a=1&b=2`; query-semicolon also splits
7765/// on `;`, the shape Adform uses; matrix style reads the semicolon-delimited
7766/// pairs Floodlight puts in the path; colon-path style reads `/key:value/`
7767/// segments, including Partnerize basket `[...]` groups.
7768pub(crate) fn extract_params(artifact: &str, style: ParamStyle) -> Vec<RawParam<'_>> {
7769    if style == ParamStyle::ColonPath {
7770        return extract_colon_path_params(artifact);
7771    }
7772
7773    let length = artifact.len();
7774    let fragment_start = artifact.find('#').unwrap_or(length);
7775    let query_start = artifact[..fragment_start].find('?');
7776
7777    let (region_start, region_end) = match style {
7778        ParamStyle::Query | ParamStyle::QuerySemicolon => match query_start {
7779            Some(start) => (start + 1, fragment_start),
7780            None => return Vec::new(),
7781        },
7782        ParamStyle::Matrix => {
7783            let (start, end) = path_span(artifact);
7784            // Matrix pairs are `name=value`. A query pixel's path has no `=`,
7785            // so skip walking it on every validate.
7786            if start >= end || !artifact.as_bytes()[start..end].contains(&b'=') {
7787                return Vec::new();
7788            }
7789            (start, end)
7790        }
7791        ParamStyle::ColonPath => unreachable!("colon_path returns above"),
7792    };
7793
7794    if region_start >= region_end {
7795        return Vec::new();
7796    }
7797
7798    let region = &artifact[region_start..region_end];
7799    let mut params = Vec::with_capacity(param_pair_capacity(region, style));
7800    let mut cursor = region_start;
7801
7802    while cursor <= region_end {
7803        let segment_end = next_pair_break(&artifact[cursor..region_end], style)
7804            .map(|offset| cursor + offset)
7805            .unwrap_or(region_end);
7806        let segment = &artifact[cursor..segment_end];
7807
7808        if !segment.is_empty()
7809            && let Some((name, value)) = segment.split_once('=')
7810        {
7811            // Matrix parameters ride on the path, so the first pair carries the
7812            // path prefix with it: `/ddm/activity/src=123`. Trim everything up
7813            // to the last slash in the name so the parameter is `src`, and move
7814            // the reported span with it.
7815            let (name, name_offset) = match name.rfind('/') {
7816                Some(index) if style == ParamStyle::Matrix => (&name[index + 1..], index + 1),
7817                _ => (name, 0),
7818            };
7819
7820            params.push(RawParam::query(
7821                percent_decode(name),
7822                decode_param_value(value),
7823                cursor + name_offset,
7824                segment_end,
7825            ));
7826        }
7827
7828        if segment_end >= region_end {
7829            break;
7830        }
7831
7832        cursor = segment_end + 1;
7833    }
7834
7835    params
7836}
7837
7838fn param_pair_capacity(region: &str, style: ParamStyle) -> usize {
7839    let bytes = region.as_bytes();
7840    let separators = match style {
7841        ParamStyle::Query => bytes.iter().filter(|&&byte| byte == b'&').count(),
7842        ParamStyle::Matrix => bytes.iter().filter(|&&byte| byte == b';').count(),
7843        ParamStyle::QuerySemicolon => bytes
7844            .iter()
7845            .filter(|&&byte| byte == b'&' || byte == b';')
7846            .count(),
7847        ParamStyle::ColonPath => 0,
7848    };
7849    separators + 1
7850}
7851
7852fn next_pair_break(haystack: &str, style: ParamStyle) -> Option<usize> {
7853    match style {
7854        ParamStyle::Query => haystack.find('&'),
7855        ParamStyle::Matrix => haystack.find(';'),
7856        ParamStyle::QuerySemicolon => match (haystack.find('&'), haystack.find(';')) {
7857            (Some(ampersand), Some(semicolon)) => Some(ampersand.min(semicolon)),
7858            (Some(ampersand), None) => Some(ampersand),
7859            (None, Some(semicolon)) => Some(semicolon),
7860            (None, None) => None,
7861        },
7862        ParamStyle::ColonPath => None,
7863    }
7864}
7865
7866/// Partnerize-style `/campaign:ID/clickref:ABC/[category:DVD/quantity:1]` paths.
7867fn extract_colon_path_params(artifact: &str) -> Vec<RawParam<'_>> {
7868    let (path_start, path_end) = path_span(artifact);
7869    if path_start >= path_end {
7870        return Vec::new();
7871    }
7872
7873    let path = &artifact[path_start..path_end];
7874    // Only outer basket delimiters are transport syntax. A closing bracket in
7875    // sku:[SKU] is part of that macro, even in a middle path segment.
7876    let mut delimiters = BTreeSet::new();
7877    let mut basket_depth = 0usize;
7878    for (index, byte) in path.bytes().enumerate() {
7879        match byte {
7880            b'[' if basket_depth > 0 => basket_depth += 1,
7881            b'[' if index == 0 || path.as_bytes()[index - 1] == b'/' => {
7882                basket_depth = 1;
7883                delimiters.insert(index);
7884            }
7885            b']' if basket_depth > 0 => {
7886                basket_depth -= 1;
7887                if basket_depth == 0 {
7888                    delimiters.insert(index);
7889                }
7890            }
7891            _ => {}
7892        }
7893    }
7894    let mut params = Vec::with_capacity(path.bytes().filter(|&byte| byte == b'/').count());
7895    let mut rel = 0;
7896
7897    for segment in path.split('/') {
7898        if !segment.is_empty() {
7899            let leading = usize::from(delimiters.contains(&rel) && segment.starts_with('['));
7900            let trailing = usize::from(
7901                delimiters.contains(&(rel + segment.len() - 1))
7902                    && segment.ends_with(']')
7903                    && segment.len() > leading,
7904            );
7905            let inner_end = segment.len() - trailing;
7906            if leading < inner_end {
7907                let inner = &segment[leading..inner_end];
7908                if let Some((name, value)) = inner.split_once(':')
7909                    && !name.is_empty()
7910                {
7911                    let abs = path_start + rel;
7912                    params.push(RawParam::query(
7913                        percent_decode(name),
7914                        decode_param_value(value),
7915                        abs + leading,
7916                        abs + inner_end,
7917                    ));
7918                }
7919            }
7920        }
7921        rel += segment.len() + 1;
7922    }
7923
7924    params
7925}
7926
7927/// Byte range of the path within a raw artifact, excluding query and fragment.
7928pub(crate) fn path_span(artifact: &str) -> (usize, usize) {
7929    let length = artifact.len();
7930    let fragment_start = artifact.find('#').unwrap_or(length);
7931    let path_end = artifact[..fragment_start]
7932        .find('?')
7933        .unwrap_or(fragment_start);
7934    let scheme_end = artifact.find("://").map(|index| index + 3).unwrap_or(0);
7935    let path_start = artifact[scheme_end..path_end]
7936        .find('/')
7937        .map(|offset| scheme_end + offset)
7938        .unwrap_or(path_end);
7939
7940    (path_start, path_end)
7941}
7942
7943/// Percent-decode a query value, unless the raw text is already a macro.
7944///
7945/// `%%CACHEBUSTER%%` and `[%ADID%]` contain `%` plus two hex digits. Decoding
7946/// that pair deletes the token, and the privacy skip then treats the leftover
7947/// as a literal flag. `%20` is not a macro, so it still decodes.
7948fn decode_param_value(value: &str) -> Cow<'_, str> {
7949    if contains_macro(value) {
7950        Cow::Borrowed(value)
7951    } else {
7952        percent_decode(value)
7953    }
7954}
7955
7956/// Path captures use URI percent-decoding, where a literal plus is data.
7957/// Preserve macro spelling as for query values, without form-style plus folding.
7958fn decode_path_param_value(value: &str) -> Cow<'_, str> {
7959    if contains_macro(value) {
7960        Cow::Borrowed(value)
7961    } else {
7962        percent_decode_component(value, false)
7963    }
7964}
7965
7966/// Minimal percent-decoding for parameter names and values. `+` is decoded as a
7967/// space because form-encoded pixel payloads are common.
7968fn percent_decode(value: &str) -> Cow<'_, str> {
7969    percent_decode_component(value, true)
7970}
7971
7972fn percent_decode_component(value: &str, plus_as_space: bool) -> Cow<'_, str> {
7973    if !value
7974        .as_bytes()
7975        .iter()
7976        .any(|&byte| byte == b'%' || (plus_as_space && byte == b'+'))
7977    {
7978        return Cow::Borrowed(value);
7979    }
7980
7981    let bytes = value.as_bytes();
7982    let mut decoded = Vec::with_capacity(bytes.len());
7983    let mut index = 0;
7984
7985    while index < bytes.len() {
7986        match bytes[index] {
7987            b'%' if index + 2 < bytes.len() => {
7988                let high = (bytes[index + 1] as char).to_digit(16);
7989                let low = (bytes[index + 2] as char).to_digit(16);
7990
7991                match (high, low) {
7992                    (Some(high), Some(low)) => {
7993                        decoded.push((high * 16 + low) as u8);
7994                        index += 3;
7995                    }
7996                    _ => {
7997                        decoded.push(bytes[index]);
7998                        index += 1;
7999                    }
8000                }
8001            }
8002            b'+' if plus_as_space => {
8003                decoded.push(b' ');
8004                index += 1;
8005            }
8006            byte => {
8007                decoded.push(byte);
8008                index += 1;
8009            }
8010        }
8011    }
8012
8013    match String::from_utf8(decoded) {
8014        Ok(text) => Cow::Owned(text),
8015        Err(error) => Cow::Owned(String::from_utf8_lossy(&error.into_bytes()).into_owned()),
8016    }
8017}
8018
8019#[cfg(test)]
8020mod tests {
8021    use super::*;
8022    use crate::{Engine, ExpansionState, ValidationOptions};
8023    use std::borrow::Cow;
8024
8025    const TEST_MANIFEST: &str = r#"{
8026        "id": "vendor/test",
8027        "display_name": "Test Vendor Pixel",
8028        "description": "Fixture pack used by the manifest loader tests.",
8029        "vendor": "test",
8030        "source_level": "official_vendor",
8031        "docs": "https://example.com/docs",
8032        "match": {
8033            "hosts": ["px.example.com"],
8034            "path_prefixes": ["/collect"]
8035        },
8036        "params": [
8037            {
8038                "name": "id",
8039                "requirement": "required",
8040                "format": { "kind": "integer", "min_digits": 3 }
8041            },
8042            {
8043                "name": "ev",
8044                "requirement": "required",
8045                "format": { "kind": "enum", "values": ["PageView", "Purchase"] }
8046            },
8047            {
8048                "name": "legacy",
8049                "requirement": "deprecated"
8050            },
8051            {
8052                "name": "debug",
8053                "requirement": "forbidden"
8054            },
8055            {
8056                "name": "url",
8057                "format": { "kind": "url", "require_https": true }
8058            }
8059        ],
8060        "rules": [
8061            {
8062                "code": "vendor.test.pii.raw_email",
8063                "kind": "forbid_value_pattern",
8064                "pattern": "@",
8065                "severity": "error",
8066                "message": "Raw email addresses must be hashed before they are sent."
8067            }
8068        ]
8069    }"#;
8070
8071    fn pack() -> ManifestRulePack {
8072        ManifestRulePack::from_json(TEST_MANIFEST).expect("compile test manifest")
8073    }
8074
8075    fn request(artifact: &str) -> ValidationRequest {
8076        ValidationRequest {
8077            artifact_kind: ArtifactKind::Url,
8078            artifact: artifact.to_string(),
8079            claimed_vendor: None,
8080            expansion_state: ExpansionState::Unknown,
8081        }
8082    }
8083
8084    fn codes(report: &ValidationReport) -> Vec<String> {
8085        report
8086            .violations
8087            .iter()
8088            .map(|violation| violation.code.clone())
8089            .collect()
8090    }
8091
8092    #[test]
8093    fn clean_artifact_produces_no_findings() {
8094        let report = pack().validate(&request(
8095            "https://px.example.com/collect?id=12345&ev=PageView",
8096        ));
8097        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8098        assert_eq!(report.detected_vendor.as_deref(), Some("test"));
8099    }
8100
8101    #[test]
8102    fn missing_required_params_are_reported() {
8103        let report = pack().validate(&request("https://px.example.com/collect?other=1"));
8104        assert_eq!(
8105            codes(&report),
8106            vec![
8107                "vendor.test.param.id.missing".to_string(),
8108                "vendor.test.param.ev.missing".to_string(),
8109            ]
8110        );
8111    }
8112
8113    #[test]
8114    fn value_formats_are_enforced() {
8115        let report = pack().validate(&request(
8116            "https://px.example.com/collect?id=ab&ev=Signup&url=http%3A%2F%2Fexample.com",
8117        ));
8118        assert_eq!(
8119            codes(&report),
8120            vec![
8121                "vendor.test.param.id.invalid".to_string(),
8122                "vendor.test.param.ev.invalid".to_string(),
8123                "vendor.test.param.url.invalid".to_string(),
8124            ]
8125        );
8126    }
8127
8128    #[test]
8129    fn integer_digit_bounds_are_enforced() {
8130        let report = pack().validate(&request("https://px.example.com/collect?id=12&ev=Purchase"));
8131        assert_eq!(codes(&report), vec!["vendor.test.param.id.invalid"]);
8132    }
8133
8134    #[test]
8135    fn deprecated_and_forbidden_params_are_reported() {
8136        let report = pack().validate(&request(
8137            "https://px.example.com/collect?id=123&ev=Purchase&legacy=1&debug=1",
8138        ));
8139        assert_eq!(
8140            codes(&report),
8141            vec![
8142                "vendor.test.param.legacy.deprecated".to_string(),
8143                "vendor.test.param.debug.forbidden".to_string(),
8144            ]
8145        );
8146    }
8147
8148    #[test]
8149    fn empty_values_are_reported_once() {
8150        let report = pack().validate(&request("https://px.example.com/collect?id=&ev=PageView"));
8151        assert_eq!(codes(&report), vec!["vendor.test.param.id.empty"]);
8152    }
8153
8154    #[test]
8155    fn macro_values_defer_to_the_core_pack() {
8156        let report = pack().validate(&request(
8157            "https://px.example.com/collect?id=[ADVERTISER_ID]&ev=PageView",
8158        ));
8159        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8160    }
8161
8162    #[test]
8163    fn pack_rules_run_over_every_param_when_unscoped() {
8164        let report = pack().validate(&request(
8165            "https://px.example.com/collect?id=123&ev=Purchase&em=buyer%40example.com",
8166        ));
8167        assert_eq!(codes(&report), vec!["vendor.test.pii.raw_email"]);
8168    }
8169
8170    #[test]
8171    fn targets_point_at_the_offending_parameter() {
8172        let artifact = "https://px.example.com/collect?id=ab&ev=PageView";
8173        let report = pack().validate(&request(artifact));
8174        let target = &report.violations[0].targets[0];
8175        assert_eq!(&artifact[target.start..target.end], "id=ab");
8176        assert_eq!(target.name.as_deref(), Some("id"));
8177    }
8178
8179    #[test]
8180    fn packs_only_match_their_own_endpoints() {
8181        let pack = pack();
8182        assert!(pack.supports(&request("https://px.example.com/collect?id=1")));
8183        assert!(!pack.supports(&request("https://px.example.com/other?id=1")));
8184        assert!(!pack.supports(&request("https://other.example.com/collect?id=1")));
8185        assert!(!pack.supports(&request("https://notpx.example.com/collect?id=1")));
8186    }
8187
8188    #[test]
8189    fn host_suffix_matching_respects_label_boundaries() {
8190        let manifest = TEST_MANIFEST.replace(
8191            r#""hosts": ["px.example.com"]"#,
8192            r#""host_suffixes": ["example.com"]"#,
8193        );
8194        let pack = ManifestRulePack::from_json(&manifest).expect("compile suffix manifest");
8195        assert!(pack.supports(&request("https://px.example.com/collect?id=1")));
8196        assert!(pack.supports(&request("https://example.com/collect?id=1")));
8197        assert!(!pack.supports(&request("https://notexample.com/collect?id=1")));
8198    }
8199
8200    #[test]
8201    fn forcing_a_pack_off_endpoint_reports_the_mismatch() {
8202        let report = pack().validate(&request("https://other.example.com/collect?id=1"));
8203        assert_eq!(codes(&report), vec!["vendor.test.endpoint_mismatch"]);
8204        assert_eq!(report.detected_vendor, None);
8205    }
8206
8207    #[test]
8208    fn claimed_vendor_mismatch_is_informational() {
8209        let mut request = request("https://px.example.com/collect?id=123&ev=PageView");
8210        request.claimed_vendor = Some("other".to_string());
8211        let report = pack().validate(&request);
8212        assert_eq!(codes(&report), vec!["vendor.test.claimed_vendor_mismatch"]);
8213    }
8214
8215    #[test]
8216    fn matrix_params_are_read_from_the_path() {
8217        let manifest = TEST_MANIFEST
8218            .replace(r#""match": {"#, r#""param_style": "matrix", "match": {"#)
8219            .replace(
8220                r#""path_prefixes": ["/collect"]"#,
8221                r#""path_prefixes": ["/activity"]"#,
8222            );
8223        let pack = ManifestRulePack::from_json(&manifest).expect("compile matrix manifest");
8224        let report = pack.validate(&request(
8225            "https://px.example.com/activity;id=12345;ev=PageView",
8226        ));
8227        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8228
8229        let report = pack.validate(&request("https://px.example.com/activity;id=12345"));
8230        assert_eq!(codes(&report), vec!["vendor.test.param.ev.missing"]);
8231    }
8232
8233    #[test]
8234    fn matrix_params_survive_a_path_prefix() {
8235        let manifest = TEST_MANIFEST
8236            .replace(r#""match": {"#, r#""param_style": "matrix", "match": {"#)
8237            .replace(
8238                r#""path_prefixes": ["/collect"]"#,
8239                r#""path_prefixes": ["/ddm/activity"]"#,
8240            );
8241        let pack = ManifestRulePack::from_json(&manifest).expect("compile matrix manifest");
8242        let artifact = "https://px.example.com/ddm/activity/id=12345;ev=Purchase;legacy=1?";
8243        let report = pack.validate(&request(artifact));
8244
8245        assert_eq!(codes(&report), vec!["vendor.test.param.legacy.deprecated"]);
8246        let target = &report.violations[0].targets[0];
8247        assert_eq!(&artifact[target.start..target.end], "legacy=1");
8248    }
8249
8250    #[test]
8251    fn query_semicolon_params_split_on_ampersand_and_semicolon() {
8252        let manifest = TEST_MANIFEST.replace(
8253            r#""match": {"#,
8254            r#""param_style": "query_semicolon", "match": {"#,
8255        );
8256        let pack =
8257            ManifestRulePack::from_json(&manifest).expect("compile query_semicolon manifest");
8258
8259        let report = pack.validate(&request(
8260            "https://px.example.com/collect?id=12345;ev=PageView",
8261        ));
8262        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8263
8264        let report = pack.validate(&request(
8265            "https://px.example.com/collect?id=12345&ev=PageView",
8266        ));
8267        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8268
8269        let artifact = "https://px.example.com/collect?id=12345;ev=Purchase;legacy=1";
8270        let report = pack.validate(&request(artifact));
8271        assert_eq!(codes(&report), vec!["vendor.test.param.legacy.deprecated"]);
8272        let target = &report.violations[0].targets[0];
8273        assert_eq!(&artifact[target.start..target.end], "legacy=1");
8274    }
8275
8276    #[test]
8277    fn colon_path_params_are_read_from_the_path() {
8278        let manifest = TEST_MANIFEST
8279            .replace(
8280                r#""match": {"#,
8281                r#""param_style": "colon_path", "match": {"#,
8282            )
8283            .replace(
8284                r#""path_prefixes": ["/collect"]"#,
8285                r#""path_prefixes": ["/conversion"]"#,
8286            );
8287        let pack = ManifestRulePack::from_json(&manifest).expect("compile colon_path manifest");
8288        let report = pack.validate(&request(
8289            "https://px.example.com/conversion/id:12345/ev:PageView",
8290        ));
8291        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8292
8293        let report = pack.validate(&request("https://px.example.com/conversion/id:12345"));
8294        assert_eq!(codes(&report), vec!["vendor.test.param.ev.missing"]);
8295    }
8296
8297    #[test]
8298    fn colon_path_strips_basket_brackets() {
8299        let manifest = TEST_MANIFEST
8300            .replace(
8301                r#""match": {"#,
8302                r#""param_style": "colon_path", "match": {"#,
8303            )
8304            .replace(
8305                r#""path_prefixes": ["/collect"]"#,
8306                r#""path_prefixes": ["/conversion"]"#,
8307            );
8308        let pack = ManifestRulePack::from_json(&manifest).expect("compile colon_path manifest");
8309        let artifact =
8310            "https://px.example.com/conversion/[id:12345]/ev:Purchase/[category:SHOES/quantity:1]";
8311        let report = pack.validate(&request(artifact));
8312        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8313
8314        let artifact = "https://px.example.com/conversion/[id:ab]/ev:PageView";
8315        let report = pack.validate(&request(artifact));
8316        assert_eq!(codes(&report), vec!["vendor.test.param.id.invalid"]);
8317        let target = &report.violations[0].targets[0];
8318        assert_eq!(&artifact[target.start..target.end], "id:ab");
8319    }
8320
8321    #[test]
8322    fn format_severity_overrides_only_value_findings() {
8323        let manifest = TEST_MANIFEST.replace(
8324            r#""format": { "kind": "enum", "values": ["PageView", "Purchase"] }"#,
8325            r#""format": { "kind": "enum", "values": ["PageView", "Purchase"] }, "format_severity": "warning""#,
8326        );
8327        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8328
8329        let report = pack.validate(&request("https://px.example.com/collect?id=123&ev=Custom"));
8330        assert_eq!(report.violations[0].severity, Severity::Warning);
8331        assert!(report.is_ok());
8332
8333        let report = pack.validate(&request("https://px.example.com/collect?id=123"));
8334        assert_eq!(report.violations[0].severity, Severity::Error);
8335    }
8336
8337    #[test]
8338    fn allow_empty_skips_blank_template_slots() {
8339        let manifest = TEST_MANIFEST.replace(
8340            r#""name": "url",
8341                "format": { "kind": "url", "require_https": true }"#,
8342            r#""name": "url",
8343                "format": { "kind": "url", "require_https": true },
8344                "allow_empty": true"#,
8345        );
8346        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8347
8348        let report = pack.validate(&request(
8349            "https://px.example.com/collect?id=123&ev=PageView&url=",
8350        ));
8351        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8352
8353        let report = pack.validate(&request(
8354            "https://px.example.com/collect?id=123&ev=PageView&url=not-a-url",
8355        ));
8356        assert_eq!(codes(&report), vec!["vendor.test.param.url.invalid"]);
8357    }
8358
8359    #[test]
8360    fn name_pattern_checks_present_keys_and_uses_the_matched_name() {
8361        let manifest = TEST_MANIFEST.replace(
8362            r#"{
8363                "name": "url",
8364                "format": { "kind": "url", "require_https": true }
8365            }"#,
8366            r#"{
8367                "name": "u1",
8368                "name_pattern": "^u([1-9]|[1-9][0-9]|100)$",
8369                "format": { "kind": "non_empty" }
8370            }"#,
8371        );
8372        let pack = ManifestRulePack::from_json(&manifest).expect("compile name_pattern");
8373
8374        let report = pack.validate(&request(
8375            "https://px.example.com/collect?id=123&ev=PageView&u21=",
8376        ));
8377        assert_eq!(codes(&report), vec!["vendor.test.param.u21.empty"]);
8378
8379        let report = pack.validate(&request(
8380            "https://px.example.com/collect?id=123&ev=PageView&u21=filled",
8381        ));
8382        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8383    }
8384
8385    #[test]
8386    fn name_pattern_cannot_be_required() {
8387        let manifest = TEST_MANIFEST.replace(
8388            r#"{
8389                "name": "url",
8390                "format": { "kind": "url", "require_https": true }
8391            }"#,
8392            r#"{
8393                "name": "u1",
8394                "name_pattern": "^u[0-9]+$",
8395                "requirement": "required"
8396            }"#,
8397        );
8398        let error = ManifestRulePack::from_json(&manifest).expect_err("required glob is rejected");
8399        assert!(
8400            matches!(error, ManifestError::NamePatternNotOptional { .. }),
8401            "{error}"
8402        );
8403    }
8404
8405    #[test]
8406    fn name_pattern_cannot_declare_aliases() {
8407        let manifest = TEST_MANIFEST.replace(
8408            r#"{
8409                "name": "url",
8410                "format": { "kind": "url", "require_https": true }
8411            }"#,
8412            r#"{
8413                "name": "u1",
8414                "name_pattern": "^u[0-9]+$",
8415                "aliases": ["u"]
8416            }"#,
8417        );
8418        let error = ManifestRulePack::from_json(&manifest).expect_err("aliases on a glob");
8419        assert!(
8420            matches!(error, ManifestError::NamePatternWithAliases { .. }),
8421            "{error}"
8422        );
8423    }
8424
8425    #[test]
8426    fn conditional_rules_fire_on_the_triggering_value_only() {
8427        let manifest = TEST_MANIFEST.replace(
8428            r#"        "rules": ["#,
8429            r#"        "rules": [
8430            {
8431                "code": "vendor.test.consent.state_requires_country",
8432                "kind": "required_when_value",
8433                "when": "ev",
8434                "equals": ["Purchase"],
8435                "requires": ["legacy"],
8436                "severity": "error",
8437                "message": "Purchase events must carry the legacy identifier."
8438            },"#,
8439        );
8440        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8441
8442        // The trigger value is present and the required parameter is not.
8443        let report = pack.validate(&request(
8444            "https://px.example.com/collect?id=123&ev=Purchase",
8445        ));
8446        assert_eq!(
8447            codes(&report),
8448            vec!["vendor.test.consent.state_requires_country"]
8449        );
8450
8451        // Trigger value present, requirement satisfied.
8452        let report = pack.validate(&request(
8453            "https://px.example.com/collect?id=123&ev=Purchase&legacy=1",
8454        ));
8455        assert_eq!(codes(&report), vec!["vendor.test.param.legacy.deprecated"]);
8456
8457        // A different value does not trigger the rule.
8458        let report = pack.validate(&request(
8459            "https://px.example.com/collect?id=123&ev=PageView",
8460        ));
8461        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8462
8463        // An unexpanded macro is not a value claim, so it cannot trigger.
8464        let report = pack.validate(&request(
8465            "https://px.example.com/collect?id=123&ev=[EVENT_NAME]",
8466        ));
8467        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8468    }
8469
8470    #[test]
8471    fn value_when_checks_a_present_field_against_the_required_value() {
8472        let manifest = TEST_MANIFEST.replace(
8473            r#"        "rules": ["#,
8474            r#"        "rules": [
8475            {
8476                "code": "vendor.test.purchase_url_must_be_https_shop",
8477                "kind": "value_when",
8478                "when": "ev",
8479                "equals": ["Purchase"],
8480                "param": "url",
8481                "value": "https://shop.example/thanks",
8482                "severity": "error",
8483                "message": "A purchase must send the documented shop URL."
8484            },"#,
8485        );
8486        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8487
8488        let report = pack.validate(&request(
8489            "https://px.example.com/collect?id=123&ev=Purchase&url=https://other.example/",
8490        ));
8491        assert_eq!(
8492            codes(&report),
8493            vec!["vendor.test.purchase_url_must_be_https_shop"]
8494        );
8495
8496        let report = pack.validate(&request(
8497            "https://px.example.com/collect?id=123&ev=Purchase&url=https://shop.example/thanks",
8498        ));
8499        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8500
8501        // Presence is a different contract. Missing `url` does not fire this.
8502        let report = pack.validate(&request(
8503            "https://px.example.com/collect?id=123&ev=Purchase",
8504        ));
8505        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8506
8507        let report = pack.validate(&request(
8508            "https://px.example.com/collect?id=123&ev=PageView&url=https://other.example/",
8509        ));
8510        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8511    }
8512
8513    #[test]
8514    fn forbidden_when_value_rejects_a_present_field() {
8515        let manifest = TEST_MANIFEST.replace(
8516            r#"        "rules": ["#,
8517            r#"        "rules": [
8518            {
8519                "code": "vendor.test.purchase_forbids_url",
8520                "kind": "forbidden_when_value",
8521                "when": "ev",
8522                "equals": ["Purchase"],
8523                "params": ["url"],
8524                "severity": "error",
8525                "message": "A purchase must not send a landing URL."
8526            },"#,
8527        );
8528        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8529
8530        let report = pack.validate(&request(
8531            "https://px.example.com/collect?id=123&ev=Purchase&url=https://shop.example/thanks",
8532        ));
8533        assert_eq!(codes(&report), vec!["vendor.test.purchase_forbids_url"]);
8534
8535        let report = pack.validate(&request(
8536            "https://px.example.com/collect?id=123&ev=Purchase",
8537        ));
8538        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8539
8540        let report = pack.validate(&request(
8541            "https://px.example.com/collect?id=123&ev=PageView&url=https://shop.example/thanks",
8542        ));
8543        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8544    }
8545
8546    #[test]
8547    fn path_captures_become_contracted_parameters() {
8548        let manifest = TEST_MANIFEST
8549            .replace(
8550                r#""match": {"#,
8551                r#""path_pattern": "^/conversion/(?<id>[^/]*)/", "match": {"#,
8552            )
8553            .replace(
8554                r#""path_prefixes": ["/collect"]"#,
8555                r#""path_prefixes": ["/conversion"]"#,
8556            );
8557        let pack = ManifestRulePack::from_json(&manifest).expect("compile manifest");
8558
8559        // The path capture satisfies the `id` contract.
8560        let artifact = "https://px.example.com/conversion/12345/?ev=PageView";
8561        let report = pack.validate(&request(artifact));
8562        assert!(codes(&report).is_empty(), "{:?}", codes(&report));
8563
8564        // It is checked like any other parameter, and points at the path span.
8565        let artifact = "https://px.example.com/conversion/not-numeric/?ev=PageView";
8566        let report = pack.validate(&request(artifact));
8567        assert_eq!(codes(&report), vec!["vendor.test.param.id.invalid"]);
8568        let target = &report.violations[0].targets[0];
8569        assert_eq!(&artifact[target.start..target.end], "not-numeric");
8570
8571        // An empty capture reads as an empty value, not a missing parameter.
8572        let report = pack.validate(&request("https://px.example.com/conversion//?ev=PageView"));
8573        assert_eq!(codes(&report), vec!["vendor.test.param.id.empty"]);
8574
8575        // A path that does not match the pattern leaves the parameter missing.
8576        let report = pack.validate(&request("https://px.example.com/conversion?ev=PageView"));
8577        assert_eq!(codes(&report), vec!["vendor.test.param.id.missing"]);
8578    }
8579
8580    #[test]
8581    fn path_capture_plus_is_data_and_percent_decoding_runs_once() {
8582        let manifest = r#"{
8583            "id":"vendor/path-capture-decoding",
8584            "display_name":"Path capture decoding",
8585            "description":"Path and query transport regression.",
8586            "source_level":"official_template",
8587            "docs":"https://url.spec.whatwg.org/",
8588            "match":{"hosts":["example.test"],"path_prefixes":["/pixel/"]},
8589            "path_pattern":"^/pixel/(?<payload>.*)$",
8590            "params":[{"name":"payload","requirement":"optional"}]
8591        }"#;
8592        let pack = ManifestRulePack::from_json(manifest).unwrap();
8593        for (path, expected) in [
8594            ("A+B/C==", "A+B/C=="),
8595            (
8596                "A+B/C==&next=value;comma,colon:at@",
8597                "A+B/C==&next=value;comma,colon:at@",
8598            ),
8599            ("A%26B%3DC%3F", "A&B=C?"),
8600            ("A%2BB%2FC%3D%3D", "A+B/C=="),
8601            ("A%252BB%252FC%253D", "A%2BB%2FC%3D"),
8602            ("%%CACHEBUSTER%%+[%ADID%]", "%%CACHEBUSTER%%+[%ADID%]"),
8603        ] {
8604            let artifact = format!("https://example.test/pixel/{path}?payload=spoof");
8605            let captured = pack.extract_path_params(&artifact);
8606            assert_eq!(captured.len(), 1);
8607            assert_eq!(captured[0].value, expected);
8608            assert_eq!(&artifact[captured[0].start..captured[0].end], path);
8609        }
8610        assert_eq!(decode_param_value("A+B%2FC%3D"), "A B/C=");
8611    }
8612
8613    #[test]
8614    fn path_patterns_must_capture_something() {
8615        let manifest = TEST_MANIFEST.replace(
8616            r#""match": {"#,
8617            r#""path_pattern": "^/conversion/[0-9]+", "match": {"#,
8618        );
8619        let error = ManifestRulePack::from_json(&manifest).expect_err("captures are required");
8620        assert!(
8621            matches!(error, ManifestError::PathPatternWithoutCaptures { .. }),
8622            "{error}"
8623        );
8624    }
8625
8626    #[test]
8627    fn engine_registers_manifests_and_runs_them_with_core() {
8628        let mut engine = Engine::default();
8629        engine
8630            .register_manifest_json(TEST_MANIFEST)
8631            .expect("register manifest");
8632
8633        let summary = engine
8634            .validate(
8635                &request("http://px.example.com/collect?id=1"),
8636                &ValidationOptions::default(),
8637            )
8638            .expect("validate");
8639
8640        let plugin_ids: Vec<&str> = summary
8641            .reports
8642            .iter()
8643            .map(|report| report.plugin_id.as_str())
8644            .collect();
8645        assert!(plugin_ids.contains(&"core"));
8646        assert!(plugin_ids.contains(&"vendor/test"));
8647    }
8648
8649    #[test]
8650    fn manifests_without_a_host_matcher_are_rejected() {
8651        let manifest = TEST_MANIFEST.replace(r#""hosts": ["px.example.com"],"#, "");
8652        let error = ManifestRulePack::from_json(&manifest).expect_err("matcher must be scoped");
8653        assert!(
8654            matches!(error, ManifestError::MatcherTooBroad(_)),
8655            "{error}"
8656        );
8657    }
8658
8659    #[test]
8660    fn vendor_rules_must_cite_documentation() {
8661        let manifest = TEST_MANIFEST.replace(r#""docs": "https://example.com/docs","#, "");
8662        let error = ManifestRulePack::from_json(&manifest).expect_err("citation is required");
8663        assert!(
8664            matches!(error, ManifestError::MissingCitation { .. }),
8665            "{error}"
8666        );
8667    }
8668
8669    #[test]
8670    fn rule_codes_must_use_the_pack_prefix() {
8671        let manifest = TEST_MANIFEST.replace("vendor.test.pii.raw_email", "custom.pii.raw_email");
8672        let error = ManifestRulePack::from_json(&manifest).expect_err("prefix is enforced");
8673        assert!(matches!(error, ManifestError::CodePrefix { .. }), "{error}");
8674    }
8675
8676    #[test]
8677    fn rules_cannot_reference_uncontracted_params() {
8678        let manifest = TEST_MANIFEST.replace(
8679            r#""kind": "forbid_value_pattern",
8680                "pattern": "@","#,
8681            r#""kind": "require_one_of",
8682                "params": ["not_contracted"],"#,
8683        );
8684        let error = ManifestRulePack::from_json(&manifest).expect_err("cross-reference is checked");
8685        assert!(
8686            matches!(error, ManifestError::UnknownParam { .. }),
8687            "{error}"
8688        );
8689    }
8690
8691    #[test]
8692    fn duplicate_param_names_are_rejected() {
8693        let manifest = TEST_MANIFEST.replace(
8694            r#"{
8695                "name": "legacy",
8696                "requirement": "deprecated"
8697            },"#,
8698            r#"{
8699                "name": "id",
8700                "requirement": "optional"
8701            },"#,
8702        );
8703        let error = ManifestRulePack::from_json(&manifest).expect_err("duplicates are rejected");
8704        assert!(
8705            matches!(error, ManifestError::DuplicateParam { .. }),
8706            "{error}"
8707        );
8708    }
8709
8710    #[test]
8711    fn invalid_regexes_are_rejected_at_load_time() {
8712        let manifest = TEST_MANIFEST.replace(r#""pattern": "@""#, r#""pattern": "([""#);
8713        let error = ManifestRulePack::from_json(&manifest).expect_err("regex is compiled at load");
8714        assert!(
8715            matches!(error, ManifestError::InvalidRegex { .. }),
8716            "{error}"
8717        );
8718    }
8719
8720    #[test]
8721    fn unknown_manifest_fields_are_rejected() {
8722        let manifest = TEST_MANIFEST.replace(
8723            r#""id": "vendor/test","#,
8724            r#""id": "vendor/test", "typo_field": true,"#,
8725        );
8726        let error = ManifestRulePack::from_json(&manifest).expect_err("unknown fields are caught");
8727        assert!(matches!(error, ManifestError::Parse(_)), "{error}");
8728    }
8729
8730    #[test]
8731    fn invalid_pack_ids_are_rejected() {
8732        let manifest = TEST_MANIFEST.replace(r#""id": "vendor/test""#, r#""id": "Vendor Test""#);
8733        let error = ManifestRulePack::from_json(&manifest).expect_err("ids are validated");
8734        assert!(matches!(error, ManifestError::InvalidId(_)), "{error}");
8735    }
8736
8737    #[test]
8738    fn percent_encoded_values_are_decoded_before_checks() {
8739        assert_eq!(percent_decode("buyer%40example.com"), "buyer@example.com");
8740        assert_eq!(percent_decode("a+b"), "a b");
8741        assert_eq!(percent_decode("100%"), "100%");
8742        assert_eq!(percent_decode("plain"), "plain");
8743        assert_eq!(percent_decode(""), "");
8744        assert_eq!(percent_decode("%2Fpath%2F"), "/path/");
8745        assert_eq!(percent_decode("%2f"), "/");
8746        assert_eq!(percent_decode("%GG"), "%GG");
8747        assert_eq!(percent_decode("%2"), "%2");
8748        assert_eq!(percent_decode("100% done"), "100% done");
8749        assert_eq!(percent_decode("a%"), "a%");
8750        assert_eq!(percent_decode("caf%C3%A9"), "café");
8751        assert_eq!(percent_decode("%FF"), "\u{FFFD}");
8752        assert!(matches!(percent_decode("plain"), Cow::Borrowed("plain")));
8753        assert!(matches!(percent_decode(""), Cow::Borrowed("")));
8754    }
8755
8756    #[test]
8757    fn typical_query_params_borrow_the_artifact() {
8758        let params = extract_params(
8759            "https://example.com/pixel?id=12345&ev=PageView",
8760            ParamStyle::Query,
8761        );
8762        assert_eq!(params.len(), 2);
8763        assert!(matches!(params[0].name, Cow::Borrowed("id")));
8764        assert!(matches!(params[0].value, Cow::Borrowed("12345")));
8765        assert!(matches!(params[1].name, Cow::Borrowed("ev")));
8766        assert!(matches!(params[1].value, Cow::Borrowed("PageView")));
8767
8768        let decoded = extract_params("https://example.com/pixel?em=a%40b.com", ParamStyle::Query);
8769        assert!(matches!(decoded[0].value, Cow::Owned(_)));
8770        assert_eq!(decoded[0].value.as_ref(), "a@b.com");
8771
8772        let matrix = extract_params(
8773            "https://www.facebook.com/tr?id=1&ev=PageView",
8774            ParamStyle::Matrix,
8775        );
8776        assert!(matrix.is_empty());
8777    }
8778}
8779
8780#[cfg(test)]
8781mod body_tests {
8782    use super::*;
8783    use crate::ExpansionState;
8784
8785    const BODY_MANIFEST: &str = r#"{
8786        "id": "vendor/test-api",
8787        "display_name": "Test Conversions API",
8788        "description": "Fixture pack used by the body loader tests.",
8789        "vendor": "test",
8790        "source_level": "official_vendor",
8791        "docs": "https://example.com/docs",
8792        "match": {
8793            "hosts": ["api.example.com"],
8794            "json_paths": ["data[].event_name"]
8795        },
8796        "body": {
8797            "scope": "data[]",
8798            "params": [
8799                { "name": "event_name", "requirement": "required" },
8800                {
8801                    "name": "event_time",
8802                    "requirement": "required",
8803                    "format": { "kind": "integer", "max_digits": 10 }
8804                },
8805                { "name": "user_data", "requirement": "required" },
8806                {
8807                    "name": "user_data.em[]",
8808                    "format": { "kind": "regex", "pattern": "^[a-f0-9]{4}$" }
8809                },
8810                { "name": "custom_data.value" }
8811            ],
8812            "rules": [
8813                {
8814                    "code": "vendor.test-api.body.purchase_needs_value",
8815                    "kind": "required_when_value",
8816                    "when": "event_name",
8817                    "equals": ["Purchase"],
8818                    "requires": ["custom_data.value"],
8819                    "severity": "error",
8820                    "message": "A purchase needs a value."
8821                }
8822            ]
8823        }
8824    }"#;
8825
8826    fn body_pack() -> ManifestRulePack {
8827        ManifestRulePack::from_json(BODY_MANIFEST).expect("compile body manifest")
8828    }
8829
8830    fn body_request(artifact: &str) -> ValidationRequest {
8831        ValidationRequest {
8832            artifact_kind: ArtifactKind::JsonPayload,
8833            artifact: artifact.to_string(),
8834            claimed_vendor: None,
8835            expansion_state: ExpansionState::Unknown,
8836        }
8837    }
8838
8839    fn codes(report: &ValidationReport) -> Vec<String> {
8840        report
8841            .violations
8842            .iter()
8843            .map(|violation| violation.code.clone())
8844            .collect()
8845    }
8846
8847    #[test]
8848    fn a_body_pack_claims_a_payload_by_shape() {
8849        let pack = body_pack();
8850
8851        assert!(pack.supports(&body_request(r#"{"data":[{"event_name":"Purchase"}]}"#)));
8852        // Same kind, different API: the shape is what keeps packs apart when
8853        // there is no host to go by.
8854        assert!(!pack.supports(&body_request(r#"{"events":[{"name":"Purchase"}]}"#)));
8855        assert!(!pack.supports(&body_request("not json at all")));
8856    }
8857
8858    #[test]
8859    fn an_unstated_kind_is_read_as_a_body_when_it_opens_like_one() {
8860        let mut request = body_request(r#"{"data":[{"event_name":"Purchase"}]}"#);
8861        request.artifact_kind = ArtifactKind::Unknown;
8862
8863        assert!(body_pack().supports(&request));
8864    }
8865
8866    #[test]
8867    fn every_element_of_the_batch_is_checked_on_its_own() {
8868        let report = body_pack().validate(&body_request(
8869            r#"{"data":[{"event_name":"A"},{"event_name":"B"}]}"#,
8870        ));
8871
8872        // Two events, each missing the same two fields: four findings, not two.
8873        assert_eq!(
8874            codes(&report),
8875            vec![
8876                "vendor.test-api.body.event_time.missing",
8877                "vendor.test-api.body.user_data.missing",
8878                "vendor.test-api.body.event_time.missing",
8879                "vendor.test-api.body.user_data.missing",
8880            ]
8881        );
8882    }
8883
8884    #[test]
8885    fn a_finding_points_at_the_bytes_it_is_about() {
8886        let artifact =
8887            r#"{"data":[{"event_name":"A","event_time":17700000000000,"user_data":{}}]}"#;
8888        let report = body_pack().validate(&body_request(artifact));
8889
8890        let violation = report
8891            .violations
8892            .iter()
8893            .find(|violation| violation.code.ends_with("event_time.invalid"))
8894            .expect("the timestamp is reported");
8895        let target = &violation.targets[0];
8896
8897        assert_eq!(target.component, ViolationTargetComponent::BodyField);
8898        assert_eq!(target.name.as_deref(), Some("data[0].event_time"));
8899        assert_eq!(&artifact[target.start..target.end], "17700000000000");
8900    }
8901
8902    #[test]
8903    fn a_missing_field_names_its_path_and_points_at_its_container() {
8904        let artifact = r#"{"data":[{"event_name":"A","user_data":{"em":["ffff"]}}]}"#;
8905        let report = body_pack().validate(&body_request(artifact));
8906
8907        let violation = report
8908            .violations
8909            .iter()
8910            .find(|violation| violation.code.ends_with("event_time.missing"))
8911            .expect("the missing timestamp is reported");
8912        let target = &violation.targets[0];
8913
8914        // The name says exactly which field is absent; the span is the event it
8915        // belongs in, since the field itself has no bytes to point at.
8916        assert_eq!(target.name.as_deref(), Some("data[0].event_time"));
8917        assert_eq!(
8918            &artifact[target.start..target.end],
8919            r#"{"event_name":"A","user_data":{"em":["ffff"]}}"#
8920        );
8921    }
8922
8923    #[test]
8924    fn a_field_missing_from_several_places_is_reported_from_each() {
8925        let manifest = r#"{
8926            "id": "vendor/multi",
8927            "display_name": "Multi",
8928            "description": "Contracts a field that repeats inside one event.",
8929            "docs": "https://example.com/docs",
8930            "match": { "hosts": ["api.example.com"], "json_paths": ["ids[].kind"] },
8931            "body": {
8932                "params": [{ "name": "ids[].kind", "requirement": "required" }]
8933            }
8934        }"#;
8935        let pack = ManifestRulePack::from_json(manifest).expect("compiles");
8936        let report = pack.validate(&body_request(r#"{"ids":[{"kind":"a"},{},{}]}"#));
8937
8938        // Two of the three entries omit it, so it is reported twice.
8939        assert_eq!(
8940            codes(&report),
8941            vec![
8942                "vendor.multi.body.ids[].kind.missing",
8943                "vendor.multi.body.ids[].kind.missing",
8944            ]
8945        );
8946    }
8947
8948    #[test]
8949    fn a_field_under_a_missing_container_is_not_reported() {
8950        let manifest = r#"{
8951            "id": "vendor/nested",
8952            "display_name": "Nested",
8953            "description": "Contracts a container and the fields inside it.",
8954            "docs": "https://example.com/docs",
8955            "match": { "hosts": ["api.example.com"], "json_paths": ["event"] },
8956            "body": {
8957                "params": [
8958                    { "name": "user.ids", "requirement": "required" },
8959                    { "name": "user.ids[].kind", "requirement": "required" }
8960                ]
8961            }
8962        }"#;
8963        let pack = ManifestRulePack::from_json(manifest).expect("compiles");
8964        let report = pack.validate(&body_request(r#"{"event":"x","user":{"name":"a"}}"#));
8965
8966        // `user.ids` is gone, so the contract on what lives inside it has
8967        // nothing to say. Reporting it too would be one defect told twice.
8968        assert_eq!(codes(&report), vec!["vendor.nested.body.user.ids.missing"]);
8969    }
8970
8971    #[test]
8972    fn a_missing_container_does_not_cascade() {
8973        let report = body_pack().validate(&body_request(
8974            r#"{"data":[{"event_name":"A","event_time":1}]}"#,
8975        ));
8976
8977        // `user_data` is absent, so it is reported once. The contract on
8978        // `user_data.em[]` underneath it stays quiet.
8979        assert_eq!(
8980            codes(&report),
8981            vec!["vendor.test-api.body.user_data.missing"]
8982        );
8983    }
8984
8985    #[test]
8986    fn a_scalar_format_does_not_fire_on_the_list_form() {
8987        let report = body_pack().validate(&body_request(
8988            r#"{"data":[{"event_name":"A","event_time":1,"user_data":{"em":["ffff","zzzz"]}}]}"#,
8989        ));
8990
8991        // The good element passes and the bad one is reported: the array itself
8992        // is never measured against a contract written for one value.
8993        assert_eq!(
8994            codes(&report),
8995            vec!["vendor.test-api.body.user_data.em[].invalid"]
8996        );
8997    }
8998
8999    #[test]
9000    fn cross_field_rules_run_per_element() {
9001        let report = body_pack().validate(&body_request(
9002            r#"{"data":[
9003                {"event_name":"Purchase","event_time":1,"user_data":{"em":["ffff"]},"custom_data":{"value":1}},
9004                {"event_name":"Purchase","event_time":1,"user_data":{"em":["ffff"]}}
9005            ]}"#,
9006        ));
9007
9008        assert_eq!(
9009            codes(&report),
9010            vec!["vendor.test-api.body.purchase_needs_value"]
9011        );
9012    }
9013
9014    #[test]
9015    fn alternative_scopes_skip_an_absent_first_envelope() {
9016        let manifest = r#"{
9017            "id": "vendor/alt",
9018            "display_name": "Alt",
9019            "description": "Tries interact then collect.",
9020            "docs": "https://example.com/docs",
9021            "match": {
9022                "hosts": ["api.example.com"],
9023                "json_paths": [{"any_of": ["event.x", "events[].x"]}]
9024            },
9025            "body": {
9026                "scope": ["event", "events[]"],
9027                "params": [
9028                    { "name": "x", "requirement": "required", "format": { "kind": "non_empty" } }
9029                ]
9030            }
9031        }"#;
9032        let pack = ManifestRulePack::from_json(manifest).expect("compiles");
9033        let report = pack.validate(&body_request(r#"{"events":[{"x":""}]}"#));
9034
9035        assert_eq!(codes(&report), vec!["vendor.alt.body.x.empty"]);
9036    }
9037
9038    #[test]
9039    fn an_empty_batch_claims_nothing() {
9040        let pack = body_pack();
9041        let request = body_request(r#"{"data":[]}"#);
9042
9043        // An empty array satisfies no shape, so the pack does not claim the
9044        // payload. Forcing it on anyway says so rather than inventing findings
9045        // about events that are not there.
9046        assert!(!pack.supports(&request));
9047        assert_eq!(
9048            codes(&pack.validate(&request)),
9049            vec!["vendor.test-api.payload_mismatch"]
9050        );
9051    }
9052
9053    #[test]
9054    fn a_payload_of_the_wrong_shape_is_reported_when_the_pack_is_forced() {
9055        let report = body_pack().validate(&body_request(r#"{"events":[{"name":"A"}]}"#));
9056
9057        assert_eq!(codes(&report), vec!["vendor.test-api.payload_mismatch"]);
9058        assert_eq!(report.detected_vendor, None);
9059    }
9060
9061    #[test]
9062    fn a_body_that_does_not_parse_is_left_to_the_core_pack() {
9063        let report = body_pack().validate(&body_request(r#"{"data":[{"event_name":}]}"#));
9064
9065        assert!(report.violations.is_empty());
9066    }
9067
9068    #[test]
9069    fn a_url_artifact_still_takes_the_url_path() {
9070        let pack = body_pack();
9071        let request = ValidationRequest {
9072            artifact_kind: ArtifactKind::Url,
9073            artifact: "https://api.example.com/v1/events".to_string(),
9074            claimed_vendor: None,
9075            expansion_state: ExpansionState::Unknown,
9076        };
9077
9078        assert!(pack.supports(&request));
9079        assert!(pack.validate(&request).violations.is_empty());
9080    }
9081
9082    #[test]
9083    fn a_body_without_a_shape_to_match_is_rejected_at_load() {
9084        let manifest = r#"{
9085            "id": "vendor/loose",
9086            "display_name": "Loose",
9087            "description": "Claims every payload it is shown.",
9088            "docs": "https://example.com/docs",
9089            "match": { "hosts": ["api.example.com"] },
9090            "body": { "params": [{ "name": "a" }] }
9091        }"#;
9092
9093        assert!(matches!(
9094            ManifestRulePack::from_json(manifest),
9095            Err(ManifestError::BodyWithoutShape(_))
9096        ));
9097    }
9098
9099    #[test]
9100    fn a_shape_with_nothing_to_check_is_rejected_at_load() {
9101        let manifest = r#"{
9102            "id": "vendor/idle",
9103            "display_name": "Idle",
9104            "description": "Matches a shape and checks nothing.",
9105            "docs": "https://example.com/docs",
9106            "match": { "hosts": ["api.example.com"], "json_paths": ["data[].a"] }
9107        }"#;
9108
9109        assert!(matches!(
9110            ManifestRulePack::from_json(manifest),
9111            Err(ManifestError::ShapeWithoutBody(_))
9112        ));
9113    }
9114
9115    #[test]
9116    fn a_malformed_path_is_rejected_at_load() {
9117        let manifest = r#"{
9118            "id": "vendor/typo",
9119            "display_name": "Typo",
9120            "description": "Has a path that addresses nothing.",
9121            "docs": "https://example.com/docs",
9122            "match": { "hosts": ["api.example.com"], "json_paths": ["data[]."] },
9123            "body": { "params": [{ "name": "a" }] }
9124        }"#;
9125
9126        assert!(matches!(
9127            ManifestRulePack::from_json(manifest),
9128            Err(ManifestError::InvalidJsonPath { .. })
9129        ));
9130    }
9131
9132    #[test]
9133    fn a_pack_can_contract_the_envelope_and_the_events_inside_it() {
9134        let manifest = r#"{
9135            "id": "vendor/two-level",
9136            "display_name": "Two Level",
9137            "description": "Contracts the envelope and each event.",
9138            "docs": "https://example.com/docs",
9139            "match": { "hosts": ["api.example.com"], "json_paths": ["events[].name"] },
9140            "body": [
9141                { "params": [{ "name": "client_id", "requirement": "required" }] },
9142                {
9143                    "scope": "events[]",
9144                    "params": [{ "name": "name", "requirement": "required" }]
9145                }
9146            ]
9147        }"#;
9148
9149        let pack = ManifestRulePack::from_json(manifest).expect("compiles");
9150        let report = pack.validate(&body_request(r#"{"events":[{"name":"a"},{"id":1}]}"#));
9151
9152        // The envelope is checked once and each event on its own.
9153        assert_eq!(
9154            codes(&report),
9155            vec![
9156                "vendor.two-level.body.client_id.missing",
9157                "vendor.two-level.body.name.missing",
9158            ]
9159        );
9160    }
9161
9162    #[test]
9163    fn url_and_body_may_contract_the_same_name() {
9164        let manifest = r#"{
9165            "id": "vendor/both",
9166            "display_name": "Both",
9167            "description": "Accepts the token in either place.",
9168            "docs": "https://example.com/docs",
9169            "match": { "hosts": ["api.example.com"], "json_paths": ["data[].a"] },
9170            "params": [{ "name": "access_token", "requirement": "required" }],
9171            "body": {
9172                "scope": "data[]",
9173                "params": [{ "name": "access_token", "requirement": "required" }]
9174            }
9175        }"#;
9176
9177        let pack = ManifestRulePack::from_json(manifest).expect("compiles");
9178        let report = pack.validate(&body_request(r#"{"data":[{"a":1}]}"#));
9179
9180        // Same field name, different carrier, so the codes have to differ.
9181        assert_eq!(
9182            codes(&report),
9183            vec!["vendor.both.body.access_token.missing"]
9184        );
9185    }
9186}
9187
9188#[cfg(test)]
9189mod indexed_query_metadata_tests {
9190    use super::QuerySpec;
9191
9192    #[test]
9193    fn absent_indexed_prefix_does_not_change_legacy_serialized_scope_metadata() {
9194        let scope: QuerySpec = serde_json::from_str(r#"{"params":[]}"#).unwrap();
9195        assert!(
9196            serde_json::to_value(scope)
9197                .unwrap()
9198                .get("key_prefix")
9199                .is_none()
9200        );
9201    }
9202}