1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ParamStyle {
32 #[default]
34 Query,
35 QuerySemicolon,
38 Matrix,
41 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Requirement {
63 Required,
65 Recommended,
67 #[default]
69 Optional,
70 Forbidden,
72 Deprecated,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
80pub enum ValueFormat {
81 NonEmpty,
83 NumericRanges { ranges: Vec<NumericRange> },
85 Integer {
87 #[serde(default)]
88 min_digits: Option<usize>,
89 #[serde(default)]
90 max_digits: Option<usize>,
91 },
92 Enum {
94 values: Vec<String>,
95 #[serde(default)]
96 case_insensitive: bool,
97 },
98 Regex { pattern: String },
100 Url {
103 #[serde(default)]
104 require_https: bool,
105 },
106 Hex { length: usize },
108 Ip {
110 #[serde(default)]
111 version: Option<IpVersion>,
112 #[serde(default)]
114 exclude_ranges: Vec<String>,
115 },
116 IpChain,
118 #[serde(rename = "datetime_formats")]
120 DateTimeFormats {
121 formats: Vec<DateTimeRepresentation>,
122 },
123 #[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 Date,
141 Json,
143 AdobeProducts,
145 AdobeEvents,
147 AdditionalConsent,
149 BrazeTime,
151 Currency {
153 #[serde(default)]
154 allow_historical: bool,
155 #[serde(default)]
156 case_insensitive: bool,
157 },
158 Tcf,
160 Gpp,
162 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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct MatchSpec {
197 #[serde(default)]
199 pub any_host: bool,
200 #[serde(default)]
202 pub artifact_kinds: Vec<ArtifactKind>,
203 #[serde(default)]
205 pub hosts: Vec<String>,
206 #[serde(default)]
209 pub host_suffixes: Vec<String>,
210 #[serde(default)]
212 pub paths: Vec<String>,
213 #[serde(default)]
215 pub path_prefixes: Vec<String>,
216 #[serde(default)]
218 pub path_contains: Vec<String>,
219 #[serde(default)]
222 pub path_patterns: Vec<String>,
223 #[serde(default)]
225 pub query_params_any: Vec<String>,
226 #[serde(default)]
228 pub query_params_none: Vec<String>,
229 #[serde(default)]
231 pub query_params_values: Vec<QueryParamValuesMatch>,
232 #[serde(default)]
236 pub json_paths: Vec<ShapeMatch>,
237}
238
239#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(untagged, deny_unknown_fields)]
250pub enum ShapeMatch {
251 Present(String),
253 Excludes { path: String, excludes: String },
265 Matches { path: String, pattern: String },
267 Type { path: String, json_type: JsonTypes },
269 Any { any_of: Vec<ShapeMatch> },
273}
274
275impl ShapeMatch {
276 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct ParamContract {
322 pub name: String,
323 #[serde(default)]
325 pub root_path: Option<String>,
326 #[serde(default)]
328 pub ancestor_path: Option<AncestorPath>,
329 #[serde(default)]
334 pub name_pattern: Option<String>,
335 #[serde(default)]
338 pub name_pattern_parent: Option<String>,
339 #[serde(default)]
341 pub aliases: Vec<String>,
342 #[serde(default)]
344 pub condition: Option<RuleCondition>,
345 #[serde(default)]
347 pub normalization: Option<StringNormalization>,
348 #[serde(default)]
349 pub requirement: Requirement,
350 #[serde(default)]
351 pub format: Option<ValueFormat>,
352 #[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 #[serde(default)]
366 pub property_exclusions: Vec<String>,
367 #[serde(default)]
369 pub max_depth: Option<usize>,
370 #[serde(default)]
372 pub max_member_values: Option<usize>,
373 #[serde(default)]
375 pub max_joined_length: Option<usize>,
376 #[serde(default)]
377 pub join_separator: Option<String>,
378 #[serde(default)]
380 pub max_compact_bytes: Option<usize>,
381 #[serde(default)]
383 pub max_key_length: Option<usize>,
384 #[serde(default)]
385 pub key_pattern: Option<String>,
386 #[serde(default)]
388 pub min_length: Option<usize>,
389 #[serde(default)]
390 pub max_length: Option<usize>,
391 #[serde(default)]
393 pub max_utf16_length: Option<usize>,
394 #[serde(default)]
396 pub min_byte_length: Option<usize>,
397 #[serde(default)]
398 pub max_byte_length: Option<usize>,
399 #[serde(default)]
401 pub minimum: Option<serde_json::Number>,
402 #[serde(default)]
403 pub maximum: Option<serde_json::Number>,
404 #[serde(default)]
406 pub multiple_of: Option<serde_json::Number>,
407 #[serde(default)]
409 pub numeric_strings: bool,
410 #[serde(default)]
412 pub null_as_missing: bool,
413 #[serde(default)]
415 pub severity: Option<Severity>,
416 #[serde(default)]
421 pub format_severity: Option<Severity>,
422 #[serde(default)]
424 pub description: Option<String>,
425 #[serde(default)]
426 pub fix_hint: Option<String>,
427 #[serde(default)]
430 pub doc: Option<String>,
431 #[serde(default)]
433 pub source_level: Option<RuleSourceLevel>,
434 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(tag = "kind", rename_all = "snake_case")]
459pub enum Assertion {
460 RequireOneOf { params: Vec<String> },
462 MutuallyExclusive { params: Vec<String> },
464 RequiredWith { when: String, requires: Vec<String> },
466 RequiredWhenValue {
470 when: String,
471 equals: Vec<String>,
472 requires: Vec<String>,
473 },
474 ForbidValuePattern {
477 pattern: String,
478 #[serde(default)]
479 params: Vec<String>,
480 },
481 ValueWhen {
485 when: String,
486 equals: Vec<String>,
487 param: String,
488 value: String,
489 },
490 ForbiddenWhenValue {
492 when: String,
493 equals: Vec<String>,
494 params: Vec<String>,
495 },
496 RequireAnyOf {
498 groups: Vec<Vec<String>>,
499 #[serde(default)]
501 allow_empty: bool,
502 },
503 FormatWhen {
505 when: String,
506 equals: Vec<String>,
507 param: String,
508 format: ValueFormat,
509 #[serde(default)]
510 pair_occurrences: bool,
511 },
512 Format { param: String, format: ValueFormat },
514 LastParam { param: String },
516 MaxQueryLength { max_length: usize },
518 RequireHttps,
520 MaxUrlLength { max_length: usize },
522 MaxBodyBytes { max_bytes: usize },
524 ValueWith {
526 when: String,
527 param: String,
528 value: String,
529 },
530 MinLengthFrom {
532 params: Vec<String>,
533 length_param: String,
534 default_length: usize,
535 },
536 LessEqual {
538 left: String,
539 right: String,
540 #[serde(default)]
541 strict: bool,
542 #[serde(default)]
543 unit: Option<TimestampUnit>,
544 },
545 EqualValues { left: String, right: String },
547 FtrackFingerprint { indexed: String, hash: String },
549 UrlPathLength { param: String, max_length: usize },
551 EqualSplitLengths {
553 params: Vec<String>,
554 separator: String,
555 },
556 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 DelimitedSum {
567 param: String,
568 total: String,
569 separator: String,
570 value_separator: String,
571 },
572 AwinBasket {
574 parts: String,
575 check: AwinBasketCheck,
576 },
577 GppSections { param: String, sections: String },
579 TcfConsent {
581 param: String,
582 vendor_id: u16,
583 purpose_ids: Vec<u8>,
584 },
585 GppFieldValues {
587 param: String,
588 section_param: String,
589 field: String,
590 values: Vec<u8>,
591 },
592 EqualArrayLengths { params: Vec<String> },
594 MaxTotalArrayItems {
596 params: Vec<String>,
597 max_items: usize,
598 },
599 UniqueArrayBy { param: String, field: String },
601 EqualOccurrences { params: Vec<String> },
603 MaxOccurrences {
605 param: String,
606 max_occurrences: usize,
607 #[serde(default)]
609 bare_url_only: bool,
610 },
611 TimeWindow {
613 param: String,
614 #[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#[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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
914pub struct PackRule {
915 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933#[serde(deny_unknown_fields)]
934pub struct RulePackManifest {
935 #[serde(default)]
937 pub gdpr_non_applicable_values: Vec<String>,
938 #[serde(default)]
940 pub gdpr_consent_aliases: Vec<String>,
941 #[serde(default)]
943 pub http_url_presence_overrides: Vec<String>,
944 pub id: String,
946 pub display_name: String,
947 pub description: String,
948 #[serde(default)]
950 pub version: Option<String>,
951 #[serde(default)]
953 pub vendor: Option<String>,
954 #[serde(default = "default_source_level")]
955 pub source_level: RuleSourceLevel,
956 #[serde(default)]
958 pub docs: Option<String>,
959 #[serde(default)]
960 pub param_style: ParamStyle,
961 #[serde(default)]
965 pub path_pattern: Option<String>,
966 #[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 #[serde(default)]
978 pub query_scopes: Vec<QuerySpec>,
979 #[serde(default)]
984 pub body: Option<BodySpecs>,
985 #[serde(default)]
988 pub http: Option<BodySpecs>,
989 #[serde(default)]
991 pub http_headerless_form: bool,
992 #[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 #[serde(default)]
1010 pub native_map: Option<RequestQueryMapCoercion>,
1011 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032#[serde(deny_unknown_fields)]
1033pub struct QuerySpec {
1034 #[serde(default)]
1036 pub source: QuerySource,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub key_prefix: Option<String>,
1040 #[serde(default)]
1042 pub global_params: Vec<String>,
1043 #[serde(default)]
1045 pub first_index: usize,
1046 #[serde(default)]
1048 pub last_index: Option<usize>,
1049 #[serde(default)]
1051 pub condition: Option<RuleCondition>,
1052 #[serde(default)]
1053 pub params: Vec<ParamContract>,
1054 #[serde(default)]
1055 pub rules: Vec<PackRule>,
1056 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(untagged)]
1106pub enum ScopeSpec {
1107 One(String),
1109 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1137#[serde(deny_unknown_fields)]
1138pub struct BodySpec {
1139 #[serde(default)]
1141 pub source_param: Option<String>,
1142 #[serde(default)]
1144 pub source_field: Option<String>,
1145 #[serde(default)]
1147 pub decoded_source_field: Option<String>,
1148 #[serde(default)]
1150 pub decoded_source_condition: Option<ShapeMatch>,
1151 #[serde(default)]
1153 pub source_max_length: Option<usize>,
1154 #[serde(default)]
1156 pub source_max_length_when: Option<ShapeMatch>,
1157 #[serde(default)]
1159 pub condition: Option<RuleCondition>,
1160 #[serde(default)]
1161 pub encoding: ParamEncoding,
1162 #[serde(default)]
1164 pub encoding_severity: Option<Severity>,
1165 #[serde(default)]
1167 pub field_encoding: ParamEncoding,
1168 #[serde(default)]
1169 pub scope: Option<ScopeSpec>,
1170 #[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 Base64Latin1Json,
1187 PipeDelimitedJson,
1189 PercentEncodedJson,
1191 QueryParamsJson,
1193 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#[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
1414struct Scope<'a> {
1418 code_segment: &'static str,
1422 artifact: &'a str,
1425 raw_artifact_len: usize,
1426 reference_time_unix_seconds: i64,
1427 complete_http_request: bool,
1428 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
1464struct 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 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#[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#[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 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
1645fn 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
1684fn 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
1965fn 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 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| ¶m.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 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(¶ms);
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 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 .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(¶ms);
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(¶ms);
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 pub fn from_json(json: &str) -> Result<Self, ManifestError> {
2837 Self::compile(RulePackManifest::from_json(json)?)
2838 }
2839
2840 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 pub fn vendor(&self) -> Option<&str> {
2850 self.vendor.as_deref()
2851 }
2852
2853 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 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 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 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 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 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, ×tamp)
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 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 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 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 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 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, ¶ms, &|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(¶ms) != 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, ¶ms, &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, ¶ms, 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 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 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 ¶m,
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 ¶m,
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 ¶m,
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 (¶m.family_parent, ¶m.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 if addressed.is_empty() {
4715 return;
4716 }
4717
4718 let missing: Vec<_> = addressed
4722 .iter()
4723 .filter(|slot| slot.missing)
4724 .map(|slot| slot.target())
4725 .collect();
4726 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 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 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 if contains_macro(param.value.as_ref())
4884 && !matches!(contract.format, Some(ValueFormat::IpChain))
4885 {
4886 continue;
4887 }
4888
4889 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 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 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 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(§ion.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 || (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 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 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) = ¶m.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 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
6367fn 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
6375fn 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 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 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
6643fn 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#[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 pub(crate) component: ViolationTargetComponent,
7360 pub(crate) location: Option<String>,
7363 pub(crate) container: bool,
7367 pub(crate) missing: bool,
7374 family_parent: Option<String>,
7375 member_name: Option<String>,
7376 json_kind: Option<JsonValueKind>,
7377 omit_target_value: bool,
7380}
7381
7382impl<'a> RawParam<'a> {
7383 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
7426fn 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 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 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
7468type 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 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 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
7642fn 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
7708fn 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
7763pub(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 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 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
7866fn 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 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
7927pub(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
7943fn 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
7956fn 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
7966fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}