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