1use std::collections::{BTreeMap, BTreeSet};
2use std::error::Error;
3use std::fmt;
4use std::path::Path;
5use std::sync::Arc;
6
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10pub mod directory;
11mod json;
12pub mod manifest;
13mod privacy;
14
15pub use directory::{DIRECTORY_ID, DirectoryError, VendorDirectory, VendorEntry};
16pub use manifest::{
17 Assertion, ManifestError, ManifestRulePack, MatchSpec, PackRule, ParamContract, ParamStyle,
18 Requirement, RulePackManifest, ValueFormat,
19};
20
21pub const BUILTIN_VENDOR_DIRECTORY: &str = include_str!("../rulepacks/directory.json");
23
24pub const BUILTIN_VENDOR_MANIFESTS: &[(&str, &str)] = &[
29 (
30 "vendor/adobe-analytics",
31 include_str!("../rulepacks/vendor/adobe-analytics.json"),
32 ),
33 (
34 "vendor/amplitude",
35 include_str!("../rulepacks/vendor/amplitude.json"),
36 ),
37 (
38 "vendor/braze",
39 include_str!("../rulepacks/vendor/braze.json"),
40 ),
41 (
42 "vendor/floodlight",
43 include_str!("../rulepacks/vendor/floodlight.json"),
44 ),
45 (
46 "vendor/google-ads-conversion",
47 include_str!("../rulepacks/vendor/google-ads-conversion.json"),
48 ),
49 (
50 "vendor/google-analytics",
51 include_str!("../rulepacks/vendor/google-analytics.json"),
52 ),
53 (
54 "vendor/google-analytics-collect",
55 include_str!("../rulepacks/vendor/google-analytics-collect.json"),
56 ),
57 (
58 "vendor/google-tag-manager",
59 include_str!("../rulepacks/vendor/google-tag-manager.json"),
60 ),
61 (
62 "vendor/klaviyo",
63 include_str!("../rulepacks/vendor/klaviyo.json"),
64 ),
65 (
66 "vendor/linkedin",
67 include_str!("../rulepacks/vendor/linkedin.json"),
68 ),
69 (
70 "vendor/linkedin-conversions-api",
71 include_str!("../rulepacks/vendor/linkedin-conversions-api.json"),
72 ),
73 ("vendor/meta", include_str!("../rulepacks/vendor/meta.json")),
74 (
75 "vendor/meta-conversions-api",
76 include_str!("../rulepacks/vendor/meta-conversions-api.json"),
77 ),
78 (
79 "vendor/microsoft-uet",
80 include_str!("../rulepacks/vendor/microsoft-uet.json"),
81 ),
82 (
83 "vendor/mixpanel",
84 include_str!("../rulepacks/vendor/mixpanel.json"),
85 ),
86 (
87 "vendor/pinterest",
88 include_str!("../rulepacks/vendor/pinterest.json"),
89 ),
90 (
91 "vendor/posthog",
92 include_str!("../rulepacks/vendor/posthog.json"),
93 ),
94 (
95 "vendor/reddit",
96 include_str!("../rulepacks/vendor/reddit.json"),
97 ),
98 (
99 "vendor/segment",
100 include_str!("../rulepacks/vendor/segment.json"),
101 ),
102 (
103 "vendor/snapchat",
104 include_str!("../rulepacks/vendor/snapchat.json"),
105 ),
106 (
107 "vendor/tiktok",
108 include_str!("../rulepacks/vendor/tiktok.json"),
109 ),
110];
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum ArtifactKind {
115 Url,
116 #[serde(rename = "html")]
117 HtmlSnippet,
118 #[serde(rename = "js")]
119 JavaScriptSnippet,
120 #[serde(rename = "gtm")]
121 GtmTemplate,
122 #[serde(rename = "request")]
123 NetworkRequest,
124 #[serde(rename = "vast")]
125 VastTracker,
126 #[serde(rename = "postback")]
127 ServerPostback,
128 #[serde(rename = "json")]
130 JsonPayload,
131 Unknown,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ExpansionState {
137 #[default]
138 Unknown,
139 Template,
140 Fired,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum RuleSourceLevel {
146 Normative,
147 OfficialVendor,
148 OfficialTemplate,
149 EcosystemReference,
150 Heuristic,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum Severity {
156 Error,
157 Warning,
158 Info,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct RuleSource {
163 pub level: RuleSourceLevel,
164 pub name: String,
165 pub reference: Option<String>,
166}
167
168impl RuleSource {
169 pub fn normative(name: impl Into<String>, reference: impl Into<String>) -> Self {
170 Self {
171 level: RuleSourceLevel::Normative,
172 name: name.into(),
173 reference: Some(reference.into()),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ViolationTargetComponent {
181 WholeUrl,
182 Scheme,
183 Authority,
184 UserInfo,
185 Host,
186 Port,
187 Path,
188 QueryParam,
189 Fragment,
190 BodyField,
192 WholeBody,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
197pub struct ViolationTarget {
198 pub component: ViolationTargetComponent,
199 pub name: Option<String>,
200 pub value: Option<String>,
201 pub start: usize,
202 pub end: usize,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct Violation {
207 pub code: String,
208 pub message: String,
209 pub severity: Severity,
210 pub field: Option<String>,
211 pub fix_hint: Option<String>,
212 pub source: RuleSource,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub targets: Vec<ViolationTarget>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct RulePackMetadata {
219 pub id: String,
220 pub display_name: String,
221 pub version: String,
222 pub description: String,
223 pub source_level: RuleSourceLevel,
224 pub vendor: Option<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
229pub struct ValidationRequest {
230 pub artifact_kind: ArtifactKind,
231 pub artifact: String,
232 pub claimed_vendor: Option<String>,
233 pub expansion_state: ExpansionState,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
237pub struct ValidationOptions {
238 pub only_rulepacks: Vec<String>,
239 pub except_rulepacks: Vec<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243pub struct ValidationReport {
244 pub plugin_id: String,
245 pub detected_vendor: Option<String>,
246 pub violations: Vec<Violation>,
247}
248
249impl ValidationReport {
250 pub fn is_ok(&self) -> bool {
251 self.violations
252 .iter()
253 .all(|violation| violation.severity != Severity::Error)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
258pub struct ValidationSummary {
259 pub reports: Vec<ValidationReport>,
260}
261
262impl ValidationSummary {
263 pub fn is_ok(&self) -> bool {
264 self.reports.iter().all(ValidationReport::is_ok)
265 }
266}
267
268pub trait ValidatorPlugin: Send + Sync {
269 fn metadata(&self) -> &RulePackMetadata;
270 fn supports(&self, request: &ValidationRequest) -> bool;
271 fn validate(&self, request: &ValidationRequest) -> ValidationReport;
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum EngineError {
276 PluginNotFound(String),
277 NoMatchingPlugin,
278 NoRulepacksSelected,
279}
280
281impl fmt::Display for EngineError {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 match self {
284 Self::PluginNotFound(plugin_id) => {
285 write!(f, "rulepack not found: {plugin_id}")
286 }
287 Self::NoMatchingPlugin => write!(f, "no matching rulepack found"),
288 Self::NoRulepacksSelected => write!(f, "no rulepacks remain after applying toggles"),
289 }
290 }
291}
292
293impl Error for EngineError {}
294
295pub struct Engine {
296 plugins: BTreeMap<String, Arc<dyn ValidatorPlugin>>,
297 directory: VendorDirectory,
298}
299
300impl Default for Engine {
301 fn default() -> Self {
305 let mut engine = Self::new();
306 engine.set_directory(VendorDirectory::builtin());
307 engine.register(CoreRulePack::default());
308
309 for (id, json) in BUILTIN_VENDOR_MANIFESTS {
310 engine.register_manifest_json(json).unwrap_or_else(|error| {
311 panic!("built-in rulepack `{id}` failed to compile: {error}")
312 });
313 }
314
315 engine
316 }
317}
318
319impl Engine {
320 pub fn new() -> Self {
321 Self {
322 plugins: BTreeMap::new(),
323 directory: VendorDirectory::default(),
324 }
325 }
326
327 pub fn set_directory(&mut self, directory: VendorDirectory) {
330 self.directory = directory;
331 }
332
333 pub fn directory(&self) -> &VendorDirectory {
335 &self.directory
336 }
337
338 pub fn register<P>(&mut self, plugin: P)
339 where
340 P: ValidatorPlugin + 'static,
341 {
342 self.plugins
343 .insert(plugin.metadata().id.clone(), Arc::new(plugin));
344 }
345
346 pub fn register_manifest_json(&mut self, json: &str) -> Result<(), ManifestError> {
348 self.register(ManifestRulePack::from_json(json)?);
349 Ok(())
350 }
351
352 pub fn register_manifest_path(&mut self, path: impl AsRef<Path>) -> Result<(), ManifestError> {
354 self.register(ManifestRulePack::from_path(path)?);
355 Ok(())
356 }
357
358 pub fn list_rulepacks(&self) -> Vec<RulePackMetadata> {
359 self.plugins
360 .values()
361 .map(|plugin| plugin.metadata().clone())
362 .collect()
363 }
364
365 pub fn validate(
366 &self,
367 request: &ValidationRequest,
368 options: &ValidationOptions,
369 ) -> Result<ValidationSummary, EngineError> {
370 let plugins = self.select_plugins(request, options)?;
371 let mut reports: Vec<ValidationReport> = plugins
372 .into_iter()
373 .map(|plugin| plugin.validate(request))
374 .collect();
375
376 if let Some(report) = self.directory_report(request, options, &reports) {
377 reports.push(report);
378 }
379
380 Ok(ValidationSummary { reports })
381 }
382
383 fn select_plugins(
384 &self,
385 request: &ValidationRequest,
386 options: &ValidationOptions,
387 ) -> Result<Vec<Arc<dyn ValidatorPlugin>>, EngineError> {
388 self.ensure_known_rulepacks(&options.only_rulepacks)?;
389 self.ensure_known_rulepacks(&options.except_rulepacks)?;
390
391 let excluded: BTreeSet<&str> = options
392 .except_rulepacks
393 .iter()
394 .map(String::as_str)
395 .collect();
396
397 let only_packs: Vec<&String> = options
400 .only_rulepacks
401 .iter()
402 .filter(|rulepack_id| rulepack_id.as_str() != DIRECTORY_ID)
403 .collect();
404
405 if !options.only_rulepacks.is_empty() {
406 if only_packs.is_empty() {
407 return Ok(Vec::new());
408 }
409
410 let selected = only_packs
411 .into_iter()
412 .filter(|rulepack_id| !excluded.contains(rulepack_id.as_str()))
413 .filter_map(|rulepack_id| self.plugins.get(rulepack_id).map(Arc::clone))
414 .collect::<Vec<_>>();
415
416 if selected.is_empty() {
417 return Err(EngineError::NoRulepacksSelected);
418 }
419
420 return Ok(selected);
421 }
422
423 let selected = self
424 .plugins
425 .values()
426 .filter(|plugin| !excluded.contains(plugin.metadata().id.as_str()))
427 .filter(|plugin| plugin.supports(request))
428 .map(Arc::clone)
429 .collect::<Vec<_>>();
430
431 if selected.is_empty() {
432 if excluded.is_empty() {
433 Err(EngineError::NoMatchingPlugin)
434 } else {
435 Err(EngineError::NoRulepacksSelected)
436 }
437 } else {
438 Ok(selected)
439 }
440 }
441
442 fn directory_report(
447 &self,
448 request: &ValidationRequest,
449 options: &ValidationOptions,
450 reports: &[ValidationReport],
451 ) -> Option<ValidationReport> {
452 if options.except_rulepacks.iter().any(|id| id == DIRECTORY_ID) {
453 return None;
454 }
455
456 if !options.only_rulepacks.is_empty()
457 && !options.only_rulepacks.iter().any(|id| id == DIRECTORY_ID)
458 {
459 return None;
460 }
461
462 if reports
463 .iter()
464 .any(|report| report.detected_vendor.is_some())
465 {
466 return None;
467 }
468
469 let artifact = request.artifact.trim();
470 let host = manifest::artifact_host(artifact)?;
471 let entry = self.directory.lookup_host(&host)?;
472
473 let mut message = format!(
474 "This endpoint belongs to {} ({}). No Pixellint rulepack covers it, so only the core checks ran.",
475 entry.display_name, entry.category
476 );
477
478 if let Some(rulepack) = &entry.rulepack {
479 message.push_str(&format!(
480 " The `{rulepack}` rulepack covers other {} endpoints, not this one.",
481 entry.display_name
482 ));
483 }
484
485 let target = artifact
486 .find(&host)
487 .map(|start| ViolationTarget {
488 component: ViolationTargetComponent::Host,
489 name: None,
490 value: Some(host.clone()),
491 start,
492 end: start + host.len(),
493 })
494 .unwrap_or(ViolationTarget {
495 component: ViolationTargetComponent::WholeUrl,
496 name: None,
497 value: None,
498 start: 0,
499 end: artifact.len(),
500 });
501
502 Some(ValidationReport {
503 plugin_id: DIRECTORY_ID.to_string(),
504 detected_vendor: Some(entry.vendor.clone()),
505 violations: vec![Violation {
506 code: "directory.no_rulepack_coverage".to_string(),
507 message,
508 severity: Severity::Info,
509 field: Some("url.host".to_string()),
510 fix_hint: Some(
511 "Write a custom rulepack for this endpoint, or ask for first-party coverage."
512 .to_string(),
513 ),
514 source: RuleSource {
515 level: RuleSourceLevel::EcosystemReference,
516 name: "Pixellint vendor directory".to_string(),
517 reference: None,
518 },
519 targets: vec![target],
520 }],
521 })
522 }
523
524 fn ensure_known_rulepacks(&self, rulepack_ids: &[String]) -> Result<(), EngineError> {
525 for rulepack_id in rulepack_ids {
526 if rulepack_id == DIRECTORY_ID {
527 continue;
528 }
529
530 if !self.plugins.contains_key(rulepack_id) {
531 return Err(EngineError::PluginNotFound(rulepack_id.clone()));
532 }
533 }
534
535 Ok(())
536 }
537}
538
539pub struct CoreRulePack {
540 metadata: RulePackMetadata,
541}
542
543impl Default for CoreRulePack {
544 fn default() -> Self {
545 Self {
546 metadata: RulePackMetadata {
547 id: "core".to_string(),
548 display_name: "Core Cardinal Rules".to_string(),
549 version: env!("CARGO_PKG_VERSION").to_string(),
550 description:
551 "Shared, spec-backed baseline checks for URL-like measurement artifacts."
552 .to_string(),
553 source_level: RuleSourceLevel::Normative,
554 vendor: None,
555 },
556 }
557 }
558}
559
560impl ValidatorPlugin for CoreRulePack {
561 fn metadata(&self) -> &RulePackMetadata {
562 &self.metadata
563 }
564
565 fn supports(&self, _request: &ValidationRequest) -> bool {
566 true
567 }
568
569 fn validate(&self, request: &ValidationRequest) -> ValidationReport {
570 let mut violations = Vec::new();
571 let artifact = request.artifact.trim();
572
573 if artifact.is_empty() {
574 violations.push(Violation {
575 code: "core.input.empty".to_string(),
576 message: "Artifact is empty.".to_string(),
577 severity: Severity::Error,
578 field: None,
579 fix_hint: Some(
580 "Provide a pixel URL, snippet, template, or request to validate.".to_string(),
581 ),
582 source: RuleSource {
583 level: RuleSourceLevel::Heuristic,
584 name: "Pixellint input baseline".to_string(),
585 reference: None,
586 },
587 targets: Vec::new(),
588 });
589 }
590
591 if !artifact.is_empty() {
592 match request.artifact_kind {
593 ArtifactKind::Url | ArtifactKind::VastTracker | ArtifactKind::ServerPostback => {
594 validate_url_like_artifact(artifact, request.expansion_state, &mut violations);
595 privacy::apply_privacy_rules(artifact, &mut violations);
596 }
597 ArtifactKind::JsonPayload => validate_json_artifact(artifact, &mut violations),
598 ArtifactKind::Unknown if json::JsonDocument::looks_like_json(artifact) => {
601 validate_json_artifact(artifact, &mut violations);
602 }
603 _ => {}
604 }
605 }
606
607 ValidationReport {
610 plugin_id: self.metadata.id.clone(),
611 detected_vendor: None,
612 violations,
613 }
614 }
615}
616
617fn validate_json_artifact(artifact: &str, violations: &mut Vec<Violation>) {
620 let Err(error) = json::JsonDocument::parse(artifact) else {
621 return;
622 };
623
624 violations.push(Violation {
625 code: "core.json.parse_error".to_string(),
626 message: format!("Request body is not valid JSON: {error}."),
627 severity: Severity::Error,
628 field: Some("body".to_string()),
629 fix_hint: Some(
630 "Fix the payload so it parses, then validate it again. Serializers that emit trailing commas or unquoted keys are the usual cause."
631 .to_string(),
632 ),
633 source: RuleSource::normative(
634 "RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format",
635 "https://www.rfc-editor.org/rfc/rfc8259",
636 ),
637 targets: vec![ViolationTarget {
638 component: ViolationTargetComponent::WholeBody,
639 name: None,
640 value: None,
641 start: error.offset.min(artifact.len()),
642 end: artifact.len(),
643 }],
644 });
645}
646
647fn validate_url_like_artifact(
648 artifact: &str,
649 expansion_state: ExpansionState,
650 violations: &mut Vec<Violation>,
651) {
652 let macro_spans = detect_macro_spans(artifact);
653 let has_unsafe_macro_positions =
654 apply_macro_rules(artifact, expansion_state, ¯o_spans, violations);
655
656 if has_unsafe_macro_positions {
657 return;
658 }
659
660 let parse_artifact = if macro_spans.is_empty() {
661 artifact.to_string()
662 } else {
663 sanitize_macro_spans(artifact, ¯o_spans)
664 };
665
666 if has_missing_network_host(&parse_artifact) {
667 violations.push(Violation {
668 code: "core.url.host_missing".to_string(),
669 message: "Network-delivered tracking URLs must include a host component.".to_string(),
670 severity: Severity::Error,
671 field: Some("url".to_string()),
672 fix_hint: Some(
673 "Provide a fully qualified endpoint such as https://example.com/pixel.".to_string(),
674 ),
675 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
676 targets: Vec::new(),
677 });
678 return;
679 }
680
681 match Url::parse(&parse_artifact) {
682 Ok(url) => {
683 if url.scheme() == "http" {
684 violations.push(Violation {
685 code: "core.url.insecure_transport".to_string(),
686 message:
687 "Plain http tracking endpoints are discouraged; use https for measurement artifacts."
688 .to_string(),
689 severity: Severity::Warning,
690 field: Some("url".to_string()),
691 fix_hint: Some(
692 "Upgrade the endpoint to https so trackers remain compatible with secure playback and delivery environments."
693 .to_string(),
694 ),
695 source: RuleSource {
696 level: RuleSourceLevel::EcosystemReference,
697 name: "Secure tracking transport baseline".to_string(),
698 reference: None,
699 },
700 targets: Vec::new(),
701 });
702 }
703
704 if !matches!(url.scheme(), "http" | "https") {
705 violations.push(Violation {
706 code: "core.url.unsupported_scheme".to_string(),
707 message:
708 "Only http and https URL artifacts are supported by the core rulepack."
709 .to_string(),
710 severity: Severity::Error,
711 field: Some("url".to_string()),
712 fix_hint: Some(
713 "Use an http or https endpoint for network-delivered tracking artifacts."
714 .to_string(),
715 ),
716 source: RuleSource::normative(
717 "W3C Beacon / URL transport baseline",
718 "https://www.w3.org/TR/beacon/",
719 ),
720 targets: Vec::new(),
721 });
722 }
723
724 if url.host_str().is_none() {
725 violations.push(Violation {
726 code: "core.url.host_missing".to_string(),
727 message: "Network-delivered tracking URLs must include a host component."
728 .to_string(),
729 severity: Severity::Error,
730 field: Some("url".to_string()),
731 fix_hint: Some(
732 "Provide a fully qualified endpoint such as https://example.com/pixel."
733 .to_string(),
734 ),
735 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
736 targets: Vec::new(),
737 });
738 }
739
740 if !url.username().is_empty() || url.password().is_some() {
741 violations.push(Violation {
742 code: "core.url.userinfo_deprecated".to_string(),
743 message:
744 "Credentials embedded in tracking URLs are deprecated and should not be used."
745 .to_string(),
746 severity: Severity::Warning,
747 field: Some("url".to_string()),
748 fix_hint: Some(
749 "Move credentials to a safer transport or server-side configuration."
750 .to_string(),
751 ),
752 source: RuleSource::normative(
753 "RFC 3986 URI generic syntax",
754 "https://www.rfc-editor.org/rfc/rfc3986",
755 ),
756 targets: Vec::new(),
757 });
758 }
759
760 if url.fragment().is_some() {
761 violations.push(Violation {
762 code: "core.url.fragment_ignored".to_string(),
763 message:
764 "URL fragments are not transmitted to the server and cannot carry measurement parameters."
765 .to_string(),
766 severity: Severity::Warning,
767 field: Some("url".to_string()),
768 fix_hint: Some(
769 "Move tracking data into the query string or request body."
770 .to_string(),
771 ),
772 source: RuleSource::normative(
773 "RFC 3986 URI generic syntax",
774 "https://www.rfc-editor.org/rfc/rfc3986",
775 ),
776 targets: Vec::new(),
777 });
778 }
779 }
780 Err(_) => violations.push(Violation {
781 code: "core.url.invalid".to_string(),
782 message: "Artifact is not a valid URL.".to_string(),
783 severity: Severity::Error,
784 field: Some("url".to_string()),
785 fix_hint: Some(
786 "Provide a fully qualified URL such as https://example.com/pixel?x=1".to_string(),
787 ),
788 source: RuleSource::normative("URL Standard", "https://url.spec.whatwg.org/"),
789 targets: Vec::new(),
790 }),
791 }
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
795enum MacroSyntax {
796 Bracket,
797 DollarBraces,
798 DoubleBraces,
799}
800
801impl MacroSyntax {
802 fn example(self) -> &'static str {
803 match self {
804 Self::Bracket => "[NAME]",
805 Self::DollarBraces => "${NAME}",
806 Self::DoubleBraces => "{{NAME}}",
807 }
808 }
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
812enum MacroPosition {
813 Scheme,
814 Authority,
815 UserInfo,
816 Host,
817 Port,
818 Path,
819 Query,
820 Fragment,
821 WholeUrl,
822}
823
824impl MacroPosition {
825 fn field(self) -> &'static str {
826 match self {
827 Self::Scheme => "url.scheme",
828 Self::Authority => "url.authority",
829 Self::UserInfo => "url.userinfo",
830 Self::Host => "url.host",
831 Self::Port => "url.port",
832 Self::Path => "url.path",
833 Self::Query => "url.query",
834 Self::Fragment => "url.fragment",
835 Self::WholeUrl => "url",
836 }
837 }
838
839 fn label(self) -> &'static str {
840 match self {
841 Self::Scheme => "scheme",
842 Self::Authority => "authority",
843 Self::UserInfo => "userinfo",
844 Self::Host => "host",
845 Self::Port => "port",
846 Self::Path => "path",
847 Self::Query => "query",
848 Self::Fragment => "fragment",
849 Self::WholeUrl => "url",
850 }
851 }
852
853 fn target_component(self) -> ViolationTargetComponent {
854 match self {
855 Self::Scheme => ViolationTargetComponent::Scheme,
856 Self::Authority => ViolationTargetComponent::Authority,
857 Self::UserInfo => ViolationTargetComponent::UserInfo,
858 Self::Host => ViolationTargetComponent::Host,
859 Self::Port => ViolationTargetComponent::Port,
860 Self::Path => ViolationTargetComponent::Path,
861 Self::Query => ViolationTargetComponent::QueryParam,
862 Self::Fragment => ViolationTargetComponent::Fragment,
863 Self::WholeUrl => ViolationTargetComponent::WholeUrl,
864 }
865 }
866
867 fn is_unsafe(self) -> bool {
868 matches!(
869 self,
870 Self::Scheme | Self::Authority | Self::UserInfo | Self::Host | Self::Port
871 )
872 }
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub(crate) struct MacroSpan {
877 start: usize,
878 end: usize,
879 syntax: MacroSyntax,
880}
881
882fn apply_macro_rules(
883 artifact: &str,
884 expansion_state: ExpansionState,
885 macro_spans: &[MacroSpan],
886 violations: &mut Vec<Violation>,
887) -> bool {
888 if macro_spans.is_empty() {
889 return false;
890 }
891
892 let syntaxes = macro_spans
893 .iter()
894 .map(|span| span.syntax)
895 .collect::<BTreeSet<_>>();
896
897 if syntaxes.len() > 1 {
898 let targets = macro_spans
899 .iter()
900 .map(|span| target_for_macro_span(artifact, span))
901 .collect();
902 let syntax_list = syntaxes
903 .iter()
904 .map(|syntax| syntax.example())
905 .collect::<Vec<_>>()
906 .join(", ");
907 violations.push(Violation {
908 code: "core.macro.mixed_syntax".to_string(),
909 message: format!(
910 "Multiple macro syntaxes were detected in the same artifact: {syntax_list}."
911 ),
912 severity: Severity::Warning,
913 field: Some("url".to_string()),
914 fix_hint: Some(
915 "Standardize on one macro syntax per artifact so trafficking and expansion behavior stays predictable."
916 .to_string(),
917 ),
918 source: macro_rule_source(),
919 targets,
920 });
921 }
922
923 if expansion_state == ExpansionState::Fired {
924 let targets = macro_spans
925 .iter()
926 .map(|span| target_for_macro_span(artifact, span))
927 .collect();
928 violations.push(Violation {
929 code: "core.macro.unexpanded_in_fired_url".to_string(),
930 message: "Observed fired URLs should not contain unresolved macro tokens."
931 .to_string(),
932 severity: Severity::Error,
933 field: Some("url".to_string()),
934 fix_hint: Some(
935 "Expand macros before the request is fired, or validate the artifact in template mode instead."
936 .to_string(),
937 ),
938 source: macro_rule_source(),
939 targets,
940 });
941 }
942
943 let unsafe_spans = macro_spans
944 .iter()
945 .map(|span| (*span, classify_macro_position(artifact, span)))
946 .filter(|(_, position)| position.is_unsafe())
947 .collect::<Vec<_>>();
948
949 let unsafe_positions = unsafe_spans
950 .iter()
951 .map(|(_, position)| *position)
952 .collect::<BTreeSet<_>>();
953
954 if unsafe_positions.is_empty() {
955 return false;
956 }
957
958 let position_list = unsafe_positions
959 .iter()
960 .map(|position| position.label())
961 .collect::<Vec<_>>()
962 .join(", ");
963 let first_position = *unsafe_positions
964 .iter()
965 .next()
966 .unwrap_or(&MacroPosition::WholeUrl);
967 let targets = unsafe_spans
968 .iter()
969 .map(|(span, _)| target_for_macro_span(artifact, span))
970 .collect();
971
972 violations.push(Violation {
973 code: "core.macro.unsafe_position".to_string(),
974 message: format!(
975 "Macros in URL {position_list} components are unsafe because they can prevent reliable endpoint resolution."
976 ),
977 severity: Severity::Error,
978 field: Some(first_position.field().to_string()),
979 fix_hint: Some(
980 "Keep macros in path or query values, or expand scheme and authority components before validation."
981 .to_string(),
982 ),
983 source: macro_rule_source(),
984 targets,
985 });
986
987 true
988}
989
990fn macro_rule_source() -> RuleSource {
991 RuleSource {
992 level: RuleSourceLevel::EcosystemReference,
993 name: "Ad-tech macro handling baseline".to_string(),
994 reference: None,
995 }
996}
997
998pub(crate) fn detect_macro_spans(artifact: &str) -> Vec<MacroSpan> {
999 let mut spans = Vec::new();
1000 let mut index = 0;
1001
1002 while index < artifact.len() {
1003 let remainder = &artifact[index..];
1004
1005 if let Some(span) = match_macro_span(artifact, index, remainder) {
1006 index = span.end;
1007 spans.push(span);
1008 continue;
1009 }
1010
1011 index += 1;
1012 }
1013
1014 spans
1015}
1016
1017const MACRO_DELIMITERS: [(&str, &str, MacroSyntax); 3] = [
1019 ("${", "}", MacroSyntax::DollarBraces),
1020 ("{{", "}}", MacroSyntax::DoubleBraces),
1021 ("[", "]", MacroSyntax::Bracket),
1022];
1023
1024fn match_macro_span(artifact: &str, index: usize, remainder: &str) -> Option<MacroSpan> {
1025 for (open, close, syntax) in MACRO_DELIMITERS {
1026 let Some(after_open) = remainder.strip_prefix(open) else {
1027 continue;
1028 };
1029 let Some(close_offset) = after_open.find(close) else {
1030 continue;
1031 };
1032
1033 let body_start = index + open.len();
1034 let body_end = body_start + close_offset;
1035
1036 if is_macro_body(&artifact[body_start..body_end]) {
1037 return Some(MacroSpan {
1038 start: index,
1039 end: body_end + close.len(),
1040 syntax,
1041 });
1042 }
1043 }
1044
1045 None
1046}
1047
1048fn is_macro_body(body: &str) -> bool {
1049 if body.is_empty() || body.trim() != body {
1050 return false;
1051 }
1052
1053 let mut has_identifier_character = false;
1054
1055 for character in body.chars() {
1056 match character {
1057 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.' | '-' => {
1058 if character.is_ascii_alphabetic() || character == '_' {
1059 has_identifier_character = true;
1060 }
1061 }
1062 _ => return false,
1063 }
1064 }
1065
1066 has_identifier_character
1067}
1068
1069pub(crate) fn sanitize_macro_spans(artifact: &str, macro_spans: &[MacroSpan]) -> String {
1070 let mut sanitized = String::with_capacity(artifact.len());
1071 let mut cursor = 0;
1072
1073 for span in macro_spans {
1074 sanitized.push_str(&artifact[cursor..span.start]);
1075 sanitized.push_str("macro");
1076 cursor = span.end;
1077 }
1078
1079 sanitized.push_str(&artifact[cursor..]);
1080 sanitized
1081}
1082
1083fn target_for_macro_span(artifact: &str, span: &MacroSpan) -> ViolationTarget {
1084 let position = classify_macro_position(artifact, span);
1085 let value = &artifact[span.start..span.end];
1086
1087 if position == MacroPosition::Query {
1088 let (name, param_value) = query_param_context(artifact, span);
1089 return ViolationTarget {
1090 component: ViolationTargetComponent::QueryParam,
1091 name,
1092 value: param_value,
1093 start: span.start,
1094 end: span.end,
1095 };
1096 }
1097
1098 ViolationTarget {
1099 component: position.target_component(),
1100 name: None,
1101 value: Some(value.to_string()),
1102 start: span.start,
1103 end: span.end,
1104 }
1105}
1106
1107fn query_param_context(artifact: &str, span: &MacroSpan) -> (Option<String>, Option<String>) {
1108 let length = artifact.len();
1109 let fragment_start = artifact.find('#').unwrap_or(length);
1110 let Some(query_start) = artifact[..fragment_start].find('?') else {
1111 return (None, Some(artifact[span.start..span.end].to_string()));
1112 };
1113
1114 let query_value_start = query_start + 1;
1115 if !overlaps(span, query_value_start, fragment_start) {
1116 return (None, Some(artifact[span.start..span.end].to_string()));
1117 }
1118
1119 let segment_start = artifact[..span.start]
1120 .rfind(['?', '&'])
1121 .map(|index| index + 1)
1122 .unwrap_or(query_value_start);
1123 let segment_end = artifact[span.end..fragment_start]
1124 .find('&')
1125 .map(|offset| span.end + offset)
1126 .unwrap_or(fragment_start);
1127 let segment = &artifact[segment_start..segment_end];
1128
1129 if let Some(separator) = segment.find('=') {
1130 let name = &segment[..separator];
1131 let value = &segment[separator + 1..];
1132 (
1133 (!name.is_empty()).then(|| name.to_string()),
1134 Some(value.to_string()),
1135 )
1136 } else {
1137 (
1138 (!segment.is_empty()).then(|| segment.to_string()),
1139 Some(segment.to_string()),
1140 )
1141 }
1142}
1143
1144fn classify_macro_position(artifact: &str, span: &MacroSpan) -> MacroPosition {
1145 let length = artifact.len();
1146 let Some(scheme_end) = artifact.find("://") else {
1147 return MacroPosition::WholeUrl;
1148 };
1149
1150 if overlaps(span, 0, scheme_end) {
1151 return MacroPosition::Scheme;
1152 }
1153
1154 let authority_start = scheme_end + 3;
1155 let authority_end = artifact[authority_start..]
1156 .find(['/', '?', '#'])
1157 .map(|offset| authority_start + offset)
1158 .unwrap_or(length);
1159
1160 let fragment_start = artifact.find('#');
1161 let query_search_end = fragment_start.unwrap_or(length);
1162 let query_start = artifact[..query_search_end].find('?');
1163 let path_end = query_start.or(fragment_start).unwrap_or(length);
1164
1165 if overlaps(span, authority_start, authority_end) {
1166 let authority = &artifact[authority_start..authority_end];
1167 let (userinfo_end, host_start) = if let Some(at_offset) = authority.find('@') {
1168 let userinfo_end = authority_start + at_offset;
1169 if overlaps(span, authority_start, userinfo_end) {
1170 return MacroPosition::UserInfo;
1171 }
1172 (Some(userinfo_end), userinfo_end + 1)
1173 } else {
1174 (None, authority_start)
1175 };
1176
1177 if host_start < authority_end {
1178 let host_port = &artifact[host_start..authority_end];
1179 if host_port.starts_with('[') {
1180 if let Some(close_offset) = host_port.find(']') {
1181 let host_end = host_start + close_offset + 1;
1182 if overlaps(span, host_start, host_end) {
1183 return MacroPosition::Host;
1184 }
1185
1186 if host_end < authority_end
1187 && artifact.as_bytes().get(host_end) == Some(&b':')
1188 && overlaps(span, host_end + 1, authority_end)
1189 {
1190 return MacroPosition::Port;
1191 }
1192 }
1193 } else if let Some(port_separator) = host_port.rfind(':') {
1194 let port_start = host_start + port_separator + 1;
1195 let host_end = host_start + port_separator;
1196
1197 if overlaps(span, host_start, host_end) {
1198 return MacroPosition::Host;
1199 }
1200
1201 if overlaps(span, port_start, authority_end) {
1202 return MacroPosition::Port;
1203 }
1204 } else if overlaps(span, host_start, authority_end) {
1205 return MacroPosition::Host;
1206 }
1207 }
1208
1209 if userinfo_end.is_some() || overlaps(span, authority_start, authority_end) {
1210 return MacroPosition::Authority;
1211 }
1212 }
1213
1214 if authority_end < path_end
1215 && artifact.as_bytes().get(authority_end) == Some(&b'/')
1216 && overlaps(span, authority_end, path_end)
1217 {
1218 return MacroPosition::Path;
1219 }
1220
1221 if let Some(query_start) = query_start {
1222 let query_value_start = query_start + 1;
1223 let query_end = fragment_start.unwrap_or(length);
1224 if overlaps(span, query_value_start, query_end) {
1225 return MacroPosition::Query;
1226 }
1227 }
1228
1229 if let Some(fragment_start) = fragment_start {
1230 let fragment_value_start = fragment_start + 1;
1231 if overlaps(span, fragment_value_start, length) {
1232 return MacroPosition::Fragment;
1233 }
1234 }
1235
1236 MacroPosition::WholeUrl
1237}
1238
1239fn overlaps(span: &MacroSpan, start: usize, end: usize) -> bool {
1240 start < end && span.start < end && span.end > start
1241}
1242
1243fn has_missing_network_host(artifact: &str) -> bool {
1244 let lowered = artifact.to_ascii_lowercase();
1245 let Some(remainder) = lowered
1246 .strip_prefix("http://")
1247 .or_else(|| lowered.strip_prefix("https://"))
1248 else {
1249 return false;
1250 };
1251
1252 matches!(
1253 remainder.chars().next(),
1254 None | Some('/') | Some('?') | Some('#') | Some(':') | Some('@')
1255 )
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261
1262 struct FixtureRulePack {
1263 metadata: RulePackMetadata,
1264 }
1265
1266 impl Default for FixtureRulePack {
1267 fn default() -> Self {
1268 Self {
1269 metadata: RulePackMetadata {
1270 id: "fixture".to_string(),
1271 display_name: "Fixture RulePack".to_string(),
1272 version: "0.0.0".to_string(),
1273 description: "Test helper rulepack".to_string(),
1274 source_level: RuleSourceLevel::Heuristic,
1275 vendor: None,
1276 },
1277 }
1278 }
1279 }
1280
1281 impl ValidatorPlugin for FixtureRulePack {
1282 fn metadata(&self) -> &RulePackMetadata {
1283 &self.metadata
1284 }
1285
1286 fn supports(&self, _request: &ValidationRequest) -> bool {
1287 true
1288 }
1289
1290 fn validate(&self, request: &ValidationRequest) -> ValidationReport {
1291 ValidationReport {
1292 plugin_id: self.metadata.id.clone(),
1293 detected_vendor: request.claimed_vendor.clone(),
1294 violations: vec![Violation {
1295 code: "fixture.info".to_string(),
1296 message: "fixture ran".to_string(),
1297 severity: Severity::Info,
1298 field: None,
1299 fix_hint: None,
1300 source: RuleSource {
1301 level: RuleSourceLevel::Heuristic,
1302 name: "Fixture".to_string(),
1303 reference: None,
1304 },
1305 targets: Vec::new(),
1306 }],
1307 }
1308 }
1309 }
1310
1311 fn sample_request(artifact: &str) -> ValidationRequest {
1312 sample_request_with_kind(ArtifactKind::Url, artifact)
1313 }
1314
1315 fn sample_request_with_kind(artifact_kind: ArtifactKind, artifact: &str) -> ValidationRequest {
1316 sample_request_with_kind_and_state(artifact_kind, ExpansionState::Unknown, artifact)
1317 }
1318
1319 fn sample_request_with_kind_and_state(
1320 artifact_kind: ArtifactKind,
1321 expansion_state: ExpansionState,
1322 artifact: &str,
1323 ) -> ValidationRequest {
1324 ValidationRequest {
1325 artifact_kind,
1326 artifact: artifact.to_string(),
1327 claimed_vendor: None,
1328 expansion_state,
1329 }
1330 }
1331
1332 fn violation_codes(summary: &ValidationSummary) -> Vec<String> {
1333 summary
1334 .reports
1335 .iter()
1336 .flat_map(|report| report.violations.iter())
1337 .map(|violation| violation.code.clone())
1338 .collect()
1339 }
1340
1341 #[test]
1342 fn core_rulepack_runs_in_auto_mode() {
1343 let engine = Engine::default();
1344 let summary = engine
1345 .validate(
1346 &sample_request("https://example.com/pixel?id=1#ignored"),
1347 &ValidationOptions::default(),
1348 )
1349 .unwrap();
1350
1351 assert_eq!(summary.reports.len(), 1);
1352 assert_eq!(summary.reports[0].plugin_id, "core");
1353 assert_eq!(
1354 summary.reports[0].violations[0].code,
1355 "core.url.fragment_ignored"
1356 );
1357 }
1358
1359 #[test]
1360 fn explicit_rulepack_selection_is_toggleable() {
1361 let mut engine = Engine::default();
1362 engine.register(FixtureRulePack::default());
1363
1364 let summary = engine
1365 .validate(
1366 &sample_request("https://example.com/pixel?id=1"),
1367 &ValidationOptions {
1368 only_rulepacks: vec!["core".to_string(), "fixture".to_string()],
1369 except_rulepacks: Vec::new(),
1370 },
1371 )
1372 .unwrap();
1373
1374 assert_eq!(summary.reports.len(), 2);
1375 assert_eq!(summary.reports[0].plugin_id, "core");
1376 assert_eq!(summary.reports[1].plugin_id, "fixture");
1377 }
1378
1379 #[test]
1380 fn excluding_all_selected_rulepacks_returns_an_error() {
1381 let engine = Engine::default();
1382 let error = engine
1383 .validate(
1384 &sample_request("https://example.com/pixel?id=1"),
1385 &ValidationOptions {
1386 only_rulepacks: vec!["core".to_string()],
1387 except_rulepacks: vec!["core".to_string()],
1388 },
1389 )
1390 .unwrap_err();
1391
1392 assert_eq!(error, EngineError::NoRulepacksSelected);
1393 }
1394
1395 #[test]
1396 fn core_rulepack_warns_on_deprecated_userinfo() {
1397 let engine = Engine::default();
1398 let summary = engine
1399 .validate(
1400 &sample_request("https://user:pass@example.com/pixel?id=1"),
1401 &ValidationOptions::default(),
1402 )
1403 .unwrap();
1404
1405 assert_eq!(
1406 summary.reports[0].violations[0].code,
1407 "core.url.userinfo_deprecated"
1408 );
1409 }
1410
1411 #[test]
1412 fn core_rulepack_reports_empty_input_without_cascading_url_noise() {
1413 let engine = Engine::default();
1414 let summary = engine
1415 .validate(&sample_request(" \n\t"), &ValidationOptions::default())
1416 .unwrap();
1417
1418 assert_eq!(violation_codes(&summary), vec!["core.input.empty"]);
1419 }
1420
1421 #[test]
1422 fn core_rulepack_errors_on_invalid_url() {
1423 let engine = Engine::default();
1424 let summary = engine
1425 .validate(&sample_request("not a url"), &ValidationOptions::default())
1426 .unwrap();
1427
1428 assert_eq!(violation_codes(&summary), vec!["core.url.invalid"]);
1429 }
1430
1431 #[test]
1432 fn core_rulepack_errors_on_unsupported_scheme() {
1433 let engine = Engine::default();
1434 let summary = engine
1435 .validate(
1436 &sample_request("ftp://example.com/pixel?id=1"),
1437 &ValidationOptions::default(),
1438 )
1439 .unwrap();
1440
1441 assert_eq!(
1442 violation_codes(&summary),
1443 vec!["core.url.unsupported_scheme"]
1444 );
1445 }
1446
1447 #[test]
1448 fn core_rulepack_warns_on_insecure_http_transport() {
1449 let engine = Engine::default();
1450 let summary = engine
1451 .validate(
1452 &sample_request("http://example.com/pixel?id=1"),
1453 &ValidationOptions::default(),
1454 )
1455 .unwrap();
1456
1457 assert_eq!(
1458 violation_codes(&summary),
1459 vec!["core.url.insecure_transport"]
1460 );
1461 }
1462
1463 #[test]
1464 fn core_rulepack_errors_on_missing_host() {
1465 let engine = Engine::default();
1466 let summary = engine
1467 .validate(
1468 &sample_request("https:///pixel?id=1"),
1469 &ValidationOptions::default(),
1470 )
1471 .unwrap();
1472
1473 assert_eq!(violation_codes(&summary), vec!["core.url.host_missing"]);
1474 }
1475
1476 #[test]
1477 fn core_rulepack_accepts_clean_https_pixel() {
1478 let engine = Engine::default();
1479 let summary = engine
1480 .validate(
1481 &sample_request("https://example.com/pixel?id=1"),
1482 &ValidationOptions::default(),
1483 )
1484 .unwrap();
1485
1486 assert!(summary.is_ok());
1487 assert!(summary.reports[0].violations.is_empty());
1488 }
1489
1490 #[test]
1491 fn core_rulepack_accepts_trimmed_https_pixel() {
1492 let engine = Engine::default();
1493 let summary = engine
1494 .validate(
1495 &sample_request(" \n https://example.com/pixel?id=1 \t "),
1496 &ValidationOptions::default(),
1497 )
1498 .unwrap();
1499
1500 assert!(summary.is_ok());
1501 assert!(summary.reports[0].violations.is_empty());
1502 }
1503
1504 #[test]
1505 fn core_rulepack_accepts_localhost_with_port() {
1506 let engine = Engine::default();
1507 let summary = engine
1508 .validate(
1509 &sample_request("https://localhost:8443/pixel?id=1&source=qa"),
1510 &ValidationOptions::default(),
1511 )
1512 .unwrap();
1513
1514 assert!(summary.is_ok());
1515 assert!(summary.reports[0].violations.is_empty());
1516 }
1517
1518 #[test]
1519 fn core_rulepack_accepts_ipv6_hosts() {
1520 let engine = Engine::default();
1521 let summary = engine
1522 .validate(
1523 &sample_request("https://[2001:db8::1]/pixel?id=1"),
1524 &ValidationOptions::default(),
1525 )
1526 .unwrap();
1527
1528 assert!(summary.is_ok());
1529 assert!(summary.reports[0].violations.is_empty());
1530 }
1531
1532 #[test]
1533 fn core_rulepack_combines_insecure_transport_userinfo_and_fragment_warnings() {
1534 let engine = Engine::default();
1535 let summary = engine
1536 .validate(
1537 &sample_request("http://user:pass@example.com/pixel?id=1#frag"),
1538 &ValidationOptions::default(),
1539 )
1540 .unwrap();
1541
1542 assert_eq!(
1543 violation_codes(&summary),
1544 vec![
1545 "core.url.insecure_transport",
1546 "core.url.userinfo_deprecated",
1547 "core.url.fragment_ignored"
1548 ]
1549 );
1550 }
1551
1552 #[test]
1553 fn core_rulepack_validates_vast_tracker_urls() {
1554 let engine = Engine::default();
1555 let summary = engine
1556 .validate(
1557 &sample_request_with_kind(
1558 ArtifactKind::VastTracker,
1559 "https://example.com/vast/track?event=start#ignored",
1560 ),
1561 &ValidationOptions::default(),
1562 )
1563 .unwrap();
1564
1565 assert_eq!(violation_codes(&summary), vec!["core.url.fragment_ignored"]);
1566 }
1567
1568 #[test]
1569 fn core_rulepack_warns_on_insecure_vast_tracker_transport() {
1570 let engine = Engine::default();
1571 let summary = engine
1572 .validate(
1573 &sample_request_with_kind(
1574 ArtifactKind::VastTracker,
1575 "http://tracker.example.com/vast/track?event=start#ignored",
1576 ),
1577 &ValidationOptions::default(),
1578 )
1579 .unwrap();
1580
1581 assert_eq!(
1582 violation_codes(&summary),
1583 vec!["core.url.insecure_transport", "core.url.fragment_ignored"]
1584 );
1585 }
1586
1587 #[test]
1588 fn core_rulepack_validates_server_postback_urls() {
1589 let engine = Engine::default();
1590 let summary = engine
1591 .validate(
1592 &sample_request_with_kind(
1593 ArtifactKind::ServerPostback,
1594 "ftp://example.com/postback?tx=abc123",
1595 ),
1596 &ValidationOptions::default(),
1597 )
1598 .unwrap();
1599
1600 assert_eq!(
1601 violation_codes(&summary),
1602 vec!["core.url.unsupported_scheme"]
1603 );
1604 }
1605
1606 #[test]
1607 fn core_rulepack_warns_on_insecure_server_postback_transport() {
1608 let engine = Engine::default();
1609 let summary = engine
1610 .validate(
1611 &sample_request_with_kind(
1612 ArtifactKind::ServerPostback,
1613 "http://collector.example.com/postback?tx=abc123",
1614 ),
1615 &ValidationOptions::default(),
1616 )
1617 .unwrap();
1618
1619 assert_eq!(
1620 violation_codes(&summary),
1621 vec!["core.url.insecure_transport"]
1622 );
1623 }
1624
1625 #[test]
1626 fn core_rulepack_accepts_query_macros_in_unknown_state() {
1627 let engine = Engine::default();
1628 let summary = engine
1629 .validate(
1630 &sample_request("https://example.com/pixel?cb=[CACHEBUSTING]&id=1"),
1631 &ValidationOptions::default(),
1632 )
1633 .unwrap();
1634
1635 assert!(summary.is_ok());
1636 assert!(summary.reports[0].violations.is_empty());
1637 }
1638
1639 #[test]
1640 fn core_rulepack_errors_on_unexpanded_macro_in_fired_url() {
1641 let engine = Engine::default();
1642 let summary = engine
1643 .validate(
1644 &sample_request_with_kind_and_state(
1645 ArtifactKind::Url,
1646 ExpansionState::Fired,
1647 "https://example.com/pixel?cb=[CACHEBUSTING]&id=1",
1648 ),
1649 &ValidationOptions::default(),
1650 )
1651 .unwrap();
1652
1653 assert_eq!(
1654 violation_codes(&summary),
1655 vec!["core.macro.unexpanded_in_fired_url"]
1656 );
1657
1658 let targets = &summary.reports[0].violations[0].targets;
1659 assert_eq!(targets.len(), 1);
1660 assert_eq!(targets[0].component, ViolationTargetComponent::QueryParam);
1661 assert_eq!(targets[0].name.as_deref(), Some("cb"));
1662 assert_eq!(targets[0].value.as_deref(), Some("[CACHEBUSTING]"));
1663 }
1664
1665 #[test]
1666 fn core_rulepack_warns_on_mixed_macro_syntax() {
1667 let engine = Engine::default();
1668 let summary = engine
1669 .validate(
1670 &sample_request_with_kind_and_state(
1671 ArtifactKind::Url,
1672 ExpansionState::Template,
1673 "https://example.com/pixel?cb=[CACHEBUSTING]&price=${AUCTION_PRICE}",
1674 ),
1675 &ValidationOptions::default(),
1676 )
1677 .unwrap();
1678
1679 assert_eq!(violation_codes(&summary), vec!["core.macro.mixed_syntax"]);
1680
1681 let targets = &summary.reports[0].violations[0].targets;
1682 assert_eq!(targets.len(), 2);
1683 assert_eq!(targets[0].name.as_deref(), Some("cb"));
1684 assert_eq!(targets[1].name.as_deref(), Some("price"));
1685 }
1686
1687 #[test]
1688 fn core_rulepack_errors_on_macro_in_host_position() {
1689 let engine = Engine::default();
1690 let summary = engine
1691 .validate(
1692 &sample_request_with_kind_and_state(
1693 ArtifactKind::Url,
1694 ExpansionState::Template,
1695 "https://${HOST}/pixel?id=1",
1696 ),
1697 &ValidationOptions::default(),
1698 )
1699 .unwrap();
1700
1701 assert_eq!(
1702 violation_codes(&summary),
1703 vec!["core.macro.unsafe_position"]
1704 );
1705 assert_eq!(
1706 summary.reports[0].violations[0].field.as_deref(),
1707 Some("url.host")
1708 );
1709 assert_eq!(summary.reports[0].violations[0].targets.len(), 1);
1710 assert_eq!(
1711 summary.reports[0].violations[0].targets[0].component,
1712 ViolationTargetComponent::Host
1713 );
1714 assert_eq!(
1715 summary.reports[0].violations[0].targets[0].value.as_deref(),
1716 Some("${HOST}")
1717 );
1718 }
1719
1720 fn directory_request(artifact: &str) -> ValidationRequest {
1721 ValidationRequest {
1722 artifact_kind: ArtifactKind::Url,
1723 artifact: artifact.to_string(),
1724 claimed_vendor: None,
1725 expansion_state: ExpansionState::Unknown,
1726 }
1727 }
1728
1729 #[test]
1730 fn the_directory_attributes_endpoints_no_rulepack_claims() {
1731 let summary = Engine::default()
1732 .validate(
1733 &directory_request("https://trc.taboola.com/actions?a=1"),
1734 &ValidationOptions::default(),
1735 )
1736 .unwrap();
1737
1738 let report = summary
1739 .reports
1740 .iter()
1741 .find(|report| report.plugin_id == DIRECTORY_ID)
1742 .expect("directory report");
1743 assert_eq!(report.detected_vendor.as_deref(), Some("taboola"));
1744 assert_eq!(report.violations[0].code, "directory.no_rulepack_coverage");
1745 assert_eq!(report.violations[0].severity, Severity::Info);
1746 assert!(report.is_ok(), "attribution must never fail an artifact");
1747 }
1748
1749 #[test]
1750 fn a_matching_rulepack_suppresses_the_directory() {
1751 let summary = Engine::default()
1752 .validate(
1753 &directory_request("https://www.facebook.com/tr?id=1234567890123456&ev=PageView"),
1754 &ValidationOptions::default(),
1755 )
1756 .unwrap();
1757
1758 assert!(
1759 summary
1760 .reports
1761 .iter()
1762 .all(|report| report.plugin_id != DIRECTORY_ID),
1763 "a vendor pack is better information than an attribution"
1764 );
1765 }
1766
1767 #[test]
1768 fn a_known_vendor_on_an_uncovered_endpoint_still_gets_attributed() {
1769 let summary = Engine::default()
1770 .validate(
1771 &directory_request("https://www.facebook.com/some/other/path"),
1772 &ValidationOptions::default(),
1773 )
1774 .unwrap();
1775
1776 let report = summary
1777 .reports
1778 .iter()
1779 .find(|report| report.plugin_id == DIRECTORY_ID)
1780 .expect("directory report");
1781 assert_eq!(report.detected_vendor.as_deref(), Some("meta"));
1782 assert!(
1783 report.violations[0].message.contains("`vendor/meta`"),
1784 "{}",
1785 report.violations[0].message
1786 );
1787 }
1788
1789 #[test]
1790 fn unknown_hosts_get_no_directory_report() {
1791 let summary = Engine::default()
1792 .validate(
1793 &directory_request("https://pixel.example.com/collect?id=1"),
1794 &ValidationOptions::default(),
1795 )
1796 .unwrap();
1797
1798 assert_eq!(summary.reports.len(), 1);
1799 assert_eq!(summary.reports[0].plugin_id, "core");
1800 }
1801
1802 #[test]
1803 fn the_directory_honors_rulepack_toggles() {
1804 let engine = Engine::default();
1805 let request = directory_request("https://trc.taboola.com/actions?a=1");
1806
1807 let summary = engine
1808 .validate(
1809 &request,
1810 &ValidationOptions {
1811 except_rulepacks: vec![DIRECTORY_ID.to_string()],
1812 ..ValidationOptions::default()
1813 },
1814 )
1815 .unwrap();
1816 assert!(
1817 summary
1818 .reports
1819 .iter()
1820 .all(|report| report.plugin_id != DIRECTORY_ID)
1821 );
1822
1823 let summary = engine
1824 .validate(
1825 &request,
1826 &ValidationOptions {
1827 only_rulepacks: vec![DIRECTORY_ID.to_string()],
1828 ..ValidationOptions::default()
1829 },
1830 )
1831 .unwrap();
1832 assert_eq!(summary.reports.len(), 1);
1833 assert_eq!(summary.reports[0].plugin_id, DIRECTORY_ID);
1834
1835 let summary = engine
1836 .validate(
1837 &request,
1838 &ValidationOptions {
1839 only_rulepacks: vec!["core".to_string()],
1840 ..ValidationOptions::default()
1841 },
1842 )
1843 .unwrap();
1844 assert_eq!(summary.reports.len(), 1);
1845 assert_eq!(summary.reports[0].plugin_id, "core");
1846 }
1847
1848 #[test]
1849 fn an_engine_without_a_directory_attributes_nothing() {
1850 let mut engine = Engine::default();
1851 engine.set_directory(VendorDirectory::default());
1852
1853 let summary = engine
1854 .validate(
1855 &directory_request("https://trc.taboola.com/actions?a=1"),
1856 &ValidationOptions::default(),
1857 )
1858 .unwrap();
1859
1860 assert_eq!(summary.reports.len(), 1);
1861 assert_eq!(summary.reports[0].plugin_id, "core");
1862 }
1863}